Add legal docs, redesign profiles and chats tab, fix fullscreen images, add code style

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-07-03 18:59:55 +03:00
Unverified
parent 306458bcd8
commit 7b82bc58f9
81 changed files with 8201 additions and 1590 deletions
+16
View File
@@ -0,0 +1,16 @@
---
description: Read CODE_STYLE.md before writing or refactoring Kotlin/Compose code
alwaysApply: true
---
# Code style
Before writing or editing Kotlin / Compose code in this repo:
1. **Read** [`CODE_STYLE.md`](../../CODE_STYLE.md) at the repository root.
2. **Follow** it — write idiomatic, well-structured code from the start; match neighboring files when a rule is ambiguous.
3. **Do not ask the user style questions** when implementing new code.
For style cleanup on an existing diff or file set, use the **`adapt-to-style`** skill.
Build and platform rules remain in `android.mdc`.
-13
View File
@@ -1,13 +0,0 @@
---
description: Keep Kotlin code clean — inline single-use helpers, match project conventions
globs: app/shared/**/*.kt,utils/shared/**/*.kt
alwaysApply: false
---
# Kotlin clean code
- If a function, variable, or small wrapper is used **once**, inline it at the call site unless it clarifies a non-obvious boundary (network call, animation controller, crypto).
- Prefer existing project components (`ActionButton`, `Category`, `ExpressiveStepFlow`, `apiRequest`) over new abstractions.
- Match surrounding naming, imports, and composable structure; read adjacent files before adding helpers.
- Extract only when shared by **2+** call sites or when the block exceeds ~40 lines of non-trivial logic (pager math, crypto, API wiring).
- No hardcoded user-visible strings in shared UI — use Compose Multiplatform resources (`composeResources/values/strings.xml` + `values-ru`).
+84
View File
@@ -0,0 +1,84 @@
---
name: adapt-to-style
description: Adapts Kotlin/Compose code (diff, single file, or multiple files) to FromChat CODE_STYLE.md strictly without changing behavior. Use when cleaning up style, refactoring for conventions, adapting a diff to code style, or when the user mentions adapt-to-style, code style cleanup, or style pass.
---
# Adapt to style
Refactor target code to match [`CODE_STYLE.md`](../../../CODE_STYLE.md) at the repository root. **Do not change behavior.**
## Writing new code
When implementing features (not a style-only pass):
1. Read `CODE_STYLE.md` before writing.
2. Follow it from the start — inline single-use bindings, idiomatic Kotlin, match neighboring files.
3. **Do not ask the user style questions** — apply the guide and use your judgment.
## Style adaptation pass
When cleaning up an existing diff or file set:
### Before you start
1. Read `CODE_STYLE.md` fully.
2. Identify scope: git diff, named files, or a directory.
3. Read surrounding files in the same package for precedent.
### Refactor checklist
Apply in order:
- [ ] **Inline** `val`/`var`/locals/`@Composable` used exactly once in the file (§1).
- [ ] **Merge** screen-only helper files into their parent screen file (§2).
- [ ] **Replace** non-idiomatic Kotlin with idioms: `runCatching {}`, `buildList {}`, `buildMap {}`, etc. (§3).
- [ ] **Group** related multi-file features into sub-packages where appropriate (§4).
- [ ] **Reuse** existing project components; remove one-off wrappers (§5).
- [ ] **Strings** — no new hardcoded user-visible copy; use compose resources (§6).
- [ ] **Formatting** — one blank line between composables; `private` screen helpers; no `.dp` named constants (§7).
- [ ] **Expression bodies** where §8 applies.
### Uncertainty log
Only during a **style adaptation pass** — not when writing new code.
When unsure how to refactor something:
1. Create or append to `.cursor/code_style_progress_<YYYYMMDD-HHmm>.md` (use current local time).
2. For each item:
```markdown
## relative/path/File.kt
- Unsure: [specific construct and why]
- Chosen approach: [what you did for now]
```
3. Continue refactoring — do not block on open questions.
4. After all files are done, **re-read** the progress file and ask the user the listed questions.
### Constraints
- No behavior, API, or logic changes.
- No magic-string sanitization of real user/message data.
- Minimal diff: only what style requires.
- Do not extract new abstractions that would be used once.
- Do not split files that §2 says should be merged.
- Do not change public API for style-only passes.
### Validation
After Android-affecting changes, run per `android.mdc`:
```bash
export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" && ./gradlew :app:shared:compileAndroidMain :app:shared:compileKotlinIosArm64
```
Fix compile errors before finishing.
### Output
Summarize:
- Files touched and main style changes.
- Any entries from the progress file that need user decisions.
- Build result.
+216
View File
@@ -0,0 +1,216 @@
# FromChat Android — Code Style
Canonical style reference for Kotlin / Compose Multiplatform code in this repository.
Contributors are **not required** to follow this guide — but sticking to it saves me cleanup time, so its appreciated when you do.
This file is created mostly for AI agents to write good and readable code.
---
## 1. Inline single-use bindings
If a `val`, `var`, local function, or `@Composable` is referenced **exactly once** in the file, inline it at the use site.
Do **not** introduce a named binding only used once.
```kotlin
// ❌ BAD — used once
val padding = MaterialTheme.spacing.medium
Box(modifier = Modifier.padding(padding))
// ✅ GOOD
Box(modifier = Modifier.padding(MaterialTheme.spacing.medium))
```
```kotlin
// ❌ BAD — composable used once
@Composable
private fun ProfileHeaderTitle(text: String) {
Text(text = text, style = MaterialTheme.typography.headlineSmall)
}
@Composable
fun ProfileScreen() {
ProfileHeaderTitle(text = title)
}
// ✅ GOOD — inline at the single call site
@Composable
fun ProfileScreen() {
Text(text = title, style = MaterialTheme.typography.headlineSmall)
}
```
**Keep a name when:**
- The expression has side effects and must not run twice.
- Inlining hides a non-obvious boundary (crypto, network, animation controller, pager math).
- Inlining hurts scanability (long chain, non-obvious subexpression).
---
## 2. Merge small screen helpers into the main file
Screen-local helpers that exist only to serve one screen should live in that screen's file, not a separate file.
Merge into the parent screen file when **all** are true:
- Used only by that screen (or its direct private helpers in the same file).
- Not shared across features or modules.
- The combined file stays readable after the merge.
```kotlin
// ❌ BAD — ProfileActionButtonRow.kt used only from ProfileScreen.kt
// ✅ GOOD — private composables at the bottom of ProfileScreen.kt
```
Extract to a separate file only when shared by **two or more** screens/features, or when the screen file would become unwieldy even after inlining.
---
## 3. Kotlin idioms
Prefer standard library helpers over verbose Java-style patterns.
```kotlin
// ❌ BAD
try {
cache.evict(key)
} catch (_: Exception) {
}
// ✅ GOOD
runCatching { cache.evict(key) }
```
```kotlin
// ❌ BAD
val items = mutableListOf<Item>()
items.add(header)
for (row in rows) items.add(row)
items.add(footer)
// ✅ GOOD
val items = buildList {
add(header)
addAll(rows)
add(footer)
}
```
Use `buildList`, `buildMap`, `buildSet`, `apply`, `also`, `takeIf`, `takeUnless`, scoped functions, and expression bodies where they match surrounding code.
---
## 4. Packages for related files
When several files belong to one feature, group them in a **package directory** instead of scattering at the parent level.
```
// ❌ BAD
ui/profile/ProfileScreen.kt
ui/profile/ProfileRoutes.kt
ui/profile/ProfileBioMarkdown.kt
ui/profile/EditProfileScreen.kt // edit is a sub-flow
// ✅ GOOD
ui/profile/ProfileScreen.kt
ui/profile/ProfileRoutes.kt
ui/profile/bio/ProfileBioMarkdown.kt
ui/profile/edit/EditProfileScreen.kt
```
Rules:
- One primary type per file; file name matches the primary type.
- Sub-packages for sub-features (e.g. `edit`, `bio`, `panels/dm`).
- Do not create a package for a single tiny file that only exists to be merged per §2.
---
## 5. Reuse project abstractions
Prefer existing project components and utilities over new wrappers:
- `com.pr0gramm3r101.utils` — clipboard, `Modifier.conditional`, etc.
- `com.pr0gramm3r101.components``Category`, `ListItem`, etc.
- `ru.fromchat.ui.components` — shared UI primitives.
- `apiRequest` / existing API client patterns.
Match naming, imports, and structure of adjacent files in the same package.
---
## 6. User-visible strings
No hardcoded user-visible copy in shared UI. Use Compose Multiplatform resources:
- `app/shared/src/commonMain/composeResources/values/strings.xml`
- `app/shared/src/commonMain/composeResources/values-ru/strings.xml`
Exception: debug API screen (`ru.fromchat.ui.debug`).
---
## 7. Compose layout and formatting
### Blank lines between composables
Separate **every** `@Composable` in a file with **one** blank line — top-level and `private`.
```kotlin
@Composable
fun Header() { ... }
@Composable
fun Body() { ... }
```
### File size
No hard line limit. Merge or split based on readability.
### Visibility
Screen-local composables merged into a screen file are `private`.
### Layout / dimension constants
Do **not** introduce named constants for bare `.dp` values — use literals inline.
```kotlin
// ❌ BAD
private val CardPadding = 16.dp
Box(modifier = Modifier.padding(CardPadding))
// ✅ GOOD
Box(modifier = Modifier.padding(16.dp))
```
For non-trivial layout values (ratios, spring specs, derived calculations), use top-level `private const` or `private val` in the same file.
---
## 8. Function bodies
- If a function contains **only** a `return` statement, always use an expression body (`=`).
- If the logic is a progressive data transform chainable with `let` / `apply` / `also` / `run`, prefer an expression body.
- Otherwise use a block body.
```kotlin
// ✅ GOOD — single return
private fun label(user: User) = user.visibleUsername ?: stringResource(Res.string.user_fallback)
// ✅ GOOD — chain
private fun normalized(input: String) = input.trim().takeIf { it.isNotEmpty() }?.lowercase().orEmpty()
```
---
## 9. General principles
- Do not strip or rewrite data by comparing to hard-coded UI placeholder strings.
- Do not introduce abstractions used only once (same rule as §1).
- When a convention is ambiguous, match neighboring files in the same package.
+5
View File
@@ -150,4 +150,9 @@ dependencies {
implementation(project(":app:shared"))
implementation(project(":utils:shared"))
testImplementation("junit:junit:4.13.2")
testImplementation(libs.androidx.compose.material3)
testImplementation("androidx.graphics:graphics-shapes:1.0.1")
testImplementation("org.robolectric:robolectric:4.14.1")
}
+3 -2
View File
@@ -19,9 +19,9 @@ kotlin {
}
listOf(
iosX64(),
iosArm64(),
iosSimulatorArm64()
iosSimulatorArm64(),
iosX64(),
).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "ComposeApp"
@@ -84,6 +84,7 @@ kotlin {
}
androidMain.dependencies {
implementation(libs.markdown.renderer.m3)
implementation(libs.bouncycastle.bcprov)
implementation(libs.androidx.exifinterface)
implementation(libs.ktor.client.okhttp)
@@ -10,3 +10,12 @@ actual suspend fun wipeFromChatCacheDirectory() {
File(UtilsLibrary.context.cacheDir, "fromchat").deleteRecursively()
}
}
actual suspend fun wipeAttachmentCacheDirectories() {
withContext(Dispatchers.IO) {
val cacheDir = UtilsLibrary.context.cacheDir
listOf("decrypted_images", "decrypted_files", "encrypted_downloads").forEach { name ->
File(cacheDir, name).deleteRecursively()
}
}
}
@@ -54,7 +54,7 @@ class OutboxSendWorker(
} finally {
progressJob?.cancel()
}
Result.success()
if (allOk) Result.success() else Result.retry()
}
companion object {
@@ -0,0 +1,18 @@
package ru.fromchat.legal
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
@Composable
actual fun Markdown(
content: String,
modifier: Modifier,
) {
val uriHandler = LocalUriHandler.current
MarkdownPlain(
content = content,
modifier = modifier,
onLinkClick = { uriHandler.openUri(it) },
)
}
@@ -49,6 +49,7 @@ actual fun rememberHapticFeedbackInternal(): (Int) -> Unit {
HapticFeedbackEvent.ProfileClosed.ordinal -> HapticFeedbackConstants.CLOCK_TICK
HapticFeedbackEvent.MessageSent.ordinal -> HapticFeedbackConstants.CONFIRM
HapticFeedbackEvent.ContextMenuOpened.ordinal -> HapticFeedbackConstants.CONFIRM
HapticFeedbackEvent.SelectionModeEntered.ordinal -> HapticFeedbackConstants.CONFIRM
else -> HapticFeedbackConstants.CLOCK_TICK
}
view.performHapticFeedback(constant)
@@ -9,6 +9,14 @@
<string name="about_link_telegram">Telegram</string>
<string name="about_link_max">MAX</string>
<string name="about_link_website">Сайт</string>
<string name="about_link_privacy">Политика конфиденциальности</string>
<string name="about_link_terms">Пользовательское соглашение</string>
<string name="legal_privacy_title">Политика конфиденциальности</string>
<string name="legal_terms_title">Пользовательское соглашение</string>
<string name="legal_document_cached_banner">Показана сохранённая копия документа. Содержимое может быть устаревшим.</string>
<string name="legal_document_load_error">Не удалось загрузить документ. Проверьте подключение к интернету и попробуйте снова.</string>
<string name="auth_legal_notice_prefix">Регистрируясь, вы соглашаетесь с </string>
<string name="auth_legal_notice_and"> и </string>
<string name="app_desc">100% бесплатный и открытый мессенджер. Поддерживает self-hosted установку на своём сервере.</string>
<string name="welcome">Добро пожаловать!</string>
<string name="login">Войти</string>
@@ -52,6 +60,17 @@
<string name="contacts_empty_body">Список контактов появится здесь, когда функция будет готова.</string>
<string name="public_chat">Общий чат</string>
<string name="chat_last_mesaage">Вы: последнее сообщение</string>
<string name="chat_preview_attachment">Вложение</string>
<string name="action_mark_read">Прочитано</string>
<string name="action_select">Выбрать</string>
<string name="action_archive">В архив</string>
<string name="chats_selected_count">Выбрано чатов: %1$d</string>
<string name="chat_delete_confirm_title">Удалить чаты?</string>
<string name="chat_delete_confirm_body">Сообщения в выбранных чатах будут удалены с устройства и сервера.</string>
<string name="chat_delete_partial_failure">Не удалось удалить чатов: %1$d</string>
<string name="cd_close_selection">Закрыть выбор</string>
<string name="cd_chat_selected">Выбрано</string>
<string name="cd_selection_more">Ещё действия</string>
<string name="search_hint">Найдите пользователя, имя или чат</string>
<string name="search_title">Поиск</string>
<string name="search_not_found">Ничего не найдено</string>
@@ -105,6 +124,9 @@
<string name="action_delete">Удалить</string>
<string name="action_copy">Копировать</string>
<string name="action_cancel_send">Отменить</string>
<string name="action_retry_send">Повторить</string>
<string name="message_send_failed">Не удалось отправить</string>
<string name="cd_message_send_failed">Сообщение не отправлено</string>
<string name="notif_media_upload_percent">%1$d\u0025</string>
<string name="notif_media_upload_progress">%1$s · %2$s</string>
<string name="notif_file_copy_channel_name">Сохранение файла</string>
@@ -123,12 +145,24 @@
<string name="cd_send">Отправить</string>
<string name="cd_emoji">Эмодзи</string>
<string name="profile_title">Профиль</string>
<string name="profile_edit_title">Изменить</string>
<string name="profile_edit_saved">Профиль обновлён</string>
<string name="profile_bio_length_error">Не более %1$d символов</string>
<string name="profile_registration_date">%1$d %2$s %3$d</string>
<string name="profile_load_failed">Не получилось загрузить профиль</string>
<string name="profile_not_found">Профиль не найден</string>
<string name="profile_open_failed">Не удалось открыть профиль. Попробуйте снова.</string>
<string name="action_open_settings">Настройки</string>
<string name="action_chat">Написать</string>
<string name="action_copy_link">Скопировать ссылку</string>
<string name="profile_action_chat">Написать</string>
<string name="profile_action_link">Ссылка</string>
<string name="profile_action_settings">Настройки</string>
<string name="profile_action_call">Позвонить</string>
<string name="profile_action_video">Видео</string>
<string name="profile_action_contact_info">Контактная информация</string>
<string name="profile_action_search">Поиск</string>
<string name="feature_not_implemented">Пока не реализовано</string>
<string name="link_copied">Ссылка скопирована</string>
<string name="profile_details_category">О человеке</string>
<string name="profile_headline_username">Имя пользователя</string>
@@ -167,6 +201,18 @@
<string name="month_oct">окт</string>
<string name="month_nov">ноя</string>
<string name="month_dec">дек</string>
<string name="month_name_jan">января</string>
<string name="month_name_feb">февраля</string>
<string name="month_name_mar">марта</string>
<string name="month_name_apr">апреля</string>
<string name="month_name_may">мая</string>
<string name="month_name_jun">июня</string>
<string name="month_name_jul">июля</string>
<string name="month_name_aug">августа</string>
<string name="month_name_sep">сентября</string>
<string name="month_name_oct">октября</string>
<string name="month_name_nov">ноября</string>
<string name="month_name_dec">декабря</string>
<string name="server_config_title">Настройка сервера</string>
<string name="server_config_subtitle">Подключение к альтернативному серверу FromChat. Это дает больше приватности и контроля над данными.</string>
<string name="server_ip_label">IP или имя сервера</string>
@@ -12,6 +12,14 @@
<string name="about_link_telegram">Telegram</string>
<string name="about_link_max">MAX</string>
<string name="about_link_website">Website</string>
<string name="about_link_privacy">Privacy policy</string>
<string name="about_link_terms">Terms of service</string>
<string name="legal_privacy_title">Privacy policy</string>
<string name="legal_terms_title">Terms of service</string>
<string name="legal_document_cached_banner">Showing a saved copy. Content may be out of date.</string>
<string name="legal_document_load_error">Couldn\'t load the document. Check your connection and try again.</string>
<string name="auth_legal_notice_prefix">By creating an account you agree to the </string>
<string name="auth_legal_notice_and"> and </string>
<string name="app_desc">100% free and open messenger. Supports self-hosted installation on your own server.</string>
<!-- Authentication -->
@@ -60,6 +68,17 @@
<string name="contacts_empty_body">Your contacts will appear here when this feature is ready.</string>
<string name="public_chat">Main chat</string>
<string name="chat_last_mesaage">You: last message</string>
<string name="chat_preview_attachment">Attachment</string>
<string name="action_mark_read">Mark as read</string>
<string name="action_select">Select</string>
<string name="action_archive">Archive</string>
<string name="chats_selected_count">%1$d chats selected</string>
<string name="chat_delete_confirm_title">Delete chats?</string>
<string name="chat_delete_confirm_body">Messages in selected chats will be deleted from this device and the server.</string>
<string name="chat_delete_partial_failure">Could not delete %1$d chat(s)</string>
<string name="cd_close_selection">Close selection</string>
<string name="cd_chat_selected">Selected</string>
<string name="cd_selection_more">More actions</string>
<string name="search_hint">Search by name, username or chat</string>
<string name="search_title">Search</string>
<string name="search_not_found">No results</string>
@@ -119,6 +138,9 @@
<string name="action_delete">Delete</string>
<string name="action_copy">Copy</string>
<string name="action_cancel_send">Cancel</string>
<string name="action_retry_send">Retry</string>
<string name="message_send_failed">Couldn\'t send</string>
<string name="cd_message_send_failed">Message failed to send</string>
<string name="action_save">Save</string>
<string name="notif_media_upload_percent">%1$d\u0025</string>
<string name="notif_media_upload_progress">%1$s · %2$s</string>
@@ -141,12 +163,24 @@
<!-- Profile -->
<string name="profile_title">Profile</string>
<string name="profile_edit_title">Edit</string>
<string name="profile_edit_saved">Profile updated</string>
<string name="profile_bio_length_error">Up to %1$d characters</string>
<string name="profile_registration_date">%1$d %2$s %3$d</string>
<string name="profile_load_failed">Couldnt load this profile</string>
<string name="profile_not_found">This profile could not be found</string>
<string name="profile_open_failed">Could not open this profile. Please try again.</string>
<string name="action_open_settings">Settings</string>
<string name="action_chat">Chat</string>
<string name="action_copy_link">Copy link</string>
<string name="profile_action_chat">Chat</string>
<string name="profile_action_link">Link</string>
<string name="profile_action_settings">Settings</string>
<string name="profile_action_call">Call</string>
<string name="profile_action_video">Video</string>
<string name="profile_action_contact_info">Contact info</string>
<string name="profile_action_search">Search</string>
<string name="feature_not_implemented">Not implemented yet</string>
<string name="link_copied">Link copied</string>
<string name="profile_details_category">About this person</string>
<string name="profile_headline_username">Username</string>
@@ -191,6 +225,18 @@
<string name="month_oct">Oct</string>
<string name="month_nov">Nov</string>
<string name="month_dec">Dec</string>
<string name="month_name_jan">january</string>
<string name="month_name_feb">february</string>
<string name="month_name_mar">march</string>
<string name="month_name_apr">april</string>
<string name="month_name_may">may</string>
<string name="month_name_jun">june</string>
<string name="month_name_jul">july</string>
<string name="month_name_aug">august</string>
<string name="month_name_sep">september</string>
<string name="month_name_oct">october</string>
<string name="month_name_nov">november</string>
<string name="month_name_dec">december</string>
<!-- Server Configuration -->
<string name="server_config_title">Connect to a server</string>
@@ -8,6 +8,8 @@ import com.pr0gramm3r101.utils.settings.settings
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.ClientRequestException
import io.ktor.client.request.forms.formData
import io.ktor.client.request.forms.submitFormWithBinaryData
import io.ktor.client.plugins.HttpResponseValidator
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.defaultRequest
@@ -29,7 +31,6 @@ import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.http.encodedPath
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.flow.MutableStateFlow
@@ -46,18 +47,21 @@ import ru.fromchat.api.crypto.transport.TransportCrypto
import ru.fromchat.api.instance.InstanceIdGuard
import ru.fromchat.api.instance.InstanceIdResolveResult
import ru.fromchat.api.instance.resolveInstanceId
import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.send.scheduleOutboxProcessing
import ru.fromchat.api.local.WebSocketManager
import ru.fromchat.api.local.db.store.MessageRepository
import ru.fromchat.api.local.send.cancelOutboxProcessing
import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.cache.readOutboundFileBytes
import ru.fromchat.api.local.db.store.InstanceRegistryStore
import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.local.db.store.PublicChatProfileCache
import ru.fromchat.api.local.download.streamEncryptedFileToDisk
import ru.fromchat.api.local.send.scheduleOutboxProcessing
import ru.fromchat.api.schema.calls.CallSignalingLiveKitControl
import ru.fromchat.api.schema.calls.CallSignalingLiveKitPayload
import ru.fromchat.api.schema.calls.LiveKitTokenRequest
import ru.fromchat.api.schema.calls.LiveKitTokenResponse
import ru.fromchat.api.schema.core.SimpleStatusResponse
import ru.fromchat.api.schema.messages.MarkReadRequest
import ru.fromchat.api.schema.messages.MessagesResponse
import ru.fromchat.api.schema.messages.dm.DmConversation
import ru.fromchat.api.schema.messages.dm.DmConversationsResponse
@@ -71,15 +75,17 @@ import ru.fromchat.api.schema.messages.dm.upload.DmUploadCompleteResponse
import ru.fromchat.api.schema.messages.dm.upload.DmUploadInitRequest
import ru.fromchat.api.schema.messages.dm.upload.DmUploadInitResponse
import ru.fromchat.api.schema.messages.dm.upload.DmUploadStatusResponse
import ru.fromchat.api.schema.chats.publicchat.PublicChatProfile
import ru.fromchat.api.schema.messages.publicchat.SendMessageRequest
import ru.fromchat.api.schema.server.RegisteredUserCountResponse
import ru.fromchat.api.schema.server.ServerInstanceIdResponse
import ru.fromchat.api.schema.server.TransportKeyResponse
import ru.fromchat.api.schema.user.ChangePasswordApiRequest
import ru.fromchat.api.schema.user.DeleteAccountRequest
import ru.fromchat.api.schema.user.VerifyPasswordRequest
import ru.fromchat.api.schema.user.FcmTokenRequest
import ru.fromchat.api.schema.user.User
import ru.fromchat.api.schema.user.UsersSearchResponse
import ru.fromchat.api.schema.user.VerifyPasswordRequest
import ru.fromchat.api.schema.user.auth.CheckAuthResponse
import ru.fromchat.api.schema.user.auth.CheckUsernameResponse
import ru.fromchat.api.schema.user.auth.LoginRequest
@@ -91,6 +97,8 @@ import ru.fromchat.api.schema.user.keys.BackupBlobRequest
import ru.fromchat.api.schema.user.keys.BackupBlobResponse
import ru.fromchat.api.schema.user.keys.PublicKeyResponse
import ru.fromchat.api.schema.user.profile.SimilarityResult
import ru.fromchat.api.schema.user.profile.UpdateProfileRequest
import ru.fromchat.api.schema.user.profile.UpdateProfileResponse
import ru.fromchat.api.schema.user.profile.UserProfile
import ru.fromchat.api.schema.user.profile.VerifyResponse
import ru.fromchat.api.schema.websocket.WebSocketCredentials
@@ -296,13 +304,11 @@ object ApiClient {
suspend fun probeHttpGet(url: String): Boolean =
runCatching {
httpProbe.get(url.trim())
true
}.getOrDefault(false)
}.isSuccess
suspend fun checkAuthAt(apiBaseUrl: String, bearer: String): Boolean =
runCatching {
val base = apiBaseUrl.trimEnd('/')
httpProbe.get("$base/check_auth") {
httpProbe.get("${apiBaseUrl.trimEnd('/')}/check_auth") {
bearerAuth(bearer)
}.body<CheckAuthResponse>().authenticated
}.getOrDefault(false)
@@ -455,6 +461,11 @@ object ApiClient {
}
}
suspend fun persistCurrentUser() {
val currentUser = user ?: return
settings.putString("user_info", json.encodeToString(currentUser))
}
// --- User & profile ---
suspend fun getMessages(limit: Int = 50, beforeId: Int? = null) =
@@ -466,6 +477,21 @@ object ApiClient {
}
.body<MessagesResponse>()
suspend fun getNewMessages(): MessagesResponse =
http
.get("${ServerConfig.apiBaseUrl}/messages/new") {
contentType(ContentType.Application.Json)
}
.body()
suspend fun markMessagesRead(messageIds: List<Int>) {
if (messageIds.isEmpty()) return
http.post("${ServerConfig.apiBaseUrl}/messages/read") {
contentType(ContentType.Application.Json)
setBody(MarkReadRequest(messageIds = messageIds))
}
}
suspend fun getOwnProfile(): UserProfile =
http
.get("${ServerConfig.apiBaseUrl}/user/profile") {
@@ -487,6 +513,54 @@ object ApiClient {
}
.body()
suspend fun updateProfile(
username: String? = null,
displayName: String? = null,
bio: String? = null,
): UpdateProfileResponse {
val response = http
.put("${ServerConfig.apiBaseUrl}/user/profile") {
contentType(ContentType.Application.Json)
setBody(
UpdateProfileRequest(
username = username,
displayName = displayName,
description = bio,
)
)
}
.body<UpdateProfileResponse>()
val currentUser = user
if (currentUser != null) {
user = currentUser.copy(
username = response.username,
displayName = response.displayName,
bio = response.bio,
)
persistCurrentUser()
}
ProfileCache.get(currentUser?.id ?: 0)?.let { cached ->
ProfileCache.put(
cached.copy(
username = response.username,
displayName = response.displayName,
bio = response.bio,
)
)
}
return response
}
suspend fun getPublicChatProfile(): PublicChatProfile =
http
.get("${ServerConfig.apiBaseUrl}/public-chat/profile") {
contentType(ContentType.Application.Json)
}
.body()
suspend fun getRegisteredUserCount(): Int =
http
.get("${ServerConfig.apiBaseUrl}/user/stats/registered-count") {
@@ -521,6 +595,18 @@ object ApiClient {
.body<DmConversationsResponse>()
.conversations
suspend fun searchUsers(query: String): List<User> {
val trimmed = query.trim()
if (trimmed.length < 2) return emptyList()
return http
.get("${ServerConfig.apiBaseUrl}/users/search") {
contentType(ContentType.Application.Json)
parameter("q", trimmed)
}
.body<UsersSearchResponse>()
.users
}
suspend fun getDmFetch(since: Int? = null): DmHistoryResponse {
return http
.get("${ServerConfig.apiBaseUrl}/dm/fetch") {
@@ -1263,6 +1349,11 @@ object ApiClient {
* or together with [logout] after remote logout).
*/
suspend fun clearLocalSession() {
val instanceId = runCatching { CacheContext.activeInstanceId.value.trim() }.getOrDefault("")
if (instanceId.isNotEmpty()) {
runCatching { cancelOutboxProcessing(instanceId) }
runCatching { MessageRepository.purgeAllPendingForInstance() }
}
val uid = user?.id
secureSettings.remove("auth_token")
settings.remove("user_info")
@@ -1277,7 +1368,9 @@ object ApiClient {
runCatching { IdentityKeyManager.clearLocalKeys() }
runCatching { ProfileCache.clear() }
runCatching { DmPanelCache.clearAll() }
runCatching { PublicChatProfileCache.clear() }
runCatching { PublicChatPanelCache.clear() }
runCatching { CacheContext.clearActive() }
}
suspend fun logout() {
@@ -1290,10 +1383,15 @@ object ApiClient {
suspend fun sendMessageViaHttp(content: String, replyToId: Int? = null) {
if (_suspensionState.value.isSuspended) return
http.post("${ServerConfig.apiBaseUrl}/send_message") {
contentType(ContentType.Application.Json)
setBody(SendMessageRequest(content = content, reply_to_id = replyToId))
}
val payloadJson = json.encodeToString(
SendMessageRequest(content = content.trim(), reply_to_id = replyToId),
)
http.submitFormWithBinaryData(
url = "${ServerConfig.apiBaseUrl}/send_message",
formData = formData {
append("payload", payloadJson)
},
)
}
// WebSocket send helpers
@@ -7,6 +7,7 @@ import ru.fromchat.api.local.db.store.InstanceRegistryStore
import ru.fromchat.config.ServerConfigData
import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.config.ServerConfig
import ru.fromchat.legal.DocumentRepository
import kotlin.time.TimeSource
sealed interface ServerProbeResult {
@@ -64,6 +65,7 @@ suspend fun applyServerConfig(
) {
val tentative = config.copy(callsEnabled = callsOk)
ServerConfig.updateServerConfig(tentative)
DocumentRepository.invalidate()
val userId = ApiClient.user?.id
InstanceRegistryStore.rebindServerInstance(tentative, instanceId)
CacheContext.setActiveInstance(instanceId, userId)
@@ -8,6 +8,7 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.db.store.InstanceRegistryStore
import ru.fromchat.api.local.db.store.MessageRepository
import ru.fromchat.api.local.download.AttachmentDownloadNotifier
import ru.fromchat.api.local.send.scheduleOutboxProcessing
import ru.fromchat.config.Settings
@@ -35,6 +36,11 @@ private fun activateInstance(instanceId: String) {
CacheContext.setActiveInstance(instanceId, ApiClient.user?.id)
scheduleOutboxProcessing(instanceId)
scheduleAttachmentResumeAfterSession()
ApiClient.user?.id?.let { userId ->
bootstrapScope.launch {
runCatching { MessageRepository.purgePendingNotFromUser(userId) }
}
}
}
/**
@@ -162,6 +162,19 @@ object DecryptedFileCache {
}
}
suspend fun invalidateForMessage(messageId: Int) {
if (messageId <= 0) return
val dir = ensureCacheDir() ?: return
withContext(Dispatchers.Default) {
cacheMutex.withLock {
memoryCache.keys.removeAll { it.startsWith("file_${messageId}_") }
}
runCatching {
PlatformFileSystem.deleteFilesWithPrefix(dir, "file_${messageId}_")
}
}
}
suspend fun getOrDecrypt(
messageId: Int,
fileIndex: Int,
@@ -1,4 +1,15 @@
package ru.fromchat.api.local.cache
import ru.fromchat.api.local.download.LocalDecodedImageCache
/** Deletes the `cacheDir/fromchat/` tree (blobs, DB file on disk). Call after [ru.fromchat.api.db.MessageRepository.clearAllCache]. */
expect suspend fun wipeFromChatCacheDirectory()
/** Removes decrypted attachment blobs and partial download state outside `fromchat/`. */
expect suspend fun wipeAttachmentCacheDirectories()
suspend fun wipeAllOnDiskAttachmentCaches() {
wipeFromChatCacheDirectory()
wipeAttachmentCacheDirectories()
LocalDecodedImageCache.evictPrefix("img_")
}
@@ -4,10 +4,12 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ru.fromchat.api.local.db.store.MessageDatabaseProvider
import ru.fromchat.api.local.send.cancelOutboxProcessing
import ru.fromchat.api.local.db.store.PublicChatProfileCache
import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.cache.wipeAttachmentCacheDirectories
import ru.fromchat.api.local.cache.wipeFromChatCacheDirectory
import ru.fromchat.ui.chat.utils.PublicChatPanelCache
import ru.fromchat.ui.chat.panels.dm.DmPanelCache
import ru.fromchat.ui.chat.utils.PublicChatPanelCache
/**
* Drops the on-disk FromChat cache tree and reopens SQLite on next access.
@@ -22,6 +24,8 @@ suspend fun wipeLocalCacheOnDisk() {
MessageDatabaseProvider.closeAndReset()
}
wipeFromChatCacheDirectory()
wipeAttachmentCacheDirectories()
PublicChatProfileCache.clear()
PublicChatPanelCache.clear()
DmPanelCache.clearAll()
}
@@ -40,6 +40,7 @@ internal object MessageDatabaseExpectedSchema {
lastMessagePreview TEXT,
unreadCount INTEGER NOT NULL DEFAULT 0,
updatedAt TEXT,
archived INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (instanceId, id)
)
""".trimIndent(),
@@ -122,6 +123,7 @@ internal object MessageDatabaseExpectedSchema {
"lastMessagePreview" to "TEXT",
"unreadCount" to "INTEGER NOT NULL DEFAULT 0",
"updatedAt" to "TEXT",
"archived" to "INTEGER NOT NULL DEFAULT 0",
),
"message" to mapOf(
"instanceId" to "TEXT NOT NULL",
@@ -0,0 +1,14 @@
package ru.fromchat.api.local.db.store
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
object DmConversationListNotifier {
private val _events = MutableSharedFlow<Unit>(extraBufferCapacity = 8)
val events: SharedFlow<Unit> = _events.asSharedFlow()
fun notifyChanged() {
_events.tryEmit(Unit)
}
}
@@ -6,6 +6,7 @@ import ru.fromchat.api.local.send.cancelOutboxProcessing
import ru.fromchat.config.ServerConfigData
import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.instance.configKey
import ru.fromchat.api.local.db.store.PublicChatProfileCache
import ru.fromchat.ui.chat.utils.PublicChatPanelCache
import ru.fromchat.ui.chat.panels.dm.DmPanelCache
import kotlin.time.Clock
@@ -68,6 +69,7 @@ object InstanceRegistryStore {
}
val userId = CacheContext.activeUserId.value
CacheContext.setActiveInstance(newId, userId)
PublicChatProfileCache.clear()
PublicChatPanelCache.clear()
DmPanelCache.clearAll()
}
@@ -19,13 +19,17 @@ import ru.fromchat.api.local.db.parseDmMessageContent
import ru.fromchat.api.local.db.resolveLocalPreviewUri
import ru.fromchat.api.local.messages.sortMessagesForChatDisplay
import ru.fromchat.api.local.send.DmAttachmentOutboxPayload
import ru.fromchat.api.local.send.SEND_ERROR_FAILED
import ru.fromchat.api.local.send.OutgoingMessageCoordinator
import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.schema.messages.dm.DmConversation
import ru.fromchat.api.schema.messages.dm.DmEnvelope
import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.cache.CacheValidator
import ru.fromchat.db.Conversation
import ru.fromchat.db.MessageDatabase
import ru.fromchat.api.crypto.decryptEnvelope
import ru.fromchat.api.local.cache.DecryptedFileCache
import ru.fromchat.api.local.cache.DecryptedImageCache
import ru.fromchat.api.local.download.DownloadedFileRegistry
import ru.fromchat.ui.chat.utils.dedupeMessagesByClientId
@@ -118,6 +122,7 @@ object MessageCacheStore {
purgeSupersededPendingRows(iid, convId, before, merged)
}
replaceMessages(convId, merged)
pruneEmptyConversations()
}
private suspend fun filterStillPendingForReplace(
@@ -156,6 +161,34 @@ object MessageCacheStore {
upsertSingle(conversationIdForPublic(), message)
}
suspend fun markSendFailed(conversationId: String, clientMessageId: String) {
val cid = clientMessageId.trim()
if (cid.isEmpty()) return
val iid = instanceId()
withContext(Dispatchers.Default) {
db.messageDatabaseQueries.updateMessageSendStatusByClientMessageId(
sendStatus = "failed",
instanceId = iid,
conversationId = conversationId,
clientMessageId = cid,
)
}
}
suspend fun clearSendFailed(conversationId: String, clientMessageId: String) {
val cid = clientMessageId.trim()
if (cid.isEmpty()) return
val iid = instanceId()
withContext(Dispatchers.Default) {
db.messageDatabaseQueries.updateMessageSendStatusByClientMessageId(
sendStatus = "pending",
instanceId = iid,
conversationId = conversationId,
clientMessageId = cid,
)
}
}
suspend fun upsertDmMessage(otherUserId: Int, message: Message) {
upsertSingle(conversationIdForDm(otherUserId), message)
syncDmConversationPreviewFromCache(otherUserId)
@@ -228,44 +261,257 @@ object MessageCacheStore {
}
}
suspend fun replaceDmConversations(conversations: List<DmConversation>) {
suspend fun replaceDmConversations(
conversations: List<DmConversation>,
attachmentOnlyPreview: String,
) {
val iid = instanceId()
val currentUserId = ApiClient.user?.id
withContext(Dispatchers.Default) {
val upserts = conversations.map { conv ->
val conversationId = conversationIdForDm(conv.user.id)
val displayLabel = conv.user.displayName?.trim()?.takeIf { it.isNotEmpty() }
?: conv.user.username.trim()
UpsertDmConversationRow(
conversationId = conversationId,
otherUserId = conv.user.id,
displayName = displayLabel,
lastMessageId = conv.lastMessage.id,
lastMessagePreview = buildDmListPreview(
conv.lastMessage,
currentUserId,
attachmentOnlyPreview,
),
unreadCount = conv.unreadCount,
updatedAt = conv.lastMessage.timestamp,
)
}
db.messageDatabaseQueries.transaction {
upserts.forEach { row ->
val archived = db.messageDatabaseQueries
.selectConversationById(iid, row.conversationId)
.executeAsOneOrNull()
?.archived ?: 0L
db.messageDatabaseQueries.upsertConversation(
instanceId = iid,
id = row.conversationId,
type = "dm",
otherUserId = row.otherUserId.toLong(),
displayName = row.displayName,
lastMessageId = row.lastMessageId.toLong(),
lastMessagePreview = row.lastMessagePreview,
unreadCount = row.unreadCount.toLong(),
updatedAt = row.updatedAt,
archived = archived,
)
}
reconcileDmConversationsLocked(iid, upserts.map { it.otherUserId }.toSet())
pruneEmptyConversationsLocked(iid)
}
}
}
private data class UpsertDmConversationRow(
val conversationId: String,
val otherUserId: Int,
val displayName: String,
val lastMessageId: Int,
val lastMessagePreview: String?,
val unreadCount: Int,
val updatedAt: String,
)
private suspend fun buildDmListPreview(
envelope: DmEnvelope,
currentUserId: Int?,
attachmentOnlyPreview: String,
): String? {
val hasFiles = !envelope.files.isNullOrEmpty()
val decrypted = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull()
?.trim()
?.takeIf { it.isNotEmpty() }
val previewSource = when {
decrypted != null -> decrypted
hasFiles -> attachmentOnlyPreview
else -> null
}
return previewSource?.let { truncateDmListPreview(it) }?.takeIf { it.isNotEmpty() }
}
private fun reconcileDmConversationsLocked(instanceId: String, serverOtherUserIds: Set<Int>) {
val localDm = db.messageDatabaseQueries
.selectConversationsForInstance(instanceId)
.executeAsList()
.filter { it.type == "dm" }
localDm.forEach { row ->
val otherId = row.otherUserId?.toInt() ?: return@forEach
if (otherId !in serverOtherUserIds) {
db.messageDatabaseQueries.deleteConversationById(instanceId, row.id)
}
}
}
suspend fun pruneEmptyConversations() {
val iid = instanceId()
withContext(Dispatchers.Default) {
db.messageDatabaseQueries.transaction {
conversations.forEach { conv ->
val conversationId = conversationIdForDm(conv.user.id)
val displayLabel = conv.user.displayName?.trim()?.takeIf { it.isNotEmpty() }
?: conv.user.username.trim()
val recent = db.messageDatabaseQueries
.selectRecentMessagesByConversation(iid, conversationId, 1)
.executeAsList()
.firstOrNull()
val rawPreview = recent?.content.orEmpty().trim()
val preview = rawPreview.takeIf { it.isNotEmpty() }
?.let { truncateDmListPreview(it) }
?.takeIf { it.isNotEmpty() }
db.messageDatabaseQueries.upsertConversation(
pruneEmptyConversationsLocked(iid)
}
}
}
private fun pruneEmptyConversationsLocked(instanceId: String) {
db.messageDatabaseQueries.deleteEmptyDmConversations(instanceId, instanceId)
}
suspend fun ensureDmConversationRow(otherUserId: Int, displayName: String? = null) {
val iid = instanceId()
val convId = conversationIdForDm(otherUserId)
withContext(Dispatchers.Default) {
val existing = db.messageDatabaseQueries
.selectConversationById(iid, convId)
.executeAsOneOrNull()
if (existing != null) return@withContext
val label = displayName?.trim()?.takeIf { it.isNotEmpty() }
?: ProfileCache.get(otherUserId)?.displayName?.trim()?.takeIf { it.isNotEmpty() }
?: ProfileCache.get(otherUserId)?.username?.trim()?.takeIf { it.isNotEmpty() }
?: ""
db.messageDatabaseQueries.upsertConversation(
instanceId = iid,
id = convId,
type = "dm",
otherUserId = otherUserId.toLong(),
displayName = label,
lastMessageId = null,
lastMessagePreview = null,
unreadCount = 0L,
updatedAt = null,
archived = 0L,
)
}
}
suspend fun markDmConversationRead(otherUserId: Int) {
val iid = instanceId()
val convId = conversationIdForDm(otherUserId)
withContext(Dispatchers.Default) {
db.messageDatabaseQueries.updateConversationUnreadCount(
unreadCount = 0L,
instanceId = iid,
id = convId,
)
}
}
suspend fun selectUnreadPublicMessageIds(): List<Int> {
val iid = instanceId()
val convId = conversationIdForPublic()
return withContext(Dispatchers.Default) {
db.messageDatabaseQueries
.selectUnreadPublicMessageIds(iid, convId)
.executeAsList()
.map { it.toInt() }
}
}
suspend fun markPublicMessagesReadLocally() {
val iid = instanceId()
val convId = conversationIdForPublic()
withContext(Dispatchers.Default) {
db.messageDatabaseQueries.markPublicMessagesRead(iid, convId)
}
}
suspend fun archiveDmConversation(otherUserId: Int) {
val iid = instanceId()
val convId = conversationIdForDm(otherUserId)
withContext(Dispatchers.Default) {
db.messageDatabaseQueries.updateConversationArchived(
archived = 1L,
instanceId = iid,
id = convId,
)
}
}
suspend fun deleteDmConversation(otherUserId: Int) {
val iid = instanceId()
val convId = conversationIdForDm(otherUserId)
withContext(Dispatchers.Default) {
val messages = db.messageDatabaseQueries
.selectMessagesByConversation(iid, convId)
.executeAsList()
messages.forEach { row ->
val msgId = row.id.toInt()
DecryptedImageCache.invalidateForMessage(msgId)
DecryptedFileCache.invalidateForMessage(msgId)
row.clientMessageId?.trim()?.takeIf { it.isNotEmpty() }?.let { cid ->
DecryptedImageCache.invalidateForClientMessage(cid)
DecryptedFileCache.invalidateForClientMessage(cid)
}
}
db.messageDatabaseQueries.transaction {
db.messageDatabaseQueries.deleteMessagesForConversation(iid, convId)
val outbox = db.messageDatabaseQueries.selectPendingOutboxForInstance(iid).executeAsList()
outbox.filter { it.conversationId == convId }.forEach { row ->
db.messageDatabaseQueries.deleteOutboxItem(iid, row.clientMessageId)
}
db.messageDatabaseQueries.deleteConversationById(iid, convId)
}
}
}
suspend fun purgePendingNotFromUser(userId: Int) {
val iid = instanceId()
withContext(Dispatchers.Default) {
val foreign = db.messageDatabaseQueries
.selectForeignPendingMessages(iid, userId.toLong())
.executeAsList()
foreign.forEach { row ->
val cid = row.clientMessageId?.trim().orEmpty()
if (cid.isNotEmpty()) {
OutgoingMessageCoordinator.cancelOutboundMessage(cid, row.conversationId)
} else {
db.messageDatabaseQueries.deleteMessageById(
instanceId = iid,
id = conversationId,
type = "dm",
otherUserId = conv.user.id.toLong(),
displayName = displayLabel,
lastMessageId = conv.lastMessage.id.toLong(),
lastMessagePreview = preview,
unreadCount = conv.unreadCount.toLong(),
updatedAt = conv.lastMessage.timestamp
conversationId = row.conversationId,
id = row.id,
)
}
}
}
}
suspend fun purgeAllPendingForInstance() {
val iid = runCatching { instanceId() }.getOrNull()?.trim().orEmpty()
if (iid.isEmpty()) return
withContext(Dispatchers.Default) {
val pending = db.messageDatabaseQueries
.selectAllPendingMessagesForInstance(iid)
.executeAsList()
pending.forEach { row ->
val cid = row.clientMessageId?.trim().orEmpty()
if (cid.isNotEmpty()) {
runCatching {
OutgoingMessageCoordinator.cancelOutboundMessage(cid, row.conversationId)
}
}
}
db.messageDatabaseQueries.transaction {
db.messageDatabaseQueries.deleteAllPendingMessagesForInstance(iid)
val outbox = db.messageDatabaseQueries.selectPendingOutboxForInstance(iid).executeAsList()
outbox.forEach { row ->
db.messageDatabaseQueries.deleteOutboxItem(iid, row.clientMessageId)
}
}
}
}
suspend fun loadCachedDmConversations(): List<CachedConversation> =
withContext(Dispatchers.Default) {
db.messageDatabaseQueries
.selectConversationsForInstance(instanceId())
.selectActiveDmConversationsForInstance(instanceId())
.executeAsList()
.filter { row: Conversation -> row.type == "dm" }
.map { row: Conversation ->
CachedConversation(
id = row.id,
@@ -302,7 +548,8 @@ object MessageCacheStore {
lastMessageId = row.lastMessageId,
lastMessagePreview = preview,
unreadCount = row.unreadCount,
updatedAt = row.updatedAt
updatedAt = row.updatedAt,
archived = row.archived,
)
}
}
@@ -374,7 +621,10 @@ object MessageCacheStore {
)
}
}
dmOtherUserIdFromConversationId(conversationId)?.let { syncDmConversationPreviewFromCache(it) }
dmOtherUserIdFromConversationId(conversationId)?.let {
syncDmConversationPreviewFromCache(it)
pruneEmptyConversations()
}
}
private suspend fun loadMessages(conversationId: String): List<Message> {
@@ -558,6 +808,7 @@ object MessageCacheStore {
?: parsed.fileDimensions?.firstOrNull()?.let { (w, h) ->
aspectRatioFromDimensionPair(w, h)
},
uploadError = if (sendStatus == "failed") SEND_ERROR_FAILED else null,
)
}
@@ -610,7 +861,10 @@ object MessageCacheStore {
}
}
}
dmOtherUserIdFromConversationId(conversationId)?.let { syncDmConversationPreviewFromCache(it) }
dmOtherUserIdFromConversationId(conversationId)?.let {
syncDmConversationPreviewFromCache(it)
pruneEmptyConversations()
}
}
suspend fun hasSentMessageWithClientId(conversationId: String, clientMessageId: String): Boolean {
@@ -1,6 +1,7 @@
package ru.fromchat.api.local.db.store
import kotlinx.coroutines.flow.Flow
import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.messages.GENERAL_PUBLIC_GROUP_ID
import ru.fromchat.api.local.messages.conversationIdForDm
import ru.fromchat.api.local.messages.conversationIdForGroup
@@ -63,11 +64,46 @@ object MessageRepository {
suspend fun deleteDmMessageById(otherUserId: Int, messageId: Int) =
MessageCacheStore.deleteDmMessageById(otherUserId, messageId)
suspend fun replaceDmConversations(conversations: List<DmConversation>) =
MessageCacheStore.replaceDmConversations(conversations)
suspend fun replaceDmConversations(
conversations: List<DmConversation>,
attachmentOnlyPreview: String,
) = MessageCacheStore.replaceDmConversations(conversations, attachmentOnlyPreview)
suspend fun loadCachedDmConversations(): List<CachedConversation> =
MessageCacheStore.loadCachedDmConversations()
suspend fun ensureDmConversationRow(otherUserId: Int, displayName: String? = null) =
MessageCacheStore.ensureDmConversationRow(otherUserId, displayName)
suspend fun markDmConversationRead(otherUserId: Int) =
MessageCacheStore.markDmConversationRead(otherUserId)
suspend fun markPublicConversationRead() {
val localIds = MessageCacheStore.selectUnreadPublicMessageIds()
val serverIds = runCatching {
ApiClient.getNewMessages().messages.map { it.id }
}.getOrDefault(emptyList())
val ids = (localIds + serverIds).distinct()
if (ids.isNotEmpty()) {
runCatching { ApiClient.markMessagesRead(ids) }
}
MessageCacheStore.markPublicMessagesReadLocally()
}
suspend fun archiveDmConversation(otherUserId: Int) =
MessageCacheStore.archiveDmConversation(otherUserId)
suspend fun deleteDmConversation(otherUserId: Int) =
MessageCacheStore.deleteDmConversation(otherUserId)
suspend fun purgePendingNotFromUser(userId: Int) =
MessageCacheStore.purgePendingNotFromUser(userId)
suspend fun purgeAllPendingForInstance() =
MessageCacheStore.purgeAllPendingForInstance()
suspend fun pruneEmptyConversations() =
MessageCacheStore.pruneEmptyConversations()
suspend fun clearAllCache() = MessageCacheStore.clearAll()
}
}
@@ -45,6 +45,63 @@ object ProfileCache {
fun get(userId: Int): UserProfile? = profiles[userId]
fun findByUsername(username: String): UserProfile? =
username.trim().takeIf { it.isNotEmpty() }?.let { needle ->
profiles.values.firstOrNull { profile ->
profile.username.trim().equals(needle, ignoreCase = true)
}
}
/**
* Merges minimal identity (name, avatar) from any UI surface that showed this user.
* Skips when a full profile row is already cached.
*/
fun mergePreview(
id: Int,
username: String? = null,
displayName: String? = null,
profilePicture: String? = null,
) {
if (id <= 0) return
val existing = get(id)
if (existing != null && !existing.isClientPreviewOnly) return
val incomingUsername = username?.trim()?.takeIf { it.isNotEmpty() }
?: existing?.username?.trim()?.takeIf { it.isNotEmpty() }
val incomingDisplayName = displayName?.trim()?.takeIf { it.isNotEmpty() }
?: existing?.displayName?.takeIf { it.isNotBlank() }
?: incomingUsername
if (incomingUsername.isNullOrEmpty() && incomingDisplayName.isNullOrBlank()) return
put(
UserProfile(
id = id,
username = incomingUsername.orEmpty(),
displayName = incomingDisplayName,
profilePicture = profilePicture?.takeIf { it.isNotBlank() }
?: existing?.profilePicture,
bio = existing?.bio,
online = existing?.online ?: false,
lastSeen = existing?.lastSeen,
createdAt = existing?.createdAt,
verified = existing?.verified,
suspended = existing?.suspended,
suspensionReason = existing?.suspensionReason,
deleted = existing?.deleted,
isClientPreviewOnly = true,
),
)
}
fun mergeFromCachedConversation(conversation: CachedConversation) {
if (conversation.otherUserId <= 0) return
mergePreview(
id = conversation.otherUserId,
displayName = conversation.displayName.takeIf { it.isNotBlank() },
)
}
fun put(profile: UserProfile) {
if (profile.isClientPreviewOnly) {
val hasIdentity =
@@ -0,0 +1,19 @@
package ru.fromchat.api.local.db.store
import ru.fromchat.api.schema.chats.publicchat.PublicChatProfile
/**
* In-memory cache of the server-provided public chat profile (title, bio, member count).
*/
object PublicChatProfileCache {
var profile: PublicChatProfile? = null
private set
fun put(profile: PublicChatProfile) {
this.profile = profile
}
fun clear() {
profile = null
}
}
@@ -39,6 +39,9 @@ internal suspend fun streamEncryptedFileToDisk(
header(HttpHeaders.Range, "bytes=$rangeOffset-")
}
}.execute { response ->
if (response.status.value == 404 || response.status.value == 410) {
error("HTTP ${response.status.value} for encrypted file download")
}
if (response.status.value !in 200..299) {
error("HTTP ${response.status.value} for encrypted file download")
}
@@ -0,0 +1,31 @@
package ru.fromchat.api.local.send
import io.ktor.client.network.sockets.ConnectTimeoutException
import io.ktor.client.network.sockets.SocketTimeoutException
import io.ktor.client.plugins.ClientRequestException
import io.ktor.client.plugins.HttpRequestTimeoutException
import kotlinx.coroutines.TimeoutCancellationException
/** Error key stored on [ru.fromchat.api.schema.messages.Message.uploadError] for localized send-failure UI. */
const val SEND_ERROR_FAILED = "send_failed"
/** HTTP status codes that mark a permanent send failure (show error, offer retry/cancel). */
private val permanentHttpStatuses = setOf(400, 401, 403, 404, 422, 500)
fun Throwable.isOutboundPermanentFailure(): Boolean = when (this) {
is ClientRequestException -> response.status.value in permanentHttpStatuses
else -> false
}
/** Transient failures: keep pending and retry (no network, timeouts, other server errors). */
fun Throwable.isOutboundTransientFailure(): Boolean = when {
isOutboundPermanentFailure() -> false
this is TimeoutCancellationException ||
this is HttpRequestTimeoutException ||
this is SocketTimeoutException ||
this is ConnectTimeoutException -> true
this is ClientRequestException -> true
else -> true
}
fun outboundFailureErrorKey(error: Throwable): String = SEND_ERROR_FAILED
@@ -0,0 +1,26 @@
package ru.fromchat.api.local.send
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.launch
sealed class OutboundSendProgress {
data class Pending(val clientMessageId: String) : OutboundSendProgress()
data class Failed(val clientMessageId: String, val error: String) : OutboundSendProgress()
}
/** Text / public-chat outbound send status (outbox worker → UI). */
object OutboundSendNotifier {
private val _progressFlow = MutableSharedFlow<OutboundSendProgress>(extraBufferCapacity = 64)
val progressFlow: SharedFlow<OutboundSendProgress> = _progressFlow
private val mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
fun emit(progress: OutboundSendProgress) {
mainScope.launch {
_progressFlow.emit(progress)
}
}
}
@@ -10,6 +10,7 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.workers.AttachmentUploadNotifier
import ru.fromchat.api.local.workers.AttachmentUploadProgress
import ru.fromchat.api.local.messages.GENERAL_PUBLIC_GROUP_ID
import ru.fromchat.api.local.db.store.MessageCacheStore
@@ -17,7 +18,11 @@ import ru.fromchat.api.local.db.store.MessageDatabaseProvider
import ru.fromchat.api.local.db.store.MessageRepository
import ru.fromchat.api.local.messages.conversationIdForDm
import ru.fromchat.api.local.messages.conversationIdForGroup
import ru.fromchat.api.local.workers.AttachmentUploadNotifier
import ru.fromchat.api.local.send.OutboundSendNotifier
import ru.fromchat.api.local.send.OutboundSendProgress
import ru.fromchat.api.local.send.isOutboundPermanentFailure
import ru.fromchat.api.local.send.isOutboundTransientFailure
import ru.fromchat.api.local.send.outboundFailureErrorKey
import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.schema.messages.dm.SendDmFile
import ru.fromchat.api.local.cache.CacheContext
@@ -178,6 +183,21 @@ object OutgoingMessageCoordinator {
}
}
/** Re-queues a failed public / text outbound row (outbox row must still exist). */
fun retryOutboundMessage(clientMessageId: String, conversationId: String) {
val cid = clientMessageId.trim()
if (cid.isEmpty()) return
val instanceId = CacheContext.activeInstanceId.value.trim()
if (instanceId.isEmpty()) return
drainScope.launch {
withContext(Dispatchers.Default) {
MessageCacheStore.clearSendFailed(conversationId, cid)
}
OutboundSendNotifier.emit(OutboundSendProgress.Pending(cid))
kickOutboxDrain(instanceId)
}
}
/** Re-queues a failed attachment upload (outbox row must still exist). */
fun retryDmAttachmentUpload(clientMessageId: String) {
val cid = clientMessageId.trim()
@@ -233,13 +253,34 @@ object OutgoingMessageCoordinator {
for (row in rows) {
when (row.kind) {
KIND_SEND_PUBLIC -> {
runCatching {
val sendResult = runCatching {
val payload = json.decodeFromString<PublicOutboxPayload>(row.payloadJson)
ApiClient.sendMessageViaHttp(payload.content, payload.replyToId)
}
sendResult.onSuccess {
withContext(Dispatchers.Default) {
MessageDatabaseProvider.database.messageDatabaseQueries
.deleteOutboxItem(id, row.clientMessageId)
}
}.onFailure { error ->
when {
error.isOutboundPermanentFailure() -> {
val errorKey = outboundFailureErrorKey(error)
withContext(Dispatchers.Default) {
MessageCacheStore.markSendFailed(row.conversationId, row.clientMessageId)
}
OutboundSendNotifier.emit(
OutboundSendProgress.Failed(row.clientMessageId, errorKey),
)
}
error.isOutboundTransientFailure() -> {
allOk = false
drainScope.launch {
delay(3_000)
drainOutboxForInstance(id)
}
}
}
}
}
KIND_SEND_DM -> {
@@ -0,0 +1,11 @@
package ru.fromchat.api.schema.chats.publicchat
import kotlinx.serialization.Serializable
@Serializable
data class PublicChatProfile(
val id: String,
val title: String,
val bio: String? = null,
val member_count: Int,
)
@@ -0,0 +1,8 @@
package ru.fromchat.api.schema.messages
import kotlinx.serialization.Serializable
@Serializable
data class MarkReadRequest(
val messageIds: List<Int>,
)
@@ -0,0 +1,8 @@
package ru.fromchat.api.schema.messages.dm
import kotlinx.serialization.Serializable
@Serializable
data class DmArchiveRequest(
val archived: Boolean,
)
@@ -0,0 +1,8 @@
package ru.fromchat.api.schema.user
import kotlinx.serialization.Serializable
@Serializable
data class UsersSearchResponse(
val users: List<User> = emptyList(),
)
@@ -0,0 +1,11 @@
package ru.fromchat.api.schema.user.profile
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class UpdateProfileRequest(
val username: String? = null,
@SerialName("display_name") val displayName: String? = null,
val description: String? = null,
)
@@ -0,0 +1,12 @@
package ru.fromchat.api.schema.user.profile
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class UpdateProfileResponse(
val message: String,
val username: String,
@SerialName("display_name") val displayName: String? = null,
val bio: String? = null,
)
@@ -0,0 +1,10 @@
package ru.fromchat.legal
sealed class DocumentLoadResult {
data class Success(
val markdown: String,
val isCached: Boolean,
) : DocumentLoadResult()
data object Failure : DocumentLoadResult()
}
@@ -0,0 +1,82 @@
package ru.fromchat.legal
import com.pr0gramm3r101.utils.settings.settings
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import kotlinx.coroutines.delay
import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.config.ServerConfig
import kotlin.time.Clock
import kotlin.time.Duration.Companion.milliseconds
object DocumentRepository {
private const val RETRY_WINDOW_MS = 5_000L
private const val RETRY_DELAY_MS = 1_000L
private var cachedInstanceId: String? = null
private val memoryCache = mutableMapOf<DocumentType, String>()
fun invalidate() {
memoryCache.clear()
cachedInstanceId = null
}
private fun ensureInstanceFresh() {
val active = CacheContext.activeInstanceId.value.trim()
if (cachedInstanceId != null && cachedInstanceId != active) {
memoryCache.clear()
}
cachedInstanceId = active
}
private fun persistentCacheKey(type: DocumentType) =
"legal_doc_cache_v1_${CacheContext.activeInstanceId.value.trim().ifEmpty { "default" }}_${type.name}"
private suspend fun readPersistentCache(type: DocumentType): String? {
ensureInstanceFresh()
return settings.getString(persistentCacheKey(type)).takeIf { it.isNotEmpty() }
}
private suspend fun writePersistentCache(type: DocumentType, markdown: String) {
settings.putString(persistentCacheKey(type), markdown)
memoryCache[type] = markdown
}
suspend fun fetch(type: DocumentType): DocumentLoadResult {
ensureInstanceFresh()
val start = Clock.System.now().toEpochMilliseconds()
while (true) {
runCatching {
return DocumentLoadResult.Success(
ApiClient.http.get(
"${ServerConfig.apiBaseUrl}/static/${type.fileName}"
).bodyAsText().also {
writePersistentCache(type, it)
},
isCached = false
)
}
if (Clock.System.now().toEpochMilliseconds() - start >= RETRY_WINDOW_MS) {
break
}
delay(RETRY_DELAY_MS.milliseconds)
}
(memoryCache[type] ?: readPersistentCache(type)).also {
if (it != null) {
memoryCache[type] = it
return DocumentLoadResult.Success(it, isCached = true)
}
}
return DocumentLoadResult.Failure
}
}
@@ -0,0 +1,363 @@
package ru.fromchat.legal
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.UriHandler
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.utils.ToggleNavScrimEffect
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.rememberHazeState
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.about_link_privacy
import ru.fromchat.about_link_terms
import ru.fromchat.attachment_retry
import ru.fromchat.back
import ru.fromchat.legal_document_cached_banner
import ru.fromchat.legal_document_load_error
import ru.fromchat.ui.chat.rememberChatSurfaceContainerHazeStyle
import ru.fromchat.ui.components.ActionButton
import ru.fromchat.ui.components.Text
private data class PendingDocument(
val parsed: ParsedDocument,
val isCached: Boolean,
)
private sealed interface DocumentDisplayState {
data object Loading : DocumentDisplayState
data object Error : DocumentDisplayState
data class Success(
val parsed: ParsedDocument,
) : DocumentDisplayState
data class CachedSuccess(
val parsed: ParsedDocument,
) : DocumentDisplayState
}
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalMaterial3ExpressiveApi::class,
)
@Composable
fun DocumentScreen(
type: DocumentType,
onBack: () -> Unit,
onOpenLegalDocument: (DocumentType) -> Unit,
) {
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
val listState = rememberLazyListState()
val hazeState = rememberHazeState(blurEnabled = true)
LaunchedEffect(type) {
listState.scrollToItem(0)
}
ToggleNavScrimEffect()
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onSurface,
contentWindowInsets = WindowInsets.navigationBars,
topBar = {
MediumTopAppBar(
title = {
Text(
text = when (type) {
DocumentType.Privacy -> stringResource(Res.string.about_link_privacy)
DocumentType.Terms -> stringResource(Res.string.about_link_terms)
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(Res.string.back),
)
}
},
scrollBehavior = scrollBehavior,
colors = TopAppBarDefaults.topAppBarColors(
scrolledContainerColor = Color.Transparent
),
modifier = Modifier
.background(MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.91f))
.hazeEffect(
state = hazeState,
style = rememberChatSurfaceContainerHazeStyle(),
),
)
},
) { innerPadding ->
var loadAttempt by remember(type) { mutableIntStateOf(0) }
var displayState by remember(type) { mutableStateOf<DocumentDisplayState>(DocumentDisplayState.Loading) }
var pendingDocument by remember(type) { mutableStateOf<PendingDocument?>(null) }
val topContentPadding = innerPadding.calculateTopPadding()
val bottomContentPadding = innerPadding.calculateBottomPadding() + 16.dp
LaunchedEffect(type, loadAttempt) {
displayState = DocumentDisplayState.Loading
pendingDocument = null
when (val result = DocumentRepository.fetch(type)) {
is DocumentLoadResult.Success -> {
pendingDocument = PendingDocument(
parsed = parseMarkdown(result.markdown),
isCached = result.isCached,
)
}
DocumentLoadResult.Failure -> {
displayState = DocumentDisplayState.Error
}
}
}
LaunchedEffect(pendingDocument) {
val pending = pendingDocument ?: return@LaunchedEffect
withFrameNanos { }
withFrameNanos { }
displayState = if (pending.isCached) {
DocumentDisplayState.CachedSuccess(pending.parsed)
} else {
DocumentDisplayState.Success(pending.parsed)
}
pendingDocument = null
}
Box(
modifier = Modifier
.fillMaxSize()
.consumeWindowInsets(innerPadding)
.background(MaterialTheme.colorScheme.background)
.hazeSource(hazeState),
) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
) {
pendingDocument?.let { pending ->
DocumentContent(
parsed = pending.parsed,
showCachedBanner = pending.isCached,
listState = listState,
onOpenLegalDocument = onOpenLegalDocument,
topContentPadding = topContentPadding,
bottomContentPadding = bottomContentPadding,
modifier = Modifier
.fillMaxSize()
.alpha(0f),
)
}
AnimatedContent(
targetState = displayState,
transitionSpec = { fadeIn() togetherWith fadeOut() },
label = "legal_document_${type.name}",
) { state ->
when (state) {
DocumentDisplayState.Loading -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(top = topContentPadding),
contentAlignment = Alignment.Center,
) {
LoadingIndicator(modifier = Modifier.padding(24.dp))
}
}
DocumentDisplayState.Error -> {
Column(
modifier = Modifier
.fillMaxSize()
.padding(top = topContentPadding)
.padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = stringResource(Res.string.legal_document_load_error),
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
)
ActionButton(onClick = { loadAttempt++ }) {
Text(stringResource(Res.string.attachment_retry))
}
}
}
is DocumentDisplayState.Success -> {
DocumentContent(
parsed = state.parsed,
showCachedBanner = false,
listState = listState,
onOpenLegalDocument = onOpenLegalDocument,
topContentPadding = topContentPadding,
bottomContentPadding = bottomContentPadding,
modifier = Modifier.fillMaxSize(),
)
}
is DocumentDisplayState.CachedSuccess -> {
DocumentContent(
parsed = state.parsed,
showCachedBanner = true,
listState = listState,
onOpenLegalDocument = onOpenLegalDocument,
topContentPadding = topContentPadding,
bottomContentPadding = bottomContentPadding,
modifier = Modifier.fillMaxSize(),
)
}
}
}
}
}
}
}
@Composable
private fun DocumentContent(
parsed: ParsedDocument,
showCachedBanner: Boolean,
listState: LazyListState,
onOpenLegalDocument: (DocumentType) -> Unit,
topContentPadding: Dp,
bottomContentPadding: Dp,
modifier: Modifier = Modifier,
) {
val defaultUriHandler = LocalUriHandler.current
CompositionLocalProvider(
LocalUriHandler provides remember(onOpenLegalDocument, defaultUriHandler) {
object : UriHandler {
override fun openUri(uri: String) {
resolveLegalDocumentType(uri)?.let { linkedType ->
onOpenLegalDocument(linkedType)
return
}
defaultUriHandler.openUri(uri)
}
}
},
) {
SelectionContainer(modifier = modifier) {
LazyColumn(
state = listState,
contentPadding = PaddingValues(
top = topContentPadding,
bottom = bottomContentPadding,
),
) {
if (showCachedBanner) {
item(key = "cached-banner") {
Surface(
color = MaterialTheme.colorScheme.secondaryContainer,
shape = RoundedCornerShape(12.dp),
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
) {
Text(
text = stringResource(Res.string.legal_document_cached_banner),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.padding(12.dp),
)
}
}
}
if (parsed.preamble.isNotBlank()) {
item(key = "preamble") {
Markdown(
content = parsed.preamble,
modifier = Modifier.padding(bottom = 16.dp),
)
}
}
parsed.sections.forEachIndexed { index, section ->
item(key = "section-$index") {
Column {
ExpressiveSectionHeader(
directive = section.directive,
title = section.title,
modifier = Modifier.padding(top = 8.dp),
)
Markdown(
content = section.bodyMarkdown,
modifier = Modifier.padding(bottom = 24.dp),
)
}
}
}
}
}
}
}
@@ -0,0 +1,21 @@
package ru.fromchat.legal
enum class DocumentType(
val fileName: String,
val routeSegment: String,
) {
Privacy("PRIVACY.md", "privacy"),
Terms("TERMS.md", "terms"),
;
companion object {
const val ROUTE_PREFIX = "document"
const val ROUTE = "$ROUTE_PREFIX/{documentType}"
const val ARG_DOCUMENT_TYPE = "documentType"
fun route(type: DocumentType): String = "$ROUTE_PREFIX/${type.routeSegment}"
fun typeFromArg(arg: String): DocumentType? =
entries.firstOrNull { it.routeSegment.equals(arg, ignoreCase = true) }
}
}
@@ -0,0 +1,624 @@
package ru.fromchat.legal
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicText
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Article
import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material.icons.filled.Block
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material.icons.filled.Phone
import androidx.compose.material.icons.filled.PrivacyTip
import androidx.compose.material.icons.filled.Shield
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.toShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withLink
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import ru.fromchat.ui.components.Text
private const val LINK_PRESS_SCALE = 0.92f
private fun AnnotatedString.linkAt(offset: Int): AnnotatedString.Range<LinkAnnotation>? {
if (isEmpty()) return null
val clamped = offset.coerceIn(0, lastIndex)
return getLinkAnnotations(clamped, clamped + 1).firstOrNull()
?: getLinkAnnotations(0, length).firstOrNull { clamped in it.start until it.end }
}
private val LEGAL_STATIC_LINK_RE = Regex(
pattern = """(?:^|/)?(?:api/)?static/(TERMS|PRIVACY)\.md$""",
option = RegexOption.IGNORE_CASE,
)
private val SECTION_DIRECTIVE_REGEX = Regex("""<!--\s*fc:([^>]+?)\s*-->""", RegexOption.IGNORE_CASE)
private val LEGAL_MATERIAL_ICON_ALIASES = mapOf(
"privacy" to "privacy_tip",
"terms" to "contract",
)
fun materialIconName(iconKey: String) = LEGAL_MATERIAL_ICON_ALIASES[iconKey] ?: iconKey
fun materialIconVector(iconKey: String): ImageVector = when (materialIconName(iconKey)) {
"privacy_tip" -> Icons.Filled.PrivacyTip
"contract" -> Icons.AutoMirrored.Filled.Article
"person_add" -> Icons.Filled.PersonAdd
"chat" -> Icons.AutoMirrored.Filled.Chat
"storage" -> Icons.Filled.Storage
"shield" -> Icons.Filled.Shield
"visibility_off" -> Icons.Filled.VisibilityOff
"block" -> Icons.Filled.Block
"call" -> Icons.Filled.Call
"delete" -> Icons.Filled.Delete
"description" -> Icons.Filled.Description
"lock" -> Icons.Filled.Lock
"notifications" -> Icons.Filled.Notifications
"person" -> Icons.Filled.Person
"phone" -> Icons.Filled.Phone
else -> Icons.Filled.Info
}
data class SectionDirective(
val shape: String,
val icon: String,
)
data class Section(
val directive: SectionDirective,
val title: String,
val bodyMarkdown: String,
)
data class ParsedDocument(
val preamble: String,
val sections: List<Section>,
)
private data class MarkdownTable(
val header: List<String>,
val rows: List<List<String>>,
)
@Composable
expect fun Markdown(
content: String,
modifier: Modifier = Modifier,
)
/** Subsection (`###`) size: geometric middle between body text and expressive section title. */
@Composable
fun markdownSubsectionStyle(): TextStyle {
val body = MaterialTheme.typography.bodyMedium
val sectionTitle = MaterialTheme.typography.titleLarge
return body.copy(
fontSize = ((body.fontSize.value + sectionTitle.fontSize.value) / 2).sp,
lineHeight = ((body.lineHeight.value + sectionTitle.lineHeight.value) / 2).sp,
fontWeight = FontWeight.SemiBold,
)
}
/**
* Maps static legal Markdown API paths to in-app legal document types.
*/
fun resolveLegalDocumentType(uri: String): DocumentType? = when (
(
LEGAL_STATIC_LINK_RE.find(
uri
.replace('\\', '/')
.substringBefore('?')
.substringBefore('#')
.trimEnd('/')
) ?: return null
).groupValues[1].uppercase()
) {
"TERMS" -> DocumentType.Terms
"PRIVACY" -> DocumentType.Privacy
else -> null
}
fun parseSectionDirective(line: String): SectionDirective? {
val body = (SECTION_DIRECTIVE_REGEX.find(line.trim()) ?: return null).groupValues[1]
return SectionDirective(
shape = Regex("""shape=([A-Za-z0-9_]+)""")
.find(body)
?.groupValues
?.getOrNull(1)
?: return null,
icon = Regex("""icon=([A-Za-z0-9_-]+)""")
.find(body)
?.groupValues
?.getOrNull(1)
?: return null
)
}
fun parseMarkdown(markdown: String): ParsedDocument {
val lines = markdown.replace("\r\n", "\n").split("\n")
val preambleLines = mutableListOf<String>()
val sections = mutableListOf<Section>()
var i = 0
while (i < lines.size) {
val directive = parseSectionDirective(lines[i])
when {
directive != null && i + 1 < lines.size && lines[i + 1].startsWith("## ") -> {
val title = lines[i + 1].removePrefix("## ").trim()
i += 2
sections += Section(
directive = directive,
title = title,
bodyMarkdown = buildList {
while (i < lines.size) {
if (
parseSectionDirective(lines[i]) != null &&
i + 1 < lines.size &&
lines[i + 1].startsWith("## ")
) break
add(lines[i])
i += 1
}
}.joinToString("\n").trim(),
)
}
sections.isEmpty() -> {
preambleLines += lines[i]
i += 1
}
else -> i += 1
}
}
return ParsedDocument(
preamble = preambleLines.joinToString("\n").trim(),
sections = sections,
)
}
@Composable
internal fun MarkdownPlain(
content: String,
modifier: Modifier = Modifier,
onLinkClick: (String) -> Unit,
) {
val bodyStyle = MaterialTheme.typography.bodyMedium
val linkStyle = SpanStyle(
color = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline,
)
Column(modifier = modifier) {
val lines = content.lineSequence().toList()
var index = 0
while (index < lines.size) {
val trimmed = lines[index].trimEnd()
when {
trimmed.isEmpty() -> {
Spacer(Modifier.height(8.dp))
index += 1
}
isLegalMarkdownTableLine(trimmed) -> {
val tableLines = buildList {
while (index < lines.size && isLegalMarkdownTableLine(lines[index].trimEnd())) {
this += lines[index].trimEnd()
index += 1
}
}
parseMarkdownTable(tableLines).let {
if (it != null) {
LegalMarkdownTableView(
table = it,
linkStyle = linkStyle,
onLinkClick = onLinkClick,
)
} else {
tableLines.forEach { line ->
MarkdownBlockText(
text = parseInlineMarkdown(line, linkStyle, onLinkClick),
style = bodyStyle,
modifier = Modifier.padding(bottom = 4.dp),
)
}
}
}
}
trimmed.startsWith("### ") -> {
MarkdownBlockText(
text = parseInlineMarkdown(trimmed.removePrefix("### "), linkStyle, onLinkClick),
style = markdownSubsectionStyle(),
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
)
index += 1
}
trimmed.startsWith("- ") -> {
Row(modifier = Modifier.padding(start = 8.dp, bottom = 4.dp)) {
Text(
text = "",
style = bodyStyle,
)
MarkdownBlockText(
text = parseInlineMarkdown(trimmed.removePrefix("- "), linkStyle, onLinkClick),
style = bodyStyle,
)
}
index += 1
}
else -> {
MarkdownBlockText(
text = parseInlineMarkdown(trimmed, linkStyle, onLinkClick),
style = bodyStyle,
modifier = Modifier.padding(bottom = 4.dp),
)
index += 1
}
}
}
}
}
@Composable
internal fun MarkdownBlockText(
text: AnnotatedString,
style: TextStyle,
modifier: Modifier = Modifier,
) {
var layoutResult by remember(text) { mutableStateOf<TextLayoutResult?>(null) }
var pressedLinkRange by remember(text) { mutableStateOf<IntRange?>(null) }
val currentLayoutResult by rememberUpdatedState(layoutResult)
val scale by animateFloatAsState(
targetValue = if (pressedLinkRange != null) LINK_PRESS_SCALE else 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium,
),
label = "legal_link_press_scale",
)
// graphicsLayer on BasicText does not scale the text draw pass; wrap in Box (see MessageItem).
Box(
modifier = modifier
.graphicsLayer {
val range = pressedLinkRange
val layout = layoutResult
compositingStrategy = CompositingStrategy.Offscreen
if (range != null && layout != null && layout.size.width > 0f && layout.size.height > 0f) {
val bounds = layout.getPathForRange(range.first, range.last + 1).getBounds()
scaleX = scale
scaleY = scale
transformOrigin = TransformOrigin(
pivotFractionX = (bounds.left + bounds.right) / 2f / layout.size.width,
pivotFractionY = (bounds.top + bounds.bottom) / 2f / layout.size.height,
)
} else {
scaleX = 1f
scaleY = 1f
transformOrigin = TransformOrigin.Center
}
}
.pointerInput(text) {
awaitEachGesture {
val down = awaitFirstDown(
pass = PointerEventPass.Initial,
requireUnconsumed = false,
)
val layout = currentLayoutResult ?: return@awaitEachGesture
val link = text.linkAt(layout.getOffsetForPosition(down.position)) ?: return@awaitEachGesture
pressedLinkRange = link.start until link.end
try {
waitForUpOrCancellation()
} finally {
pressedLinkRange = null
}
}
},
) {
BasicText(
text = text,
style = style.merge(color = MaterialTheme.colorScheme.onSurface),
onTextLayout = { layoutResult = it },
)
}
}
internal fun parseInlineMarkdown(
text: String,
linkStyle: SpanStyle,
onLinkClick: (String) -> Unit,
): AnnotatedString =
buildAnnotatedString {
var index = 0
while (index < text.length) {
when {
text.startsWith("**", index) -> {
val end = text.indexOf("**", index + 2)
if (end != -1) {
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append(text.substring(index + 2, end))
}
index = end + 2
} else {
append(text[index])
index += 1
}
}
text[index] == '[' -> {
val closeBracket = text.indexOf(']', index + 1)
val openParen = if (closeBracket != -1) text.indexOf('(', closeBracket + 1) else -1
val closeParen = if (openParen != -1) text.indexOf(')', openParen + 1) else -1
if (closeBracket != -1 && openParen == closeBracket + 1 && closeParen != -1) {
withLink(
LinkAnnotation.Clickable(
tag = text.substring(openParen + 1, closeParen),
styles = TextLinkStyles(style = linkStyle),
linkInteractionListener = { link ->
(link as? LinkAnnotation.Clickable)?.tag?.let(onLinkClick)
},
),
) {
append(text.substring(index + 1, closeBracket))
}
index = closeParen + 1
} else {
append(text[index])
index += 1
}
}
else -> {
append(text[index])
index += 1
}
}
}
}
private fun parseMarkdownTable(lines: List<String>): MarkdownTable? {
return if (lines.size < 2 || !isMarkdownTableSeparator(lines[1])) null else MarkdownTable(
header = parseMarkdownTableRow(lines.first()) ?: return null,
rows = lines.drop(2).mapNotNull(::parseMarkdownTableRow)
)
}
private fun isLegalMarkdownTableLine(line: String) =
line.trim().startsWith("|") && line.trim().endsWith("|")
private fun parseMarkdownTableRow(line: String) =
if (!isLegalMarkdownTableLine(line)) null else
line.trim().trim('|').split('|').map { it.trim() }
private fun isMarkdownTableSeparator(line: String) =
parseMarkdownTableRow(line)?.all { cell ->
cell.all { it == '-' || it == ':' || it.isWhitespace() }
} == true
@Composable
private fun LegalMarkdownTableView(
table: MarkdownTable,
linkStyle: SpanStyle,
onLinkClick: (String) -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(bottom = 8.dp),
) {
Column(Modifier.width(IntrinsicSize.Max)) {
val borderColor = MaterialTheme.colorScheme.outlineVariant
val columnCount = maxOf(
table.header.size,
table.rows.maxOfOrNull { it.size } ?: 0
)
Row(Modifier.fillMaxWidth()) {
repeat(columnCount) { columnIndex ->
MarkdownTableCell(
text = table.header.getOrNull(columnIndex).orEmpty(),
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
backgroundColor = MaterialTheme.colorScheme.surfaceContainerLow,
borderColor = borderColor,
linkStyle = linkStyle,
onLinkClick = onLinkClick,
)
}
}
table.rows.forEach { row ->
Row(Modifier.fillMaxWidth()) {
repeat(columnCount) { columnIndex ->
MarkdownTableCell(
text = row.getOrNull(columnIndex).orEmpty(),
style = MaterialTheme.typography.bodyMedium,
backgroundColor = MaterialTheme.colorScheme.surface,
borderColor = borderColor,
linkStyle = linkStyle,
onLinkClick = onLinkClick,
)
}
}
}
}
}
}
@Composable
private fun MarkdownTableCell(
text: String,
style: TextStyle,
backgroundColor: Color,
borderColor: Color,
linkStyle: SpanStyle,
onLinkClick: (String) -> Unit,
) {
Box(
modifier = Modifier
.width(160.dp)
.fillMaxHeight()
.background(backgroundColor)
.border(0.5.dp, borderColor)
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
MarkdownBlockText(
text = parseInlineMarkdown(text, linkStyle, onLinkClick),
style = style,
)
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ExpressiveSectionHeader(
directive: SectionDirective,
title: String,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
modifier = Modifier
.size(110.dp)
.clip(
when (directive.shape) {
"Arch" -> MaterialShapes.Arch
"Arrow" -> MaterialShapes.Arrow
"Boom" -> MaterialShapes.Boom
"Bun" -> MaterialShapes.Bun
"Burst" -> MaterialShapes.Burst
"ClamShell" -> MaterialShapes.ClamShell
"Clover4Leaf" -> MaterialShapes.Clover4Leaf
"Clover8Leaf" -> MaterialShapes.Clover8Leaf
"Cookie12Sided" -> MaterialShapes.Cookie12Sided
"Cookie4Sided" -> MaterialShapes.Cookie4Sided
"Cookie6Sided" -> MaterialShapes.Cookie6Sided
"Cookie7Sided" -> MaterialShapes.Cookie7Sided
"Cookie9Sided" -> MaterialShapes.Cookie9Sided
"Diamond" -> MaterialShapes.Diamond
"Fan" -> MaterialShapes.Fan
"Flower" -> MaterialShapes.Flower
"Gem" -> MaterialShapes.Gem
"Ghostish" -> MaterialShapes.Ghostish
"Heart" -> MaterialShapes.Heart
"Oval" -> MaterialShapes.Oval
"Pentagon" -> MaterialShapes.Pentagon
"Pill" -> MaterialShapes.Pill
"PixelCircle" -> MaterialShapes.PixelCircle
"PixelTriangle" -> MaterialShapes.PixelTriangle
"Puffy" -> MaterialShapes.Puffy
"PuffyDiamond" -> MaterialShapes.PuffyDiamond
"SemiCircle" -> MaterialShapes.SemiCircle
"Slanted" -> MaterialShapes.Slanted
"SoftBoom" -> MaterialShapes.SoftBoom
"SoftBurst" -> MaterialShapes.SoftBurst
"Square" -> MaterialShapes.Square
"Sunny" -> MaterialShapes.Sunny
"Triangle" -> MaterialShapes.Triangle
"VerySunny" -> MaterialShapes.VerySunny
else -> MaterialShapes.Circle
}.normalized().toShape()
)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = materialIconVector(directive.icon),
contentDescription = null,
modifier = Modifier.size(50.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 12.dp),
)
}
}
@@ -1,16 +1,15 @@
package ru.fromchat.ui
import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.End
import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.Start
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.SnackbarDuration
@@ -25,9 +24,9 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.IntOffset
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.navigation.NamedNavArgument
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
@@ -38,7 +37,9 @@ import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import coil3.ImageLoader
import coil3.compose.setSingletonImageLoaderFactory
import coil3.network.ktor3.KtorNetworkFetcherFactory
import coil3.svg.SvgDecoder
import ru.fromchat.api.ApiClient
import com.pr0gramm3r101.utils.LocalSystemBarsVisibility
import com.pr0gramm3r101.utils.navigateAndWipeBackStack
import com.pr0gramm3r101.utils.rememberSystemBarsController
@@ -54,7 +55,6 @@ import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import ru.fromchat.AppForeground
import ru.fromchat.Logger
import ru.fromchat.api.ApiClient
import ru.fromchat.api.DeferredStartupNetwork
import ru.fromchat.api.UpdateSyncManager
import ru.fromchat.api.calls.CallStore
@@ -70,12 +70,16 @@ import ru.fromchat.api.local.send.scheduleOutboxProcessing
import ru.fromchat.api.schema.websocket.WebSocketMessage
import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData
import ru.fromchat.config.ServerConfig
import ru.fromchat.legal.DocumentScreen
import ru.fromchat.legal.DocumentType
import ru.fromchat.ui.auth.AuthScreen
import ru.fromchat.ui.calls.CallOverlay
import ru.fromchat.ui.chat.panels.dm.DmChatRoute
import ru.fromchat.ui.chat.panels.dm.DmNav
import ru.fromchat.ui.chat.panels.dm.DmProfileRoute
import ru.fromchat.ui.chat.panels.publicchat.PublicChatScreen
import ru.fromchat.ui.chat.panels.publicchat.PublicChatChatRoute
import ru.fromchat.ui.chat.panels.publicchat.PublicChatNav
import ru.fromchat.ui.chat.panels.publicchat.PublicChatProfileRoute
import ru.fromchat.ui.main.MainScreen
import ru.fromchat.ui.main.chats.ChatsSearchScreen
import ru.fromchat.ui.main.settings.AboutScreen
@@ -84,14 +88,43 @@ import ru.fromchat.ui.main.settings.DevicesScreen
import ru.fromchat.ui.main.settings.NotificationsScreen
import ru.fromchat.ui.main.settings.SettingsRoutes
import ru.fromchat.ui.main.settings.account.AccountScreen
import ru.fromchat.ui.main.settings.account.delete.DeleteAccountScreen
import ru.fromchat.ui.main.settings.account.changepassword.ChangePasswordScreen
import ru.fromchat.ui.main.settings.account.delete.DeleteAccountScreen
import ru.fromchat.ui.main.settings.server.ServerConfigScreen
import ru.fromchat.ui.profile.EditProfileFocusField
import ru.fromchat.ui.profile.EditProfileScreen
import ru.fromchat.ui.profile.ProfileRoutes
import ru.fromchat.ui.profile.ProfileScreen
import ru.fromchat.utils.NetworkConnectivity
val LocalNavController = compositionLocalOf<NavController> { error("NavController not provided") }
private val rootNavTween = tween<Float>(durationMillis = 250, easing = FastOutSlowInEasing)
private fun rootNavEnterTransition(): EnterTransition =
scaleIn(initialScale = 0.9f, animationSpec = rootNavTween) +
fadeIn(animationSpec = rootNavTween)
private fun rootNavExitTransition(): ExitTransition =
scaleOut(targetScale = 1.1f, animationSpec = rootNavTween) +
fadeOut(animationSpec = rootNavTween)
private fun rootNavPopEnterTransition(): EnterTransition =
scaleIn(initialScale = 1.1f, animationSpec = rootNavTween) +
fadeIn(animationSpec = rootNavTween)
private fun rootNavPopExitTransition(): ExitTransition =
scaleOut(targetScale = 0.9f, animationSpec = rootNavTween) +
fadeOut(animationSpec = rootNavTween)
private val searchScreenFade = tween<Float>(durationMillis = 260)
private fun searchScreenEnterTransition(): EnterTransition =
fadeIn(animationSpec = searchScreenFade)
private fun searchScreenExitTransition(): ExitTransition =
fadeOut(animationSpec = searchScreenFade)
private fun handlePresenceStatus(data: JsonObject?) {
val userId = data?.get("userId")?.jsonPrimitive?.content?.toIntOrNull() ?: return
val online = data["online"]?.jsonPrimitive?.booleanOrNull == true
@@ -149,25 +182,14 @@ private fun handlePresenceEvent(message: WebSocketMessage) {
}
}
private fun NavGraphBuilder.settingsSlideComposable(
private fun NavGraphBuilder.settingsComposable(
route: String,
animationSpec: FiniteAnimationSpec<IntOffset>,
arguments: List<NamedNavArgument> = emptyList(),
content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit,
) {
composable(
route = route,
enterTransition = {
slideInHorizontally(animationSpec = animationSpec) { it }
},
exitTransition = {
slideOutHorizontally(animationSpec = animationSpec) { -it }
},
popEnterTransition = {
slideInHorizontally(animationSpec = animationSpec) { -it }
},
popExitTransition = {
slideOutHorizontally(animationSpec = animationSpec) { it }
},
arguments = arguments,
content = content,
)
}
@@ -186,6 +208,7 @@ fun App(
ImageLoader.Builder(context)
.components {
add(SvgDecoder.Factory())
add(KtorNetworkFetcherFactory(httpClient = { ApiClient.http }))
}
.build()
}
@@ -287,7 +310,6 @@ fun App(
SharedTransitionLayout {
val navController = rememberNavController()
val profileLookupSnackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(profileLookupErrorMessage) {
profileLookupErrorMessage?.let { message ->
Logger.d("ProfileDeepLink", "showing snackbar for deep-link lookup failure: $message")
@@ -359,35 +381,14 @@ fun App(
LocalSystemBarsVisibility provides rememberSystemBarsController()
) {
if (startDestination != null) {
val rootNavMotion = spring<IntOffset>(dampingRatio = 0.88f, stiffness = 420f)
Box(Modifier.fillMaxSize()) {
NavHost(
navController = navController,
startDestination = startDestination!!,
enterTransition = {
slideIntoContainer(
Start,
animationSpec = rootNavMotion
)
},
exitTransition = {
slideOutOfContainer(
Start,
animationSpec = rootNavMotion
)
},
popEnterTransition = {
slideIntoContainer(
End,
animationSpec = rootNavMotion
)
},
popExitTransition = {
slideOutOfContainer(
End,
animationSpec = rootNavMotion
)
}
enterTransition = { rootNavEnterTransition() },
exitTransition = { rootNavExitTransition() },
popEnterTransition = { rootNavPopEnterTransition() },
popExitTransition = { rootNavPopExitTransition() },
) {
composable("serverConfig") {
ServerConfigScreen()
@@ -426,21 +427,29 @@ fun App(
)
}
composable("chats/publicChat") {
PublicChatScreen(
composable(PublicChatNav.CHAT_ROUTE) {
PublicChatChatRoute(
scrollToMessageId = scrollToMessageId,
navController = navController,
sharedTransitionScope = this@SharedTransitionLayout,
animatedContentScope = this@composable
animatedVisibilityScope = this,
)
}
composable(PublicChatNav.PROFILE_ROUTE) {
PublicChatProfileRoute(
navController = navController,
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this,
)
}
val searchScreenFade = tween<Float>(260)
composable(
route = "search/conversations",
enterTransition = { fadeIn(animationSpec = searchScreenFade) },
exitTransition = { fadeOut(animationSpec = searchScreenFade) },
popEnterTransition = { fadeIn(animationSpec = searchScreenFade) },
popExitTransition = { fadeOut(animationSpec = searchScreenFade) }
enterTransition = { searchScreenEnterTransition() },
exitTransition = { searchScreenExitTransition() },
popEnterTransition = { searchScreenEnterTransition() },
popExitTransition = { searchScreenExitTransition() },
) {
ChatsSearchScreen(
onBack = { navController.popBackStack() },
@@ -460,21 +469,13 @@ fun App(
}
composable(
route = "profile/{userId}?fromDeepLink={fromDeepLink}&useSharedElement={useSharedElement}&sourceMessageId={sourceMessageId}",
route = "profile/{userId}?fromDeepLink={fromDeepLink}",
arguments = listOf(
navArgument("userId") { type = NavType.StringType },
navArgument("fromDeepLink") {
type = NavType.BoolType
defaultValue = false
},
navArgument("useSharedElement") {
type = NavType.StringType
defaultValue = "false"
},
navArgument("sourceMessageId") {
type = NavType.StringType
defaultValue = "-1"
}
)
) { backStackEntry ->
val args = backStackEntry.savedStateHandle
@@ -483,20 +484,8 @@ fun App(
val userId = if ((parsedUserId ?: 0) > 0) parsedUserId else null
val profileUsername = if (userId == null) {
userIdParam?.trim()?.takeIf { it.isNotBlank() }
} else {
null
}
val useSharedElement = when (val rawUseSharedElement = args.get<Any?>("useSharedElement")) {
is Boolean -> rawUseSharedElement
is String -> rawUseSharedElement == "true"
else -> false
}
val sourceMessageId = when (val rawSourceMessageId = args.get<Any?>("sourceMessageId")) {
is Int -> rawSourceMessageId
is String -> rawSourceMessageId.toIntOrNull() ?: -1
is Long -> rawSourceMessageId.toInt()
else -> -1
}
} else null
val fromDeepLink = when (val rawFromDeepLink = args.get<Any?>("fromDeepLink")) {
is Boolean -> rawFromDeepLink
is String -> rawFromDeepLink == "true"
@@ -506,24 +495,39 @@ fun App(
Logger.d(
"ProfileRoute",
"profile entry args: rawUserId=$userIdParam parsedUserId=$parsedUserId resolvedUserId=$userId " +
"resolvedUsername=$profileUsername sourceMessageId=$sourceMessageId fromDeepLink=$fromDeepLink " +
"useSharedElement=$useSharedElement currentRoute=${backStackEntry.destination.route}"
"resolvedUsername=$profileUsername fromDeepLink=$fromDeepLink " +
"currentRoute=${backStackEntry.destination.route}"
)
ProfileScreen(
userId = userId,
username = profileUsername,
showBackButton = true,
onBack = { navController.navigateUp() },
onChat = { navController.navigate(DmNav.chatRoute(it)) },
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this@composable,
useSharedElementFromNavigation = useSharedElement,
sharedSourceMessageId = sourceMessageId,
showErrorAsToast = fromDeepLink
)
}
val dmChatProfileFade = tween<Float>(durationMillis = 280)
composable(
route = ProfileRoutes.Edit,
arguments = listOf(
navArgument(ProfileRoutes.ARG_FOCUS) {
type = NavType.StringType
defaultValue = ""
},
),
) { entry ->
val focusField = EditProfileFocusField.fromArg(
entry.arguments?.getString(ProfileRoutes.ARG_FOCUS),
)
EditProfileScreen(
onBack = { navController.navigateUp() },
initialFocusField = focusField,
)
}
composable(
route = DmNav.CHAT_ROUTE,
@@ -531,30 +535,6 @@ fun App(
navArgument("otherUserId") { type = NavType.StringType },
navArgument("sourceMessageId") { type = NavType.IntType; defaultValue = -1 },
),
enterTransition = {
when (initialState.destination.route) {
DmNav.PROFILE_ROUTE -> fadeIn(animationSpec = dmChatProfileFade)
else -> slideIntoContainer(Start, animationSpec = rootNavMotion)
}
},
exitTransition = {
when (targetState.destination.route) {
DmNav.PROFILE_ROUTE -> fadeOut(animationSpec = dmChatProfileFade)
else -> slideOutOfContainer(Start, animationSpec = rootNavMotion)
}
},
popEnterTransition = {
when (initialState.destination.route) {
DmNav.PROFILE_ROUTE -> fadeIn(animationSpec = dmChatProfileFade)
else -> slideIntoContainer(End, animationSpec = rootNavMotion)
}
},
popExitTransition = {
when (targetState.destination.route) {
DmNav.PROFILE_ROUTE -> fadeOut(animationSpec = dmChatProfileFade)
else -> slideOutOfContainer(End, animationSpec = rootNavMotion)
}
},
) { entry ->
val otherUserId = entry.savedStateHandle.get<String>("otherUserId")?.toIntOrNull() ?: 0
val sourceMessageId = entry.savedStateHandle.get<Int>("sourceMessageId") ?: -1
@@ -571,10 +551,6 @@ fun App(
composable(
route = DmNav.PROFILE_ROUTE,
arguments = listOf(navArgument("otherUserId") { type = NavType.StringType }),
enterTransition = { fadeIn(animationSpec = dmChatProfileFade) },
exitTransition = { fadeOut(animationSpec = dmChatProfileFade) },
popEnterTransition = { fadeIn(animationSpec = dmChatProfileFade) },
popExitTransition = { fadeOut(animationSpec = dmChatProfileFade) },
) { entry ->
val otherUserId = entry.savedStateHandle.get<String>("otherUserId")?.toIntOrNull() ?: 0
if (otherUserId <= 0) return@composable
@@ -586,30 +562,51 @@ fun App(
)
}
settingsSlideComposable("about", rootNavMotion) {
settingsComposable("about") {
AboutScreen()
}
settingsSlideComposable(SettingsRoutes.Appearance, rootNavMotion) {
settingsComposable(
route = DocumentType.ROUTE,
arguments = listOf(
navArgument(DocumentType.ARG_DOCUMENT_TYPE) { type = NavType.StringType },
),
) { entry ->
val type = entry.savedStateHandle
.get<String>(DocumentType.ARG_DOCUMENT_TYPE)
?.let(DocumentType::typeFromArg)
?: return@settingsComposable
DocumentScreen(
type = type,
onBack = { navController.navigateUp() },
onOpenLegalDocument = { linkedType ->
navController.navigate(DocumentType.route(linkedType)) {
launchSingleTop = true
}
},
)
}
settingsComposable(SettingsRoutes.Appearance) {
AppearanceScreen(onBack = { navController.navigateUp() })
}
settingsSlideComposable(SettingsRoutes.Notifications, rootNavMotion) {
settingsComposable(SettingsRoutes.Notifications) {
NotificationsScreen(onBack = { navController.navigateUp() })
}
settingsSlideComposable(SettingsRoutes.Devices, rootNavMotion) {
settingsComposable(SettingsRoutes.Devices) {
DevicesScreen(onBack = { navController.navigateUp() })
}
settingsSlideComposable(SettingsRoutes.SecurityPasswordFlow, rootNavMotion) {
settingsComposable(SettingsRoutes.SecurityPasswordFlow) {
ChangePasswordScreen(
onBack = { navController.navigateUp() },
onDone = { navController.popBackStack() },
)
}
settingsSlideComposable(SettingsRoutes.AccountDeleteFlow, rootNavMotion) {
settingsComposable(SettingsRoutes.AccountDeleteFlow) {
DeleteAccountScreen(
onBack = { navController.navigateUp() },
onDeleted = {
@@ -620,7 +617,7 @@ fun App(
)
}
settingsSlideComposable(SettingsRoutes.Account, rootNavMotion) {
settingsComposable(SettingsRoutes.Account) {
AccountScreen(
onBack = { navController.navigateUp() },
onLogout = {
@@ -1,5 +1,7 @@
package ru.fromchat.ui.auth.register
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -10,6 +12,7 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -17,11 +20,16 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.about_link_privacy
import ru.fromchat.about_link_terms
import ru.fromchat.auth_char_count
import ru.fromchat.auth_legal_notice_and
import ru.fromchat.auth_legal_notice_prefix
import ru.fromchat.auth_step_profile_body
import ru.fromchat.auth_step_profile_title
import ru.fromchat.auth_username_taken
@@ -30,6 +38,8 @@ import ru.fromchat.display_name_error
import ru.fromchat.error_unexpected
import ru.fromchat.profile_headline_bio
import ru.fromchat.register_button
import ru.fromchat.legal.DocumentType
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.auth.RegisterResult
import ru.fromchat.ui.auth.register
import ru.fromchat.ui.components.ActionButton
@@ -60,6 +70,7 @@ internal fun profileStepPage(
onSnackbar: (String) -> Unit,
): ExpressiveStepPage {
val scope = rememberCoroutineScope()
val navController = LocalNavController.current
val fieldColors = expressiveStepFieldColors()
val colorScheme = MaterialTheme.colorScheme
@@ -123,6 +134,36 @@ internal fun profileStepPage(
)
},
button = {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = SettingsStepHorizontalPadding, vertical = 8.dp),
horizontalArrangement = Arrangement.Center,
) {
Text(
text = stringResource(Res.string.auth_legal_notice_prefix),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
TextButton(onClick = { navController.navigate(DocumentType.route(DocumentType.Terms)) }) {
Text(
text = stringResource(Res.string.about_link_terms),
style = MaterialTheme.typography.bodySmall,
)
}
Text(
text = stringResource(Res.string.auth_legal_notice_and),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
TextButton(onClick = { navController.navigate(DocumentType.route(DocumentType.Privacy)) }) {
Text(
text = stringResource(Res.string.about_link_privacy),
style = MaterialTheme.typography.bodySmall,
)
}
}
ActionButton(
onClick = {
if (busy) return@ActionButton
@@ -80,6 +80,8 @@ import ru.fromchat.api.local.download.resolveSavableMessageImage
import ru.fromchat.api.local.messages.generateClientMessageId
import ru.fromchat.api.local.messages.nowMessageTimestampIso
import ru.fromchat.api.local.messages.optimisticMessageIdForClientMessageId
import ru.fromchat.api.local.send.OutboundSendNotifier
import ru.fromchat.api.local.send.OutboundSendProgress
import ru.fromchat.api.local.send.OutgoingMessageCoordinator
import ru.fromchat.api.local.send.prepareOutboundFileForSend
import ru.fromchat.api.local.send.prepareOutboundImageForSend
@@ -94,7 +96,6 @@ import ru.fromchat.chat_group_label
import ru.fromchat.status_connecting
import ru.fromchat.status_updating
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.chat.panels.publicchat.publicChatProfileSharedAvatarKey
import ru.fromchat.ui.chat.utils.AttachmentDownloadVisibility
import ru.fromchat.ui.chat.utils.getImageAspectRatio
import ru.fromchat.ui.chat.utils.getImageDimensions
@@ -171,13 +172,6 @@ fun ChatScreen(
Logger.d("ChatScreen", "currentTypingUsers updated (from panelState): ${currentTypingUsers.map { it.username }}")
}
var profileSharedSourceMessageId by remember(scrollToMessageId) {
mutableStateOf(scrollToMessageId?.takeIf { it > 0 })
}
LaunchedEffect(scrollToMessageId) {
scrollToMessageId?.takeIf { it > 0 }?.let { profileSharedSourceMessageId = it }
}
val subtitleKey = when {
!online -> "connecting"
connectionStatus == ConnectionStatus.UPDATING -> "updating"
@@ -436,6 +430,21 @@ fun ChatScreen(
}
}
LaunchedEffect(panel) {
OutboundSendNotifier.progressFlow.collect { progress ->
when (progress) {
is OutboundSendProgress.Pending ->
panel.updateMessageByClientMessageId(progress.clientMessageId) {
it.copy(uploadError = null)
}
is OutboundSendProgress.Failed ->
panel.updateMessageByClientMessageId(progress.clientMessageId) {
it.copy(uploadError = progress.error)
}
}
}
}
LaunchedEffect(panel) {
if (panel.getRecipientId() != null) {
AttachmentUploadNotifier.progressFlow.collect { progress ->
@@ -635,7 +644,7 @@ fun ChatScreen(
}
}
) {
if (panelState.isLoading) {
if (panelState.isLoading && panelState.messages.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
@@ -698,12 +707,8 @@ fun ChatScreen(
message.user_id > 0
) {
{
profileSharedSourceMessageId = message.id
ProfileCache.mergePreviewFromPublicMessage(message)
navController.navigate(
"profile/${message.user_id}" +
"?useSharedElement=true&sourceMessageId=${message.id}"
)
navController.navigate("profile/${message.user_id}")
}
} else {
null
@@ -716,8 +721,6 @@ fun ChatScreen(
isImageClosing = isImageClosing,
showUsername = panel.showUsernamesInMessages,
currentUserId = currentUserId,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
onCancelOutboundAttachment = { msg ->
scope.launch { panel.cancelQueuedMessage(msg) }
},
@@ -731,22 +734,6 @@ fun ChatScreen(
OutgoingMessageCoordinator.retryDmAttachmentUpload(cid)
}
},
sharedAvatarNavKey =
if (
panel.supportsNavigateToSenderProfile &&
sharedTransitionScope != null &&
animatedVisibilityScope != null &&
message.user_id != currentUserId &&
message.user_id > 0 &&
profileSharedSourceMessageId == message.id
) {
publicChatProfileSharedAvatarKey(
message.user_id,
message.id,
)
} else {
null
}
)
}
@@ -837,6 +824,12 @@ fun ChatScreen(
onCancelSend = { message ->
scope.launch { panel.cancelQueuedMessage(message) }
},
onRetrySend = { message ->
val cid = message.client_message_id?.trim().orEmpty()
if (cid.isNotEmpty()) {
OutgoingMessageCoordinator.retryOutboundMessage(cid, panel.outboxConversationId())
}
},
)
}
}
@@ -92,7 +92,7 @@ fun ChatTopBarInner(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = modifier.conditional(
profileUserId != null && onTitleClick != null
onTitleClick != null
) {
Modifier.scaleOnPress(
scale = 0.96f,
@@ -148,16 +148,20 @@ fun ImageFullscreenPreview(
fileIndex = fileIndex,
confirmed = message.id > 0,
)
val thumbLayoutAspect = thumbnailBounds?.takeIf { it.width > 0f && it.height > 0f }
var animationBounds by remember(decryptCacheKey) { mutableStateOf<Rect?>(null) }
if (animationBounds == null && thumbnailBounds != null) {
animationBounds = thumbnailBounds
}
val thumbLayoutAspect = animationBounds?.takeIf { it.width > 0f && it.height > 0f }
?.let { bounds -> bounds.width / bounds.height }
var menusVisible by remember { mutableStateOf(true) }
var dismissRequested by remember { mutableStateOf(false) }
val backgroundAlpha = remember { Animatable(1f) }
var hasPlayedOpenAnimation by remember(thumbnailBounds) { mutableStateOf(thumbnailBounds == null) }
var hasPlayedOpenAnimation by remember(decryptCacheKey) { mutableStateOf(false) }
var isOpenAnimationPlaying by remember { mutableStateOf(false) }
var dismissProgress by remember { mutableStateOf(0f) }
val isInitialOpenState = thumbnailBounds != null && !hasPlayedOpenAnimation
val isInitialOpenState = animationBounds != null && !hasPlayedOpenAnimation
val isTransitioning = isOpenAnimationPlaying || dismissRequested
val effectiveMenusVisible = if (isInitialOpenState) false else menusVisible && dismissProgress < 0.01f
var effectiveBgAlpha by remember { mutableStateOf(1f) }
@@ -168,8 +172,10 @@ fun ImageFullscreenPreview(
val systemBarsVisibility = LocalSystemBarsVisibility.current
LaunchedEffect(menusVisible) {
systemBarsVisibility?.invoke(menusVisible)
// Keep status bars visible while dismissing so ChatScreen underneath keeps
// correct status-bar insets until the overlay is removed.
LaunchedEffect(menusVisible, dismissRequested) {
systemBarsVisibility?.invoke(menusVisible || dismissRequested)
}
DisposableEffect(Unit) {
onDispose {
@@ -237,7 +243,9 @@ fun ImageFullscreenPreview(
.background(Color.Black)
)
LaunchedEffect(dismissRequested) {
if (dismissRequested) onDismiss()
if (!dismissRequested) return@LaunchedEffect
onClosingChange(true)
onDismiss()
}
androidx.compose.material3.CircularProgressIndicator(
modifier = Modifier.size(48.dp),
@@ -255,16 +263,17 @@ fun ImageFullscreenPreview(
contentHeightAtScale1
}
val initial = remember(
thumbnailBounds, containerWidth, containerHeight, layoutContentHeight,
animationBounds, containerWidth, containerHeight, layoutContentHeight,
) {
if (thumbnailBounds != null && layoutAspect != null) {
val bounds = animationBounds
if (bounds != null && layoutAspect != null) {
val fullTop = (containerHeight - layoutContentHeight) / 2f
val fullCenter = Offset(
x = containerWidth / 2f,
y = fullTop + layoutContentHeight / 2f,
)
val thumbCenter = thumbnailBounds.center
val thumbWidth = thumbnailBounds.width
val thumbCenter = bounds.center
val thumbWidth = bounds.width
val s = thumbWidth / containerWidth
val o = thumbCenter - fullCenter
InitialTransform(s, o.x, o.y, 12f, 0f)
@@ -288,8 +297,9 @@ fun ImageFullscreenPreview(
var lastStableOffsetX by remember { mutableStateOf(initial.offsetX) }
var lastStableOffsetY by remember { mutableStateOf(initial.offsetY) }
LaunchedEffect(thumbnailBounds) {
if (thumbnailBounds != null && !hasPlayedOpenAnimation) {
LaunchedEffect(animationBounds) {
val bounds = animationBounds ?: return@LaunchedEffect
if (!hasPlayedOpenAnimation) {
hasPlayedOpenAnimation = true
isOpenAnimationPlaying = true
@@ -298,8 +308,8 @@ fun ImageFullscreenPreview(
x = containerWidth / 2f,
y = fullTop + layoutContentHeight / 2f,
)
val thumbCenter = thumbnailBounds.center
val thumbWidth = thumbnailBounds.width
val thumbCenter = bounds.center
val thumbWidth = bounds.width
val startScale = thumbWidth / containerWidth
val startOffset = thumbCenter - fullCenter
@@ -342,12 +352,14 @@ fun ImageFullscreenPreview(
}
LaunchedEffect(dismissRequested) {
if (!dismissRequested) return@LaunchedEffect
onClosingChange(true)
val hasTransform = lastStableScale != 1f ||
lastStableOffsetX != 0f ||
lastStableOffsetY != 0f
if (thumbnailBounds != null) {
val bounds = animationBounds
if (bounds != null) {
menusVisible = false
val wasOpenAnimating = isOpenAnimationPlaying
isOpenAnimationPlaying = false
@@ -357,8 +369,8 @@ fun ImageFullscreenPreview(
x = containerWidth / 2f,
y = fullTop + layoutContentHeight / 2f,
)
val thumbCenter = thumbnailBounds.center
val thumbWidth = thumbnailBounds.width
val thumbCenter = bounds.center
val thumbWidth = bounds.width
val targetScale = thumbWidth / containerWidth
val targetOffset = thumbCenter - fullCenter
@@ -395,9 +407,6 @@ fun ImageFullscreenPreview(
}
}
onClosingChange(true)
delay(50)
onClosingChange(false)
onDismiss()
}
LaunchedEffect(scale, offset, isTransformInProgress) {
@@ -23,6 +23,7 @@ import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.ContentCopy
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.rounded.SaveAlt
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -55,6 +56,7 @@ import ru.fromchat.action_copy
import ru.fromchat.action_delete
import ru.fromchat.action_edit
import ru.fromchat.action_reply
import ru.fromchat.action_retry_send
import ru.fromchat.action_save
import ru.fromchat.api.local.download.resolveSavableMessageFile
import ru.fromchat.api.local.download.resolveSavableMessageImage
@@ -76,9 +78,11 @@ internal fun messageContextMenuFingerprint(
isReadOnly: Boolean,
): String {
val isQueued = message.isQueuedOutbound() && isAuthor
val sendFailed = isQueued && !message.uploadError.isNullOrBlank()
val corrupted = message.isContentCorrupted
return buildString {
append("q=").append(isQueued)
append("|failed=").append(sendFailed)
append("|copy=").append(!corrupted)
append("|save=").append(resolveSavableMessageImage(message) != null)
if (!isQueued && !isReadOnly) {
@@ -100,6 +104,7 @@ fun MessageContextMenu(
onCopy: (Message) -> Unit,
onSave: (Message) -> Unit,
onCancelSend: (Message) -> Unit,
onRetrySend: (Message) -> Unit = {},
isReadOnly: Boolean = false,
screenWidthPx: Int,
screenHeightPx: Int,
@@ -162,6 +167,7 @@ fun MessageContextMenu(
onCopy = {},
onSave = {},
onCancelSend = {},
onRetrySend = {},
modifier = modifier.graphicsLayer(alpha = 0f),
animated = false,
withShadow = false,
@@ -260,6 +266,10 @@ fun MessageContextMenu(
onCancelSend(it)
onDismiss()
},
onRetrySend = {
onRetrySend(it)
onDismiss()
},
modifier = modifier,
animated = true,
scale = scale,
@@ -283,6 +293,7 @@ private fun ContextMenuContent(
onCopy: (Message) -> Unit,
onSave: (Message) -> Unit,
onCancelSend: (Message) -> Unit,
onRetrySend: (Message) -> Unit,
isReadOnly: Boolean = false,
modifier: Modifier,
animated: Boolean,
@@ -330,7 +341,9 @@ private fun ContextMenuContent(
val labelCopy = stringResource(Res.string.action_copy)
val labelSave = stringResource(Res.string.action_save)
val labelCancelSend = stringResource(Res.string.action_cancel_send)
val labelRetrySend = stringResource(Res.string.action_retry_send)
val isQueued = message.isQueuedOutbound() && isAuthor
val sendFailed = isQueued && !message.uploadError.isNullOrBlank()
val savableImage = resolveSavableMessageImage(message)
val savableFile = resolveSavableMessageFile(message)
val canSave = savableImage != null || savableFile != null
@@ -357,7 +370,19 @@ private fun ContextMenuContent(
onClick = { onSave(message) }
)
}
if (isQueued) {
if (sendFailed) {
ContextMenuItem(
icon = Icons.Rounded.Refresh,
text = labelRetrySend,
onClick = { onRetrySend(message) },
)
ContextMenuItem(
icon = Icons.Rounded.Close,
text = labelCancelSend,
onClick = { onCancelSend(message) },
isError = true,
)
} else if (isQueued) {
ContextMenuItem(
icon = Icons.Rounded.Close,
text = labelCancelSend,
@@ -1,7 +1,5 @@
package ru.fromchat.ui.chat
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
@@ -26,7 +24,10 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ErrorOutline
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -47,6 +48,8 @@ import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.pr0gramm3r101.utils.conditional
@@ -58,6 +61,7 @@ import ru.fromchat.api.local.messages.isQueuedOutbound
import ru.fromchat.api.schema.messages.Message
import ru.fromchat.message_corrupted
import ru.fromchat.message_edited_suffix
import ru.fromchat.message_send_failed
import ru.fromchat.ui.chat.components.getMessageGradient
import ru.fromchat.ui.chat.components.getReplyMessageGradient
import ru.fromchat.ui.chat.utils.imageAspectRatioForMessage
@@ -103,9 +107,6 @@ fun MessageItem(
isImageClosing: Boolean = false,
isContextMenuOpen: Boolean = false,
isContextMenuForThisMessage: Boolean = false,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
sharedAvatarNavKey: String? = null,
onCancelOutboundAttachment: ((Message) -> Unit)? = null,
onRetryOutboundAttachment: ((Message) -> Unit)? = null,
) {
@@ -118,6 +119,7 @@ fun MessageItem(
}
val corruptedBody = stringResource(Res.string.message_corrupted)
val editedSuffix = stringResource(Res.string.message_edited_suffix)
val sendFailedLabel = stringResource(Res.string.message_send_failed)
val displayUsername = messageDisplayUsername(message, currentUserId)
val replyRef = message.reply_to
@@ -178,29 +180,11 @@ fun MessageItem(
)
}
) {
val navSharedKey = sharedAvatarNavKey
val navStScope = sharedTransitionScope
val navVisScope = animatedVisibilityScope
if (navSharedKey != null && navStScope != null && navVisScope != null) {
with(navStScope) {
Avatar(
profilePictureUrl = message.profile_picture,
displayName = message.username,
modifier = Modifier
.sharedElement(
rememberSharedContentState(key = navSharedKey),
animatedVisibilityScope = navVisScope
)
.size(32.dp)
)
}
} else {
Avatar(
profilePictureUrl = message.profile_picture,
displayName = message.username,
modifier = Modifier.size(32.dp)
)
}
Avatar(
profilePictureUrl = message.profile_picture,
displayName = message.username,
modifier = Modifier.size(32.dp)
)
}
Spacer(modifier = Modifier.width(8.dp))
@@ -608,13 +592,14 @@ fun MessageItem(
// Timestamp, sending indicator, and edited indicator
val isPendingOutbound = message.id < 0 && message.files.isNullOrEmpty()
val sendFailed = isPendingOutbound && !message.uploadError.isNullOrBlank()
Row(
modifier = Modifier
.padding(start = 12.dp, end = 12.dp, top = 4.dp, bottom = 8.dp),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
if (isPendingOutbound) {
if (isPendingOutbound && !sendFailed) {
CircularProgressIndicator(
modifier = Modifier.size(12.dp),
strokeWidth = 1.5.dp,
@@ -625,6 +610,20 @@ fun MessageItem(
}
)
Spacer(modifier = Modifier.width(6.dp))
} else if (sendFailed) {
Icon(
imageVector = Icons.Rounded.ErrorOutline,
contentDescription = sendFailedLabel,
modifier = Modifier
.size(14.dp)
.semantics { contentDescription = sendFailedLabel },
tint = if (isAuthor) {
Color.White.copy(alpha = 0.85f)
} else {
MaterialTheme.colorScheme.error
},
)
Spacer(modifier = Modifier.width(6.dp))
}
Text(
text = formattedTime,
@@ -10,6 +10,7 @@ import androidx.compose.runtime.remember
import ru.fromchat.api.local.cache.CacheContext
import androidx.compose.ui.Modifier
import androidx.navigation.NavController
import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.utils.haptic.HapticFeedbackEvent
import ru.fromchat.ui.profile.ProfileScreen
import ru.fromchat.utils.haptic.rememberHapticFeedback
@@ -75,9 +76,17 @@ fun DmProfileRoute(
val stateSnapshot = panel.getState()
val initialDisplayName = stateSnapshot.titleAvatar?.displayName?.takeIf { it.isNotBlank() }
?: stateSnapshot.title.takeIf { it.isNotBlank() }
val initialProfilePictureUrl = stateSnapshot.titleAvatar?.profilePictureUrl
ProfileCache.mergePreview(
id = otherUserId,
displayName = initialDisplayName,
profilePicture = initialProfilePictureUrl,
)
ProfileScreen(
userId = otherUserId,
showBackButton = true,
onBack = {
haptic(HapticFeedbackEvent.ProfileClosed)
navController.popBackStack()
@@ -90,6 +99,7 @@ fun DmProfileRoute(
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedAvatarKey = sharedAvatarKey,
initialDisplayName = initialDisplayName
initialDisplayName = initialDisplayName,
initialProfilePictureUrl = initialProfilePictureUrl,
)
}
@@ -61,6 +61,7 @@ class DmPanel(
private var otherDisplayName: String = ""
private var otherProfilePicture: String? = null
private val dmEnvelopeMutex = Mutex()
private var messagesLoaded = false
private data class DmDecryptOutcome(val plaintext: String, val isCorrupted: Boolean)
@@ -168,16 +169,28 @@ class DmPanel(
}
override suspend fun loadMessages() {
setLoading(true)
if (messagesLoaded) return
messagesLoaded = true
runCatching { MessageRepository.ensureDmConversationRow(otherUserId) }
// Read cache first. Do not setLoading(true) before this: that forced a 1-frame spinner
// when the chat screen re-entered composition (e.g. pop back from profile).
val cached = runCatching { MessageCacheStore.loadDmMessages(otherUserId) }.getOrDefault(emptyList())
if (cached.isNotEmpty()) {
batchStateUpdates {
clearMessages()
addMessages(cached)
setLoading(false)
}
} else {
setLoading(true)
}
try {
OutgoingMessageCoordinator.pruneStaleAttachmentOutboxForInstance(
CacheContext.requireActiveInstanceId(),
)
val cached = runCatching { MessageCacheStore.loadDmMessages(otherUserId) }.getOrDefault(emptyList())
if (cached.isNotEmpty()) {
clearMessages()
addMessages(cached)
}
val historyResult = runCatching { ApiClient.getDmHistory(otherUserId) }
if (historyResult.isSuccess) {
@@ -512,9 +525,11 @@ class DmPanel(
deleteMessageImmediately(messageId)
DownloadedFileRegistry.invalidateForMessage(messageId)
DecryptedImageCache.invalidateForMessage(messageId)
DecryptedFileCache.invalidateForMessage(messageId)
clientId?.trim()?.takeIf { it.isNotEmpty() }?.let {
DownloadedFileRegistry.invalidateForClientMessage(it)
DecryptedImageCache.invalidateForClientMessage(it)
DecryptedFileCache.invalidateForClientMessage(it)
}
runCatching { ApiClient.deleteDm(messageId, otherUserId) }
withContext(Dispatchers.Default) {
@@ -535,9 +550,11 @@ class DmPanel(
val clientId = _state.messages.find { it.id == data.id }?.client_message_id
DownloadedFileRegistry.invalidateForMessage(data.id)
DecryptedImageCache.invalidateForMessage(data.id)
DecryptedFileCache.invalidateForMessage(data.id)
clientId?.trim()?.takeIf { it.isNotEmpty() }?.let {
DownloadedFileRegistry.invalidateForClientMessage(it)
DecryptedImageCache.invalidateForClientMessage(it)
DecryptedFileCache.invalidateForClientMessage(it)
}
deleteMessageImmediately(data.id)
MessageRepository.deleteDmMessageById(otherUserId, data.id)
@@ -36,8 +36,13 @@ fun DmScreen(
val activeInstanceId by CacheContext.activeInstanceId.collectAsState()
val otherUserId = panel.getState().profileUserId
LaunchedEffect(panel, otherUserId) {
if (otherUserId != null && otherUserId > 0) {
LaunchedEffect(panel, activeInstanceId, otherUserId) {
if (activeInstanceId.isBlank()) return@LaunchedEffect
val peerId = otherUserId ?: return@LaunchedEffect
if (peerId <= 0) return@LaunchedEffect
// Panel is retained in [DmPanelCache]; only cold-load when the list is still empty
// (e.g. returning from profile must not call loadMessages and flash the chat spinner).
if (panel.getState().messages.isEmpty()) {
panel.loadMessages()
}
}
@@ -0,0 +1,81 @@
package ru.fromchat.ui.chat.panels.publicchat
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.navigation.NavController
import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.db.store.PublicChatProfileCache
import ru.fromchat.ui.chat.utils.PublicChatPanelCache
import ru.fromchat.ui.profile.PublicChatProfileScreen
import ru.fromchat.utils.haptic.HapticFeedbackEvent
import ru.fromchat.utils.haptic.rememberHapticFeedback
/** Route patterns for public chat + profile (stacked for predictive / system back). */
object PublicChatNav {
const val CHAT_ROUTE = "chats/publicChat"
const val PROFILE_ROUTE = "chats/publicChat/profile"
const val SHARED_HEADER_KEY = "public-chat-header"
}
@Composable
fun PublicChatChatRoute(
scrollToMessageId: Int? = null,
navController: NavController,
sharedTransitionScope: SharedTransitionScope,
animatedVisibilityScope: AnimatedVisibilityScope,
modifier: Modifier = Modifier,
) {
val haptic = rememberHapticFeedback()
PublicChatScreen(
scrollToMessageId = scrollToMessageId,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedAvatarKey = PublicChatNav.SHARED_HEADER_KEY,
onTitleClick = {
haptic(HapticFeedbackEvent.ProfileOpened)
navController.navigate(PublicChatNav.PROFILE_ROUTE)
},
modifier = modifier.fillMaxSize(),
)
}
@Composable
fun PublicChatProfileRoute(
navController: NavController,
sharedTransitionScope: SharedTransitionScope,
animatedVisibilityScope: AnimatedVisibilityScope,
modifier: Modifier = Modifier,
) {
val currentUserId = ApiClient.user?.id
val panel = remember(currentUserId) {
PublicChatPanelCache.getOrCreateGeneralChat(currentUserId)
}
val haptic = rememberHapticFeedback()
val stateSnapshot = panel.getState()
val initialDisplayName = stateSnapshot.titleAvatar?.displayName?.takeIf { it.isNotBlank() }
?: stateSnapshot.title.takeIf { it.isNotBlank() }
?: PublicChatProfileCache.profile?.title?.takeIf { it.isNotBlank() }
PublicChatProfileScreen(
showBackButton = true,
onBack = {
haptic(HapticFeedbackEvent.ProfileClosed)
navController.popBackStack()
},
onChat = {
haptic(HapticFeedbackEvent.ProfileClosed)
navController.popBackStack()
},
modifier = modifier.fillMaxSize(),
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
sharedAvatarKey = PublicChatNav.SHARED_HEADER_KEY,
initialDisplayName = initialDisplayName,
)
}
@@ -11,10 +11,12 @@ import ru.fromchat.Logger
import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.cache.DecryptedImageCache
import ru.fromchat.api.local.db.store.MessageCacheStore
import ru.fromchat.api.local.db.store.PublicChatProfileCache
import ru.fromchat.api.local.db.store.MessageRepository
import ru.fromchat.api.local.messages.GENERAL_PUBLIC_GROUP_ID
import ru.fromchat.api.local.messages.conversationIdForGroup
import ru.fromchat.api.local.send.OutgoingMessageCoordinator
import ru.fromchat.api.schema.chats.publicchat.PublicChatProfile
import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.schema.messages.publicchat.SendMessageResponse
import ru.fromchat.api.schema.websocket.WebSocketMessage
@@ -30,8 +32,6 @@ import ru.fromchat.ui.chat.utils.TypingHandler
class PublicChatPanel(
/** Stable cache / panel id (not localized; hardcoded in [ru.fromchat.ui.chat.utils.PublicChatPanelCache]). */
panelKey: String,
/** Shown in the app bar and avatars. */
displayTitle: String,
currentUserId: Int?,
scope: CoroutineScope
) : ChatPanel(
@@ -70,8 +70,8 @@ class PublicChatPanel(
init {
updateState {
it.copy(
title = displayTitle,
titleAvatar = AvatarInfo(displayName = displayTitle, profilePictureUrl = null),
title = "",
titleAvatar = null,
publicGroupMetaLoading = true,
publicGroupMemberCount = null
)
@@ -83,27 +83,30 @@ class PublicChatPanel(
}
}
scope.launch(Dispatchers.Default) {
runCatching { ApiClient.getRegisteredUserCount() }
.onSuccess { n ->
updateState { s ->
s.copy(publicGroupMemberCount = n, publicGroupMetaLoading = false)
}
val cached = PublicChatProfileCache.profile
if (cached != null) {
applyPublicChatProfile(cached)
}
runCatching { ApiClient.getPublicChatProfile() }
.onSuccess { profile ->
PublicChatProfileCache.put(profile)
applyPublicChatProfile(profile)
}
.onFailure {
updateState { s -> s.copy(publicGroupMetaLoading = false) }
if (cached == null) {
updateState { s -> s.copy(publicGroupMetaLoading = false) }
}
}
}
}
/** When locale changes, keep the same panel but refresh the visible title. */
fun applyDisplayTitle(title: String) {
private fun applyPublicChatProfile(profile: PublicChatProfile) {
updateState { s ->
s.copy(
title = title,
titleAvatar = AvatarInfo(
displayName = title,
profilePictureUrl = s.titleAvatar?.profilePictureUrl
)
title = profile.title,
titleAvatar = AvatarInfo(displayName = profile.title, profilePictureUrl = null),
publicGroupMemberCount = profile.member_count,
publicGroupMetaLoading = false,
)
}
}
@@ -353,6 +356,9 @@ class PublicChatPanel(
val obj = data.jsonObject
val c = obj["count"]?.jsonPrimitive?.content?.toIntOrNull()
if (c != null) {
PublicChatProfileCache.profile?.let { cached ->
PublicChatProfileCache.put(cached.copy(member_count = c))
}
updateState { s ->
s.copy(publicGroupMemberCount = c, publicGroupMetaLoading = false)
}
@@ -1,19 +1,18 @@
package ru.fromchat.ui.chat.panels.publicchat
import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import androidx.compose.ui.Modifier
import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.db.store.MessageRepository
import ru.fromchat.public_chat
import ru.fromchat.ui.chat.ChatScreen
import ru.fromchat.ui.chat.utils.PublicChatPanelCache
@@ -21,14 +20,15 @@ import ru.fromchat.ui.chat.utils.PublicChatPanelCache
fun PublicChatScreen(
scrollToMessageId: Int? = null,
sharedTransitionScope: SharedTransitionScope? = null,
animatedContentScope: AnimatedContentScope? = null
animatedVisibilityScope: AnimatedVisibilityScope? = null,
sharedAvatarKey: Any? = null,
onTitleClick: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
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, publicChatTitle) {
PublicChatPanelCache.getOrCreateGeneralChat(publicChatTitle, currentUserId)
val panel = remember(currentUserId) {
PublicChatPanelCache.getOrCreateGeneralChat(currentUserId)
}
val activeInstanceId by CacheContext.activeInstanceId.collectAsState()
@@ -47,7 +47,6 @@ fun PublicChatScreen(
}
}
// Track visibility for notifications
DisposableEffect(Unit) {
isPublicChatVisible = true
onDispose {
@@ -55,14 +54,15 @@ fun PublicChatScreen(
}
}
// Render with ChatScreen
ChatScreen(
panel = panel,
currentUserId = currentUserId,
scrollToMessageId = scrollToMessageId,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedContentScope,
sharedAvatarKey = null,
animatedVisibilityScope = animatedVisibilityScope,
sharedAvatarKey = sharedAvatarKey,
onTitleClick = onTitleClick,
modifier = modifier.fillMaxSize(),
)
}
@@ -74,4 +74,4 @@ fun PublicChatScreen(
fun publicChatProfileSharedAvatarKey(userId: Int, sourceMessageId: Int): String =
"public-chat-profile-avatar-$userId-$sourceMessageId"
var isPublicChatVisible = false
var isPublicChatVisible = false
@@ -24,7 +24,6 @@ object PublicChatPanelCache {
private var panel: PublicChatPanel? = null
private var cachedPanelKey: String? = null
private var cachedDisplayTitle: String? = null
private var cachedUserId: Int? = null
private var cachedInstanceId: String = ""
@@ -42,10 +41,7 @@ object PublicChatPanelCache {
}
}
/**
* @param displayTitle Localized title from e.g. [ru.fromchat.Res.string.public_chat].
*/
fun getOrCreateGeneralChat(displayTitle: String, currentUserId: Int?): PublicChatPanel {
fun getOrCreateGeneralChat(currentUserId: Int?): PublicChatPanel {
ensureScope()
val instanceId = CacheContext.activeInstanceId.value.trim()
if (instanceId.isNotEmpty() && cachedInstanceId.isNotEmpty() && cachedInstanceId != instanceId) {
@@ -58,21 +54,15 @@ object PublicChatPanelCache {
cachedUserId == currentUserId &&
(instanceId.isEmpty() || cachedInstanceId == instanceId)
) {
if (cachedDisplayTitle != displayTitle) {
cachedDisplayTitle = displayTitle
panel!!.applyDisplayTitle(displayTitle)
}
return panel!!
}
panel?.destroy()
panel = PublicChatPanel(
panelKey = GeneralPublicPanelKey,
displayTitle = displayTitle,
currentUserId = currentUserId,
scope = panelScope
)
cachedPanelKey = GeneralPublicPanelKey
cachedDisplayTitle = displayTitle
cachedUserId = currentUserId
return panel!!
}
@@ -81,8 +71,6 @@ object PublicChatPanelCache {
panel?.destroy()
panel = null
cachedPanelKey = null
cachedDisplayTitle = null
cachedUserId = null
supervisorJob.cancel()
}
}
}
@@ -1,3 +1,7 @@
/**
* Мне лень чистить этот файл, я устал.
* TODO Почищу когда-нибудь потом.
*/
package ru.fromchat.ui.components
import androidx.compose.animation.ExperimentalAnimationApi
@@ -344,7 +348,6 @@ fun ExpressiveStepFlowScaffold(
val scope = flowState.scope
val pageCount = pages.size.coerceAtLeast(1)
val heroSpecs = remember(pages) { pages.map { it.hero } }
val isHazeLazyMode = hazeScaffold
val pageOffset by derivedStateOf { pagerState.currentPageOffsetFraction }
val predictiveThreshold = 0.15f
@@ -352,6 +355,7 @@ fun ExpressiveStepFlowScaffold(
enabled = pagerState.currentPage > 0,
onProgress = { p ->
val clamped = p.coerceIn(0f, 1f)
if (clamped <= 0f) {
flowState.resetPredictiveState()
} else {
@@ -368,6 +372,7 @@ fun ExpressiveStepFlowScaffold(
val fromPageSnapshot = flowState.predictiveFromPage
val toPageSnapshot = flowState.predictiveToPage
val lastProgress = flowState.predictiveProgress.coerceIn(0f, 1f)
if (lastProgress < predictiveThreshold || fromPageSnapshot == null || toPageSnapshot == null) {
scope.launch {
finishPredictiveMorph(
@@ -426,6 +431,7 @@ fun ExpressiveStepFlowScaffold(
val fromIndex: Int
val toIndex: Int
val morphProgress: Float
if (flowState.predictiveFromPage != null && flowState.predictiveToPage != null && flowState.predictiveProgress > 0f) {
fromIndex = flowState.predictiveFromPage!!
toIndex = flowState.predictiveToPage!!
@@ -443,6 +449,7 @@ fun ExpressiveStepFlowScaffold(
toIndex = fromIndex
morphProgress = 0f
}
val effectiveMorphProgress = morphProgress.coerceIn(0f, 1f)
val currentPage = pagerState.currentPage
val morphing = fromIndex != toIndex
@@ -592,65 +599,139 @@ fun ExpressiveStepFlowScaffold(
LocalExpressiveStepFocusEnabled provides isPageTransitionSettled,
LocalExpressiveStepAutoFocusPrimary provides autoFocusPrimaryField,
) {
if (isHazeLazyMode) {
val hazeState = rememberHazeState()
val listState = rememberLazyListState()
val imeScrollState = rememberLazyListImeScrollState()
var listViewportBounds by remember { mutableStateOf<Rect?>(null) }
if (hazeScaffold) {
val hazeState = rememberHazeState()
val listState = rememberLazyListState()
val imeScrollState = rememberLazyListImeScrollState()
var listViewportBounds by remember { mutableStateOf<Rect?>(null) }
Box(
Modifier
.fillMaxSize()
.onGloballyPositioned { snackbarOverlayBounds = it.boundsInWindow() },
) {
Scaffold(
modifier = Modifier.fillMaxSize(),
contentWindowInsets = WindowInsets.navigationBars,
containerColor = Color.Transparent,
contentColor = scheme.onSurface,
topBar = { HazeTopBar(hazeState = hazeState) },
bottomBar = {
HazeBottomBar(hazeState = hazeState) {
Box(
Modifier.trackExpressiveStepSnackbarAnchor(
anchors = snackbarAnchors,
role = ExpressiveStepSnackbarAnchorRole.PrimaryCta,
),
Box(
Modifier
.fillMaxSize()
.onGloballyPositioned { snackbarOverlayBounds = it.boundsInWindow() },
) {
Scaffold(
modifier = Modifier.fillMaxSize(),
contentWindowInsets = WindowInsets.navigationBars,
containerColor = Color.Transparent,
contentColor = scheme.onSurface,
topBar = { HazeTopBar(hazeState = hazeState) },
bottomBar = {
HazeBottomBar(hazeState = hazeState) {
Box(
Modifier.trackExpressiveStepSnackbarAnchor(
anchors = snackbarAnchors,
role = ExpressiveStepSnackbarAnchorRole.PrimaryCta,
),
) {
ExpressiveStepBottomBar()
}
}
},
) { innerPadding ->
LazyListImeScrollEffect(
listState = listState,
scrollState = imeScrollState,
viewportBoundsInWindow = listViewportBounds,
contentPaddingTop = innerPadding.calculateTopPadding(),
contentPaddingBottom = innerPadding.calculateBottomPadding(),
predictiveBackProgress = { flowState.predictiveProgress },
)
DisabledBringIntoViewSpec {
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.background(scheme.background)
.hazeSource(hazeState)
.onGloballyPositioned {
listViewportBounds = it.boundsInWindow()
listViewportBoundsForSnackbar = it.boundsInWindow()
},
contentPadding = innerPadding,
verticalArrangement = remember { LastAnchoredBottomArrangement(space = 4.dp) },
) {
ExpressiveStepBottomBar()
item { Spacer(Modifier.height(8.dp)) }
item {
Column(modifier = Modifier.fillMaxWidth()) {
ExpressiveStepHeroSection()
Spacer(Modifier.height(ExpressiveStepHeroTitleSpacing))
HorizontalPager(
state = pagerState,
userScrollEnabled = false,
beyondViewportPageCount = 1,
pageSpacing = 0.dp,
verticalAlignment = Alignment.Top,
modifier = Modifier.fillMaxWidth(),
) { page ->
Column(modifier = Modifier.pagerPageFullWidth()) {
pages[page].content(imeScrollState)
}
}
}
}
val currentListFooter = pages.getOrNull(currentPage)?.listFooter
if (currentListFooter != null) {
item(key = "expressive_step_footer_$currentPage") {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp, bottom = 4.dp)
.trackExpressiveStepSnackbarAnchor(
anchors = snackbarAnchors,
role = ExpressiveStepSnackbarAnchorRole.SecondaryCta,
),
) {
currentListFooter()
}
}
} else {
// Absorbs [LastAnchoredBottomArrangement] slack so hero/content stays at the top.
item(key = "expressive_step_bottom_anchor") {
Spacer(Modifier.height(1.dp))
}
}
}
}
},
) { innerPadding ->
LazyListImeScrollEffect(
listState = listState,
scrollState = imeScrollState,
viewportBoundsInWindow = listViewportBounds,
contentPaddingTop = innerPadding.calculateTopPadding(),
contentPaddingBottom = innerPadding.calculateBottomPadding(),
predictiveBackProgress = { flowState.predictiveProgress },
)
}
DisabledBringIntoViewSpec {
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.background(scheme.background)
.hazeSource(hazeState)
.onGloballyPositioned {
listViewportBounds = it.boundsInWindow()
listViewportBoundsForSnackbar = it.boundsInWindow()
},
contentPadding = innerPadding,
verticalArrangement = remember { LastAnchoredBottomArrangement(space = 4.dp) },
) {
item { Spacer(Modifier.height(8.dp)) }
ExpressiveStepSnackbarHost()
}
} else {
val imeScrollState = rememberLazyListImeScrollState()
item {
Column(modifier = Modifier.fillMaxWidth()) {
Surface(
modifier = Modifier.fillMaxSize(),
color = scheme.surface,
contentColor = scheme.onSurface,
) {
Box(
modifier = Modifier
.fillMaxSize()
.imePadding()
.onGloballyPositioned { snackbarOverlayBounds = it.boundsInWindow() },
) {
Column(modifier = Modifier.fillMaxSize()) {
BackButtonRow()
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
) {
Column(
modifier = Modifier
.align(Alignment.TopStart)
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
) {
ExpressiveStepHeroSection()
Spacer(Modifier.height(ExpressiveStepHeroTitleSpacing))
HorizontalPager(
state = pagerState,
userScrollEnabled = false,
@@ -659,108 +740,35 @@ fun ExpressiveStepFlowScaffold(
verticalAlignment = Alignment.Top,
modifier = Modifier.fillMaxWidth(),
) { page ->
Column(modifier = Modifier.pagerPageFullWidth()) {
Column(
modifier = Modifier.pagerPageFullWidth(),
) {
pages[page].content(imeScrollState)
}
}
}
}
val currentListFooter = pages.getOrNull(currentPage)?.listFooter
if (currentListFooter != null) {
item(key = "expressive_step_footer_$currentPage") {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp, bottom = 4.dp)
.trackExpressiveStepSnackbarAnchor(
anchors = snackbarAnchors,
role = ExpressiveStepSnackbarAnchorRole.SecondaryCta,
),
) {
currentListFooter()
}
}
} else {
// Absorbs [LastAnchoredBottomArrangement] slack so hero/content stays at the top.
item(key = "expressive_step_bottom_anchor") {
Spacer(Modifier.height(1.dp))
}
}
}
}
}
ExpressiveStepSnackbarHost()
}
} else {
val imeScrollState = rememberLazyListImeScrollState()
Surface(
modifier = Modifier.fillMaxSize(),
color = scheme.surface,
contentColor = scheme.onSurface,
) {
Box(
modifier = Modifier
.fillMaxSize()
.imePadding()
.onGloballyPositioned { snackbarOverlayBounds = it.boundsInWindow() },
) {
Column(modifier = Modifier.fillMaxSize()) {
BackButtonRow()
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
) {
Column(
Box(
modifier = Modifier
.align(Alignment.TopStart)
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
.navigationBarsPadding()
.padding(horizontal = SettingsStepHorizontalPadding)
.padding(top = 12.dp, bottom = 16.dp)
.trackExpressiveStepSnackbarAnchor(
anchors = snackbarAnchors,
role = ExpressiveStepSnackbarAnchorRole.PrimaryCta,
),
) {
ExpressiveStepHeroSection()
Spacer(Modifier.height(ExpressiveStepHeroTitleSpacing))
HorizontalPager(
state = pagerState,
userScrollEnabled = false,
beyondViewportPageCount = 1,
pageSpacing = 0.dp,
verticalAlignment = Alignment.Top,
modifier = Modifier.fillMaxWidth(),
) { page ->
Column(
modifier = Modifier.pagerPageFullWidth(),
) {
pages[page].content(imeScrollState)
}
}
ExpressiveStepBottomBar()
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(horizontal = SettingsStepHorizontalPadding)
.padding(top = 12.dp, bottom = 16.dp)
.trackExpressiveStepSnackbarAnchor(
anchors = snackbarAnchors,
role = ExpressiveStepSnackbarAnchorRole.PrimaryCta,
),
) {
ExpressiveStepBottomBar()
}
ExpressiveStepSnackbarHost()
}
ExpressiveStepSnackbarHost()
}
}
}
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalAnimationApi::class)
@@ -791,6 +799,7 @@ fun MorphedExpressiveHero(
) {
Canvas(Modifier.fillMaxSize()) {
val unit = minOf(size.width, size.height) * 0.94f
translate(left = size.width / 2f, top = size.height / 2f) {
scale(scaleX = unit, scaleY = unit, pivot = Offset.Zero) {
translate(left = -0.5f, top = -0.5f) {
@@ -800,7 +809,7 @@ fun MorphedExpressiveHero(
colors = listOf(light, deep),
start = Offset.Zero,
end = Offset(1f, 1f),
),
)
)
}
}
@@ -820,6 +829,7 @@ fun MorphedExpressiveHero(
.graphicsLayer { alpha = 1f - p },
tint = contentColor,
)
Icon(
imageVector = toSpec.icon,
contentDescription = null,
@@ -14,6 +14,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import dev.chrisbanes.haze.HazeProgressive
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.HazeStyle
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
@@ -24,18 +25,24 @@ import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
fun HazeBottomBar(
hazeState: HazeState,
modifier: Modifier = Modifier,
hazeStyle: HazeStyle? = null,
content: @Composable () -> Unit,
) {
val effectModifier = if (hazeStyle != null) {
Modifier.hazeEffect(state = hazeState, style = hazeStyle)
} else {
Modifier.hazeEffect(state = hazeState, style = HazeMaterials.thin()) {
progressive = HazeProgressive.verticalGradient(
startIntensity = 0f,
endIntensity = 1f,
)
}
}
Column(
modifier = Modifier
.windowInsetsPadding(WindowInsets.ime)
.fillMaxWidth()
.hazeEffect(state = hazeState, style = HazeMaterials.thin()) {
progressive = HazeProgressive.verticalGradient(
startIntensity = 0f,
endIntensity = 1f,
)
}
.then(effectModifier)
.then(modifier),
) {
Column(
@@ -57,12 +64,13 @@ fun HazeActionButton(
hazeState: HazeState,
modifier: Modifier = Modifier,
innerModifier: Modifier = Modifier,
hazeStyle: HazeStyle? = null,
enabled: Boolean = true,
loading: Boolean = false,
interactionSource: MutableInteractionSource? = null,
content: @Composable (RowScope.() -> Unit)
) {
HazeBottomBar(hazeState = hazeState, modifier = modifier) {
HazeBottomBar(hazeState = hazeState, modifier = modifier, hazeStyle = hazeStyle) {
ActionButton(
onClick = onClick,
modifier = innerModifier,
@@ -18,7 +18,6 @@ import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import ru.fromchat.ui.components.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
@@ -63,16 +62,6 @@ fun SearchBar(
Surface(
modifier = modifier
.conditional(
readOnly && onReadOnlyActivate != null,
`if` = {
clickable(
interactionSource = interactionSource,
indication = null,
onClick = onReadOnlyActivate!!
)
}
)
.conditional(
(
sharedTransitionScope != null &&
@@ -98,49 +87,78 @@ fun SearchBar(
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
.padding(horizontal = 14.dp),
.padding(horizontal = 14.dp)
.conditional(
readOnly && onReadOnlyActivate != null,
`if` = {
clickable(
interactionSource = interactionSource,
indication = null,
onClick = onReadOnlyActivate!!,
)
},
),
verticalAlignment = Alignment.CenterVertically
) {
Box(modifier = Modifier.size(24.dp)) {
leadingIcon()
}
BasicTextField(
value = query,
onValueChange = onQueryChange,
singleLine = true,
textStyle = typography.copy(
color = MaterialTheme.colorScheme.onSurface
),
enabled = !readOnly,
readOnly = readOnly,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(
onSearch = { onSearch() }
),
decorationBox = { innerTextField ->
Box(
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth()
.padding(start = 12.dp, end = 8.dp),
contentAlignment = Alignment.CenterStart
) {
if (query.isEmpty()) {
Text(
text = placeholder,
style = typography.copy(color = MaterialTheme.colorScheme.onSurfaceVariant)
)
if (readOnly) {
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.padding(start = 12.dp, end = 8.dp),
contentAlignment = Alignment.CenterStart,
) {
Text(
text = query.ifEmpty { placeholder },
style = typography.copy(
color = if (query.isEmpty()) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurface
},
),
)
}
} else {
BasicTextField(
value = query,
onValueChange = onQueryChange,
singleLine = true,
textStyle = typography.copy(
color = MaterialTheme.colorScheme.onSurface
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(
onSearch = { onSearch() }
),
decorationBox = { innerTextField ->
Box(
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth()
.padding(start = 12.dp, end = 8.dp),
contentAlignment = Alignment.CenterStart
) {
if (query.isEmpty()) {
Text(
text = placeholder,
style = typography.copy(color = MaterialTheme.colorScheme.onSurfaceVariant)
)
}
innerTextField()
}
innerTextField()
}
},
modifier = Modifier
.weight(1f)
.padding(start = 4.dp)
.focusRequester(focusRequester)
)
},
modifier = Modifier
.weight(1f)
.padding(start = 4.dp)
.focusRequester(focusRequester)
)
}
Box(modifier = Modifier.width(6.dp))
Box(modifier = Modifier.size(24.dp)) {
@@ -2,13 +2,18 @@ package ru.fromchat.ui.main
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
@@ -18,15 +23,27 @@ import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHostState
import ru.fromchat.ui.components.Text
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.HazeStyle
import dev.chrisbanes.haze.HazeTint
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.rememberHazeState
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
@@ -36,7 +53,10 @@ import ru.fromchat.contacts
import ru.fromchat.profile
import ru.fromchat.settings
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.chat.rememberChatSurfaceContainerHazeStyle
import ru.fromchat.ui.components.FromChatSnackbarHost
import ru.fromchat.ui.main.chats.ChatContextMenuOverlayController
import ru.fromchat.ui.main.chats.ChatContextMenuOverlayHost
import ru.fromchat.ui.main.chats.ChatsTab
import ru.fromchat.ui.main.settings.SettingsTab
import ru.fromchat.ui.profile.ProfileScreen
@@ -48,7 +68,7 @@ private const val PAGE_SETTINGS = 2
private const val PAGE_PROFILE = 3
private const val PAGE_COUNT = 4
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@Composable
fun MainScreen(
sharedTransitionScope: SharedTransitionScope? = null,
@@ -62,87 +82,163 @@ fun MainScreen(
pageCount = { PAGE_COUNT },
)
val scope = rememberCoroutineScope()
val navBarHazeState = rememberHazeState(blurEnabled = true)
val contextMenuHazeState = rememberHazeState(blurEnabled = true)
val chatContextMenuOverlay = remember { ChatContextMenuOverlayController() }
// Pager is the single source of truth; tabs only call animateScrollToPage (no write/read loop).
val selectedPage = pagerState.currentPage
val isChatsPage = selectedPage == PAGE_CHATS
val chatMenuBlurProgress = chatContextMenuOverlay.blurProgress
Scaffold(
snackbarHost = { FromChatSnackbarHost(hostState = effectiveSnackbarHostState) },
bottomBar = {
NavigationBar {
NavigationBarItem(
selected = selectedPage == PAGE_CHATS,
onClick = {
scope.launch { pagerState.animateScrollToPage(PAGE_CHATS) }
},
label = { Text(stringResource(Res.string.chats)) },
icon = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null) }
)
NavigationBarItem(
selected = selectedPage == PAGE_CONTACTS,
onClick = {
scope.launch { pagerState.animateScrollToPage(PAGE_CONTACTS) }
},
label = { Text(stringResource(Res.string.contacts)) },
icon = { Icon(Icons.Filled.Contacts, contentDescription = null) }
)
NavigationBarItem(
selected = selectedPage == PAGE_SETTINGS,
onClick = {
scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) }
},
label = { Text(stringResource(Res.string.settings)) },
icon = { Icon(Icons.Filled.Settings, contentDescription = null) }
)
NavigationBarItem(
selected = selectedPage == PAGE_PROFILE,
onClick = {
scope.launch { pagerState.animateScrollToPage(PAGE_PROFILE) }
},
label = { Text(stringResource(Res.string.profile)) },
icon = { Icon(Icons.Filled.Person, contentDescription = null) }
)
}
},
contentWindowInsets = WindowInsets.safeDrawing.exclude(WindowInsetsSides.Top),
modifier = Modifier.imePadding()
) { innerPadding ->
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.hazeSource(contextMenuHazeState),
) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
beyondViewportPageCount = 1,
) { page ->
when (page) {
PAGE_CHATS -> ChatsTab(
isVisible = isChatsPage,
onOpenSearch = {
navController.navigate("search/conversations")
},
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
)
PAGE_CONTACTS -> ContactsTab()
PAGE_SETTINGS -> SettingsTab()
PAGE_PROFILE -> {
ProfileScreen(
userId = ApiClient.user?.id,
onBack = {},
onChat = { _ -> },
modifier = Modifier.fillMaxSize(),
onOpenSettings = {
Scaffold(
snackbarHost = { FromChatSnackbarHost(hostState = effectiveSnackbarHostState) },
bottomBar = {
Column(
modifier = Modifier
.windowInsetsPadding(WindowInsets.ime)
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainer)
.hazeEffect(
state = navBarHazeState,
style = rememberChatSurfaceContainerHazeStyle(),
),
) {
NavigationBar(
containerColor = Color.Transparent,
tonalElevation = 0.dp,
) {
NavigationBarItem(
selected = selectedPage == PAGE_CHATS,
onClick = {
scope.launch { pagerState.animateScrollToPage(PAGE_CHATS) }
},
label = { Text(stringResource(Res.string.chats)) },
icon = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null) }
)
NavigationBarItem(
selected = selectedPage == PAGE_CONTACTS,
onClick = {
scope.launch { pagerState.animateScrollToPage(PAGE_CONTACTS) }
},
label = { Text(stringResource(Res.string.contacts)) },
icon = { Icon(Icons.Filled.Contacts, contentDescription = null) }
)
NavigationBarItem(
selected = selectedPage == PAGE_SETTINGS,
onClick = {
scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) }
}
},
label = { Text(stringResource(Res.string.settings)) },
icon = { Icon(Icons.Filled.Settings, contentDescription = null) }
)
NavigationBarItem(
selected = selectedPage == PAGE_PROFILE,
onClick = {
scope.launch { pagerState.animateScrollToPage(PAGE_PROFILE) }
},
label = { Text(stringResource(Res.string.profile)) },
icon = { Icon(Icons.Filled.Person, contentDescription = null) }
)
}
else -> Unit
}
},
contentWindowInsets = WindowInsets.safeDrawing.exclude(WindowInsetsSides.Top),
modifier = Modifier.imePadding(),
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.hazeSource(navBarHazeState),
) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
beyondViewportPageCount = 1,
) { page ->
when (page) {
PAGE_CHATS -> ChatsTab(
isVisible = isChatsPage,
onOpenSearch = {
navController.navigate("search/conversations")
},
chatContextMenuOverlay = chatContextMenuOverlay,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
)
PAGE_CONTACTS -> ContactsTab()
PAGE_SETTINGS -> SettingsTab()
PAGE_PROFILE -> {
ProfileScreen(
userId = ApiClient.user?.id,
onBack = {},
onChat = { _ -> },
modifier = Modifier.fillMaxSize(),
onOpenSettings = {
scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) }
}
)
}
else -> Unit
}
}
}
}
}
}
if (chatMenuBlurProgress > 0f) {
ChatContextMenuBlurLayer(
hazeState = contextMenuHazeState,
blurProgress = chatMenuBlurProgress,
modifier = Modifier
.fillMaxSize()
.zIndex(1f),
)
}
ChatContextMenuOverlayHost(
controller = chatContextMenuOverlay,
screenWidthPx = constraints.maxWidth,
screenHeightPx = constraints.maxHeight,
modifier = Modifier
.fillMaxSize()
.zIndex(2f),
)
}
}
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable
private fun ChatContextMenuBlurLayer(
hazeState: HazeState,
blurProgress: Float,
modifier: Modifier = Modifier,
) {
val blurRadius = 12.dp * blurProgress
if (blurRadius <= 0.dp) return
Box(
modifier = modifier.hazeEffect(
state = hazeState,
style = HazeStyle(
blurRadius = blurRadius,
tints = emptyList(),
backgroundColor = Color.Transparent,
noiseFactor = 0f,
fallbackTint = HazeTint(Color.Transparent),
),
),
)
}
@@ -0,0 +1,324 @@
package ru.fromchat.ui.main.chats
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.rounded.Call
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.MarkEmailRead
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.utils.scaleOnPress
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.action_delete
import ru.fromchat.action_mark_read
import ru.fromchat.action_select
import ru.fromchat.profile_action_call
import ru.fromchat.profile_action_chat
import ru.fromchat.profile_action_link
import ru.fromchat.ui.components.Text
@Composable
internal fun ChatContextMenuMeasurer(
state: ChatContextMenuState,
listFilter: ChatListFilter,
callsEnabled: Boolean,
dmUnreadCount: Int,
showPublicMarkRead: Boolean,
hasPublicLink: Boolean,
isReadOnly: Boolean,
screenWidthPx: Int,
screenHeightPx: Int,
onMeasured: (IntSize) -> Unit,
) {
SubcomposeLayout(Modifier.size(0.dp)) { _ ->
val looseConstraints = Constraints(
minWidth = 0,
minHeight = 0,
maxWidth = screenWidthPx,
maxHeight = screenHeightPx,
)
val placeables = subcompose("measure") {
ChatContextMenuContent(
state = state,
listFilter = listFilter,
callsEnabled = callsEnabled,
dmUnreadCount = dmUnreadCount,
showPublicMarkRead = showPublicMarkRead,
hasPublicLink = hasPublicLink,
onMessage = {},
onCall = {},
onLink = {},
onMarkRead = {},
onDelete = {},
onSelect = {},
isReadOnly = isReadOnly,
modifier = Modifier.graphicsLayer(alpha = 0f),
withShadow = false,
)
}.map { it.measure(looseConstraints) }
val measured = placeables.firstOrNull()?.let { IntSize(it.width, it.height) } ?: IntSize.Zero
if (measured != IntSize.Zero) {
onMeasured(measured)
}
layout(0, 0) {
placeables.forEach { it.placeRelative(-10000, -10000) }
}
}
}
@Composable
internal fun ChatContextMenuPanel(
state: ChatContextMenuState,
listFilter: ChatListFilter,
callsEnabled: Boolean,
dmUnreadCount: Int,
showPublicMarkRead: Boolean,
hasPublicLink: Boolean,
onDismiss: () -> Unit,
onMessage: () -> Unit,
onCall: () -> Unit,
onLink: () -> Unit,
onMarkRead: () -> Unit,
onDelete: () -> Unit,
onSelect: () -> Unit,
isReadOnly: Boolean,
modifier: Modifier = Modifier,
scale: Float = 1f,
alpha: Float = 1f,
cornerRadius: Dp = 16.dp,
) {
if (isReadOnly) {
onDismiss()
return
}
ChatContextMenuContent(
state = state,
listFilter = listFilter,
callsEnabled = callsEnabled,
dmUnreadCount = dmUnreadCount,
showPublicMarkRead = showPublicMarkRead,
hasPublicLink = hasPublicLink,
onMessage = {
onMessage()
onDismiss()
},
onCall = {
onCall()
onDismiss()
},
onLink = {
onLink()
onDismiss()
},
onMarkRead = {
onMarkRead()
onDismiss()
},
onDelete = {
onDelete()
onDismiss()
},
onSelect = onSelect,
isReadOnly = isReadOnly,
scale = scale,
alpha = alpha,
cornerRadius = cornerRadius,
transformOriginX = 0f,
transformOriginY = 0f,
modifier = modifier,
)
}
@Composable
internal fun ChatContextMenuContent(
state: ChatContextMenuState,
listFilter: ChatListFilter,
callsEnabled: Boolean,
dmUnreadCount: Int,
showPublicMarkRead: Boolean,
hasPublicLink: Boolean,
onMessage: () -> Unit,
onCall: () -> Unit,
onLink: () -> Unit,
onMarkRead: () -> Unit,
onDelete: () -> Unit,
onSelect: () -> Unit,
isReadOnly: Boolean,
modifier: Modifier = Modifier,
withShadow: Boolean = true,
scale: Float = 1f,
alpha: Float = 1f,
cornerRadius: Dp = 16.dp,
transformOriginX: Float = 0.5f,
transformOriginY: Float = 0f,
) {
val menuShape = RoundedCornerShape(cornerRadius)
val menuScrollState = rememberScrollState()
val density = androidx.compose.ui.platform.LocalDensity.current
val shadowElevationPx = if (withShadow) {
with(density) { 12.dp.toPx() }
} else {
0f
}
val containerModifier = modifier
.width(IntrinsicSize.Max)
.graphicsLayer(
scaleX = scale,
scaleY = scale,
alpha = alpha,
transformOrigin = TransformOrigin(transformOriginX, transformOriginY),
shadowElevation = shadowElevationPx,
shape = menuShape,
clip = true,
)
val menuColor = MaterialTheme.colorScheme.surfaceContainerHighest
val labelMessage = stringResource(Res.string.profile_action_chat)
val labelCall = stringResource(Res.string.profile_action_call)
val labelLink = stringResource(Res.string.profile_action_link)
val labelMarkRead = stringResource(Res.string.action_mark_read)
val labelDelete = stringResource(Res.string.action_delete)
val labelSelect = stringResource(Res.string.action_select)
Box(modifier = containerModifier) {
Box(modifier = Modifier.matchParentSize().background(menuColor, menuShape))
Column(
modifier = Modifier
.padding(horizontal = 8.dp, vertical = 8.dp)
.verticalScroll(menuScrollState),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
if (!isReadOnly) {
when (state.target) {
ChatContextMenuTarget.Public -> {
ChatContextMenuItem(
icon = Icons.AutoMirrored.Filled.Chat,
text = labelMessage,
onClick = onMessage,
)
if (showPublicMarkRead) {
ChatContextMenuItem(
icon = Icons.Rounded.MarkEmailRead,
text = labelMarkRead,
onClick = onMarkRead,
)
}
if (hasPublicLink) {
ChatContextMenuItem(
icon = Icons.Filled.Link,
text = labelLink,
onClick = onLink,
)
}
ChatContextMenuItem(
icon = Icons.Filled.CheckCircle,
text = labelSelect,
onClick = onSelect,
)
}
ChatContextMenuTarget.Dm -> {
ChatContextMenuItem(
icon = Icons.AutoMirrored.Filled.Chat,
text = labelMessage,
onClick = onMessage,
)
if (callsEnabled) {
ChatContextMenuItem(
icon = Icons.Rounded.Call,
text = labelCall,
onClick = onCall,
)
}
ChatContextMenuItem(
icon = Icons.Filled.Link,
text = labelLink,
onClick = onLink,
)
if (dmUnreadCount > 0) {
ChatContextMenuItem(
icon = Icons.Rounded.MarkEmailRead,
text = labelMarkRead,
onClick = onMarkRead,
)
}
ChatContextMenuItem(
icon = Icons.Filled.CheckCircle,
text = labelSelect,
onClick = onSelect,
)
ChatContextMenuItem(
icon = Icons.Rounded.Delete,
text = labelDelete,
onClick = onDelete,
isError = true,
)
}
}
}
}
}
}
@Composable
private fun ChatContextMenuItem(
icon: ImageVector,
text: String,
onClick: () -> Unit,
isError: Boolean = false,
) {
val contentColor = if (isError) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurface
}
Row(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.scaleOnPress(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = contentColor,
)
Text(
text = text,
style = MaterialTheme.typography.bodyLarge,
color = contentColor,
)
}
}
@@ -0,0 +1,881 @@
package ru.fromchat.ui.main.chats
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.SpringSpec
import androidx.compose.animation.core.spring
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.components.Category
import com.pr0gramm3r101.components.ListItem
import com.pr0gramm3r101.components.ListItemPosition
import com.pr0gramm3r101.components.listItemClipShape
import com.pr0gramm3r101.components.listItemPositionInGroup
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.db.store.CachedConversation
import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.local.db.store.UserStatus
import ru.fromchat.api.local.db.store.visibleUsername
import ru.fromchat.api.schema.user.User
import ru.fromchat.cd_chat_selected
import ru.fromchat.presence_online
import ru.fromchat.ui.chat.Avatar
import ru.fromchat.ui.chat.TypingIndicator
import ru.fromchat.ui.components.Text
import ru.fromchat.unread_count
import ru.fromchat.user_fallback
internal object ChatListLayout {
private const val CATEGORY_TOP_SPACER = 0
const val PUBLIC_CHAT_ROW = CATEGORY_TOP_SPACER + 1
fun dmRow(dmIndex: Int): Int = PUBLIC_CHAT_ROW + 1 + dmIndex
fun dmIndexFromLazy(lazyIndex: Int): Int? =
lazyIndex.takeIf { it >= PUBLIC_CHAT_ROW + 1 }?.minus(PUBLIC_CHAT_ROW + 1)
}
internal object SearchListIndices {
private const val FIRST_RESULT_ROW = 1
fun resultRow(resultIndex: Int): Int = FIRST_RESULT_ROW + resultIndex
fun resultIndexFromLazy(lazyIndex: Int): Int? =
lazyIndex.takeIf { it >= FIRST_RESULT_ROW }?.minus(FIRST_RESULT_ROW)
}
private val ChatListCategoryMargin = PaddingValues(
start = 16.dp,
end = 16.dp,
top = 16.dp,
bottom = 20.dp,
)
@Composable
internal fun ChatListItemSpacer() {
Spacer(
Modifier
.fillMaxWidth()
.height(3.dp),
)
}
@Composable
internal fun ChatConversationsList(
listState: LazyListState,
listFilter: ChatListFilter,
conversations: List<CachedConversation>,
publicChatTitle: String?,
publicLastMessagePreview: String?,
defaultLastMessage: String,
statusMap: Map<Int, UserStatus>,
listMode: ChatsListMode,
selectionTransitionProgress: Float,
publicChatSelected: Boolean,
selectedOtherUserIds: Set<Int>,
contextMenuState: ChatContextMenuState,
overlayCloneReady: Boolean,
rowRevealProgress: Float,
modifier: Modifier = Modifier,
onOpenPublic: () -> Unit,
onOpenConversation: (Int) -> Unit,
onAvatarContextMenuPressStart: (
lazyIndex: Int,
target: ChatContextMenuTarget,
userId: Int?,
rowOffset: Offset,
rowSize: IntSize,
listItemPosition: ListItemPosition,
groupItemCount: Int,
) -> Unit,
onAvatarContextMenuPressEnd: () -> Unit,
onAvatarContextMenuOpen: (
lazyIndex: Int,
target: ChatContextMenuTarget,
userId: Int?,
menuPosition: Offset,
rowOffset: Offset,
rowSize: IntSize,
listItemPosition: ListItemPosition,
groupItemCount: Int,
) -> Unit,
onEnterSelectionMode: (lazyIndex: Int, target: ChatContextMenuTarget, userId: Int?) -> Unit,
onRowPositioned: (lazyIndex: Int, offset: Offset, size: IntSize) -> Unit,
) {
val scrollBlocked = contextMenuState.isOverlayActive
val showPublicChat = listFilter == ChatListFilter.Active
val groupCount = (if (showPublicChat) 1 else 0) + conversations.size
LazyColumn(
state = listState,
modifier = modifier,
userScrollEnabled = !scrollBlocked,
contentPadding = PaddingValues(bottom = 12.dp),
) {
if (groupCount > 0) {
Category(
margin = ChatListCategoryMargin,
containerColor = Color.Transparent,
roundedCorners = false,
) {
if (showPublicChat) {
item {
val position = listItemPositionInGroup(0, groupCount)
PublicChatRow(
publicChatTitle = publicChatTitle,
publicLastMessagePreview = publicLastMessagePreview,
defaultLastMessage = defaultLastMessage,
lazyIndex = ChatListLayout.PUBLIC_CHAT_ROW,
listMode = listMode,
selectionTransitionProgress = selectionTransitionProgress,
isSelected = publicChatSelected,
isHiddenForOverlay = contextMenuState.listIndex == ChatListLayout.PUBLIC_CHAT_ROW &&
contextMenuState.isOverlayReplicaActive &&
overlayCloneReady,
isPressingForContextMenu = contextMenuState.listIndex == ChatListLayout.PUBLIC_CHAT_ROW &&
contextMenuState.phase == ChatContextMenuPhase.Pressing,
contextMenuPressScaleActive = contextMenuState.phase == ChatContextMenuPhase.Animating &&
!contextMenuState.animatingOut,
rowRevealProgress = if (contextMenuState.listIndex == ChatListLayout.PUBLIC_CHAT_ROW) {
rowRevealProgress
} else {
0f
},
listItemPosition = position,
groupItemCount = groupCount,
onOpenPublic = onOpenPublic,
onAvatarPressStart = { offset, size ->
onAvatarContextMenuPressStart(
ChatListLayout.PUBLIC_CHAT_ROW,
ChatContextMenuTarget.Public,
null,
offset,
size,
position,
groupCount,
)
},
onAvatarPressEnd = onAvatarContextMenuPressEnd,
onAvatarLongPress = { menuPosition, rowOffset, rowSize ->
onAvatarContextMenuOpen(
ChatListLayout.PUBLIC_CHAT_ROW,
ChatContextMenuTarget.Public,
null,
menuPosition,
rowOffset,
rowSize,
position,
groupCount,
)
},
onBodyLongPress = {
onEnterSelectionMode(ChatListLayout.PUBLIC_CHAT_ROW, ChatContextMenuTarget.Public, null)
},
onRowPositioned = { offset, size ->
onRowPositioned(ChatListLayout.PUBLIC_CHAT_ROW, offset, size)
},
)
}
if (conversations.isNotEmpty()) {
item { ChatListItemSpacer() }
}
}
conversations.forEachIndexed { index, conversation ->
item {
val groupIndex = (if (showPublicChat) 1 else 0) + index
val lazyIndex = if (showPublicChat) {
ChatListLayout.dmRow(index)
} else {
ChatListLayout.PUBLIC_CHAT_ROW + index
}
val position = listItemPositionInGroup(groupIndex, groupCount)
DmConversationRow(
conversation = conversation,
lazyIndex = lazyIndex,
defaultLastMessage = defaultLastMessage,
statusMap = statusMap,
listMode = listMode,
selectionTransitionProgress = selectionTransitionProgress,
isSelected = conversation.otherUserId in selectedOtherUserIds,
isHiddenForOverlay = contextMenuState.listIndex == lazyIndex &&
contextMenuState.isOverlayReplicaActive &&
overlayCloneReady,
isPressingForContextMenu = contextMenuState.listIndex == lazyIndex &&
contextMenuState.phase == ChatContextMenuPhase.Pressing,
contextMenuPressScaleActive = contextMenuState.phase == ChatContextMenuPhase.Animating &&
!contextMenuState.animatingOut,
rowRevealProgress = if (contextMenuState.listIndex == lazyIndex) {
rowRevealProgress
} else {
0f
},
listItemPosition = position,
groupItemCount = groupCount,
onOpenConversation = { onOpenConversation(conversation.otherUserId) },
onAvatarPressStart = { offset, size ->
onAvatarContextMenuPressStart(
lazyIndex,
ChatContextMenuTarget.Dm,
conversation.otherUserId,
offset,
size,
position,
groupCount,
)
},
onAvatarPressEnd = onAvatarContextMenuPressEnd,
onAvatarLongPress = { menuPosition, rowOffset, rowSize ->
onAvatarContextMenuOpen(
lazyIndex,
ChatContextMenuTarget.Dm,
conversation.otherUserId,
menuPosition,
rowOffset,
rowSize,
position,
groupCount,
)
},
onBodyLongPress = {
onEnterSelectionMode(lazyIndex, ChatContextMenuTarget.Dm, conversation.otherUserId)
},
onRowPositioned = { offset, size -> onRowPositioned(lazyIndex, offset, size) },
)
}
if (index < conversations.lastIndex) {
item { ChatListItemSpacer() }
}
}
}
}
}
}
@Composable
internal fun SearchConversationsList(
listState: LazyListState,
conversations: List<CachedConversation>,
defaultLastMessage: String,
statusMap: Map<Int, UserStatus>,
modifier: Modifier = Modifier,
remoteUsers: List<User> = emptyList(),
onOpenConversation: (Int) -> Unit,
) {
val totalCount = conversations.size + remoteUsers.size
LazyColumn(
state = listState,
modifier = modifier,
contentPadding = PaddingValues(bottom = 12.dp),
) {
if (totalCount > 0) {
Category(
margin = ChatListCategoryMargin,
containerColor = Color.Transparent,
roundedCorners = false,
) {
var resultIndex = 0
val dmCount = conversations.size
val remoteCount = remoteUsers.size
val groupCount = dmCount + remoteCount
conversations.forEach { conversation ->
val lazyIndex = SearchListIndices.resultRow(resultIndex)
val position = listItemPositionInGroup(resultIndex, groupCount)
resultIndex++
item {
DmConversationRow(
conversation = conversation,
lazyIndex = lazyIndex,
defaultLastMessage = defaultLastMessage,
statusMap = statusMap,
listMode = ChatsListMode.Normal,
selectionTransitionProgress = 0f,
isSelected = false,
isHiddenForOverlay = false,
isPressingForContextMenu = false,
contextMenuPressScaleActive = false,
rowRevealProgress = 0f,
listItemPosition = position,
groupItemCount = groupCount,
onOpenConversation = { onOpenConversation(conversation.otherUserId) },
onAvatarPressStart = { _, _ -> },
onAvatarPressEnd = {},
onAvatarLongPress = { _, _, _ -> },
onBodyLongPress = {},
onRowPositioned = { _, _ -> },
avatarEnabled = true,
)
}
if (resultIndex < groupCount) {
item { ChatListItemSpacer() }
}
}
remoteUsers.forEach { user ->
val position = listItemPositionInGroup(resultIndex, groupCount)
resultIndex++
item {
val cached = ProfileCache.get(user.id)
val avatarUrl = cached?.profilePicture ?: user.profile_picture
val peerTitle = cached?.displayName?.takeIf { it.isNotBlank() }
?: user.displayName?.takeIf { it.isNotBlank() }
?: cached?.visibleUsername(ApiClient.user?.id)
?: user.username
val username = cached?.visibleUsername(ApiClient.user?.id) ?: user.username
ChatRowScaleContainer(
listItemPosition = position,
groupItemCount = groupCount,
pressScale = 1f,
) {
ListItem(
headline = peerTitle,
supportingText = username,
containerColor = Color.Transparent,
position = position,
groupItemCount = groupCount,
divider = false,
onClick = { onOpenConversation(user.id) },
leadingContent = {
ChatRowAvatar(
profilePictureUrl = avatarUrl,
displayNameForInitials = peerTitle,
enabled = false,
onPressStart = {},
onPressEnd = {},
onLongPress = {},
)
},
)
}
}
if (resultIndex < groupCount) {
item { ChatListItemSpacer() }
}
}
}
}
}
}
@Composable
internal fun ChatRowScaleContainer(
listItemPosition: ListItemPosition,
groupItemCount: Int,
pressScale: Float,
modifier: Modifier = Modifier,
shadowElevationPx: Float = 0f,
content: @Composable () -> Unit,
) {
val clipShape = listItemClipShape(listItemPosition, groupItemCount)
val containerColor = MaterialTheme.colorScheme.surfaceContainerLow
Box(
modifier = modifier
.fillMaxWidth()
.graphicsLayer {
scaleX = pressScale
scaleY = pressScale
transformOrigin = TransformOrigin(0.5f, 0.5f)
this.shadowElevation = shadowElevationPx
shape = clipShape
clip = true
}
.clip(clipShape)
.background(containerColor, clipShape),
) {
content()
}
}
@Composable
internal fun SelectionCheckmarkSlot(
selectionTransitionProgress: Float,
isSelected: Boolean,
modifier: Modifier = Modifier,
) {
val selectedCd = stringResource(Res.string.cd_chat_selected)
val progress = selectionTransitionProgress.coerceIn(0f, 1f)
Box(
modifier = modifier.width(30.dp * progress),
contentAlignment = Alignment.CenterStart,
) {
Icon(
imageVector = if (isSelected) Icons.Default.CheckCircle else Icons.Default.RadioButtonUnchecked,
contentDescription = selectedCd,
tint = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.offset(x = (-22).dp * (1f - progress))
.padding(end = 8.dp)
.size(22.dp)
.alpha(progress),
)
}
}
@Composable
internal fun ChatRowAvatar(
profilePictureUrl: String?,
displayNameForInitials: String,
enabled: Boolean,
onPressStart: () -> Unit,
onPressEnd: () -> Unit,
onLongPress: (Offset) -> Unit,
modifier: Modifier = Modifier,
) {
Box(
modifier
.size(40.dp)
.pointerInput(enabled) {
if (!enabled) return@pointerInput
detectTapGestures(
onPress = {
onPressStart()
try {
awaitRelease()
} finally {
onPressEnd()
}
},
onLongPress = onLongPress,
)
},
) {
Avatar(
profilePictureUrl = profilePictureUrl,
displayName = displayNameForInitials,
modifier = Modifier.fillMaxSize(),
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun PublicChatRow(
publicChatTitle: String?,
publicLastMessagePreview: String?,
defaultLastMessage: String,
lazyIndex: Int,
listMode: ChatsListMode,
selectionTransitionProgress: Float,
isSelected: Boolean,
isHiddenForOverlay: Boolean,
isPressingForContextMenu: Boolean,
contextMenuPressScaleActive: Boolean,
rowRevealProgress: Float,
listItemPosition: ListItemPosition,
groupItemCount: Int,
onOpenPublic: () -> Unit,
onAvatarPressStart: (rowOffset: Offset, rowSize: IntSize) -> Unit,
onAvatarPressEnd: () -> Unit,
onAvatarLongPress: (menuPosition: Offset, rowOffset: Offset, rowSize: IntSize) -> Unit,
onBodyLongPress: () -> Unit,
onRowPositioned: (offset: Offset, size: IntSize) -> Unit,
) {
var rowRootOffset by remember { mutableStateOf(Offset.Zero) }
var rowSize by remember { mutableStateOf(IntSize.Zero) }
val pressScaleAnim = remember { Animatable(1f) }
val contextMenuScale = chatRowContextMenuScale(rowRevealProgress, contextMenuPressScaleActive)
val rowInteractionSource = remember { MutableInteractionSource() }
LaunchedEffect(isPressingForContextMenu, isHiddenForOverlay, rowRevealProgress, contextMenuPressScaleActive) {
when {
isHiddenForOverlay -> pressScaleAnim.snapTo(contextMenuScale)
isPressingForContextMenu || (contextMenuPressScaleActive && rowRevealProgress > 0f) ->
pressScaleAnim.animateTo(ChatRowContextMenuPressScale, ChatRowPressSpring)
else -> pressScaleAnim.animateTo(1f, ChatRowPressSpring)
}
}
val clipShape = listItemClipShape(listItemPosition, groupItemCount)
ChatRowScaleContainer(
listItemPosition = listItemPosition,
groupItemCount = groupItemCount,
pressScale = pressScaleAnim.value,
modifier = Modifier
.alpha(if (isHiddenForOverlay) 0f else 1f)
.fillMaxWidth()
.clip(clipShape)
.combinedClickable(
interactionSource = rowInteractionSource,
indication = ripple(),
onClick = onOpenPublic,
onLongClick = if (listMode == ChatsListMode.Normal) {
{ onBodyLongPress() }
} else {
null
},
)
.onGloballyPositioned { coords ->
rowRootOffset = coords.positionInRoot()
rowSize = coords.size
onRowPositioned(rowRootOffset, rowSize)
},
) {
PublicChatRowContent(
publicChatTitle = publicChatTitle,
publicLastMessagePreview = publicLastMessagePreview,
defaultLastMessage = defaultLastMessage,
listMode = listMode,
selectionTransitionProgress = selectionTransitionProgress,
isSelected = isSelected,
listItemPosition = listItemPosition,
groupItemCount = groupItemCount,
avatarEnabled = listMode == ChatsListMode.Normal,
onOpenPublic = onOpenPublic,
onAvatarPressStart = { onAvatarPressStart(rowRootOffset, rowSize) },
onAvatarPressEnd = onAvatarPressEnd,
onAvatarLongPress = { localOffset ->
onAvatarLongPress(rowRootOffset + localOffset, rowRootOffset, rowSize)
},
onBodyLongPress = onBodyLongPress,
)
}
}
@Composable
internal fun PublicChatRowContent(
publicChatTitle: String?,
publicLastMessagePreview: String?,
defaultLastMessage: String,
listMode: ChatsListMode,
selectionTransitionProgress: Float,
isSelected: Boolean,
listItemPosition: ListItemPosition,
groupItemCount: Int,
avatarEnabled: Boolean,
onOpenPublic: () -> Unit,
onAvatarPressStart: () -> Unit,
onAvatarPressEnd: () -> Unit,
onAvatarLongPress: (Offset) -> Unit,
onBodyLongPress: () -> Unit,
modifier: Modifier = Modifier,
) {
val preview = publicLastMessagePreview ?: defaultLastMessage
ListItem(
headline = publicChatTitle.orEmpty(),
supportingText = if (publicChatTitle != null) preview else null,
containerColor = Color.Transparent,
position = listItemPosition,
groupItemCount = groupItemCount,
divider = false,
leadingContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
SelectionCheckmarkSlot(
selectionTransitionProgress = selectionTransitionProgress,
isSelected = isSelected,
)
ChatRowAvatar(
profilePictureUrl = null,
displayNameForInitials = publicChatTitle.orEmpty(),
enabled = avatarEnabled,
onPressStart = onAvatarPressStart,
onPressEnd = onAvatarPressEnd,
onLongPress = onAvatarLongPress,
)
}
},
trailingContent = {
if (publicChatTitle == null) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
}
},
bodyModifier = Modifier
.fillMaxWidth()
.fillMaxHeight(),
modifier = modifier,
)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun DmConversationRow(
conversation: CachedConversation,
lazyIndex: Int,
defaultLastMessage: String,
statusMap: Map<Int, UserStatus>,
listMode: ChatsListMode,
selectionTransitionProgress: Float,
isSelected: Boolean,
isHiddenForOverlay: Boolean,
isPressingForContextMenu: Boolean,
contextMenuPressScaleActive: Boolean,
rowRevealProgress: Float,
listItemPosition: ListItemPosition,
groupItemCount: Int,
onOpenConversation: () -> Unit,
onAvatarPressStart: (rowOffset: Offset, rowSize: IntSize) -> Unit,
onAvatarPressEnd: () -> Unit,
onAvatarLongPress: (menuPosition: Offset, rowOffset: Offset, rowSize: IntSize) -> Unit,
onBodyLongPress: () -> Unit,
onRowPositioned: (offset: Offset, size: IntSize) -> Unit,
avatarEnabled: Boolean = listMode == ChatsListMode.Normal,
) {
var rowRootOffset by remember { mutableStateOf(Offset.Zero) }
var rowSize by remember { mutableStateOf(IntSize.Zero) }
val pressScaleAnim = remember { Animatable(1f) }
val contextMenuScale = chatRowContextMenuScale(rowRevealProgress, contextMenuPressScaleActive)
val rowInteractionSource = remember { MutableInteractionSource() }
LaunchedEffect(isPressingForContextMenu, isHiddenForOverlay, rowRevealProgress, contextMenuPressScaleActive) {
when {
isHiddenForOverlay -> pressScaleAnim.snapTo(contextMenuScale)
isPressingForContextMenu || (contextMenuPressScaleActive && rowRevealProgress > 0f) ->
pressScaleAnim.animateTo(ChatRowContextMenuPressScale, ChatRowPressSpring)
else -> pressScaleAnim.animateTo(1f, ChatRowPressSpring)
}
}
val clipShape = listItemClipShape(listItemPosition, groupItemCount)
ChatRowScaleContainer(
listItemPosition = listItemPosition,
groupItemCount = groupItemCount,
pressScale = pressScaleAnim.value,
modifier = Modifier
.alpha(if (isHiddenForOverlay) 0f else 1f)
.fillMaxWidth()
.clip(clipShape)
.combinedClickable(
interactionSource = rowInteractionSource,
indication = ripple(),
onClick = onOpenConversation,
onLongClick = if (listMode == ChatsListMode.Normal) {
{ onBodyLongPress() }
} else {
null
},
)
.onGloballyPositioned { coords ->
rowRootOffset = coords.positionInRoot()
rowSize = coords.size
onRowPositioned(rowRootOffset, rowSize)
},
) {
DmConversationRowContent(
conversation = conversation,
defaultLastMessage = defaultLastMessage,
statusMap = statusMap,
listMode = listMode,
selectionTransitionProgress = selectionTransitionProgress,
isSelected = isSelected,
listItemPosition = listItemPosition,
groupItemCount = groupItemCount,
avatarEnabled = avatarEnabled,
onOpenConversation = onOpenConversation,
onAvatarPressStart = { onAvatarPressStart(rowRootOffset, rowSize) },
onAvatarPressEnd = onAvatarPressEnd,
onAvatarLongPress = { localOffset ->
onAvatarLongPress(rowRootOffset + localOffset, rowRootOffset, rowSize)
},
onBodyLongPress = onBodyLongPress,
)
}
}
@Composable
internal fun DmConversationRowContent(
conversation: CachedConversation,
defaultLastMessage: String,
statusMap: Map<Int, UserStatus>,
listMode: ChatsListMode,
selectionTransitionProgress: Float,
isSelected: Boolean,
listItemPosition: ListItemPosition,
groupItemCount: Int,
avatarEnabled: Boolean,
onOpenConversation: () -> Unit,
onAvatarPressStart: () -> Unit,
onAvatarPressEnd: () -> Unit,
onAvatarLongPress: (Offset) -> Unit,
onBodyLongPress: () -> Unit,
modifier: Modifier = Modifier,
) {
val cached = ProfileCache.get(conversation.otherUserId)
val avatarUrl = cached?.profilePicture
val peerTitle = cached?.displayName?.takeIf { it.isNotBlank() }
?: cached?.visibleUsername(ApiClient.user?.id)
?: conversation.displayName.ifBlank {
stringResource(Res.string.user_fallback, conversation.otherUserId)
}
val preview = conversation.lastMessagePreview?.trim().orEmpty().ifEmpty { defaultLastMessage }
val status = statusMap[conversation.otherUserId]
val typingUsers = status?.typingUsernames.orEmpty()
val isTyping = typingUsers.isNotEmpty()
val isOnline = status?.online ?: (cached?.online == true)
val statusKey = when {
isTyping -> "typing:${typingUsers.joinToString("|")}"
isOnline -> "online"
else -> "offline"
}
ListItem(
headline = peerTitle,
supportingSlot = {
AnimatedContent(
targetState = statusKey,
transitionSpec = {
(slideInVertically { it / 2 } + fadeIn()) togetherWith
(slideOutVertically { -it / 2 } + fadeOut())
},
label = "dm_status_${conversation.otherUserId}",
) { state ->
when {
state.startsWith("typing:") -> TypingIndicator(typingUsers = typingUsers)
state == "online" -> Text(
text = stringResource(Res.string.presence_online),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.primary,
)
else -> Text(
text = preview,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
containerColor = Color.Transparent,
position = listItemPosition,
groupItemCount = groupItemCount,
divider = false,
leadingContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
SelectionCheckmarkSlot(
selectionTransitionProgress = selectionTransitionProgress,
isSelected = isSelected,
)
ChatRowAvatar(
profilePictureUrl = avatarUrl,
displayNameForInitials = peerTitle,
enabled = avatarEnabled,
onPressStart = onAvatarPressStart,
onPressEnd = onAvatarPressEnd,
onLongPress = onAvatarLongPress,
)
}
},
trailingContent = {
if (conversation.unreadCount > 0 && listMode == ChatsListMode.Normal) {
Text(stringResource(Res.string.unread_count, conversation.unreadCount))
}
},
bodyModifier = Modifier
.fillMaxWidth()
.fillMaxHeight(),
modifier = modifier,
)
}
/** Forward reveal only: 0.96 at progress 0 → 1.0 at progress 1. Reverse uses [applyPressScale] = false → 1.0. */
internal fun chatRowContextMenuScale(
revealProgress: Float,
applyPressScale: Boolean = true,
): Float =
if (!applyPressScale) {
1f
} else {
ChatRowContextMenuPressScale + (1f - ChatRowContextMenuPressScale) * revealProgress
}
internal fun chatContextMenuCenteredBlockTopY(
rowOffsetY: Float,
blockHeightPx: Float,
overlayOriginY: Float,
overlayHeightPx: Int,
paddingPx: Float,
): Float {
if (blockHeightPx <= 0f || overlayHeightPx == 0) return rowOffsetY
return overlayOriginY + ((overlayHeightPx - blockHeightPx) / 2f).coerceAtLeast(paddingPx)
}
internal fun chatContextMenuClampedMenuX(
preferredLeftX: Float,
menuWidth: Int,
overlayOriginX: Float,
overlayWidth: Int,
paddingPx: Float,
): Int {
if (menuWidth <= 0 || overlayWidth == 0) return preferredLeftX.toInt()
var x = preferredLeftX.toInt()
val rightEdge = (overlayOriginX + overlayWidth - paddingPx).toInt()
val leftEdge = (overlayOriginX + paddingPx).toInt()
if (x < leftEdge) x = leftEdge
if (x + menuWidth > rightEdge) x = rightEdge - menuWidth
return x
}
internal const val ChatRowContextMenuPressScale = 0.96f
internal val ChatRowPressSpring = spring<Float>(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMediumLow,
)
internal val ChatContextMenuRevealSpring = spring<Float>(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMediumLow,
)
internal val ChatContextMenuOpenSpring = spring<Float>(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessMediumLow,
)
internal val ChatSelectionTransitionSpring: SpringSpec<Float> = spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMediumLow,
)
@@ -38,7 +38,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -52,6 +51,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.utils.resetFocus
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
@@ -59,15 +59,17 @@ import ru.fromchat.Res
import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.db.store.CachedConversation
import ru.fromchat.api.local.db.store.MessageCacheStore
import ru.fromchat.api.local.db.store.MessageRepository
import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.local.db.store.UserStatusStore
import ru.fromchat.api.local.db.store.visibleUsername
import ru.fromchat.api.schema.user.User
import ru.fromchat.chat_last_mesaage
import ru.fromchat.chat_preview_attachment
import ru.fromchat.search_hint
import ru.fromchat.search_not_found
import ru.fromchat.search_not_found_message
import ru.fromchat.search_title
import ru.fromchat.ui.components.BackHandler
import ru.fromchat.ui.components.SearchBar
import ru.fromchat.ui.components.SearchBarSharedElement
import ru.fromchat.ui.components.Text
@@ -85,9 +87,11 @@ fun ChatsSearchScreen(
val searchListState = rememberLazyListState()
val statusMap by UserStatusStore.status.collectAsState()
val defaultLastMessage = stringResource(Res.string.chat_last_mesaage)
val attachmentOnlyPreview = stringResource(Res.string.chat_preview_attachment)
val searchHint = stringResource(Res.string.search_hint)
val searchBarHint = stringResource(Res.string.search_title)
var dmConversations by remember { mutableStateOf<List<CachedConversation>>(emptyList()) }
var remoteUsers by remember { mutableStateOf<List<User>>(emptyList()) }
val statusSubscriptionScope = rememberCoroutineScope()
var subscribedDmUserIds by remember { mutableStateOf<Set<Int>>(emptySet()) }
val keyboardController = LocalSoftwareKeyboardController.current
@@ -99,7 +103,7 @@ fun ChatsSearchScreen(
val searchQuery = searchText.trim().lowercase().trimStart('@')
val filteredDmConversations = remember(dmConversations, searchQuery) {
if (searchQuery.isBlank()) {
dmConversations
emptyList()
} else {
dmConversations.filter { conv ->
matchesSearchConversations(conv, searchQuery)
@@ -107,19 +111,50 @@ fun ChatsSearchScreen(
}
}
val searchUiState by remember(searchText, filteredDmConversations) {
derivedStateOf {
when {
searchText.isBlank() -> SearchScreenState.EmptyQuery
filteredDmConversations.isEmpty() -> SearchScreenState.NotFound
else -> SearchScreenState.Results
val searchResults = remember(filteredDmConversations, remoteUsers, searchQuery) {
if (searchQuery.isBlank()) {
emptyList()
} else {
val dmUserIds = filteredDmConversations.map { it.otherUserId }.toSet()
val remoteOnly = remoteUsers.filter { it.id !in dmUserIds }
filteredDmConversations.map { SearchResult.Conversation(it) } +
remoteOnly.map { SearchResult.UserResult(it) }
}
}
val searchUiState = when {
searchText.isBlank() -> SearchScreenState.EmptyQuery
searchResults.isEmpty() -> SearchScreenState.NotFound
else -> SearchScreenState.Results
}
LaunchedEffect(searchQuery) {
if (searchQuery.length < 2) {
remoteUsers = emptyList()
return@LaunchedEffect
}
delay(300)
val querySnapshot = searchQuery
runCatching {
ApiClient.searchUsers(querySnapshot)
}.onSuccess { users ->
if (querySnapshot == searchText.trim().lowercase().trimStart('@')) {
users.forEach { ProfileCache.mergeFromDmUser(it) }
remoteUsers = users
}
}.onFailure {
if (querySnapshot == searchText.trim().lowercase().trimStart('@')) {
remoteUsers = emptyList()
}
}
}
LaunchedEffect(Unit) {
// Load cached DM conversations first for immediate display.
runCatching {
dmConversations = MessageCacheStore.loadCachedDmConversations()
MessageCacheStore.loadCachedDmConversations()
}.onSuccess { conversations ->
conversations.forEach { ProfileCache.mergeFromCachedConversation(it) }
dmConversations = conversations
}
// Then refresh from network and update cache + state.
@@ -128,17 +163,18 @@ fun ChatsSearchScreen(
}.onSuccess { conversations ->
runCatching {
conversations.forEach { ProfileCache.mergeFromDmUser(it.user) }
MessageCacheStore.replaceDmConversations(conversations)
dmConversations = MessageCacheStore.loadCachedDmConversations()
MessageRepository.replaceDmConversations(conversations, attachmentOnlyPreview)
dmConversations = MessageRepository.loadCachedDmConversations()
}
}
}
LaunchedEffect(filteredDmConversations, searchListState) {
LaunchedEffect(searchResults, searchListState) {
snapshotFlow {
searchListState.layoutInfo.visibleItemsInfo
.mapNotNull { item ->
filteredDmConversations.getOrNull(item.index)?.otherUserId
SearchListIndices.resultIndexFromLazy(item.index)
?.let { searchResults.getOrNull(it)?.userId }
}
.filter { it > 0 }
.toSet()
@@ -160,6 +196,7 @@ fun ChatsSearchScreen(
DisposableEffect(Unit) {
onDispose {
hideIme()
if (subscribedDmUserIds.isNotEmpty()) {
statusSubscriptionScope.launch {
subscribedDmUserIds.forEach { ApiClient.sendUnsubscribeStatus(it) }
@@ -169,11 +206,6 @@ fun ChatsSearchScreen(
}
}
BackHandler(enabled = true) {
hideIme()
onBack()
}
Scaffold(
modifier = Modifier
.imePadding()
@@ -244,16 +276,12 @@ fun ChatsSearchScreen(
SearchConversationsList(
listState = searchListState,
conversations = filteredDmConversations,
remoteUsers = remoteUsers.filter { user ->
filteredDmConversations.none { it.otherUserId == user.id }
},
defaultLastMessage = defaultLastMessage,
statusMap = statusMap,
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 12.dp),
onOpenProfile = { userId ->
if (userId != 0) {
onOpenProfile(userId)
}
},
modifier = Modifier.fillMaxSize(),
onOpenConversation = { userId ->
if (userId != 0) {
onOpenConversation(userId)
@@ -267,16 +295,26 @@ fun ChatsSearchScreen(
}
}
private fun matchesSearchConversations(conv: CachedConversation, normalizedQuery: String): Boolean {
val cached = ProfileCache.get(conv.otherUserId)
val candidates = buildList {
add(conv.displayName)
cached?.displayName?.let { add(it) }
cached?.visibleUsername(ApiClient.user?.id)?.let { add(it) }
private fun matchesSearchConversations(conv: CachedConversation, normalizedQuery: String) =
ProfileCache.get(conv.otherUserId).let { cached ->
buildList {
add(conv.displayName)
cached?.displayName?.let { add(it) }
cached?.visibleUsername(ApiClient.user?.id)?.let { add(it) }
}.any { candidate ->
candidate.lowercase().contains(normalizedQuery)
}
}
return candidates.any { candidate ->
candidate.lowercase().contains(normalizedQuery)
private sealed interface SearchResult {
val userId: Int
data class Conversation(val conversation: CachedConversation) : SearchResult {
override val userId: Int = conversation.otherUserId
}
data class UserResult(val user: User) : SearchResult {
override val userId: Int = user.id
}
}
@@ -0,0 +1,124 @@
package ru.fromchat.ui.main.chats
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.IntSize
import com.pr0gramm3r101.components.ListItemPosition
import ru.fromchat.api.local.db.store.CachedConversation
import ru.fromchat.api.local.db.store.UserStatus
enum class ChatsListMode {
Normal,
Selecting,
}
enum class ChatListFilter {
Active,
}
enum class ChatContextMenuTarget {
Dm,
Public,
}
enum class ChatContextMenuPhase {
Closed,
/** Avatar held — scale-down only, no blur. */
Pressing,
/** Blur + scale-up + row centering run concurrently. */
Animating,
/** Animations finished — context menu visible. */
Open,
}
data class ChatContextMenuState(
val phase: ChatContextMenuPhase = ChatContextMenuPhase.Closed,
val target: ChatContextMenuTarget = ChatContextMenuTarget.Dm,
val otherUserId: Int? = null,
val listIndex: Int = -1,
val positionX: Int = 0,
val positionY: Int = 0,
val rowOffset: Offset = Offset.Zero,
val rowSize: IntSize = IntSize.Zero,
val animatingOut: Boolean = false,
val listItemPosition: ListItemPosition = ListItemPosition.MIDDLE,
val groupItemCount: Int = 1,
) {
val isOpen: Boolean get() = phase == ChatContextMenuPhase.Open
val isPressing: Boolean get() = phase == ChatContextMenuPhase.Pressing
val isOverlayActive: Boolean get() = phase != ChatContextMenuPhase.Closed
/** List row is hidden only while the MainScreen overlay replica is shown. */
val isOverlayReplicaActive: Boolean get() =
phase == ChatContextMenuPhase.Animating || phase == ChatContextMenuPhase.Open
val isBlurActive: Boolean get() = phase == ChatContextMenuPhase.Animating || phase == ChatContextMenuPhase.Open
}
data class ChatsSelection(
val publicChatSelected: Boolean = false,
val selectedOtherUserIds: Set<Int> = emptySet(),
) {
val count: Int get() = selectedOtherUserIds.size + if (publicChatSelected) 1 else 0
val isEmpty: Boolean get() = !publicChatSelected && selectedOtherUserIds.isEmpty()
}
data class ChatsBulkActions(
val canDelete: Boolean = false,
val canMarkRead: Boolean = false,
)
fun resolveChatsBulkActions(selection: ChatsSelection): ChatsBulkActions =
if (selection.isEmpty) {
ChatsBulkActions()
} else {
val onlyDms = !selection.publicChatSelected && selection.selectedOtherUserIds.isNotEmpty()
ChatsBulkActions(canDelete = onlyDms, canMarkRead = true)
}
/** Snapshot of chats context-menu overlay data rendered above [hazeSource] in [ru.fromchat.ui.main.MainScreen]. */
data class ChatContextMenuOverlayUiState(
val contextMenuState: ChatContextMenuState,
val blurProgress: Float = 0f,
val listFilter: ChatListFilter = ChatListFilter.Active,
val publicChatTitle: String? = null,
val publicLastMessagePreview: String? = null,
val publicChatLink: String? = null,
val defaultLastMessage: String = "",
val conversations: List<CachedConversation> = emptyList(),
val statusMap: Map<Int, UserStatus> = emptyMap(),
val listMode: ChatsListMode = ChatsListMode.Normal,
val selectionTransitionProgress: Float = 0f,
val publicChatSelected: Boolean = false,
val selectedOtherUserIds: Set<Int> = emptySet(),
val isReadOnly: Boolean = false,
val callsEnabled: Boolean = false,
val publicHasUnread: Boolean = false,
)
class ChatContextMenuOverlayController {
var uiState by mutableStateOf<ChatContextMenuOverlayUiState?>(null)
var blurProgress by mutableFloatStateOf(0f)
/** Shared row scale progress (0 = pressed, 1 = full) for overlay ↔ list handoff. */
var rowRevealProgress by mutableFloatStateOf(0f)
/** True once the overlay row clone has been composed and positioned. */
var overlayCloneReady by mutableStateOf(false)
var onStateChange: (ChatContextMenuState) -> Unit = {}
var onDismiss: () -> Unit = {}
var onMessage: () -> Unit = {}
var onCall: (Int) -> Unit = {}
var onLink: () -> Unit = {}
var onMarkRead: (Int) -> Unit = {}
var onMarkPublicRead: () -> Unit = {}
var onDelete: (Int) -> Unit = {}
var onSelect: () -> Unit = {}
var onOverlayCloneReady: () -> Unit = {}
fun clear() {
uiState = null
blurProgress = 0f
rowRevealProgress = 0f
overlayCloneReady = false
}
}
File diff suppressed because it is too large Load Diff
@@ -1,121 +0,0 @@
package ru.fromchat.ui.main.chats
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import ru.fromchat.ui.components.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
data class ChatsTabBannerCandidate(
val id: String,
val priority: Int,
val title: String,
val message: String,
val icon: String = "",
val onTap: () -> Unit = {},
val onDismiss: (() -> Unit)? = null,
)
@Composable
fun ChatsTabBannerHost(
candidates: List<ChatsTabBannerCandidate>,
modifier: Modifier = Modifier
) {
val dismissedByUser = remember { mutableStateMapOf<String, Boolean>() }
val sortedVisibleCandidates = remember(candidates, dismissedByUser.size) {
candidates
.filter { candidate ->
val canDismiss = candidate.onDismiss != null
!canDismiss || dismissedByUser[candidate.id] != true
}
.sortedByDescending { it.priority }
}
val activeCandidate = sortedVisibleCandidates.firstOrNull()
AnimatedVisibility(activeCandidate != null) {
activeCandidate?.let { candidate ->
ChatsTabBanner(
candidate = candidate,
modifier = modifier.padding(
horizontal = 12.dp,
vertical = 8.dp
),
onDismiss = {
if (candidate.onDismiss != null) {
dismissedByUser[candidate.id] = true
candidate.onDismiss.invoke()
}
}
)
}
}
}
@Composable
private fun ChatsTabBanner(
candidate: ChatsTabBannerCandidate,
modifier: Modifier = Modifier,
onDismiss: () -> Unit
) {
val canDismiss = candidate.onDismiss != null
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(20.dp),
modifier = modifier
.fillMaxWidth()
.clickable(onClick = candidate.onTap)
) {
Row(
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = candidate.icon,
style = MaterialTheme.typography.titleMedium
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = candidate.title,
style = MaterialTheme.typography.labelLarge
)
Text(
text = candidate.message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3
)
}
if (canDismiss) {
IconButton(onClick = onDismiss) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Dismiss banner"
)
}
}
}
}
}
@@ -10,6 +10,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.outlined.Description
import androidx.compose.material.icons.outlined.Shield
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -38,11 +40,14 @@ import org.jetbrains.compose.resources.vectorResource
import ru.fromchat.Res
import ru.fromchat.about
import ru.fromchat.about_link_max
import ru.fromchat.about_link_privacy
import ru.fromchat.about_link_telegram
import ru.fromchat.about_link_terms
import ru.fromchat.about_link_website
import ru.fromchat.about_version
import ru.fromchat.app_desc
import ru.fromchat.back
import ru.fromchat.legal.DocumentType
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.components.BrandTitle
@@ -146,6 +151,7 @@ fun AboutScreen() {
headline = stringResource(Res.string.about_link_website),
supportingText = URL_WEBSITE,
onClick = { uriHandler.openUri(URL_WEBSITE) },
divider = true,
leadingContent = {
Icon(
imageVector = Icons.Outlined.Website,
@@ -155,6 +161,33 @@ fun AboutScreen() {
)
}
)
ListItem(
headline = stringResource(Res.string.about_link_privacy),
onClick = { navController.navigate(DocumentType.route(DocumentType.Privacy)) },
divider = true,
leadingContent = {
Icon(
imageVector = Icons.Outlined.Shield,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
}
)
ListItem(
headline = stringResource(Res.string.about_link_terms),
onClick = { navController.navigate(DocumentType.route(DocumentType.Terms)) },
leadingContent = {
Icon(
imageVector = Icons.Outlined.Description,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
}
)
}
}
}
@@ -0,0 +1,502 @@
package ru.fromchat.ui.profile
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.RestartAlt
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
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.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import dev.chrisbanes.haze.HazeProgressive
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.chrisbanes.haze.rememberHazeState
import io.ktor.client.call.body
import io.ktor.client.plugins.ClientRequestException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.action_save
import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.schema.core.ErrorResponse
import ru.fromchat.api.schema.user.profile.UserProfile
import ru.fromchat.auth_char_count
import ru.fromchat.auth_username_taken
import ru.fromchat.back
import ru.fromchat.display_name
import ru.fromchat.display_name_error
import ru.fromchat.error_unexpected
import ru.fromchat.profile_edit_saved
import ru.fromchat.profile_edit_title
import ru.fromchat.profile_headline_bio
import ru.fromchat.server_config_action_reset
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.chat.Avatar
import ru.fromchat.ui.components.DisabledBringIntoViewSpec
import ru.fromchat.ui.components.FromChatSnackbarHost
import ru.fromchat.ui.components.HazeActionButton
import ru.fromchat.ui.components.LazyListFocusScrollEffect
import ru.fromchat.ui.components.Text
import ru.fromchat.ui.components.expressiveStepFieldColors
import ru.fromchat.ui.components.rememberLazyListFocusScrollState
import ru.fromchat.ui.components.SettingsPasswordOutlineFieldShape
import ru.fromchat.ui.components.showReplacingSnackbar
import ru.fromchat.ui.components.scrollFocusedItemIntoView
import ru.fromchat.ui.components.trackLazyListFocus
import ru.fromchat.profile_bio_length_error
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
import ru.fromchat.username
import ru.fromchat.username_length_error
private const val DISPLAY_NAME_MAX = 64
private const val BIO_MAX = 500
private const val USERNAME_MIN = 3
private const val USERNAME_MAX = 20
private object EditProfileLazyListIndices {
const val USERNAME_FIELD = 1
const val DISPLAY_NAME_FIELD = 2
const val BIO_FIELD = 3
}
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalMaterial3ExpressiveApi::class,
ExperimentalFoundationApi::class,
ExperimentalHazeMaterialsApi::class,
)
@Composable
fun EditProfileScreen(
onBack: () -> Unit,
initialFocusField: EditProfileFocusField? = null,
) {
val navController = LocalNavController.current
val scope = rememberCoroutineScope()
val density = LocalDensity.current
val snackbarHostState = remember { SnackbarHostState() }
val fieldColors = expressiveStepFieldColors()
val hazeState = rememberHazeState()
val focusScrollState = rememberLazyListFocusScrollState()
val listState = rememberLazyListState()
val usernameFocusRequester = remember { FocusRequester() }
val displayNameFocusRequester = remember { FocusRequester() }
val bioFocusRequester = remember { FocusRequester() }
var usernameField by remember { mutableStateOf(TextFieldValue()) }
var displayNameField by remember { mutableStateOf(TextFieldValue()) }
var bioField by remember { mutableStateOf(TextFieldValue()) }
var savedUsername by remember { mutableStateOf("") }
var savedDisplayName by remember { mutableStateOf("") }
var savedBio by remember { mutableStateOf("") }
var busy by remember { mutableStateOf(false) }
var loaded by remember { mutableStateOf(false) }
var listViewportBounds by remember { mutableStateOf<Rect?>(null) }
val savedMessage = stringResource(Res.string.profile_edit_saved)
val unexpectedError = stringResource(Res.string.error_unexpected)
val displayNameError = stringResource(Res.string.display_name_error)
val usernameLengthError = stringResource(Res.string.username_length_error)
val usernameTaken = stringResource(Res.string.auth_username_taken)
LaunchedEffect(Unit) {
val profile = withContext(Dispatchers.Default) {
runCatching { ApiClient.getOwnProfile() }.getOrNull()
?: ApiClient.user?.id?.let { ProfileCache.get(it) }
}
profile?.let {
usernameField = TextFieldValue(it.username)
displayNameField = TextFieldValue(it.displayName.orEmpty())
bioField = TextFieldValue(it.bio.orEmpty())
savedUsername = it.username
savedDisplayName = it.displayName.orEmpty()
savedBio = it.bio.orEmpty()
}
loaded = true
}
LaunchedEffect(loaded, initialFocusField) {
if (!loaded || initialFocusField == null) return@LaunchedEffect
val itemIndex = when (initialFocusField) {
EditProfileFocusField.Username -> EditProfileLazyListIndices.USERNAME_FIELD
EditProfileFocusField.DisplayName -> EditProfileLazyListIndices.DISPLAY_NAME_FIELD
EditProfileFocusField.Bio -> EditProfileLazyListIndices.BIO_FIELD
}
val focusRequester = when (initialFocusField) {
EditProfileFocusField.Username -> usernameFocusRequester
EditProfileFocusField.DisplayName -> displayNameFocusRequester
EditProfileFocusField.Bio -> bioFocusRequester
}
delay(100)
listState.scrollFocusedItemIntoView(
itemIndex,
viewportMarginPx = with(density) { 12.dp.toPx() },
)
when (initialFocusField) {
EditProfileFocusField.Username -> {
usernameField = usernameField.copy(
selection = TextRange(0, usernameField.text.length),
)
}
EditProfileFocusField.DisplayName -> {
displayNameField = displayNameField.copy(
selection = TextRange(0, displayNameField.text.length),
)
}
EditProfileFocusField.Bio -> Unit
}
focusRequester.requestFocus()
}
val username = usernameField.text
val displayName = displayNameField.text
val bio = bioField.text
val trimmedUsername = username.trim()
val trimmedDisplayName = displayName.trim()
val trimmedBio = bio.trim()
val usernameError = trimmedUsername.isNotEmpty() &&
trimmedUsername.length !in USERNAME_MIN..USERNAME_MAX
val displayNameFieldError = trimmedDisplayName.isNotEmpty() &&
(trimmedDisplayName.isBlank() || trimmedDisplayName.length > DISPLAY_NAME_MAX)
val bioError = bio.length > BIO_MAX
val hasChanges = loaded && (
trimmedUsername != savedUsername.trim() ||
trimmedDisplayName != savedDisplayName.trim() ||
trimmedBio != savedBio.trim()
)
val hasValidationErrors = usernameError || displayNameFieldError || bioError ||
(hasChanges && trimmedDisplayName.isBlank())
val canSave = loaded && hasChanges && !hasValidationErrors && !busy
val avatarDisplayName = trimmedDisplayName.ifBlank { trimmedUsername }.ifBlank { "?" }
fun showSnack(text: String) {
scope.launch {
snackbarHostState.showReplacingSnackbar(
message = text,
withDismissAction = false,
duration = SnackbarDuration.Short,
)
}
}
fun resetToSaved() {
usernameField = TextFieldValue(savedUsername)
displayNameField = TextFieldValue(savedDisplayName)
bioField = TextFieldValue(savedBio)
}
fun saveProfile() {
if (!canSave) return
if (trimmedUsername.length !in USERNAME_MIN..USERNAME_MAX) {
showSnack(usernameLengthError)
return
}
if (trimmedDisplayName.isBlank() || trimmedDisplayName.length > DISPLAY_NAME_MAX) {
showSnack(displayNameError)
return
}
if (trimmedBio.length > BIO_MAX) {
showSnack(unexpectedError)
return
}
scope.launch {
busy = true
try {
if (
!trimmedUsername.equals(savedUsername, ignoreCase = true) &&
runCatching { ApiClient.checkUsername(trimmedUsername).exists }
.getOrDefault(false)
) {
showSnack(usernameTaken)
return@launch
}
val response = withContext(Dispatchers.Default) {
ApiClient.updateProfile(
username = trimmedUsername,
displayName = trimmedDisplayName,
bio = trimmedBio.ifEmpty { "" },
)
}
savedUsername = response.username
savedDisplayName = response.displayName.orEmpty()
savedBio = response.bio.orEmpty()
usernameField = TextFieldValue(savedUsername)
displayNameField = TextFieldValue(savedDisplayName)
bioField = TextFieldValue(savedBio)
val ownUserId = ApiClient.user?.id
if (ownUserId != null) {
val existing = ProfileCache.get(ownUserId)
val updatedProfile = existing?.copy(
username = response.username,
displayName = response.displayName,
bio = response.bio,
) ?: UserProfile(
id = ownUserId,
username = response.username,
displayName = response.displayName,
bio = response.bio,
profilePicture = ApiClient.user?.profile_picture,
online = ApiClient.user?.online ?: false,
lastSeen = ApiClient.user?.last_seen,
createdAt = existing?.createdAt,
)
ProfileCache.put(updatedProfile)
}
showSnack(savedMessage)
navController.previousBackStackEntry
?.savedStateHandle
?.set(ProfileRoutes.REFRESH_KEY, true)
onBack()
} catch (error: ClientRequestException) {
val detail = if (error.response.status.value in arrayOf(400, 401, 403, 429)) {
runCatching { error.response.body<ErrorResponse>().detail }.getOrDefault(unexpectedError)
} else {
unexpectedError
}
showSnack(detail)
} catch (_: Exception) {
showSnack(unexpectedError)
} finally {
busy = false
}
}
}
Scaffold(
modifier = Modifier.fillMaxSize(),
contentWindowInsets = WindowInsets.navigationBars,
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onSurface,
snackbarHost = { FromChatSnackbarHost(hostState = snackbarHostState) },
bottomBar = {
HazeActionButton(
hazeState = hazeState,
onClick = ::saveProfile,
enabled = canSave,
loading = busy,
) {
Text(stringResource(Res.string.action_save))
}
},
topBar = {
TopAppBar(
title = { Text(stringResource(Res.string.profile_edit_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(Res.string.back),
modifier = Modifier.size(24.dp),
)
}
},
actions = {
IconButton(
onClick = ::resetToSaved,
enabled = loaded && hasChanges && !busy,
) {
Icon(
imageVector = Icons.Filled.RestartAlt,
contentDescription = stringResource(Res.string.server_config_action_reset),
modifier = Modifier.size(24.dp),
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent,
),
modifier = Modifier.hazeEffect(state = hazeState, style = HazeMaterials.thin()) {
progressive = HazeProgressive.verticalGradient(
startIntensity = 1f,
endIntensity = 0f,
)
},
)
},
) { innerPadding ->
LazyListFocusScrollEffect(
listState = listState,
focusState = focusScrollState,
viewportBoundsInWindow = listViewportBounds,
contentPaddingTop = innerPadding.calculateTopPadding(),
contentPaddingBottom = innerPadding.calculateBottomPadding(),
)
DisabledBringIntoViewSpec {
Box(
modifier = Modifier
.fillMaxSize()
.consumeWindowInsets(innerPadding)
.background(MaterialTheme.colorScheme.background)
.hazeSource(hazeState)
.onGloballyPositioned { listViewportBounds = it.boundsInWindow() },
) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = innerPadding,
) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp, bottom = 8.dp),
contentAlignment = Alignment.Center,
) {
Avatar(
profilePictureUrl = ApiClient.user?.profile_picture,
displayName = avatarDisplayName,
modifier = Modifier.size(96.dp),
)
}
}
item {
OutlinedTextField(
value = usernameField,
onValueChange = { usernameField = it },
label = { Text(stringResource(Res.string.username)) },
modifier = Modifier
.fillMaxWidth()
.focusRequester(usernameFocusRequester)
.trackLazyListFocus(focusScrollState, EditProfileLazyListIndices.USERNAME_FIELD)
.padding(horizontal = SettingsStepHorizontalPadding, vertical = 8.dp),
enabled = loaded && !busy,
singleLine = true,
isError = usernameError,
supportingText = if (usernameError) {
{ Text(stringResource(Res.string.username_length_error)) }
} else null,
colors = fieldColors,
shape = SettingsPasswordOutlineFieldShape,
)
}
item {
OutlinedTextField(
value = displayNameField,
onValueChange = { displayNameField = it },
label = { Text(stringResource(Res.string.display_name)) },
modifier = Modifier
.fillMaxWidth()
.focusRequester(displayNameFocusRequester)
.trackLazyListFocus(focusScrollState, EditProfileLazyListIndices.DISPLAY_NAME_FIELD)
.padding(horizontal = SettingsStepHorizontalPadding),
enabled = loaded && !busy,
singleLine = true,
isError = displayNameFieldError || (hasChanges && trimmedDisplayName.isBlank()),
supportingText = {
when {
displayNameFieldError || (hasChanges && trimmedDisplayName.isBlank()) ->
Text(stringResource(Res.string.display_name_error))
else ->
Text(
stringResource(
Res.string.auth_char_count,
displayName.length,
DISPLAY_NAME_MAX,
),
)
}
},
colors = fieldColors,
shape = SettingsPasswordOutlineFieldShape,
)
}
item { Spacer(Modifier.height(12.dp)) }
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = SettingsStepHorizontalPadding),
) {
OutlinedTextField(
value = bioField,
onValueChange = { bioField = it },
label = { Text(stringResource(Res.string.profile_headline_bio)) },
modifier = Modifier
.fillMaxWidth()
.focusRequester(bioFocusRequester)
.trackLazyListFocus(focusScrollState, EditProfileLazyListIndices.BIO_FIELD),
enabled = loaded && !busy,
minLines = 3,
maxLines = 6,
isError = bioError,
supportingText = {
if (bioError) {
Text(stringResource(Res.string.profile_bio_length_error, BIO_MAX))
} else if (bio.isNotEmpty()) {
Text(stringResource(Res.string.auth_char_count, bio.length, BIO_MAX))
}
},
colors = fieldColors,
shape = SettingsPasswordOutlineFieldShape,
)
}
}
}
}
}
}
}
@@ -0,0 +1,23 @@
package ru.fromchat.ui.profile
enum class EditProfileFocusField(val arg: String) {
Username("username"),
DisplayName("display_name"),
Bio("bio"),
;
companion object {
fun fromArg(value: String?) =
value?.takeIf { it.isNotBlank() }?.let { arg -> entries.firstOrNull { it.arg == arg } }
}
}
object ProfileRoutes {
const val ARG_FOCUS = "focus"
const val Edit = "profile/edit?focus={focus}"
const val REFRESH_KEY = "profile_refresh"
fun editRoute(focus: EditProfileFocusField? = null): String =
if (focus == null) "profile/edit"
else "profile/edit?focus=${focus.arg}"
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,93 @@
package ru.fromchat.utils
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.number
import kotlinx.datetime.toLocalDateTime
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.month_name_apr
import ru.fromchat.month_name_aug
import ru.fromchat.month_name_dec
import ru.fromchat.month_name_feb
import ru.fromchat.month_name_jan
import ru.fromchat.month_name_jul
import ru.fromchat.month_name_jun
import ru.fromchat.month_name_mar
import ru.fromchat.month_name_may
import ru.fromchat.month_name_nov
import ru.fromchat.month_name_oct
import ru.fromchat.month_name_sep
import ru.fromchat.profile_registration_date
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
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 RegistrationDateFormatStrings(
val template: String,
val monthName: (Int) -> String,
)
@Composable
fun rememberRegistrationDateFormatStrings(): RegistrationDateFormatStrings {
val template = stringResource(Res.string.profile_registration_date)
val jan = stringResource(Res.string.month_name_jan)
val feb = stringResource(Res.string.month_name_feb)
val mar = stringResource(Res.string.month_name_mar)
val apr = stringResource(Res.string.month_name_apr)
val may = stringResource(Res.string.month_name_may)
val jun = stringResource(Res.string.month_name_jun)
val jul = stringResource(Res.string.month_name_jul)
val aug = stringResource(Res.string.month_name_aug)
val sep = stringResource(Res.string.month_name_sep)
val oct = stringResource(Res.string.month_name_oct)
val nov = stringResource(Res.string.month_name_nov)
val dec = stringResource(Res.string.month_name_dec)
return remember(
template, jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec,
) {
val months = listOf(jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec)
RegistrationDateFormatStrings(
template = template,
monthName = { monthNumber -> months.getOrElse(monthNumber - 1) { "" } },
)
}
}
@OptIn(ExperimentalTime::class)
private fun parseRegistrationLocalDate(iso: String): LocalDate? {
runCatching { Instant.parse(iso) }.getOrNull()?.let {
return it.toLocalDateTime(TimeZone.currentSystemDefault()).date
}
runCatching { LocalDateTime.parse(iso).date }.getOrNull()?.let { return it }
return runCatching { LocalDate.parse(iso.substringBefore('T')) }.getOrNull()
}
/** Formats an ISO registration timestamp as e.g. "13 june 2026" (localized). */
@OptIn(ExperimentalTime::class)
fun formatProfileRegistrationDate(
createdAtIso: String?,
strings: RegistrationDateFormatStrings,
): String? {
val iso = createdAtIso?.trim()?.takeIf { it.isNotBlank() } ?: return null
val date = parseRegistrationLocalDate(iso) ?: return iso
return formatFromXmlTemplate(
strings.template,
date.day,
strings.monthName(date.month.number),
date.year,
)
}
@@ -4,5 +4,6 @@ enum class HapticFeedbackEvent {
ProfileOpened,
ProfileClosed,
MessageSent,
ContextMenuOpened
ContextMenuOpened,
SelectionModeEntered,
}
@@ -20,6 +20,7 @@ CREATE TABLE conversation (
lastMessagePreview TEXT,
unreadCount INTEGER NOT NULL DEFAULT 0,
updatedAt TEXT,
archived INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (instanceId, id)
);
@@ -157,6 +158,11 @@ deleteMessageById:
DELETE FROM message
WHERE instanceId = ? AND conversationId = ? AND id = ?;
updateMessageSendStatusByClientMessageId:
UPDATE message
SET sendStatus = ?
WHERE instanceId = ? AND conversationId = ? AND clientMessageId = ?;
upsertMessage:
INSERT OR REPLACE INTO message(
instanceId,
@@ -178,6 +184,16 @@ UPDATE message
SET deletedFlag = 1
WHERE instanceId = ? AND id = ? AND conversationId = ?;
selectUnreadPublicMessageIds:
SELECT id
FROM message
WHERE instanceId = ? AND conversationId = ? AND isRead = 0 AND id > 0 AND deletedFlag = 0;
markPublicMessagesRead:
UPDATE message
SET isRead = 1
WHERE instanceId = ? AND conversationId = ? AND isRead = 0 AND deletedFlag = 0;
deleteAllMessagesForInstance:
DELETE FROM message WHERE instanceId = ?;
@@ -209,6 +225,67 @@ FROM conversation
WHERE instanceId = ?
ORDER BY updatedAt DESC;
selectActiveDmConversationsForInstance:
SELECT *
FROM conversation
WHERE instanceId = ? AND type = 'dm' AND archived = 0
ORDER BY updatedAt DESC;
selectConversationById:
SELECT *
FROM conversation
WHERE instanceId = ? AND id = ?
LIMIT 1;
countNonDeletedMessagesForConversation:
SELECT COUNT(*)
FROM message
WHERE instanceId = ? AND conversationId = ? AND deletedFlag = 0;
countPendingMessagesForConversation:
SELECT COUNT(*)
FROM message
WHERE instanceId = ? AND conversationId = ? AND deletedFlag = 0 AND id < 0;
deleteConversationById:
DELETE FROM conversation
WHERE instanceId = ? AND id = ?;
deleteEmptyDmConversations:
DELETE FROM conversation
WHERE instanceId = ? AND type = 'dm' AND id NOT IN (
SELECT DISTINCT conversationId FROM message
WHERE instanceId = ? AND deletedFlag = 0
);
selectForeignPendingMessages:
SELECT *
FROM message
WHERE instanceId = ? AND id < 0 AND deletedFlag = 0 AND userId != ?;
selectAllPendingMessagesForInstance:
SELECT *
FROM message
WHERE instanceId = ? AND id < 0 AND deletedFlag = 0;
deleteAllPendingMessagesForInstance:
DELETE FROM message
WHERE instanceId = ? AND id < 0;
deletePendingMessagesNotFromUser:
DELETE FROM message
WHERE instanceId = ? AND id < 0 AND userId != ?;
updateConversationArchived:
UPDATE conversation
SET archived = ?
WHERE instanceId = ? AND id = ?;
updateConversationUnreadCount:
UPDATE conversation
SET unreadCount = ?
WHERE instanceId = ? AND id = ?;
upsertConversation:
INSERT OR REPLACE INTO conversation(
instanceId,
@@ -219,8 +296,9 @@ INSERT OR REPLACE INTO conversation(
lastMessageId,
lastMessagePreview,
unreadCount,
updatedAt
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);
updatedAt,
archived
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
-- outbox
selectPendingOutboxForInstance:
@@ -21,3 +21,20 @@ actual suspend fun wipeFromChatCacheDirectory() {
NSFileManager.defaultManager.removeItemAtPath(path, error = null)
}
}
@OptIn(ExperimentalForeignApi::class)
actual suspend fun wipeAttachmentCacheDirectories() {
withContext(Dispatchers.Default) {
val url = NSFileManager.defaultManager.URLForDirectory(
directory = NSCachesDirectory,
inDomain = NSUserDomainMask,
appropriateForURL = null,
create = false,
error = null,
) ?: return@withContext
val base = url.path ?: return@withContext
listOf("decrypted_images", "decrypted_files", "encrypted_downloads").forEach { name ->
NSFileManager.defaultManager.removeItemAtPath("$base/$name", error = null)
}
}
}
@@ -0,0 +1,18 @@
package ru.fromchat.legal
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
@Composable
actual fun Markdown(
content: String,
modifier: Modifier,
) {
val uriHandler = LocalUriHandler.current
MarkdownPlain(
content = content,
modifier = modifier,
onLinkClick = { uriHandler.openUri(it) },
)
}
@@ -20,6 +20,7 @@ actual fun rememberHapticFeedbackInternal(): (Int) -> Unit {
val style: UIImpactFeedbackStyle = when (ordinal) {
HapticFeedbackEvent.MessageSent.ordinal -> UIImpactFeedbackStyle.UIImpactFeedbackStyleMedium
HapticFeedbackEvent.ContextMenuOpened.ordinal -> UIImpactFeedbackStyle.UIImpactFeedbackStyleHeavy
HapticFeedbackEvent.SelectionModeEntered.ordinal -> UIImpactFeedbackStyle.UIImpactFeedbackStyleMedium
else -> UIImpactFeedbackStyle.UIImpactFeedbackStyleLight
}
val generator = UIImpactFeedbackGenerator(style)
+2
View File
@@ -44,6 +44,7 @@ krypto = "4.0.10"
sqldelight = "2.3.2"
livekitAndroid = "2.25.2"
livekitAndroidComposeComponents = "2.3.0"
markdownRendererM3 = "0.41.0"
bouncycastle = "1.79"
[libraries]
@@ -115,6 +116,7 @@ sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", ver
sqldelight-driver-native = { module = "app.cash.sqldelight:native-driver", version.ref = "sqldelight" }
livekit-android = { module = "io.livekit:livekit-android", version.ref = "livekitAndroid" }
livekit-android-compose-components = { module = "io.livekit:livekit-android-compose-components", version.ref = "livekitAndroidComposeComponents" }
markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "markdownRendererM3" }
bouncycastle-bcprov = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncycastle" }
[plugins]
+2 -2
View File
@@ -17,9 +17,9 @@ kotlin {
}
listOf(
iosX64(),
iosArm64(),
iosSimulatorArm64()
iosSimulatorArm64(),
iosX64(),
).forEach {
it.binaries.framework {
baseName = "shared"
@@ -55,7 +55,12 @@ import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.isImeVisible
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.window.DialogWindowProvider
import androidx.core.view.WindowCompat
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -120,17 +125,45 @@ actual fun Modifier.clearFocusOnKeyboardDismiss(): Modifier = composed {
@SuppressLint("ComposableNaming")
@Composable
actual fun ToggleNavScrimEffect(enabled: Boolean) {
val context = (LocalContext() as Activity)
LaunchedEffect(enabled) {
runCatching {
val window = context.window
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = enabled
val context = LocalContext.current as Activity
DisposableEffect(enabled) {
val window = context.window
val previousContrastEnforced = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced
} else {
null
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = enabled
}
onDispose {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && previousContrastEnforced != null) {
window.isNavigationBarContrastEnforced = previousContrastEnforced
}
}
}
}
@Composable
actual fun DialogEdgeToEdgeEffect() {
val view = LocalView.current
SideEffect {
val window = (view.parent as? DialogWindowProvider)?.window ?: return@SideEffect
runCatching {
WindowCompat.setDecorFitsSystemWindows(window, false)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
}
@Suppress("DEPRECATION")
window.navigationBarColor = android.graphics.Color.TRANSPARENT
@Suppress("DEPRECATION")
window.statusBarColor = android.graphics.Color.TRANSPARENT
}
}
}
val screenWidth: Int inline get() = Resources.getSystem().displayMetrics.widthPixels
@Suppress("unused")
val screenHeight: Int inline get() = Resources.getSystem().displayMetrics.heightPixels
@@ -27,6 +27,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.utils.conditional
object CategoryDefaults {
val margin = PaddingValues(start = 16.dp, end = 16.dp, bottom = 20.dp)
@@ -42,6 +43,7 @@ private fun CategoryBase(
margin: PaddingValues = CategoryDefaults.margin,
containerColor: Color = CategoryDefaults.containerColor,
backgroundColor: Color = Color.Transparent,
roundedCorners: Boolean = true,
content: @Composable ColumnScope.() -> Unit
) {
CompositionLocalProvider(
@@ -70,7 +72,7 @@ private fun CategoryBase(
modifier = modifier
.padding(margin)
.fillMaxWidth(),
shape = CategoryDefaults.shape,
shape = if (roundedCorners) CategoryDefaults.shape else RoundedCornerShape(0.dp),
colors = cardColors(containerColor = backgroundColor),
content = content
)
@@ -84,6 +86,7 @@ fun LazyItemScope.Category(
margin: PaddingValues = CategoryDefaults.margin,
containerColor: Color = CategoryDefaults.containerColor,
backgroundColor: Color = Color.Transparent,
roundedCorners: Boolean = true,
content: @Composable ColumnScope.() -> Unit
) {
CategoryBase(
@@ -92,6 +95,7 @@ fun LazyItemScope.Category(
margin = margin,
containerColor = containerColor,
backgroundColor = backgroundColor,
roundedCorners = roundedCorners,
content = content
)
}
@@ -103,6 +107,7 @@ fun ColumnScope.Category(
margin: PaddingValues = CategoryDefaults.margin,
containerColor: Color = CategoryDefaults.containerColor,
backgroundColor: Color = Color.Transparent,
roundedCorners: Boolean = true,
content: @Composable ColumnScope.() -> Unit
) {
CategoryBase(
@@ -111,6 +116,7 @@ fun ColumnScope.Category(
margin = margin,
containerColor = containerColor,
backgroundColor = backgroundColor,
roundedCorners = roundedCorners,
content = content
)
}
@@ -128,6 +134,7 @@ fun LazyListScope.Category(
title: String? = null,
margin: PaddingValues = CategoryDefaults.margin,
containerColor: Color? = null,
roundedCorners: Boolean = true,
content: CategoryScope.() -> Unit
) {
val scope = CategoryScope().apply(content)
@@ -165,26 +172,28 @@ fun LazyListScope.Category(
start = margin.calculateStartPadding(LocalLayoutDirection.current),
end = margin.calculateEndPadding(LocalLayoutDirection.current)
)
.clip(
RoundedCornerShape(
topStart =
if (index == 0)
CategoryDefaults.shape.topStart
else CornerSize(0.dp),
topEnd =
if (index == 0)
CategoryDefaults.shape.topEnd
else CornerSize(0.dp),
bottomStart =
if (index == items.lastIndex)
CategoryDefaults.shape.bottomStart
else CornerSize(0.dp),
bottomEnd =
if (index == items.lastIndex)
CategoryDefaults.shape.bottomEnd
else CornerSize(0.dp)
.conditional(roundedCorners) {
clip(
RoundedCornerShape(
topStart =
if (index == 0)
CategoryDefaults.shape.topStart
else CornerSize(0.dp),
topEnd =
if (index == 0)
CategoryDefaults.shape.topEnd
else CornerSize(0.dp),
bottomStart =
if (index == items.lastIndex)
CategoryDefaults.shape.bottomStart
else CornerSize(0.dp),
bottomEnd =
if (index == items.lastIndex)
CategoryDefaults.shape.bottomEnd
else CornerSize(0.dp)
)
)
)
}
) {
composableItem()
}
@@ -3,18 +3,24 @@
package com.pr0gramm3r101.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
@@ -28,6 +34,8 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CornerBasedShape
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Archive
@@ -66,30 +74,49 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import com.pr0gramm3r101.utils.conditional
import com.pr0gramm3r101.utils.currentWindowSize
import com.pr0gramm3r101.utils.invoke
import com.pr0gramm3r101.utils.left
import com.pr0gramm3r101.utils.link
import com.pr0gramm3r101.utils.plus
import com.pr0gramm3r101.utils.right
import com.pr0gramm3r101.utils.scaleOnPress
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import tech.annexflow.constraintlayout.compose.ConstraintLayout
@@ -102,6 +129,12 @@ val LocalDividerThickness = compositionLocalOf<Dp?> { null }
val LocalBeforeDividerRadius = compositionLocalOf<Dp?> { null }
val LocalContainerColor = compositionLocalOf<Color?> { null }
enum class ListItemPosition {
START,
MIDDLE,
END,
}
object ListItemDefaults {
val dividerColor @Composable get() = DividerDefaults.color
val thickness = DividerDefaults.Thickness
@@ -109,12 +142,408 @@ object ListItemDefaults {
val containerColor = Color.Transparent
}
@Composable
fun listItemClipShape(
position: ListItemPosition,
groupItemCount: Int? = null,
cornerShape: CornerBasedShape = CategoryDefaults.shape,
beforeDividerRadius: Dp = ListItemDefaults.beforeDividerRadius,
): Shape {
val useGroupShape = position != ListItemPosition.MIDDLE || groupItemCount != null
if (!useGroupShape) {
return RoundedCornerShape(beforeDividerRadius)
}
val solo = groupItemCount == 1
val roundTop = position == ListItemPosition.START || solo
val roundBottom = position == ListItemPosition.END || solo
val innerRadius = CornerSize(beforeDividerRadius)
return RoundedCornerShape(
topStart = if (roundTop) cornerShape.topStart else innerRadius,
topEnd = if (roundTop) cornerShape.topEnd else innerRadius,
bottomStart = if (roundBottom) cornerShape.bottomStart else innerRadius,
bottomEnd = if (roundBottom) cornerShape.bottomEnd else innerRadius,
)
}
fun listItemPositionInGroup(index: Int, count: Int): ListItemPosition {
require(count > 0)
return when {
index == 0 -> ListItemPosition.START
index == count - 1 -> ListItemPosition.END
else -> ListItemPosition.MIDDLE
}
}
class ListItemContextMenuScope internal constructor(
private val dismiss: () -> Unit,
) {
internal val items = mutableListOf<ListItemContextMenuEntry>()
fun item(
icon: ImageVector,
label: String,
onClick: () -> Unit,
) {
items.add(ListItemContextMenuEntry(icon, label, onClick))
}
fun close() {
dismiss()
}
}
internal data class ListItemContextMenuEntry(
val icon: ImageVector,
val label: String,
val onClick: () -> Unit,
)
private val listItemContextMenuShape = RoundedCornerShape(16.dp)
private val listItemContextMenuItemShape = RoundedCornerShape(12.dp)
@Composable
private fun ListItemContextMenuPopup(
open: Boolean,
position: IntOffset,
anchorPositionInRoot: Offset,
onDismiss: () -> Unit,
menuContent: ListItemContextMenuScope.() -> Unit,
) {
var shouldShowPopup by remember { mutableStateOf(open) }
val animationProgress = remember { mutableFloatStateOf(0f) }
val latestMenuContent by rememberUpdatedState(menuContent)
val windowSize = currentWindowSize()
val screenWidthPx = windowSize.width
val screenHeightPx = windowSize.height
LaunchedEffect(open) {
if (!open) {
animate(
initialValue = 1f,
targetValue = 0f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessMediumLow,
),
) { value, _ ->
animationProgress.floatValue = value
}
shouldShowPopup = false
}
}
LaunchedEffect(open) {
if (open) {
shouldShowPopup = true
animationProgress.floatValue = 0f
}
}
if (!shouldShowPopup) return
val scope = remember(onDismiss) { ListItemContextMenuScope(onDismiss) }
scope.items.clear()
latestMenuContent(scope)
val items = scope.items.toList()
if (items.isEmpty()) {
if (open) onDismiss()
return
}
var measuredSize by remember { mutableStateOf(IntSize.Zero) }
SubcomposeLayout(Modifier.size(0.dp)) { _ ->
val looseConstraints = Constraints(
minWidth = 0,
minHeight = 0,
maxWidth = screenWidthPx,
maxHeight = screenHeightPx,
)
val placeables = subcompose("measure") {
ListItemContextMenuContent(
items = items,
animated = false,
withShadow = false,
modifier = Modifier.graphicsLayer(alpha = 0f),
)
}.map { it.measure(looseConstraints) }
val p = placeables.firstOrNull()
if (p != null && measuredSize == IntSize.Zero) {
measuredSize = IntSize(p.width, p.height)
}
layout(0, 0) {
placeables.forEach { it.placeRelative(-10000, -10000) }
}
}
val density = LocalDensity.current
val paddingPx = with(density) { 16.dp.toPx().toInt() }
val rightEdge = screenWidthPx - paddingPx
val bottomEdge = screenHeightPx - paddingPx
val adjustedOffset = remember(
measuredSize,
position,
anchorPositionInRoot,
rightEdge,
bottomEdge,
paddingPx,
) {
if (measuredSize == IntSize.Zero) {
position
} else {
var screenX = anchorPositionInRoot.x.toInt() + position.x
var screenY = anchorPositionInRoot.y.toInt() + position.y
if (screenX + measuredSize.width > rightEdge) screenX = rightEdge - measuredSize.width
if (screenY + measuredSize.height > bottomEdge) screenY = bottomEdge - measuredSize.height
if (screenX < paddingPx) screenX = paddingPx
if (screenY < paddingPx) screenY = paddingPx
IntOffset(
screenX - anchorPositionInRoot.x.toInt(),
screenY - anchorPositionInRoot.y.toInt(),
)
}
}
val transformOriginX = if (measuredSize.width > 0) {
((position.x - adjustedOffset.x).toFloat() / measuredSize.width).coerceIn(0f, 1f)
} else 0f
val transformOriginY = if (measuredSize.height > 0) {
((position.y - adjustedOffset.y).toFloat() / measuredSize.height).coerceIn(0f, 1f)
} else 0f
LaunchedEffect(measuredSize) {
if (measuredSize != IntSize.Zero) {
animate(
initialValue = 0f,
targetValue = 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessMediumLow,
),
) { value, _ ->
animationProgress.floatValue = value
}
}
}
if (measuredSize == IntSize.Zero) return
val scale = 0.5f + 0.5f * animationProgress.floatValue
val alpha = animationProgress.floatValue
Popup(
onDismissRequest = onDismiss,
alignment = Alignment.TopStart,
offset = adjustedOffset,
properties = PopupProperties(
dismissOnBackPress = true,
dismissOnClickOutside = true,
clippingEnabled = false,
),
) {
ListItemContextMenuContent(
items = items,
animated = true,
scale = scale,
alpha = alpha,
transformOriginX = transformOriginX,
transformOriginY = transformOriginY,
onItemClick = { entry ->
entry.onClick()
onDismiss()
},
)
}
}
@Composable
private fun ListItemContextMenuContent(
items: List<ListItemContextMenuEntry>,
animated: Boolean,
withShadow: Boolean = true,
scale: Float = 1f,
alpha: Float = 1f,
transformOriginX: Float = 0f,
transformOriginY: Float = 0f,
modifier: Modifier = Modifier,
onItemClick: (ListItemContextMenuEntry) -> Unit = { it.onClick() },
) {
val density = LocalDensity.current
val shadowElevationPx = if (withShadow) with(density) { 12.dp.toPx() } else 0f
val menuColor = MaterialTheme.colorScheme.surfaceContainerHighest
val baseModifier = modifier.width(IntrinsicSize.Max)
val containerModifier = if (animated) {
baseModifier.graphicsLayer(
scaleX = scale,
scaleY = scale,
alpha = alpha,
transformOrigin = TransformOrigin(transformOriginX, transformOriginY),
shadowElevation = shadowElevationPx,
shape = listItemContextMenuShape,
clip = true,
)
} else {
baseModifier.graphicsLayer(
shadowElevation = shadowElevationPx,
shape = listItemContextMenuShape,
clip = true,
)
}
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
Box(modifier = containerModifier) {
Box(modifier = Modifier.matchParentSize().background(menuColor, listItemContextMenuShape))
Column(
modifier = Modifier.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
items.forEach { entry ->
ListItemContextMenuRow(
icon = entry.icon,
label = entry.label,
onClick = { onItemClick(entry) },
)
}
}
}
}
}
@Composable
private fun ListItemContextMenuRow(
icon: ImageVector,
label: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.fillMaxWidth()
.padding(4.dp)
.clip(listItemContextMenuItemShape)
.scaleOnPress(
scale = 0.96f,
onClick = onClick,
indication = LocalIndication.current,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium,
),
)
.padding(horizontal = 12.dp, vertical = 8.dp),
contentAlignment = Alignment.CenterStart,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = icon,
contentDescription = label,
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(20.dp),
)
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
}
@Composable
fun ContextMenuPressable(
modifier: Modifier = Modifier,
enabled: Boolean = true,
openMenuOnTap: Boolean = false,
pressScale: Float = 0.96f,
onContextMenuOpen: (() -> Unit)? = null,
contextMenu: ListItemContextMenuScope.() -> Unit,
content: @Composable () -> Unit,
) {
if (!enabled) {
Box(modifier = modifier) { content() }
return
}
var isPressed by remember { mutableStateOf(false) }
var menuOpen by remember { mutableStateOf(false) }
var menuPosition by remember { mutableStateOf(IntOffset.Zero) }
var anchorPositionInRoot by remember { mutableStateOf(Offset.Zero) }
val latestContextMenu by rememberUpdatedState(contextMenu)
val scaleTarget = if (isPressed && !menuOpen) pressScale else 1f
val scale by animateFloatAsState(
targetValue = scaleTarget,
animationSpec = spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMediumLow,
),
visibilityThreshold = 0.001f,
label = "contextMenuPressableScale",
)
Column(
modifier = Modifier.onGloballyPositioned { coordinates ->
anchorPositionInRoot = coordinates.positionInRoot()
},
) {
Box(
modifier = modifier
.graphicsLayer(
scaleX = scale,
scaleY = scale,
transformOrigin = TransformOrigin.Center,
)
.pointerInput(latestContextMenu, openMenuOnTap) {
detectTapGestures(
onPress = {
isPressed = true
try {
awaitRelease()
} finally {
isPressed = false
}
},
onTap = {
if (openMenuOnTap) {
onContextMenuOpen?.invoke()
menuPosition = IntOffset.Zero
menuOpen = true
}
},
onLongPress = { localOffset ->
onContextMenuOpen?.invoke()
menuPosition = IntOffset(
localOffset.x.toInt(),
localOffset.y.toInt(),
)
menuOpen = true
},
)
},
) {
content()
}
ListItemContextMenuPopup(
open = menuOpen,
position = menuPosition,
anchorPositionInRoot = anchorPositionInRoot,
onDismiss = { menuOpen = false },
menuContent = latestContextMenu,
)
}
}
@Composable
fun ListItem(
modifier: Modifier = Modifier,
bodyModifier: Modifier = Modifier,
headline: String,
supportingText: String? = null,
supportingSlot: (@Composable () -> Unit)? = null,
leadingContent: (@Composable () -> Unit)? = null,
trailingContent: (@Composable ConstraintLayoutScope.() -> Unit)? = null,
containerColor: Color = LocalContainerColor.current ?: ListItemDefaults.containerColor,
@@ -124,12 +553,43 @@ fun ListItem(
dividerThickness: Dp = LocalDividerThickness.current ?: ListItemDefaults.thickness,
dividerAnimated: Boolean = false,
beforeDividerRadius: Dp = LocalBeforeDividerRadius.current ?: ListItemDefaults.beforeDividerRadius,
position: ListItemPosition = ListItemPosition.MIDDLE,
groupItemCount: Int? = null,
onClick: (() -> Unit)? = null,
bodyOnClick: (() -> Unit)? = null,
leadingAndBodyShared: Boolean = false,
bottomContent: (@Composable () -> Unit)? = null
bottomContent: (@Composable () -> Unit)? = null,
onContextMenuOpen: (() -> Unit)? = null,
contextMenu: (ListItemContextMenuScope.() -> Unit)? = null,
) {
Column {
val showDivider = divider && position != ListItemPosition.END
val clipShape = listItemClipShape(
position = position,
groupItemCount = groupItemCount,
beforeDividerRadius = beforeDividerRadius,
)
var isPressed by remember { mutableStateOf(false) }
var menuOpen by remember { mutableStateOf(false) }
var menuPosition by remember { mutableStateOf(IntOffset.Zero) }
var anchorPositionInRoot by remember { mutableStateOf(Offset.Zero) }
val latestContextMenu by rememberUpdatedState(contextMenu)
val hasContextMenu = contextMenu != null
val scaleTarget = if (isPressed && !menuOpen) 0.96f else 1f
val scale by animateFloatAsState(
targetValue = scaleTarget,
animationSpec = spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMediumLow,
),
visibilityThreshold = 0.001f,
label = "listItemScale",
)
Column(
modifier = Modifier.onGloballyPositioned { coordinates ->
anchorPositionInRoot = coordinates.positionInRoot()
},
) {
@Composable
fun ProvideStyle(
content: @Composable BoxScope?.() -> Unit
@@ -149,12 +609,44 @@ fun ListItem(
}
ProvideStyle {
Box(
modifier = Modifier
.fillMaxWidth()
.graphicsLayer(
scaleX = scale,
scaleY = scale,
transformOrigin = TransformOrigin.Center,
)
.conditional(hasContextMenu) {
pointerInput(latestContextMenu, onClick) {
detectTapGestures(
onPress = {
isPressed = true
try {
awaitRelease()
} finally {
isPressed = false
}
},
onTap = { onClick?.invoke() },
onLongPress = { localOffset ->
onContextMenuOpen?.invoke()
menuPosition = IntOffset(
localOffset.x.toInt(),
localOffset.y.toInt(),
)
menuOpen = true
},
)
}
},
) {
ConstraintLayout(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(beforeDividerRadius))
.clip(clipShape)
.background(containerColor)
.conditional(onClick != null) {
.conditional(onClick != null && !hasContextMenu) {
clickable(onClick = onClick!!)
}
.then(modifier)
@@ -187,7 +679,12 @@ fun ListItem(
ListItem(
headlineContent = { Text(headline) },
supportingContent = { if (supportingText != null) Text(supportingText) },
supportingContent = {
when {
supportingSlot != null -> supportingSlot()
supportingText != null -> Text(supportingText)
}
},
modifier = Modifier
.constrainAs(listItem) {
top link parent.top
@@ -203,7 +700,7 @@ fun ListItem(
width = Dimension.fillToConstraints
}
.conditional(bodyOnClick != null && !leadingAndBodyShared) {
.conditional(bodyOnClick != null && !leadingAndBodyShared && !hasContextMenu) {
Modifier.clickable(onClick = bodyOnClick!!)
}
.then(bodyModifier),
@@ -227,7 +724,7 @@ fun ListItem(
else parent.right
width = Dimension.fillToConstraints
}
.conditional(bodyOnClick != null) {
.conditional(bodyOnClick != null && !hasContextMenu) {
Modifier.clickable(onClick = bodyOnClick!!)
},
verticalAlignment = Alignment.CenterVertically
@@ -273,12 +770,13 @@ fun ListItem(
}
}
}
}
}
when {
dividerAnimated && divider -> {
dividerAnimated && showDivider -> {
AnimatedVisibility(
visible = divider,
visible = showDivider,
enter = expandVertically(),
exit = shrinkVertically()
) {
@@ -288,13 +786,23 @@ fun ListItem(
)
}
}
divider -> {
showDivider -> {
HorizontalDivider(
color = dividerColor,
thickness = dividerThickness
)
}
}
if (hasContextMenu) {
ListItemContextMenuPopup(
open = menuOpen,
position = menuPosition,
anchorPositionInRoot = anchorPositionInRoot,
onDismiss = { menuOpen = false },
menuContent = { latestContextMenu?.invoke(this) },
)
}
}
}
@@ -311,7 +819,11 @@ inline fun SwitchListItem(
dividerColor: Color = LocalDividerColor.current ?: DividerDefaults.color,
dividerThickness: Dp = LocalDividerThickness.current ?: DividerDefaults.Thickness,
dividerAnimated: Boolean = false,
enabled: Boolean = true
enabled: Boolean = true,
position: ListItemPosition = ListItemPosition.MIDDLE,
groupItemCount: Int? = null,
noinline onContextMenuOpen: (() -> Unit)? = null,
noinline contextMenu: (ListItemContextMenuScope.() -> Unit)? = null,
) {
val interactionSource = remember { MutableInteractionSource() }
ListItem(
@@ -341,7 +853,11 @@ inline fun SwitchListItem(
dividerColor = dividerColor,
dividerThickness = dividerThickness,
dividerAnimated = dividerAnimated,
enabled = enabled
enabled = enabled,
position = position,
groupItemCount = groupItemCount,
onContextMenuOpen = onContextMenuOpen,
contextMenu = contextMenu,
)
}
@@ -359,7 +875,11 @@ inline fun SeparatedSwitchListItem(
dividerColor: Color = LocalDividerColor.current ?: DividerDefaults.color,
dividerThickness: Dp = LocalDividerThickness.current ?: DividerDefaults.Thickness,
dividerAnimated: Boolean = false,
enabled: Boolean = true
enabled: Boolean = true,
position: ListItemPosition = ListItemPosition.MIDDLE,
groupItemCount: Int? = null,
noinline onContextMenuOpen: (() -> Unit)? = null,
noinline contextMenu: (ListItemContextMenuScope.() -> Unit)? = null,
) {
ListItem(
modifier = modifier,
@@ -399,7 +919,11 @@ inline fun SeparatedSwitchListItem(
dividerThickness = dividerThickness,
dividerAnimated = dividerAnimated,
leadingAndBodyShared = true,
enabled = enabled
enabled = enabled,
position = position,
groupItemCount = groupItemCount,
onContextMenuOpen = onContextMenuOpen,
contextMenu = contextMenu,
)
}
@@ -70,6 +70,10 @@ operator fun Modifier.plus(other: Modifier) = then(other)
@Composable
expect fun ToggleNavScrimEffect(enabled: Boolean = false)
/** Configures the hosting dialog window for edge-to-edge bottom sheets (Android). */
@Composable
expect fun DialogEdgeToEdgeEffect()
// TODO fix implementation
interface SupportClipboardManager {
suspend fun setText(string: String)
@@ -16,6 +16,9 @@ actual fun Modifier.clearFocusOnKeyboardDismiss() = this
@Composable
actual fun ToggleNavScrimEffect(enabled: Boolean) {}
@Composable
actual fun DialogEdgeToEdgeEffect() {}
actual val materialYouAvailable get() = false
inline fun htons(short: UShort) = if (isLittleEndian) _OSSwapInt16(short) else short