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(":app:shared"))
implementation(project(":utils: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( listOf(
iosX64(),
iosArm64(), iosArm64(),
iosSimulatorArm64() iosSimulatorArm64(),
iosX64(),
).forEach { iosTarget -> ).forEach { iosTarget ->
iosTarget.binaries.framework { iosTarget.binaries.framework {
baseName = "ComposeApp" baseName = "ComposeApp"
@@ -84,6 +84,7 @@ kotlin {
} }
androidMain.dependencies { androidMain.dependencies {
implementation(libs.markdown.renderer.m3)
implementation(libs.bouncycastle.bcprov) implementation(libs.bouncycastle.bcprov)
implementation(libs.androidx.exifinterface) implementation(libs.androidx.exifinterface)
implementation(libs.ktor.client.okhttp) implementation(libs.ktor.client.okhttp)
@@ -10,3 +10,12 @@ actual suspend fun wipeFromChatCacheDirectory() {
File(UtilsLibrary.context.cacheDir, "fromchat").deleteRecursively() 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 { } finally {
progressJob?.cancel() progressJob?.cancel()
} }
Result.success() if (allOk) Result.success() else Result.retry()
} }
companion object { 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.ProfileClosed.ordinal -> HapticFeedbackConstants.CLOCK_TICK
HapticFeedbackEvent.MessageSent.ordinal -> HapticFeedbackConstants.CONFIRM HapticFeedbackEvent.MessageSent.ordinal -> HapticFeedbackConstants.CONFIRM
HapticFeedbackEvent.ContextMenuOpened.ordinal -> HapticFeedbackConstants.CONFIRM HapticFeedbackEvent.ContextMenuOpened.ordinal -> HapticFeedbackConstants.CONFIRM
HapticFeedbackEvent.SelectionModeEntered.ordinal -> HapticFeedbackConstants.CONFIRM
else -> HapticFeedbackConstants.CLOCK_TICK else -> HapticFeedbackConstants.CLOCK_TICK
} }
view.performHapticFeedback(constant) view.performHapticFeedback(constant)
@@ -9,6 +9,14 @@
<string name="about_link_telegram">Telegram</string> <string name="about_link_telegram">Telegram</string>
<string name="about_link_max">MAX</string> <string name="about_link_max">MAX</string>
<string name="about_link_website">Сайт</string> <string name="about_link_website">Сайт</string>
<string name="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="app_desc">100% бесплатный и открытый мессенджер. Поддерживает self-hosted установку на своём сервере.</string>
<string name="welcome">Добро пожаловать!</string> <string name="welcome">Добро пожаловать!</string>
<string name="login">Войти</string> <string name="login">Войти</string>
@@ -52,6 +60,17 @@
<string name="contacts_empty_body">Список контактов появится здесь, когда функция будет готова.</string> <string name="contacts_empty_body">Список контактов появится здесь, когда функция будет готова.</string>
<string name="public_chat">Общий чат</string> <string name="public_chat">Общий чат</string>
<string name="chat_last_mesaage">Вы: последнее сообщение</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_hint">Найдите пользователя, имя или чат</string>
<string name="search_title">Поиск</string> <string name="search_title">Поиск</string>
<string name="search_not_found">Ничего не найдено</string> <string name="search_not_found">Ничего не найдено</string>
@@ -105,6 +124,9 @@
<string name="action_delete">Удалить</string> <string name="action_delete">Удалить</string>
<string name="action_copy">Копировать</string> <string name="action_copy">Копировать</string>
<string name="action_cancel_send">Отменить</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_percent">%1$d\u0025</string>
<string name="notif_media_upload_progress">%1$s · %2$s</string> <string name="notif_media_upload_progress">%1$s · %2$s</string>
<string name="notif_file_copy_channel_name">Сохранение файла</string> <string name="notif_file_copy_channel_name">Сохранение файла</string>
@@ -123,12 +145,24 @@
<string name="cd_send">Отправить</string> <string name="cd_send">Отправить</string>
<string name="cd_emoji">Эмодзи</string> <string name="cd_emoji">Эмодзи</string>
<string name="profile_title">Профиль</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_load_failed">Не получилось загрузить профиль</string>
<string name="profile_not_found">Профиль не найден</string> <string name="profile_not_found">Профиль не найден</string>
<string name="profile_open_failed">Не удалось открыть профиль. Попробуйте снова.</string> <string name="profile_open_failed">Не удалось открыть профиль. Попробуйте снова.</string>
<string name="action_open_settings">Настройки</string> <string name="action_open_settings">Настройки</string>
<string name="action_chat">Написать</string> <string name="action_chat">Написать</string>
<string name="action_copy_link">Скопировать ссылку</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="link_copied">Ссылка скопирована</string>
<string name="profile_details_category">О человеке</string> <string name="profile_details_category">О человеке</string>
<string name="profile_headline_username">Имя пользователя</string> <string name="profile_headline_username">Имя пользователя</string>
@@ -167,6 +201,18 @@
<string name="month_oct">окт</string> <string name="month_oct">окт</string>
<string name="month_nov">ноя</string> <string name="month_nov">ноя</string>
<string name="month_dec">дек</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_title">Настройка сервера</string>
<string name="server_config_subtitle">Подключение к альтернативному серверу FromChat. Это дает больше приватности и контроля над данными.</string> <string name="server_config_subtitle">Подключение к альтернативному серверу FromChat. Это дает больше приватности и контроля над данными.</string>
<string name="server_ip_label">IP или имя сервера</string> <string name="server_ip_label">IP или имя сервера</string>
@@ -12,6 +12,14 @@
<string name="about_link_telegram">Telegram</string> <string name="about_link_telegram">Telegram</string>
<string name="about_link_max">MAX</string> <string name="about_link_max">MAX</string>
<string name="about_link_website">Website</string> <string name="about_link_website">Website</string>
<string name="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> <string name="app_desc">100% free and open messenger. Supports self-hosted installation on your own server.</string>
<!-- Authentication --> <!-- Authentication -->
@@ -60,6 +68,17 @@
<string name="contacts_empty_body">Your contacts will appear here when this feature is ready.</string> <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="public_chat">Main chat</string>
<string name="chat_last_mesaage">You: last message</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_hint">Search by name, username or chat</string>
<string name="search_title">Search</string> <string name="search_title">Search</string>
<string name="search_not_found">No results</string> <string name="search_not_found">No results</string>
@@ -119,6 +138,9 @@
<string name="action_delete">Delete</string> <string name="action_delete">Delete</string>
<string name="action_copy">Copy</string> <string name="action_copy">Copy</string>
<string name="action_cancel_send">Cancel</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="action_save">Save</string>
<string name="notif_media_upload_percent">%1$d\u0025</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_media_upload_progress">%1$s · %2$s</string>
@@ -141,12 +163,24 @@
<!-- Profile --> <!-- Profile -->
<string name="profile_title">Profile</string> <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_load_failed">Couldnt load this profile</string>
<string name="profile_not_found">This profile could not be found</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="profile_open_failed">Could not open this profile. Please try again.</string>
<string name="action_open_settings">Settings</string> <string name="action_open_settings">Settings</string>
<string name="action_chat">Chat</string> <string name="action_chat">Chat</string>
<string name="action_copy_link">Copy link</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="link_copied">Link copied</string>
<string name="profile_details_category">About this person</string> <string name="profile_details_category">About this person</string>
<string name="profile_headline_username">Username</string> <string name="profile_headline_username">Username</string>
@@ -191,6 +225,18 @@
<string name="month_oct">Oct</string> <string name="month_oct">Oct</string>
<string name="month_nov">Nov</string> <string name="month_nov">Nov</string>
<string name="month_dec">Dec</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 --> <!-- Server Configuration -->
<string name="server_config_title">Connect to a server</string> <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.HttpClient
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.plugins.ClientRequestException 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.HttpResponseValidator
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.defaultRequest 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.client.statement.HttpResponse
import io.ktor.http.ContentType import io.ktor.http.ContentType
import io.ktor.http.contentType import io.ktor.http.contentType
import io.ktor.http.encodedPath
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.MainScope import kotlinx.coroutines.MainScope
import kotlinx.coroutines.flow.MutableStateFlow 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.InstanceIdGuard
import ru.fromchat.api.instance.InstanceIdResolveResult import ru.fromchat.api.instance.InstanceIdResolveResult
import ru.fromchat.api.instance.resolveInstanceId 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.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.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.ProfileCache
import ru.fromchat.api.local.db.store.PublicChatProfileCache
import ru.fromchat.api.local.download.streamEncryptedFileToDisk 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.CallSignalingLiveKitControl
import ru.fromchat.api.schema.calls.CallSignalingLiveKitPayload import ru.fromchat.api.schema.calls.CallSignalingLiveKitPayload
import ru.fromchat.api.schema.calls.LiveKitTokenRequest import ru.fromchat.api.schema.calls.LiveKitTokenRequest
import ru.fromchat.api.schema.calls.LiveKitTokenResponse import ru.fromchat.api.schema.calls.LiveKitTokenResponse
import ru.fromchat.api.schema.core.SimpleStatusResponse 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.MessagesResponse
import ru.fromchat.api.schema.messages.dm.DmConversation import ru.fromchat.api.schema.messages.dm.DmConversation
import ru.fromchat.api.schema.messages.dm.DmConversationsResponse 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.DmUploadInitRequest
import ru.fromchat.api.schema.messages.dm.upload.DmUploadInitResponse import ru.fromchat.api.schema.messages.dm.upload.DmUploadInitResponse
import ru.fromchat.api.schema.messages.dm.upload.DmUploadStatusResponse 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.messages.publicchat.SendMessageRequest
import ru.fromchat.api.schema.server.RegisteredUserCountResponse import ru.fromchat.api.schema.server.RegisteredUserCountResponse
import ru.fromchat.api.schema.server.ServerInstanceIdResponse import ru.fromchat.api.schema.server.ServerInstanceIdResponse
import ru.fromchat.api.schema.server.TransportKeyResponse import ru.fromchat.api.schema.server.TransportKeyResponse
import ru.fromchat.api.schema.user.ChangePasswordApiRequest import ru.fromchat.api.schema.user.ChangePasswordApiRequest
import ru.fromchat.api.schema.user.DeleteAccountRequest 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.FcmTokenRequest
import ru.fromchat.api.schema.user.User 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.CheckAuthResponse
import ru.fromchat.api.schema.user.auth.CheckUsernameResponse import ru.fromchat.api.schema.user.auth.CheckUsernameResponse
import ru.fromchat.api.schema.user.auth.LoginRequest 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.BackupBlobResponse
import ru.fromchat.api.schema.user.keys.PublicKeyResponse import ru.fromchat.api.schema.user.keys.PublicKeyResponse
import ru.fromchat.api.schema.user.profile.SimilarityResult 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.UserProfile
import ru.fromchat.api.schema.user.profile.VerifyResponse import ru.fromchat.api.schema.user.profile.VerifyResponse
import ru.fromchat.api.schema.websocket.WebSocketCredentials import ru.fromchat.api.schema.websocket.WebSocketCredentials
@@ -296,13 +304,11 @@ object ApiClient {
suspend fun probeHttpGet(url: String): Boolean = suspend fun probeHttpGet(url: String): Boolean =
runCatching { runCatching {
httpProbe.get(url.trim()) httpProbe.get(url.trim())
true }.isSuccess
}.getOrDefault(false)
suspend fun checkAuthAt(apiBaseUrl: String, bearer: String): Boolean = suspend fun checkAuthAt(apiBaseUrl: String, bearer: String): Boolean =
runCatching { runCatching {
val base = apiBaseUrl.trimEnd('/') httpProbe.get("${apiBaseUrl.trimEnd('/')}/check_auth") {
httpProbe.get("$base/check_auth") {
bearerAuth(bearer) bearerAuth(bearer)
}.body<CheckAuthResponse>().authenticated }.body<CheckAuthResponse>().authenticated
}.getOrDefault(false) }.getOrDefault(false)
@@ -455,6 +461,11 @@ object ApiClient {
} }
} }
suspend fun persistCurrentUser() {
val currentUser = user ?: return
settings.putString("user_info", json.encodeToString(currentUser))
}
// --- User & profile --- // --- User & profile ---
suspend fun getMessages(limit: Int = 50, beforeId: Int? = null) = suspend fun getMessages(limit: Int = 50, beforeId: Int? = null) =
@@ -466,6 +477,21 @@ object ApiClient {
} }
.body<MessagesResponse>() .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 = suspend fun getOwnProfile(): UserProfile =
http http
.get("${ServerConfig.apiBaseUrl}/user/profile") { .get("${ServerConfig.apiBaseUrl}/user/profile") {
@@ -487,6 +513,54 @@ object ApiClient {
} }
.body() .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 = suspend fun getRegisteredUserCount(): Int =
http http
.get("${ServerConfig.apiBaseUrl}/user/stats/registered-count") { .get("${ServerConfig.apiBaseUrl}/user/stats/registered-count") {
@@ -521,6 +595,18 @@ object ApiClient {
.body<DmConversationsResponse>() .body<DmConversationsResponse>()
.conversations .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 { suspend fun getDmFetch(since: Int? = null): DmHistoryResponse {
return http return http
.get("${ServerConfig.apiBaseUrl}/dm/fetch") { .get("${ServerConfig.apiBaseUrl}/dm/fetch") {
@@ -1263,6 +1349,11 @@ object ApiClient {
* or together with [logout] after remote logout). * or together with [logout] after remote logout).
*/ */
suspend fun clearLocalSession() { suspend fun clearLocalSession() {
val instanceId = runCatching { CacheContext.activeInstanceId.value.trim() }.getOrDefault("")
if (instanceId.isNotEmpty()) {
runCatching { cancelOutboxProcessing(instanceId) }
runCatching { MessageRepository.purgeAllPendingForInstance() }
}
val uid = user?.id val uid = user?.id
secureSettings.remove("auth_token") secureSettings.remove("auth_token")
settings.remove("user_info") settings.remove("user_info")
@@ -1277,7 +1368,9 @@ object ApiClient {
runCatching { IdentityKeyManager.clearLocalKeys() } runCatching { IdentityKeyManager.clearLocalKeys() }
runCatching { ProfileCache.clear() } runCatching { ProfileCache.clear() }
runCatching { DmPanelCache.clearAll() } runCatching { DmPanelCache.clearAll() }
runCatching { PublicChatProfileCache.clear() }
runCatching { PublicChatPanelCache.clear() } runCatching { PublicChatPanelCache.clear() }
runCatching { CacheContext.clearActive() }
} }
suspend fun logout() { suspend fun logout() {
@@ -1290,10 +1383,15 @@ object ApiClient {
suspend fun sendMessageViaHttp(content: String, replyToId: Int? = null) { suspend fun sendMessageViaHttp(content: String, replyToId: Int? = null) {
if (_suspensionState.value.isSuspended) return if (_suspensionState.value.isSuspended) return
http.post("${ServerConfig.apiBaseUrl}/send_message") { val payloadJson = json.encodeToString(
contentType(ContentType.Application.Json) SendMessageRequest(content = content.trim(), reply_to_id = replyToId),
setBody(SendMessageRequest(content = content, reply_to_id = replyToId)) )
} http.submitFormWithBinaryData(
url = "${ServerConfig.apiBaseUrl}/send_message",
formData = formData {
append("payload", payloadJson)
},
)
} }
// WebSocket send helpers // WebSocket send helpers
@@ -7,6 +7,7 @@ import ru.fromchat.api.local.db.store.InstanceRegistryStore
import ru.fromchat.config.ServerConfigData import ru.fromchat.config.ServerConfigData
import ru.fromchat.api.local.cache.CacheContext import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.config.ServerConfig import ru.fromchat.config.ServerConfig
import ru.fromchat.legal.DocumentRepository
import kotlin.time.TimeSource import kotlin.time.TimeSource
sealed interface ServerProbeResult { sealed interface ServerProbeResult {
@@ -64,6 +65,7 @@ suspend fun applyServerConfig(
) { ) {
val tentative = config.copy(callsEnabled = callsOk) val tentative = config.copy(callsEnabled = callsOk)
ServerConfig.updateServerConfig(tentative) ServerConfig.updateServerConfig(tentative)
DocumentRepository.invalidate()
val userId = ApiClient.user?.id val userId = ApiClient.user?.id
InstanceRegistryStore.rebindServerInstance(tentative, instanceId) InstanceRegistryStore.rebindServerInstance(tentative, instanceId)
CacheContext.setActiveInstance(instanceId, userId) CacheContext.setActiveInstance(instanceId, userId)
@@ -8,6 +8,7 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.db.store.InstanceRegistryStore 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.download.AttachmentDownloadNotifier
import ru.fromchat.api.local.send.scheduleOutboxProcessing import ru.fromchat.api.local.send.scheduleOutboxProcessing
import ru.fromchat.config.Settings import ru.fromchat.config.Settings
@@ -35,6 +36,11 @@ private fun activateInstance(instanceId: String) {
CacheContext.setActiveInstance(instanceId, ApiClient.user?.id) CacheContext.setActiveInstance(instanceId, ApiClient.user?.id)
scheduleOutboxProcessing(instanceId) scheduleOutboxProcessing(instanceId)
scheduleAttachmentResumeAfterSession() 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( suspend fun getOrDecrypt(
messageId: Int, messageId: Int,
fileIndex: Int, fileIndex: Int,
@@ -1,4 +1,15 @@
package ru.fromchat.api.local.cache 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]. */ /** Deletes the `cacheDir/fromchat/` tree (blobs, DB file on disk). Call after [ru.fromchat.api.db.MessageRepository.clearAllCache]. */
expect suspend fun wipeFromChatCacheDirectory() 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 kotlinx.coroutines.withContext
import ru.fromchat.api.local.db.store.MessageDatabaseProvider import ru.fromchat.api.local.db.store.MessageDatabaseProvider
import ru.fromchat.api.local.send.cancelOutboxProcessing 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.CacheContext
import ru.fromchat.api.local.cache.wipeAttachmentCacheDirectories
import ru.fromchat.api.local.cache.wipeFromChatCacheDirectory 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.panels.dm.DmPanelCache
import ru.fromchat.ui.chat.utils.PublicChatPanelCache
/** /**
* Drops the on-disk FromChat cache tree and reopens SQLite on next access. * Drops the on-disk FromChat cache tree and reopens SQLite on next access.
@@ -22,6 +24,8 @@ suspend fun wipeLocalCacheOnDisk() {
MessageDatabaseProvider.closeAndReset() MessageDatabaseProvider.closeAndReset()
} }
wipeFromChatCacheDirectory() wipeFromChatCacheDirectory()
wipeAttachmentCacheDirectories()
PublicChatProfileCache.clear()
PublicChatPanelCache.clear() PublicChatPanelCache.clear()
DmPanelCache.clearAll() DmPanelCache.clearAll()
} }
@@ -40,6 +40,7 @@ internal object MessageDatabaseExpectedSchema {
lastMessagePreview TEXT, lastMessagePreview TEXT,
unreadCount INTEGER NOT NULL DEFAULT 0, unreadCount INTEGER NOT NULL DEFAULT 0,
updatedAt TEXT, updatedAt TEXT,
archived INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (instanceId, id) PRIMARY KEY (instanceId, id)
) )
""".trimIndent(), """.trimIndent(),
@@ -122,6 +123,7 @@ internal object MessageDatabaseExpectedSchema {
"lastMessagePreview" to "TEXT", "lastMessagePreview" to "TEXT",
"unreadCount" to "INTEGER NOT NULL DEFAULT 0", "unreadCount" to "INTEGER NOT NULL DEFAULT 0",
"updatedAt" to "TEXT", "updatedAt" to "TEXT",
"archived" to "INTEGER NOT NULL DEFAULT 0",
), ),
"message" to mapOf( "message" to mapOf(
"instanceId" to "TEXT NOT NULL", "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.config.ServerConfigData
import ru.fromchat.api.local.cache.CacheContext import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.instance.configKey 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.utils.PublicChatPanelCache
import ru.fromchat.ui.chat.panels.dm.DmPanelCache import ru.fromchat.ui.chat.panels.dm.DmPanelCache
import kotlin.time.Clock import kotlin.time.Clock
@@ -68,6 +69,7 @@ object InstanceRegistryStore {
} }
val userId = CacheContext.activeUserId.value val userId = CacheContext.activeUserId.value
CacheContext.setActiveInstance(newId, userId) CacheContext.setActiveInstance(newId, userId)
PublicChatProfileCache.clear()
PublicChatPanelCache.clear() PublicChatPanelCache.clear()
DmPanelCache.clearAll() 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.db.resolveLocalPreviewUri
import ru.fromchat.api.local.messages.sortMessagesForChatDisplay import ru.fromchat.api.local.messages.sortMessagesForChatDisplay
import ru.fromchat.api.local.send.DmAttachmentOutboxPayload 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.local.send.OutgoingMessageCoordinator
import ru.fromchat.api.schema.messages.Message import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.schema.messages.dm.DmConversation 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.CacheContext
import ru.fromchat.api.local.cache.CacheValidator import ru.fromchat.api.local.cache.CacheValidator
import ru.fromchat.db.Conversation import ru.fromchat.db.Conversation
import ru.fromchat.db.MessageDatabase 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.cache.DecryptedImageCache
import ru.fromchat.api.local.download.DownloadedFileRegistry import ru.fromchat.api.local.download.DownloadedFileRegistry
import ru.fromchat.ui.chat.utils.dedupeMessagesByClientId import ru.fromchat.ui.chat.utils.dedupeMessagesByClientId
@@ -118,6 +122,7 @@ object MessageCacheStore {
purgeSupersededPendingRows(iid, convId, before, merged) purgeSupersededPendingRows(iid, convId, before, merged)
} }
replaceMessages(convId, merged) replaceMessages(convId, merged)
pruneEmptyConversations()
} }
private suspend fun filterStillPendingForReplace( private suspend fun filterStillPendingForReplace(
@@ -156,6 +161,34 @@ object MessageCacheStore {
upsertSingle(conversationIdForPublic(), message) 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) { suspend fun upsertDmMessage(otherUserId: Int, message: Message) {
upsertSingle(conversationIdForDm(otherUserId), message) upsertSingle(conversationIdForDm(otherUserId), message)
syncDmConversationPreviewFromCache(otherUserId) syncDmConversationPreviewFromCache(otherUserId)
@@ -228,34 +261,248 @@ object MessageCacheStore {
} }
} }
suspend fun replaceDmConversations(conversations: List<DmConversation>) { suspend fun replaceDmConversations(
conversations: List<DmConversation>,
attachmentOnlyPreview: String,
) {
val iid = instanceId() val iid = instanceId()
val currentUserId = ApiClient.user?.id
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
db.messageDatabaseQueries.transaction { val upserts = conversations.map { conv ->
conversations.forEach { conv ->
val conversationId = conversationIdForDm(conv.user.id) val conversationId = conversationIdForDm(conv.user.id)
val displayLabel = conv.user.displayName?.trim()?.takeIf { it.isNotEmpty() } val displayLabel = conv.user.displayName?.trim()?.takeIf { it.isNotEmpty() }
?: conv.user.username.trim() ?: conv.user.username.trim()
val recent = db.messageDatabaseQueries UpsertDmConversationRow(
.selectRecentMessagesByConversation(iid, conversationId, 1) conversationId = conversationId,
.executeAsList() otherUserId = conv.user.id,
.firstOrNull() displayName = displayLabel,
val rawPreview = recent?.content.orEmpty().trim() lastMessageId = conv.lastMessage.id,
val preview = rawPreview.takeIf { it.isNotEmpty() } lastMessagePreview = buildDmListPreview(
?.let { truncateDmListPreview(it) } conv.lastMessage,
?.takeIf { it.isNotEmpty() } 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( db.messageDatabaseQueries.upsertConversation(
instanceId = iid, instanceId = iid,
id = conversationId, id = row.conversationId,
type = "dm", type = "dm",
otherUserId = conv.user.id.toLong(), otherUserId = row.otherUserId.toLong(),
displayName = displayLabel, displayName = row.displayName,
lastMessageId = conv.lastMessage.id.toLong(), lastMessageId = row.lastMessageId.toLong(),
lastMessagePreview = preview, lastMessagePreview = row.lastMessagePreview,
unreadCount = conv.unreadCount.toLong(), unreadCount = row.unreadCount.toLong(),
updatedAt = conv.lastMessage.timestamp 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 {
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,
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)
}
} }
} }
} }
@@ -263,9 +510,8 @@ object MessageCacheStore {
suspend fun loadCachedDmConversations(): List<CachedConversation> = suspend fun loadCachedDmConversations(): List<CachedConversation> =
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
db.messageDatabaseQueries db.messageDatabaseQueries
.selectConversationsForInstance(instanceId()) .selectActiveDmConversationsForInstance(instanceId())
.executeAsList() .executeAsList()
.filter { row: Conversation -> row.type == "dm" }
.map { row: Conversation -> .map { row: Conversation ->
CachedConversation( CachedConversation(
id = row.id, id = row.id,
@@ -302,7 +548,8 @@ object MessageCacheStore {
lastMessageId = row.lastMessageId, lastMessageId = row.lastMessageId,
lastMessagePreview = preview, lastMessagePreview = preview,
unreadCount = row.unreadCount, 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> { private suspend fun loadMessages(conversationId: String): List<Message> {
@@ -558,6 +808,7 @@ object MessageCacheStore {
?: parsed.fileDimensions?.firstOrNull()?.let { (w, h) -> ?: parsed.fileDimensions?.firstOrNull()?.let { (w, h) ->
aspectRatioFromDimensionPair(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 { suspend fun hasSentMessageWithClientId(conversationId: String, clientMessageId: String): Boolean {
@@ -1,6 +1,7 @@
package ru.fromchat.api.local.db.store package ru.fromchat.api.local.db.store
import kotlinx.coroutines.flow.Flow 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.GENERAL_PUBLIC_GROUP_ID
import ru.fromchat.api.local.messages.conversationIdForDm import ru.fromchat.api.local.messages.conversationIdForDm
import ru.fromchat.api.local.messages.conversationIdForGroup import ru.fromchat.api.local.messages.conversationIdForGroup
@@ -63,11 +64,46 @@ object MessageRepository {
suspend fun deleteDmMessageById(otherUserId: Int, messageId: Int) = suspend fun deleteDmMessageById(otherUserId: Int, messageId: Int) =
MessageCacheStore.deleteDmMessageById(otherUserId, messageId) MessageCacheStore.deleteDmMessageById(otherUserId, messageId)
suspend fun replaceDmConversations(conversations: List<DmConversation>) = suspend fun replaceDmConversations(
MessageCacheStore.replaceDmConversations(conversations) conversations: List<DmConversation>,
attachmentOnlyPreview: String,
) = MessageCacheStore.replaceDmConversations(conversations, attachmentOnlyPreview)
suspend fun loadCachedDmConversations(): List<CachedConversation> = suspend fun loadCachedDmConversations(): List<CachedConversation> =
MessageCacheStore.loadCachedDmConversations() 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() suspend fun clearAllCache() = MessageCacheStore.clearAll()
} }
@@ -45,6 +45,63 @@ object ProfileCache {
fun get(userId: Int): UserProfile? = profiles[userId] 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) { fun put(profile: UserProfile) {
if (profile.isClientPreviewOnly) { if (profile.isClientPreviewOnly) {
val hasIdentity = 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-") header(HttpHeaders.Range, "bytes=$rangeOffset-")
} }
}.execute { response -> }.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) { if (response.status.value !in 200..299) {
error("HTTP ${response.status.value} for encrypted file download") 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.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.workers.AttachmentUploadNotifier
import ru.fromchat.api.local.workers.AttachmentUploadProgress import ru.fromchat.api.local.workers.AttachmentUploadProgress
import ru.fromchat.api.local.messages.GENERAL_PUBLIC_GROUP_ID import ru.fromchat.api.local.messages.GENERAL_PUBLIC_GROUP_ID
import ru.fromchat.api.local.db.store.MessageCacheStore 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.db.store.MessageRepository
import ru.fromchat.api.local.messages.conversationIdForDm import ru.fromchat.api.local.messages.conversationIdForDm
import ru.fromchat.api.local.messages.conversationIdForGroup 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.Message
import ru.fromchat.api.schema.messages.dm.SendDmFile import ru.fromchat.api.schema.messages.dm.SendDmFile
import ru.fromchat.api.local.cache.CacheContext 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). */ /** Re-queues a failed attachment upload (outbox row must still exist). */
fun retryDmAttachmentUpload(clientMessageId: String) { fun retryDmAttachmentUpload(clientMessageId: String) {
val cid = clientMessageId.trim() val cid = clientMessageId.trim()
@@ -233,13 +253,34 @@ object OutgoingMessageCoordinator {
for (row in rows) { for (row in rows) {
when (row.kind) { when (row.kind) {
KIND_SEND_PUBLIC -> { KIND_SEND_PUBLIC -> {
runCatching { val sendResult = runCatching {
val payload = json.decodeFromString<PublicOutboxPayload>(row.payloadJson) val payload = json.decodeFromString<PublicOutboxPayload>(row.payloadJson)
ApiClient.sendMessageViaHttp(payload.content, payload.replyToId) ApiClient.sendMessageViaHttp(payload.content, payload.replyToId)
}
sendResult.onSuccess {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
MessageDatabaseProvider.database.messageDatabaseQueries MessageDatabaseProvider.database.messageDatabaseQueries
.deleteOutboxItem(id, row.clientMessageId) .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 -> { 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 package ru.fromchat.ui
import androidx.compose.animation.AnimatedContentScope import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.End import androidx.compose.animation.EnterTransition
import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.Start import androidx.compose.animation.ExitTransition
import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.scaleIn
import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarDuration
@@ -25,9 +24,9 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.IntOffset
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.navigation.NamedNavArgument
import androidx.navigation.NavBackStackEntry import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
@@ -38,7 +37,9 @@ import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import coil3.ImageLoader import coil3.ImageLoader
import coil3.compose.setSingletonImageLoaderFactory import coil3.compose.setSingletonImageLoaderFactory
import coil3.network.ktor3.KtorNetworkFetcherFactory
import coil3.svg.SvgDecoder import coil3.svg.SvgDecoder
import ru.fromchat.api.ApiClient
import com.pr0gramm3r101.utils.LocalSystemBarsVisibility import com.pr0gramm3r101.utils.LocalSystemBarsVisibility
import com.pr0gramm3r101.utils.navigateAndWipeBackStack import com.pr0gramm3r101.utils.navigateAndWipeBackStack
import com.pr0gramm3r101.utils.rememberSystemBarsController import com.pr0gramm3r101.utils.rememberSystemBarsController
@@ -54,7 +55,6 @@ import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import ru.fromchat.AppForeground import ru.fromchat.AppForeground
import ru.fromchat.Logger import ru.fromchat.Logger
import ru.fromchat.api.ApiClient
import ru.fromchat.api.DeferredStartupNetwork import ru.fromchat.api.DeferredStartupNetwork
import ru.fromchat.api.UpdateSyncManager import ru.fromchat.api.UpdateSyncManager
import ru.fromchat.api.calls.CallStore 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.WebSocketMessage
import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData
import ru.fromchat.config.ServerConfig 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.auth.AuthScreen
import ru.fromchat.ui.calls.CallOverlay import ru.fromchat.ui.calls.CallOverlay
import ru.fromchat.ui.chat.panels.dm.DmChatRoute import ru.fromchat.ui.chat.panels.dm.DmChatRoute
import ru.fromchat.ui.chat.panels.dm.DmNav import ru.fromchat.ui.chat.panels.dm.DmNav
import ru.fromchat.ui.chat.panels.dm.DmProfileRoute 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.MainScreen
import ru.fromchat.ui.main.chats.ChatsSearchScreen import ru.fromchat.ui.main.chats.ChatsSearchScreen
import ru.fromchat.ui.main.settings.AboutScreen 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.NotificationsScreen
import ru.fromchat.ui.main.settings.SettingsRoutes import ru.fromchat.ui.main.settings.SettingsRoutes
import ru.fromchat.ui.main.settings.account.AccountScreen 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.changepassword.ChangePasswordScreen
import ru.fromchat.ui.main.settings.account.delete.DeleteAccountScreen
import ru.fromchat.ui.main.settings.server.ServerConfigScreen 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.ui.profile.ProfileScreen
import ru.fromchat.utils.NetworkConnectivity import ru.fromchat.utils.NetworkConnectivity
val LocalNavController = compositionLocalOf<NavController> { error("NavController not provided") } 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?) { private fun handlePresenceStatus(data: JsonObject?) {
val userId = data?.get("userId")?.jsonPrimitive?.content?.toIntOrNull() ?: return val userId = data?.get("userId")?.jsonPrimitive?.content?.toIntOrNull() ?: return
val online = data["online"]?.jsonPrimitive?.booleanOrNull == true 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, route: String,
animationSpec: FiniteAnimationSpec<IntOffset>, arguments: List<NamedNavArgument> = emptyList(),
content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit, content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit,
) { ) {
composable( composable(
route = route, route = route,
enterTransition = { arguments = arguments,
slideInHorizontally(animationSpec = animationSpec) { it }
},
exitTransition = {
slideOutHorizontally(animationSpec = animationSpec) { -it }
},
popEnterTransition = {
slideInHorizontally(animationSpec = animationSpec) { -it }
},
popExitTransition = {
slideOutHorizontally(animationSpec = animationSpec) { it }
},
content = content, content = content,
) )
} }
@@ -186,6 +208,7 @@ fun App(
ImageLoader.Builder(context) ImageLoader.Builder(context)
.components { .components {
add(SvgDecoder.Factory()) add(SvgDecoder.Factory())
add(KtorNetworkFetcherFactory(httpClient = { ApiClient.http }))
} }
.build() .build()
} }
@@ -287,7 +310,6 @@ fun App(
SharedTransitionLayout { SharedTransitionLayout {
val navController = rememberNavController() val navController = rememberNavController()
val profileLookupSnackbarHostState = remember { SnackbarHostState() } val profileLookupSnackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(profileLookupErrorMessage) { LaunchedEffect(profileLookupErrorMessage) {
profileLookupErrorMessage?.let { message -> profileLookupErrorMessage?.let { message ->
Logger.d("ProfileDeepLink", "showing snackbar for deep-link lookup failure: $message") Logger.d("ProfileDeepLink", "showing snackbar for deep-link lookup failure: $message")
@@ -359,35 +381,14 @@ fun App(
LocalSystemBarsVisibility provides rememberSystemBarsController() LocalSystemBarsVisibility provides rememberSystemBarsController()
) { ) {
if (startDestination != null) { if (startDestination != null) {
val rootNavMotion = spring<IntOffset>(dampingRatio = 0.88f, stiffness = 420f)
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = startDestination!!, startDestination = startDestination!!,
enterTransition = { enterTransition = { rootNavEnterTransition() },
slideIntoContainer( exitTransition = { rootNavExitTransition() },
Start, popEnterTransition = { rootNavPopEnterTransition() },
animationSpec = rootNavMotion popExitTransition = { rootNavPopExitTransition() },
)
},
exitTransition = {
slideOutOfContainer(
Start,
animationSpec = rootNavMotion
)
},
popEnterTransition = {
slideIntoContainer(
End,
animationSpec = rootNavMotion
)
},
popExitTransition = {
slideOutOfContainer(
End,
animationSpec = rootNavMotion
)
}
) { ) {
composable("serverConfig") { composable("serverConfig") {
ServerConfigScreen() ServerConfigScreen()
@@ -426,21 +427,29 @@ fun App(
) )
} }
composable("chats/publicChat") { composable(PublicChatNav.CHAT_ROUTE) {
PublicChatScreen( PublicChatChatRoute(
scrollToMessageId = scrollToMessageId, scrollToMessageId = scrollToMessageId,
navController = navController,
sharedTransitionScope = this@SharedTransitionLayout, 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( composable(
route = "search/conversations", route = "search/conversations",
enterTransition = { fadeIn(animationSpec = searchScreenFade) }, enterTransition = { searchScreenEnterTransition() },
exitTransition = { fadeOut(animationSpec = searchScreenFade) }, exitTransition = { searchScreenExitTransition() },
popEnterTransition = { fadeIn(animationSpec = searchScreenFade) }, popEnterTransition = { searchScreenEnterTransition() },
popExitTransition = { fadeOut(animationSpec = searchScreenFade) } popExitTransition = { searchScreenExitTransition() },
) { ) {
ChatsSearchScreen( ChatsSearchScreen(
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
@@ -460,21 +469,13 @@ fun App(
} }
composable( composable(
route = "profile/{userId}?fromDeepLink={fromDeepLink}&useSharedElement={useSharedElement}&sourceMessageId={sourceMessageId}", route = "profile/{userId}?fromDeepLink={fromDeepLink}",
arguments = listOf( arguments = listOf(
navArgument("userId") { type = NavType.StringType }, navArgument("userId") { type = NavType.StringType },
navArgument("fromDeepLink") { navArgument("fromDeepLink") {
type = NavType.BoolType type = NavType.BoolType
defaultValue = false defaultValue = false
}, },
navArgument("useSharedElement") {
type = NavType.StringType
defaultValue = "false"
},
navArgument("sourceMessageId") {
type = NavType.StringType
defaultValue = "-1"
}
) )
) { backStackEntry -> ) { backStackEntry ->
val args = backStackEntry.savedStateHandle val args = backStackEntry.savedStateHandle
@@ -483,20 +484,8 @@ fun App(
val userId = if ((parsedUserId ?: 0) > 0) parsedUserId else null val userId = if ((parsedUserId ?: 0) > 0) parsedUserId else null
val profileUsername = if (userId == null) { val profileUsername = if (userId == null) {
userIdParam?.trim()?.takeIf { it.isNotBlank() } userIdParam?.trim()?.takeIf { it.isNotBlank() }
} else { } else null
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
}
val fromDeepLink = when (val rawFromDeepLink = args.get<Any?>("fromDeepLink")) { val fromDeepLink = when (val rawFromDeepLink = args.get<Any?>("fromDeepLink")) {
is Boolean -> rawFromDeepLink is Boolean -> rawFromDeepLink
is String -> rawFromDeepLink == "true" is String -> rawFromDeepLink == "true"
@@ -506,24 +495,39 @@ fun App(
Logger.d( Logger.d(
"ProfileRoute", "ProfileRoute",
"profile entry args: rawUserId=$userIdParam parsedUserId=$parsedUserId resolvedUserId=$userId " + "profile entry args: rawUserId=$userIdParam parsedUserId=$parsedUserId resolvedUserId=$userId " +
"resolvedUsername=$profileUsername sourceMessageId=$sourceMessageId fromDeepLink=$fromDeepLink " + "resolvedUsername=$profileUsername fromDeepLink=$fromDeepLink " +
"useSharedElement=$useSharedElement currentRoute=${backStackEntry.destination.route}" "currentRoute=${backStackEntry.destination.route}"
) )
ProfileScreen( ProfileScreen(
userId = userId, userId = userId,
username = profileUsername, username = profileUsername,
showBackButton = true,
onBack = { navController.navigateUp() }, onBack = { navController.navigateUp() },
onChat = { navController.navigate(DmNav.chatRoute(it)) }, onChat = { navController.navigate(DmNav.chatRoute(it)) },
sharedTransitionScope = this@SharedTransitionLayout, sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this@composable, animatedVisibilityScope = this@composable,
useSharedElementFromNavigation = useSharedElement,
sharedSourceMessageId = sourceMessageId,
showErrorAsToast = fromDeepLink 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( composable(
route = DmNav.CHAT_ROUTE, route = DmNav.CHAT_ROUTE,
@@ -531,30 +535,6 @@ fun App(
navArgument("otherUserId") { type = NavType.StringType }, navArgument("otherUserId") { type = NavType.StringType },
navArgument("sourceMessageId") { type = NavType.IntType; defaultValue = -1 }, 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 -> ) { entry ->
val otherUserId = entry.savedStateHandle.get<String>("otherUserId")?.toIntOrNull() ?: 0 val otherUserId = entry.savedStateHandle.get<String>("otherUserId")?.toIntOrNull() ?: 0
val sourceMessageId = entry.savedStateHandle.get<Int>("sourceMessageId") ?: -1 val sourceMessageId = entry.savedStateHandle.get<Int>("sourceMessageId") ?: -1
@@ -571,10 +551,6 @@ fun App(
composable( composable(
route = DmNav.PROFILE_ROUTE, route = DmNav.PROFILE_ROUTE,
arguments = listOf(navArgument("otherUserId") { type = NavType.StringType }), arguments = listOf(navArgument("otherUserId") { type = NavType.StringType }),
enterTransition = { fadeIn(animationSpec = dmChatProfileFade) },
exitTransition = { fadeOut(animationSpec = dmChatProfileFade) },
popEnterTransition = { fadeIn(animationSpec = dmChatProfileFade) },
popExitTransition = { fadeOut(animationSpec = dmChatProfileFade) },
) { entry -> ) { entry ->
val otherUserId = entry.savedStateHandle.get<String>("otherUserId")?.toIntOrNull() ?: 0 val otherUserId = entry.savedStateHandle.get<String>("otherUserId")?.toIntOrNull() ?: 0
if (otherUserId <= 0) return@composable if (otherUserId <= 0) return@composable
@@ -586,30 +562,51 @@ fun App(
) )
} }
settingsSlideComposable("about", rootNavMotion) { settingsComposable("about") {
AboutScreen() 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() }) AppearanceScreen(onBack = { navController.navigateUp() })
} }
settingsSlideComposable(SettingsRoutes.Notifications, rootNavMotion) { settingsComposable(SettingsRoutes.Notifications) {
NotificationsScreen(onBack = { navController.navigateUp() }) NotificationsScreen(onBack = { navController.navigateUp() })
} }
settingsSlideComposable(SettingsRoutes.Devices, rootNavMotion) { settingsComposable(SettingsRoutes.Devices) {
DevicesScreen(onBack = { navController.navigateUp() }) DevicesScreen(onBack = { navController.navigateUp() })
} }
settingsSlideComposable(SettingsRoutes.SecurityPasswordFlow, rootNavMotion) { settingsComposable(SettingsRoutes.SecurityPasswordFlow) {
ChangePasswordScreen( ChangePasswordScreen(
onBack = { navController.navigateUp() }, onBack = { navController.navigateUp() },
onDone = { navController.popBackStack() }, onDone = { navController.popBackStack() },
) )
} }
settingsSlideComposable(SettingsRoutes.AccountDeleteFlow, rootNavMotion) { settingsComposable(SettingsRoutes.AccountDeleteFlow) {
DeleteAccountScreen( DeleteAccountScreen(
onBack = { navController.navigateUp() }, onBack = { navController.navigateUp() },
onDeleted = { onDeleted = {
@@ -620,7 +617,7 @@ fun App(
) )
} }
settingsSlideComposable(SettingsRoutes.Account, rootNavMotion) { settingsComposable(SettingsRoutes.Account) {
AccountScreen( AccountScreen(
onBack = { navController.navigateUp() }, onBack = { navController.navigateUp() },
onLogout = { onLogout = {
@@ -1,5 +1,7 @@
package ru.fromchat.ui.auth.register 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.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -10,6 +12,7 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialShapes import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -17,11 +20,16 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.about_link_privacy
import ru.fromchat.about_link_terms
import ru.fromchat.auth_char_count 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_body
import ru.fromchat.auth_step_profile_title import ru.fromchat.auth_step_profile_title
import ru.fromchat.auth_username_taken import ru.fromchat.auth_username_taken
@@ -30,6 +38,8 @@ import ru.fromchat.display_name_error
import ru.fromchat.error_unexpected import ru.fromchat.error_unexpected
import ru.fromchat.profile_headline_bio import ru.fromchat.profile_headline_bio
import ru.fromchat.register_button 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.RegisterResult
import ru.fromchat.ui.auth.register import ru.fromchat.ui.auth.register
import ru.fromchat.ui.components.ActionButton import ru.fromchat.ui.components.ActionButton
@@ -60,6 +70,7 @@ internal fun profileStepPage(
onSnackbar: (String) -> Unit, onSnackbar: (String) -> Unit,
): ExpressiveStepPage { ): ExpressiveStepPage {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val navController = LocalNavController.current
val fieldColors = expressiveStepFieldColors() val fieldColors = expressiveStepFieldColors()
val colorScheme = MaterialTheme.colorScheme val colorScheme = MaterialTheme.colorScheme
@@ -123,6 +134,36 @@ internal fun profileStepPage(
) )
}, },
button = { 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( ActionButton(
onClick = { onClick = {
if (busy) return@ActionButton 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.generateClientMessageId
import ru.fromchat.api.local.messages.nowMessageTimestampIso import ru.fromchat.api.local.messages.nowMessageTimestampIso
import ru.fromchat.api.local.messages.optimisticMessageIdForClientMessageId 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.OutgoingMessageCoordinator
import ru.fromchat.api.local.send.prepareOutboundFileForSend import ru.fromchat.api.local.send.prepareOutboundFileForSend
import ru.fromchat.api.local.send.prepareOutboundImageForSend 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_connecting
import ru.fromchat.status_updating import ru.fromchat.status_updating
import ru.fromchat.ui.LocalNavController 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.AttachmentDownloadVisibility
import ru.fromchat.ui.chat.utils.getImageAspectRatio import ru.fromchat.ui.chat.utils.getImageAspectRatio
import ru.fromchat.ui.chat.utils.getImageDimensions import ru.fromchat.ui.chat.utils.getImageDimensions
@@ -171,13 +172,6 @@ fun ChatScreen(
Logger.d("ChatScreen", "currentTypingUsers updated (from panelState): ${currentTypingUsers.map { it.username }}") 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 { val subtitleKey = when {
!online -> "connecting" !online -> "connecting"
connectionStatus == ConnectionStatus.UPDATING -> "updating" 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) { LaunchedEffect(panel) {
if (panel.getRecipientId() != null) { if (panel.getRecipientId() != null) {
AttachmentUploadNotifier.progressFlow.collect { progress -> AttachmentUploadNotifier.progressFlow.collect { progress ->
@@ -635,7 +644,7 @@ fun ChatScreen(
} }
} }
) { ) {
if (panelState.isLoading) { if (panelState.isLoading && panelState.messages.isEmpty()) {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
@@ -698,12 +707,8 @@ fun ChatScreen(
message.user_id > 0 message.user_id > 0
) { ) {
{ {
profileSharedSourceMessageId = message.id
ProfileCache.mergePreviewFromPublicMessage(message) ProfileCache.mergePreviewFromPublicMessage(message)
navController.navigate( navController.navigate("profile/${message.user_id}")
"profile/${message.user_id}" +
"?useSharedElement=true&sourceMessageId=${message.id}"
)
} }
} else { } else {
null null
@@ -716,8 +721,6 @@ fun ChatScreen(
isImageClosing = isImageClosing, isImageClosing = isImageClosing,
showUsername = panel.showUsernamesInMessages, showUsername = panel.showUsernamesInMessages,
currentUserId = currentUserId, currentUserId = currentUserId,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope,
onCancelOutboundAttachment = { msg -> onCancelOutboundAttachment = { msg ->
scope.launch { panel.cancelQueuedMessage(msg) } scope.launch { panel.cancelQueuedMessage(msg) }
}, },
@@ -731,22 +734,6 @@ fun ChatScreen(
OutgoingMessageCoordinator.retryDmAttachmentUpload(cid) 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 -> onCancelSend = { message ->
scope.launch { panel.cancelQueuedMessage(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, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center,
modifier = modifier.conditional( modifier = modifier.conditional(
profileUserId != null && onTitleClick != null onTitleClick != null
) { ) {
Modifier.scaleOnPress( Modifier.scaleOnPress(
scale = 0.96f, scale = 0.96f,
@@ -148,16 +148,20 @@ fun ImageFullscreenPreview(
fileIndex = fileIndex, fileIndex = fileIndex,
confirmed = message.id > 0, 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 } ?.let { bounds -> bounds.width / bounds.height }
var menusVisible by remember { mutableStateOf(true) } var menusVisible by remember { mutableStateOf(true) }
var dismissRequested by remember { mutableStateOf(false) } var dismissRequested by remember { mutableStateOf(false) }
val backgroundAlpha = remember { Animatable(1f) } 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 isOpenAnimationPlaying by remember { mutableStateOf(false) }
var dismissProgress by remember { mutableStateOf(0f) } var dismissProgress by remember { mutableStateOf(0f) }
val isInitialOpenState = thumbnailBounds != null && !hasPlayedOpenAnimation val isInitialOpenState = animationBounds != null && !hasPlayedOpenAnimation
val isTransitioning = isOpenAnimationPlaying || dismissRequested val isTransitioning = isOpenAnimationPlaying || dismissRequested
val effectiveMenusVisible = if (isInitialOpenState) false else menusVisible && dismissProgress < 0.01f val effectiveMenusVisible = if (isInitialOpenState) false else menusVisible && dismissProgress < 0.01f
var effectiveBgAlpha by remember { mutableStateOf(1f) } var effectiveBgAlpha by remember { mutableStateOf(1f) }
@@ -168,8 +172,10 @@ fun ImageFullscreenPreview(
val systemBarsVisibility = LocalSystemBarsVisibility.current val systemBarsVisibility = LocalSystemBarsVisibility.current
LaunchedEffect(menusVisible) { // Keep status bars visible while dismissing so ChatScreen underneath keeps
systemBarsVisibility?.invoke(menusVisible) // correct status-bar insets until the overlay is removed.
LaunchedEffect(menusVisible, dismissRequested) {
systemBarsVisibility?.invoke(menusVisible || dismissRequested)
} }
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { onDispose {
@@ -237,7 +243,9 @@ fun ImageFullscreenPreview(
.background(Color.Black) .background(Color.Black)
) )
LaunchedEffect(dismissRequested) { LaunchedEffect(dismissRequested) {
if (dismissRequested) onDismiss() if (!dismissRequested) return@LaunchedEffect
onClosingChange(true)
onDismiss()
} }
androidx.compose.material3.CircularProgressIndicator( androidx.compose.material3.CircularProgressIndicator(
modifier = Modifier.size(48.dp), modifier = Modifier.size(48.dp),
@@ -255,16 +263,17 @@ fun ImageFullscreenPreview(
contentHeightAtScale1 contentHeightAtScale1
} }
val initial = remember( 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 fullTop = (containerHeight - layoutContentHeight) / 2f
val fullCenter = Offset( val fullCenter = Offset(
x = containerWidth / 2f, x = containerWidth / 2f,
y = fullTop + layoutContentHeight / 2f, y = fullTop + layoutContentHeight / 2f,
) )
val thumbCenter = thumbnailBounds.center val thumbCenter = bounds.center
val thumbWidth = thumbnailBounds.width val thumbWidth = bounds.width
val s = thumbWidth / containerWidth val s = thumbWidth / containerWidth
val o = thumbCenter - fullCenter val o = thumbCenter - fullCenter
InitialTransform(s, o.x, o.y, 12f, 0f) InitialTransform(s, o.x, o.y, 12f, 0f)
@@ -288,8 +297,9 @@ fun ImageFullscreenPreview(
var lastStableOffsetX by remember { mutableStateOf(initial.offsetX) } var lastStableOffsetX by remember { mutableStateOf(initial.offsetX) }
var lastStableOffsetY by remember { mutableStateOf(initial.offsetY) } var lastStableOffsetY by remember { mutableStateOf(initial.offsetY) }
LaunchedEffect(thumbnailBounds) { LaunchedEffect(animationBounds) {
if (thumbnailBounds != null && !hasPlayedOpenAnimation) { val bounds = animationBounds ?: return@LaunchedEffect
if (!hasPlayedOpenAnimation) {
hasPlayedOpenAnimation = true hasPlayedOpenAnimation = true
isOpenAnimationPlaying = true isOpenAnimationPlaying = true
@@ -298,8 +308,8 @@ fun ImageFullscreenPreview(
x = containerWidth / 2f, x = containerWidth / 2f,
y = fullTop + layoutContentHeight / 2f, y = fullTop + layoutContentHeight / 2f,
) )
val thumbCenter = thumbnailBounds.center val thumbCenter = bounds.center
val thumbWidth = thumbnailBounds.width val thumbWidth = bounds.width
val startScale = thumbWidth / containerWidth val startScale = thumbWidth / containerWidth
val startOffset = thumbCenter - fullCenter val startOffset = thumbCenter - fullCenter
@@ -342,12 +352,14 @@ fun ImageFullscreenPreview(
} }
LaunchedEffect(dismissRequested) { LaunchedEffect(dismissRequested) {
if (!dismissRequested) return@LaunchedEffect if (!dismissRequested) return@LaunchedEffect
onClosingChange(true)
val hasTransform = lastStableScale != 1f || val hasTransform = lastStableScale != 1f ||
lastStableOffsetX != 0f || lastStableOffsetX != 0f ||
lastStableOffsetY != 0f lastStableOffsetY != 0f
if (thumbnailBounds != null) { val bounds = animationBounds
if (bounds != null) {
menusVisible = false menusVisible = false
val wasOpenAnimating = isOpenAnimationPlaying val wasOpenAnimating = isOpenAnimationPlaying
isOpenAnimationPlaying = false isOpenAnimationPlaying = false
@@ -357,8 +369,8 @@ fun ImageFullscreenPreview(
x = containerWidth / 2f, x = containerWidth / 2f,
y = fullTop + layoutContentHeight / 2f, y = fullTop + layoutContentHeight / 2f,
) )
val thumbCenter = thumbnailBounds.center val thumbCenter = bounds.center
val thumbWidth = thumbnailBounds.width val thumbWidth = bounds.width
val targetScale = thumbWidth / containerWidth val targetScale = thumbWidth / containerWidth
val targetOffset = thumbCenter - fullCenter val targetOffset = thumbCenter - fullCenter
@@ -395,9 +407,6 @@ fun ImageFullscreenPreview(
} }
} }
onClosingChange(true)
delay(50)
onClosingChange(false)
onDismiss() onDismiss()
} }
LaunchedEffect(scale, offset, isTransformInProgress) { 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.ContentCopy
import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.rounded.SaveAlt import androidx.compose.material.icons.rounded.SaveAlt
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -55,6 +56,7 @@ import ru.fromchat.action_copy
import ru.fromchat.action_delete import ru.fromchat.action_delete
import ru.fromchat.action_edit import ru.fromchat.action_edit
import ru.fromchat.action_reply import ru.fromchat.action_reply
import ru.fromchat.action_retry_send
import ru.fromchat.action_save import ru.fromchat.action_save
import ru.fromchat.api.local.download.resolveSavableMessageFile import ru.fromchat.api.local.download.resolveSavableMessageFile
import ru.fromchat.api.local.download.resolveSavableMessageImage import ru.fromchat.api.local.download.resolveSavableMessageImage
@@ -76,9 +78,11 @@ internal fun messageContextMenuFingerprint(
isReadOnly: Boolean, isReadOnly: Boolean,
): String { ): String {
val isQueued = message.isQueuedOutbound() && isAuthor val isQueued = message.isQueuedOutbound() && isAuthor
val sendFailed = isQueued && !message.uploadError.isNullOrBlank()
val corrupted = message.isContentCorrupted val corrupted = message.isContentCorrupted
return buildString { return buildString {
append("q=").append(isQueued) append("q=").append(isQueued)
append("|failed=").append(sendFailed)
append("|copy=").append(!corrupted) append("|copy=").append(!corrupted)
append("|save=").append(resolveSavableMessageImage(message) != null) append("|save=").append(resolveSavableMessageImage(message) != null)
if (!isQueued && !isReadOnly) { if (!isQueued && !isReadOnly) {
@@ -100,6 +104,7 @@ fun MessageContextMenu(
onCopy: (Message) -> Unit, onCopy: (Message) -> Unit,
onSave: (Message) -> Unit, onSave: (Message) -> Unit,
onCancelSend: (Message) -> Unit, onCancelSend: (Message) -> Unit,
onRetrySend: (Message) -> Unit = {},
isReadOnly: Boolean = false, isReadOnly: Boolean = false,
screenWidthPx: Int, screenWidthPx: Int,
screenHeightPx: Int, screenHeightPx: Int,
@@ -162,6 +167,7 @@ fun MessageContextMenu(
onCopy = {}, onCopy = {},
onSave = {}, onSave = {},
onCancelSend = {}, onCancelSend = {},
onRetrySend = {},
modifier = modifier.graphicsLayer(alpha = 0f), modifier = modifier.graphicsLayer(alpha = 0f),
animated = false, animated = false,
withShadow = false, withShadow = false,
@@ -260,6 +266,10 @@ fun MessageContextMenu(
onCancelSend(it) onCancelSend(it)
onDismiss() onDismiss()
}, },
onRetrySend = {
onRetrySend(it)
onDismiss()
},
modifier = modifier, modifier = modifier,
animated = true, animated = true,
scale = scale, scale = scale,
@@ -283,6 +293,7 @@ private fun ContextMenuContent(
onCopy: (Message) -> Unit, onCopy: (Message) -> Unit,
onSave: (Message) -> Unit, onSave: (Message) -> Unit,
onCancelSend: (Message) -> Unit, onCancelSend: (Message) -> Unit,
onRetrySend: (Message) -> Unit,
isReadOnly: Boolean = false, isReadOnly: Boolean = false,
modifier: Modifier, modifier: Modifier,
animated: Boolean, animated: Boolean,
@@ -330,7 +341,9 @@ private fun ContextMenuContent(
val labelCopy = stringResource(Res.string.action_copy) val labelCopy = stringResource(Res.string.action_copy)
val labelSave = stringResource(Res.string.action_save) val labelSave = stringResource(Res.string.action_save)
val labelCancelSend = stringResource(Res.string.action_cancel_send) val labelCancelSend = stringResource(Res.string.action_cancel_send)
val labelRetrySend = stringResource(Res.string.action_retry_send)
val isQueued = message.isQueuedOutbound() && isAuthor val isQueued = message.isQueuedOutbound() && isAuthor
val sendFailed = isQueued && !message.uploadError.isNullOrBlank()
val savableImage = resolveSavableMessageImage(message) val savableImage = resolveSavableMessageImage(message)
val savableFile = resolveSavableMessageFile(message) val savableFile = resolveSavableMessageFile(message)
val canSave = savableImage != null || savableFile != null val canSave = savableImage != null || savableFile != null
@@ -357,7 +370,19 @@ private fun ContextMenuContent(
onClick = { onSave(message) } 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( ContextMenuItem(
icon = Icons.Rounded.Close, icon = Icons.Rounded.Close,
text = labelCancelSend, text = labelCancelSend,
@@ -1,7 +1,5 @@
package ru.fromchat.ui.chat 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.Spring
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring 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.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape 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.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue 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.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.text.font.FontWeight 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.pr0gramm3r101.utils.conditional 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.api.schema.messages.Message
import ru.fromchat.message_corrupted import ru.fromchat.message_corrupted
import ru.fromchat.message_edited_suffix 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.getMessageGradient
import ru.fromchat.ui.chat.components.getReplyMessageGradient import ru.fromchat.ui.chat.components.getReplyMessageGradient
import ru.fromchat.ui.chat.utils.imageAspectRatioForMessage import ru.fromchat.ui.chat.utils.imageAspectRatioForMessage
@@ -103,9 +107,6 @@ fun MessageItem(
isImageClosing: Boolean = false, isImageClosing: Boolean = false,
isContextMenuOpen: Boolean = false, isContextMenuOpen: Boolean = false,
isContextMenuForThisMessage: Boolean = false, isContextMenuForThisMessage: Boolean = false,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
sharedAvatarNavKey: String? = null,
onCancelOutboundAttachment: ((Message) -> Unit)? = null, onCancelOutboundAttachment: ((Message) -> Unit)? = null,
onRetryOutboundAttachment: ((Message) -> Unit)? = null, onRetryOutboundAttachment: ((Message) -> Unit)? = null,
) { ) {
@@ -118,6 +119,7 @@ fun MessageItem(
} }
val corruptedBody = stringResource(Res.string.message_corrupted) val corruptedBody = stringResource(Res.string.message_corrupted)
val editedSuffix = stringResource(Res.string.message_edited_suffix) val editedSuffix = stringResource(Res.string.message_edited_suffix)
val sendFailedLabel = stringResource(Res.string.message_send_failed)
val displayUsername = messageDisplayUsername(message, currentUserId) val displayUsername = messageDisplayUsername(message, currentUserId)
val replyRef = message.reply_to val replyRef = message.reply_to
@@ -178,30 +180,12 @@ 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( Avatar(
profilePictureUrl = message.profile_picture, profilePictureUrl = message.profile_picture,
displayName = message.username, displayName = message.username,
modifier = Modifier.size(32.dp) modifier = Modifier.size(32.dp)
) )
} }
}
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
} }
@@ -608,13 +592,14 @@ fun MessageItem(
// Timestamp, sending indicator, and edited indicator // Timestamp, sending indicator, and edited indicator
val isPendingOutbound = message.id < 0 && message.files.isNullOrEmpty() val isPendingOutbound = message.id < 0 && message.files.isNullOrEmpty()
val sendFailed = isPendingOutbound && !message.uploadError.isNullOrBlank()
Row( Row(
modifier = Modifier modifier = Modifier
.padding(start = 12.dp, end = 12.dp, top = 4.dp, bottom = 8.dp), .padding(start = 12.dp, end = 12.dp, top = 4.dp, bottom = 8.dp),
horizontalArrangement = Arrangement.End, horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
if (isPendingOutbound) { if (isPendingOutbound && !sendFailed) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.size(12.dp), modifier = Modifier.size(12.dp),
strokeWidth = 1.5.dp, strokeWidth = 1.5.dp,
@@ -625,6 +610,20 @@ fun MessageItem(
} }
) )
Spacer(modifier = Modifier.width(6.dp)) 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(
text = formattedTime, text = formattedTime,
@@ -10,6 +10,7 @@ import androidx.compose.runtime.remember
import ru.fromchat.api.local.cache.CacheContext import ru.fromchat.api.local.cache.CacheContext
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.navigation.NavController import androidx.navigation.NavController
import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.utils.haptic.HapticFeedbackEvent import ru.fromchat.utils.haptic.HapticFeedbackEvent
import ru.fromchat.ui.profile.ProfileScreen import ru.fromchat.ui.profile.ProfileScreen
import ru.fromchat.utils.haptic.rememberHapticFeedback import ru.fromchat.utils.haptic.rememberHapticFeedback
@@ -75,9 +76,17 @@ fun DmProfileRoute(
val stateSnapshot = panel.getState() val stateSnapshot = panel.getState()
val initialDisplayName = stateSnapshot.titleAvatar?.displayName?.takeIf { it.isNotBlank() } val initialDisplayName = stateSnapshot.titleAvatar?.displayName?.takeIf { it.isNotBlank() }
?: stateSnapshot.title.takeIf { it.isNotBlank() } ?: stateSnapshot.title.takeIf { it.isNotBlank() }
val initialProfilePictureUrl = stateSnapshot.titleAvatar?.profilePictureUrl
ProfileCache.mergePreview(
id = otherUserId,
displayName = initialDisplayName,
profilePicture = initialProfilePictureUrl,
)
ProfileScreen( ProfileScreen(
userId = otherUserId, userId = otherUserId,
showBackButton = true,
onBack = { onBack = {
haptic(HapticFeedbackEvent.ProfileClosed) haptic(HapticFeedbackEvent.ProfileClosed)
navController.popBackStack() navController.popBackStack()
@@ -90,6 +99,7 @@ fun DmProfileRoute(
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
sharedAvatarKey = sharedAvatarKey, sharedAvatarKey = sharedAvatarKey,
initialDisplayName = initialDisplayName initialDisplayName = initialDisplayName,
initialProfilePictureUrl = initialProfilePictureUrl,
) )
} }
@@ -61,6 +61,7 @@ class DmPanel(
private var otherDisplayName: String = "" private var otherDisplayName: String = ""
private var otherProfilePicture: String? = null private var otherProfilePicture: String? = null
private val dmEnvelopeMutex = Mutex() private val dmEnvelopeMutex = Mutex()
private var messagesLoaded = false
private data class DmDecryptOutcome(val plaintext: String, val isCorrupted: Boolean) private data class DmDecryptOutcome(val plaintext: String, val isCorrupted: Boolean)
@@ -168,16 +169,28 @@ class DmPanel(
} }
override suspend fun loadMessages() { override suspend fun loadMessages() {
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) setLoading(true)
}
try { try {
OutgoingMessageCoordinator.pruneStaleAttachmentOutboxForInstance( OutgoingMessageCoordinator.pruneStaleAttachmentOutboxForInstance(
CacheContext.requireActiveInstanceId(), CacheContext.requireActiveInstanceId(),
) )
val cached = runCatching { MessageCacheStore.loadDmMessages(otherUserId) }.getOrDefault(emptyList())
if (cached.isNotEmpty()) {
clearMessages()
addMessages(cached)
}
val historyResult = runCatching { ApiClient.getDmHistory(otherUserId) } val historyResult = runCatching { ApiClient.getDmHistory(otherUserId) }
if (historyResult.isSuccess) { if (historyResult.isSuccess) {
@@ -512,9 +525,11 @@ class DmPanel(
deleteMessageImmediately(messageId) deleteMessageImmediately(messageId)
DownloadedFileRegistry.invalidateForMessage(messageId) DownloadedFileRegistry.invalidateForMessage(messageId)
DecryptedImageCache.invalidateForMessage(messageId) DecryptedImageCache.invalidateForMessage(messageId)
DecryptedFileCache.invalidateForMessage(messageId)
clientId?.trim()?.takeIf { it.isNotEmpty() }?.let { clientId?.trim()?.takeIf { it.isNotEmpty() }?.let {
DownloadedFileRegistry.invalidateForClientMessage(it) DownloadedFileRegistry.invalidateForClientMessage(it)
DecryptedImageCache.invalidateForClientMessage(it) DecryptedImageCache.invalidateForClientMessage(it)
DecryptedFileCache.invalidateForClientMessage(it)
} }
runCatching { ApiClient.deleteDm(messageId, otherUserId) } runCatching { ApiClient.deleteDm(messageId, otherUserId) }
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
@@ -535,9 +550,11 @@ class DmPanel(
val clientId = _state.messages.find { it.id == data.id }?.client_message_id val clientId = _state.messages.find { it.id == data.id }?.client_message_id
DownloadedFileRegistry.invalidateForMessage(data.id) DownloadedFileRegistry.invalidateForMessage(data.id)
DecryptedImageCache.invalidateForMessage(data.id) DecryptedImageCache.invalidateForMessage(data.id)
DecryptedFileCache.invalidateForMessage(data.id)
clientId?.trim()?.takeIf { it.isNotEmpty() }?.let { clientId?.trim()?.takeIf { it.isNotEmpty() }?.let {
DownloadedFileRegistry.invalidateForClientMessage(it) DownloadedFileRegistry.invalidateForClientMessage(it)
DecryptedImageCache.invalidateForClientMessage(it) DecryptedImageCache.invalidateForClientMessage(it)
DecryptedFileCache.invalidateForClientMessage(it)
} }
deleteMessageImmediately(data.id) deleteMessageImmediately(data.id)
MessageRepository.deleteDmMessageById(otherUserId, data.id) MessageRepository.deleteDmMessageById(otherUserId, data.id)
@@ -36,8 +36,13 @@ fun DmScreen(
val activeInstanceId by CacheContext.activeInstanceId.collectAsState() val activeInstanceId by CacheContext.activeInstanceId.collectAsState()
val otherUserId = panel.getState().profileUserId val otherUserId = panel.getState().profileUserId
LaunchedEffect(panel, otherUserId) { LaunchedEffect(panel, activeInstanceId, otherUserId) {
if (otherUserId != null && otherUserId > 0) { 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() 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.ApiClient
import ru.fromchat.api.local.cache.DecryptedImageCache import ru.fromchat.api.local.cache.DecryptedImageCache
import ru.fromchat.api.local.db.store.MessageCacheStore 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.db.store.MessageRepository
import ru.fromchat.api.local.messages.GENERAL_PUBLIC_GROUP_ID import ru.fromchat.api.local.messages.GENERAL_PUBLIC_GROUP_ID
import ru.fromchat.api.local.messages.conversationIdForGroup import ru.fromchat.api.local.messages.conversationIdForGroup
import ru.fromchat.api.local.send.OutgoingMessageCoordinator 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.Message
import ru.fromchat.api.schema.messages.publicchat.SendMessageResponse import ru.fromchat.api.schema.messages.publicchat.SendMessageResponse
import ru.fromchat.api.schema.websocket.WebSocketMessage import ru.fromchat.api.schema.websocket.WebSocketMessage
@@ -30,8 +32,6 @@ import ru.fromchat.ui.chat.utils.TypingHandler
class PublicChatPanel( class PublicChatPanel(
/** Stable cache / panel id (not localized; hardcoded in [ru.fromchat.ui.chat.utils.PublicChatPanelCache]). */ /** Stable cache / panel id (not localized; hardcoded in [ru.fromchat.ui.chat.utils.PublicChatPanelCache]). */
panelKey: String, panelKey: String,
/** Shown in the app bar and avatars. */
displayTitle: String,
currentUserId: Int?, currentUserId: Int?,
scope: CoroutineScope scope: CoroutineScope
) : ChatPanel( ) : ChatPanel(
@@ -70,8 +70,8 @@ class PublicChatPanel(
init { init {
updateState { updateState {
it.copy( it.copy(
title = displayTitle, title = "",
titleAvatar = AvatarInfo(displayName = displayTitle, profilePictureUrl = null), titleAvatar = null,
publicGroupMetaLoading = true, publicGroupMetaLoading = true,
publicGroupMemberCount = null publicGroupMemberCount = null
) )
@@ -83,27 +83,30 @@ class PublicChatPanel(
} }
} }
scope.launch(Dispatchers.Default) { scope.launch(Dispatchers.Default) {
runCatching { ApiClient.getRegisteredUserCount() } val cached = PublicChatProfileCache.profile
.onSuccess { n -> if (cached != null) {
updateState { s -> applyPublicChatProfile(cached)
s.copy(publicGroupMemberCount = n, publicGroupMetaLoading = false)
} }
runCatching { ApiClient.getPublicChatProfile() }
.onSuccess { profile ->
PublicChatProfileCache.put(profile)
applyPublicChatProfile(profile)
} }
.onFailure { .onFailure {
if (cached == null) {
updateState { s -> s.copy(publicGroupMetaLoading = false) } updateState { s -> s.copy(publicGroupMetaLoading = false) }
} }
} }
} }
}
/** When locale changes, keep the same panel but refresh the visible title. */ private fun applyPublicChatProfile(profile: PublicChatProfile) {
fun applyDisplayTitle(title: String) {
updateState { s -> updateState { s ->
s.copy( s.copy(
title = title, title = profile.title,
titleAvatar = AvatarInfo( titleAvatar = AvatarInfo(displayName = profile.title, profilePictureUrl = null),
displayName = title, publicGroupMemberCount = profile.member_count,
profilePictureUrl = s.titleAvatar?.profilePictureUrl publicGroupMetaLoading = false,
)
) )
} }
} }
@@ -353,6 +356,9 @@ class PublicChatPanel(
val obj = data.jsonObject val obj = data.jsonObject
val c = obj["count"]?.jsonPrimitive?.content?.toIntOrNull() val c = obj["count"]?.jsonPrimitive?.content?.toIntOrNull()
if (c != null) { if (c != null) {
PublicChatProfileCache.profile?.let { cached ->
PublicChatProfileCache.put(cached.copy(member_count = c))
}
updateState { s -> updateState { s ->
s.copy(publicGroupMemberCount = c, publicGroupMetaLoading = false) s.copy(publicGroupMemberCount = c, publicGroupMetaLoading = false)
} }
@@ -1,19 +1,18 @@
package ru.fromchat.ui.chat.panels.publicchat 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.animation.SharedTransitionScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import org.jetbrains.compose.resources.stringResource import androidx.compose.ui.Modifier
import ru.fromchat.Res
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.cache.CacheContext import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.db.store.MessageRepository import ru.fromchat.api.local.db.store.MessageRepository
import ru.fromchat.public_chat
import ru.fromchat.ui.chat.ChatScreen import ru.fromchat.ui.chat.ChatScreen
import ru.fromchat.ui.chat.utils.PublicChatPanelCache import ru.fromchat.ui.chat.utils.PublicChatPanelCache
@@ -21,14 +20,15 @@ import ru.fromchat.ui.chat.utils.PublicChatPanelCache
fun PublicChatScreen( fun PublicChatScreen(
scrollToMessageId: Int? = null, scrollToMessageId: Int? = null,
sharedTransitionScope: SharedTransitionScope? = 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 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) {
val panel = remember(currentUserId, publicChatTitle) { PublicChatPanelCache.getOrCreateGeneralChat(currentUserId)
PublicChatPanelCache.getOrCreateGeneralChat(publicChatTitle, currentUserId)
} }
val activeInstanceId by CacheContext.activeInstanceId.collectAsState() val activeInstanceId by CacheContext.activeInstanceId.collectAsState()
@@ -47,7 +47,6 @@ fun PublicChatScreen(
} }
} }
// Track visibility for notifications
DisposableEffect(Unit) { DisposableEffect(Unit) {
isPublicChatVisible = true isPublicChatVisible = true
onDispose { onDispose {
@@ -55,14 +54,15 @@ fun PublicChatScreen(
} }
} }
// Render with ChatScreen
ChatScreen( ChatScreen(
panel = panel, panel = panel,
currentUserId = currentUserId, currentUserId = currentUserId,
scrollToMessageId = scrollToMessageId, scrollToMessageId = scrollToMessageId,
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedContentScope, animatedVisibilityScope = animatedVisibilityScope,
sharedAvatarKey = null, sharedAvatarKey = sharedAvatarKey,
onTitleClick = onTitleClick,
modifier = modifier.fillMaxSize(),
) )
} }
@@ -24,7 +24,6 @@ object PublicChatPanelCache {
private var panel: PublicChatPanel? = null private var panel: PublicChatPanel? = null
private var cachedPanelKey: String? = null private var cachedPanelKey: String? = null
private var cachedDisplayTitle: String? = null
private var cachedUserId: Int? = null private var cachedUserId: Int? = null
private var cachedInstanceId: String = "" private var cachedInstanceId: String = ""
@@ -42,10 +41,7 @@ object PublicChatPanelCache {
} }
} }
/** fun getOrCreateGeneralChat(currentUserId: Int?): PublicChatPanel {
* @param displayTitle Localized title from e.g. [ru.fromchat.Res.string.public_chat].
*/
fun getOrCreateGeneralChat(displayTitle: String, currentUserId: Int?): PublicChatPanel {
ensureScope() ensureScope()
val instanceId = CacheContext.activeInstanceId.value.trim() val instanceId = CacheContext.activeInstanceId.value.trim()
if (instanceId.isNotEmpty() && cachedInstanceId.isNotEmpty() && cachedInstanceId != instanceId) { if (instanceId.isNotEmpty() && cachedInstanceId.isNotEmpty() && cachedInstanceId != instanceId) {
@@ -58,21 +54,15 @@ object PublicChatPanelCache {
cachedUserId == currentUserId && cachedUserId == currentUserId &&
(instanceId.isEmpty() || cachedInstanceId == instanceId) (instanceId.isEmpty() || cachedInstanceId == instanceId)
) { ) {
if (cachedDisplayTitle != displayTitle) {
cachedDisplayTitle = displayTitle
panel!!.applyDisplayTitle(displayTitle)
}
return panel!! return panel!!
} }
panel?.destroy() panel?.destroy()
panel = PublicChatPanel( panel = PublicChatPanel(
panelKey = GeneralPublicPanelKey, panelKey = GeneralPublicPanelKey,
displayTitle = displayTitle,
currentUserId = currentUserId, currentUserId = currentUserId,
scope = panelScope scope = panelScope
) )
cachedPanelKey = GeneralPublicPanelKey cachedPanelKey = GeneralPublicPanelKey
cachedDisplayTitle = displayTitle
cachedUserId = currentUserId cachedUserId = currentUserId
return panel!! return panel!!
} }
@@ -81,8 +71,6 @@ object PublicChatPanelCache {
panel?.destroy() panel?.destroy()
panel = null panel = null
cachedPanelKey = null cachedPanelKey = null
cachedDisplayTitle = null
cachedUserId = null cachedUserId = null
supervisorJob.cancel()
} }
} }
@@ -1,3 +1,7 @@
/**
* Мне лень чистить этот файл, я устал.
* TODO Почищу когда-нибудь потом.
*/
package ru.fromchat.ui.components package ru.fromchat.ui.components
import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.ExperimentalAnimationApi
@@ -344,7 +348,6 @@ fun ExpressiveStepFlowScaffold(
val scope = flowState.scope val scope = flowState.scope
val pageCount = pages.size.coerceAtLeast(1) val pageCount = pages.size.coerceAtLeast(1)
val heroSpecs = remember(pages) { pages.map { it.hero } } val heroSpecs = remember(pages) { pages.map { it.hero } }
val isHazeLazyMode = hazeScaffold
val pageOffset by derivedStateOf { pagerState.currentPageOffsetFraction } val pageOffset by derivedStateOf { pagerState.currentPageOffsetFraction }
val predictiveThreshold = 0.15f val predictiveThreshold = 0.15f
@@ -352,6 +355,7 @@ fun ExpressiveStepFlowScaffold(
enabled = pagerState.currentPage > 0, enabled = pagerState.currentPage > 0,
onProgress = { p -> onProgress = { p ->
val clamped = p.coerceIn(0f, 1f) val clamped = p.coerceIn(0f, 1f)
if (clamped <= 0f) { if (clamped <= 0f) {
flowState.resetPredictiveState() flowState.resetPredictiveState()
} else { } else {
@@ -368,6 +372,7 @@ fun ExpressiveStepFlowScaffold(
val fromPageSnapshot = flowState.predictiveFromPage val fromPageSnapshot = flowState.predictiveFromPage
val toPageSnapshot = flowState.predictiveToPage val toPageSnapshot = flowState.predictiveToPage
val lastProgress = flowState.predictiveProgress.coerceIn(0f, 1f) val lastProgress = flowState.predictiveProgress.coerceIn(0f, 1f)
if (lastProgress < predictiveThreshold || fromPageSnapshot == null || toPageSnapshot == null) { if (lastProgress < predictiveThreshold || fromPageSnapshot == null || toPageSnapshot == null) {
scope.launch { scope.launch {
finishPredictiveMorph( finishPredictiveMorph(
@@ -426,6 +431,7 @@ fun ExpressiveStepFlowScaffold(
val fromIndex: Int val fromIndex: Int
val toIndex: Int val toIndex: Int
val morphProgress: Float val morphProgress: Float
if (flowState.predictiveFromPage != null && flowState.predictiveToPage != null && flowState.predictiveProgress > 0f) { if (flowState.predictiveFromPage != null && flowState.predictiveToPage != null && flowState.predictiveProgress > 0f) {
fromIndex = flowState.predictiveFromPage!! fromIndex = flowState.predictiveFromPage!!
toIndex = flowState.predictiveToPage!! toIndex = flowState.predictiveToPage!!
@@ -443,6 +449,7 @@ fun ExpressiveStepFlowScaffold(
toIndex = fromIndex toIndex = fromIndex
morphProgress = 0f morphProgress = 0f
} }
val effectiveMorphProgress = morphProgress.coerceIn(0f, 1f) val effectiveMorphProgress = morphProgress.coerceIn(0f, 1f)
val currentPage = pagerState.currentPage val currentPage = pagerState.currentPage
val morphing = fromIndex != toIndex val morphing = fromIndex != toIndex
@@ -592,7 +599,7 @@ fun ExpressiveStepFlowScaffold(
LocalExpressiveStepFocusEnabled provides isPageTransitionSettled, LocalExpressiveStepFocusEnabled provides isPageTransitionSettled,
LocalExpressiveStepAutoFocusPrimary provides autoFocusPrimaryField, LocalExpressiveStepAutoFocusPrimary provides autoFocusPrimaryField,
) { ) {
if (isHazeLazyMode) { if (hazeScaffold) {
val hazeState = rememberHazeState() val hazeState = rememberHazeState()
val listState = rememberLazyListState() val listState = rememberLazyListState()
val imeScrollState = rememberLazyListImeScrollState() val imeScrollState = rememberLazyListImeScrollState()
@@ -695,6 +702,7 @@ fun ExpressiveStepFlowScaffold(
} }
} else { } else {
val imeScrollState = rememberLazyListImeScrollState() val imeScrollState = rememberLazyListImeScrollState()
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = scheme.surface, color = scheme.surface,
@@ -791,6 +799,7 @@ fun MorphedExpressiveHero(
) { ) {
Canvas(Modifier.fillMaxSize()) { Canvas(Modifier.fillMaxSize()) {
val unit = minOf(size.width, size.height) * 0.94f val unit = minOf(size.width, size.height) * 0.94f
translate(left = size.width / 2f, top = size.height / 2f) { translate(left = size.width / 2f, top = size.height / 2f) {
scale(scaleX = unit, scaleY = unit, pivot = Offset.Zero) { scale(scaleX = unit, scaleY = unit, pivot = Offset.Zero) {
translate(left = -0.5f, top = -0.5f) { translate(left = -0.5f, top = -0.5f) {
@@ -800,7 +809,7 @@ fun MorphedExpressiveHero(
colors = listOf(light, deep), colors = listOf(light, deep),
start = Offset.Zero, start = Offset.Zero,
end = Offset(1f, 1f), end = Offset(1f, 1f),
), )
) )
} }
} }
@@ -820,6 +829,7 @@ fun MorphedExpressiveHero(
.graphicsLayer { alpha = 1f - p }, .graphicsLayer { alpha = 1f - p },
tint = contentColor, tint = contentColor,
) )
Icon( Icon(
imageVector = toSpec.icon, imageVector = toSpec.icon,
contentDescription = null, contentDescription = null,
@@ -14,6 +14,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.chrisbanes.haze.HazeProgressive import dev.chrisbanes.haze.HazeProgressive
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.HazeStyle
import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials import dev.chrisbanes.haze.materials.HazeMaterials
@@ -24,18 +25,24 @@ import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
fun HazeBottomBar( fun HazeBottomBar(
hazeState: HazeState, hazeState: HazeState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
hazeStyle: HazeStyle? = null,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
Column( val effectModifier = if (hazeStyle != null) {
modifier = Modifier Modifier.hazeEffect(state = hazeState, style = hazeStyle)
.windowInsetsPadding(WindowInsets.ime) } else {
.fillMaxWidth() Modifier.hazeEffect(state = hazeState, style = HazeMaterials.thin()) {
.hazeEffect(state = hazeState, style = HazeMaterials.thin()) {
progressive = HazeProgressive.verticalGradient( progressive = HazeProgressive.verticalGradient(
startIntensity = 0f, startIntensity = 0f,
endIntensity = 1f, endIntensity = 1f,
) )
} }
}
Column(
modifier = Modifier
.windowInsetsPadding(WindowInsets.ime)
.fillMaxWidth()
.then(effectModifier)
.then(modifier), .then(modifier),
) { ) {
Column( Column(
@@ -57,12 +64,13 @@ fun HazeActionButton(
hazeState: HazeState, hazeState: HazeState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
innerModifier: Modifier = Modifier, innerModifier: Modifier = Modifier,
hazeStyle: HazeStyle? = null,
enabled: Boolean = true, enabled: Boolean = true,
loading: Boolean = false, loading: Boolean = false,
interactionSource: MutableInteractionSource? = null, interactionSource: MutableInteractionSource? = null,
content: @Composable (RowScope.() -> Unit) content: @Composable (RowScope.() -> Unit)
) { ) {
HazeBottomBar(hazeState = hazeState, modifier = modifier) { HazeBottomBar(hazeState = hazeState, modifier = modifier, hazeStyle = hazeStyle) {
ActionButton( ActionButton(
onClick = onClick, onClick = onClick,
modifier = innerModifier, modifier = innerModifier,
@@ -18,7 +18,6 @@ import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import ru.fromchat.ui.components.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -63,16 +62,6 @@ fun SearchBar(
Surface( Surface(
modifier = modifier modifier = modifier
.conditional(
readOnly && onReadOnlyActivate != null,
`if` = {
clickable(
interactionSource = interactionSource,
indication = null,
onClick = onReadOnlyActivate!!
)
}
)
.conditional( .conditional(
( (
sharedTransitionScope != null && sharedTransitionScope != null &&
@@ -98,13 +87,43 @@ fun SearchBar(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(56.dp) .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 verticalAlignment = Alignment.CenterVertically
) { ) {
Box(modifier = Modifier.size(24.dp)) { Box(modifier = Modifier.size(24.dp)) {
leadingIcon() leadingIcon()
} }
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( BasicTextField(
value = query, value = query,
onValueChange = onQueryChange, onValueChange = onQueryChange,
@@ -112,8 +131,6 @@ fun SearchBar(
textStyle = typography.copy( textStyle = typography.copy(
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface
), ),
enabled = !readOnly,
readOnly = readOnly,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions( keyboardActions = KeyboardActions(
@@ -141,6 +158,7 @@ fun SearchBar(
.padding(start = 4.dp) .padding(start = 4.dp)
.focusRequester(focusRequester) .focusRequester(focusRequester)
) )
}
Box(modifier = Modifier.width(6.dp)) Box(modifier = Modifier.width(6.dp))
Box(modifier = Modifier.size(24.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.AnimatedVisibilityScope
import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.SharedTransitionScope
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize 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.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons 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.material.icons.filled.Settings
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier 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 kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
@@ -36,7 +53,10 @@ import ru.fromchat.contacts
import ru.fromchat.profile import ru.fromchat.profile
import ru.fromchat.settings import ru.fromchat.settings
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.chat.rememberChatSurfaceContainerHazeStyle
import ru.fromchat.ui.components.FromChatSnackbarHost 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.chats.ChatsTab
import ru.fromchat.ui.main.settings.SettingsTab import ru.fromchat.ui.main.settings.SettingsTab
import ru.fromchat.ui.profile.ProfileScreen 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_PROFILE = 3
private const val PAGE_COUNT = 4 private const val PAGE_COUNT = 4
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@Composable @Composable
fun MainScreen( fun MainScreen(
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
@@ -62,15 +82,38 @@ fun MainScreen(
pageCount = { PAGE_COUNT }, pageCount = { PAGE_COUNT },
) )
val scope = rememberCoroutineScope() 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). // Pager is the single source of truth; tabs only call animateScrollToPage (no write/read loop).
val selectedPage = pagerState.currentPage val selectedPage = pagerState.currentPage
val isChatsPage = selectedPage == PAGE_CHATS val isChatsPage = selectedPage == PAGE_CHATS
val chatMenuBlurProgress = chatContextMenuOverlay.blurProgress
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxSize()
.hazeSource(contextMenuHazeState),
) {
Scaffold( Scaffold(
snackbarHost = { FromChatSnackbarHost(hostState = effectiveSnackbarHostState) }, snackbarHost = { FromChatSnackbarHost(hostState = effectiveSnackbarHostState) },
bottomBar = { bottomBar = {
NavigationBar { Column(
modifier = Modifier
.windowInsetsPadding(WindowInsets.ime)
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainer)
.hazeEffect(
state = navBarHazeState,
style = rememberChatSurfaceContainerHazeStyle(),
),
) {
NavigationBar(
containerColor = Color.Transparent,
tonalElevation = 0.dp,
) {
NavigationBarItem( NavigationBarItem(
selected = selectedPage == PAGE_CHATS, selected = selectedPage == PAGE_CHATS,
onClick = { onClick = {
@@ -104,14 +147,20 @@ fun MainScreen(
icon = { Icon(Icons.Filled.Person, contentDescription = null) } icon = { Icon(Icons.Filled.Person, contentDescription = null) }
) )
} }
}
}, },
contentWindowInsets = WindowInsets.safeDrawing.exclude(WindowInsetsSides.Top), contentWindowInsets = WindowInsets.safeDrawing.exclude(WindowInsetsSides.Top),
modifier = Modifier.imePadding() modifier = Modifier.imePadding(),
) { innerPadding -> ) { innerPadding ->
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(innerPadding) .hazeSource(navBarHazeState),
) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) { ) {
HorizontalPager( HorizontalPager(
state = pagerState, state = pagerState,
@@ -124,6 +173,7 @@ fun MainScreen(
onOpenSearch = { onOpenSearch = {
navController.navigate("search/conversations") navController.navigate("search/conversations")
}, },
chatContextMenuOverlay = chatContextMenuOverlay,
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope,
) )
@@ -145,4 +195,50 @@ fun MainScreen(
} }
} }
} }
}
}
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.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.utils.resetFocus import com.pr0gramm3r101.utils.resetFocus
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@@ -59,15 +59,17 @@ import ru.fromchat.Res
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.db.store.CachedConversation import ru.fromchat.api.local.db.store.CachedConversation
import ru.fromchat.api.local.db.store.MessageCacheStore 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.ProfileCache
import ru.fromchat.api.local.db.store.UserStatusStore import ru.fromchat.api.local.db.store.UserStatusStore
import ru.fromchat.api.local.db.store.visibleUsername 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_last_mesaage
import ru.fromchat.chat_preview_attachment
import ru.fromchat.search_hint import ru.fromchat.search_hint
import ru.fromchat.search_not_found import ru.fromchat.search_not_found
import ru.fromchat.search_not_found_message import ru.fromchat.search_not_found_message
import ru.fromchat.search_title import ru.fromchat.search_title
import ru.fromchat.ui.components.BackHandler
import ru.fromchat.ui.components.SearchBar import ru.fromchat.ui.components.SearchBar
import ru.fromchat.ui.components.SearchBarSharedElement import ru.fromchat.ui.components.SearchBarSharedElement
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
@@ -85,9 +87,11 @@ fun ChatsSearchScreen(
val searchListState = rememberLazyListState() val searchListState = rememberLazyListState()
val statusMap by UserStatusStore.status.collectAsState() val statusMap by UserStatusStore.status.collectAsState()
val defaultLastMessage = stringResource(Res.string.chat_last_mesaage) val defaultLastMessage = stringResource(Res.string.chat_last_mesaage)
val attachmentOnlyPreview = stringResource(Res.string.chat_preview_attachment)
val searchHint = stringResource(Res.string.search_hint) val searchHint = stringResource(Res.string.search_hint)
val searchBarHint = stringResource(Res.string.search_title) val searchBarHint = stringResource(Res.string.search_title)
var dmConversations by remember { mutableStateOf<List<CachedConversation>>(emptyList()) } var dmConversations by remember { mutableStateOf<List<CachedConversation>>(emptyList()) }
var remoteUsers by remember { mutableStateOf<List<User>>(emptyList()) }
val statusSubscriptionScope = rememberCoroutineScope() val statusSubscriptionScope = rememberCoroutineScope()
var subscribedDmUserIds by remember { mutableStateOf<Set<Int>>(emptySet()) } var subscribedDmUserIds by remember { mutableStateOf<Set<Int>>(emptySet()) }
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
@@ -99,7 +103,7 @@ fun ChatsSearchScreen(
val searchQuery = searchText.trim().lowercase().trimStart('@') val searchQuery = searchText.trim().lowercase().trimStart('@')
val filteredDmConversations = remember(dmConversations, searchQuery) { val filteredDmConversations = remember(dmConversations, searchQuery) {
if (searchQuery.isBlank()) { if (searchQuery.isBlank()) {
dmConversations emptyList()
} else { } else {
dmConversations.filter { conv -> dmConversations.filter { conv ->
matchesSearchConversations(conv, searchQuery) matchesSearchConversations(conv, searchQuery)
@@ -107,19 +111,50 @@ fun ChatsSearchScreen(
} }
} }
val searchUiState by remember(searchText, filteredDmConversations) { val searchResults = remember(filteredDmConversations, remoteUsers, searchQuery) {
derivedStateOf { if (searchQuery.isBlank()) {
when { 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 searchText.isBlank() -> SearchScreenState.EmptyQuery
filteredDmConversations.isEmpty() -> SearchScreenState.NotFound searchResults.isEmpty() -> SearchScreenState.NotFound
else -> SearchScreenState.Results 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) { LaunchedEffect(Unit) {
// Load cached DM conversations first for immediate display. // Load cached DM conversations first for immediate display.
runCatching { runCatching {
dmConversations = MessageCacheStore.loadCachedDmConversations() MessageCacheStore.loadCachedDmConversations()
}.onSuccess { conversations ->
conversations.forEach { ProfileCache.mergeFromCachedConversation(it) }
dmConversations = conversations
} }
// Then refresh from network and update cache + state. // Then refresh from network and update cache + state.
@@ -128,17 +163,18 @@ fun ChatsSearchScreen(
}.onSuccess { conversations -> }.onSuccess { conversations ->
runCatching { runCatching {
conversations.forEach { ProfileCache.mergeFromDmUser(it.user) } conversations.forEach { ProfileCache.mergeFromDmUser(it.user) }
MessageCacheStore.replaceDmConversations(conversations) MessageRepository.replaceDmConversations(conversations, attachmentOnlyPreview)
dmConversations = MessageCacheStore.loadCachedDmConversations() dmConversations = MessageRepository.loadCachedDmConversations()
} }
} }
} }
LaunchedEffect(filteredDmConversations, searchListState) { LaunchedEffect(searchResults, searchListState) {
snapshotFlow { snapshotFlow {
searchListState.layoutInfo.visibleItemsInfo searchListState.layoutInfo.visibleItemsInfo
.mapNotNull { item -> .mapNotNull { item ->
filteredDmConversations.getOrNull(item.index)?.otherUserId SearchListIndices.resultIndexFromLazy(item.index)
?.let { searchResults.getOrNull(it)?.userId }
} }
.filter { it > 0 } .filter { it > 0 }
.toSet() .toSet()
@@ -160,6 +196,7 @@ fun ChatsSearchScreen(
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { onDispose {
hideIme()
if (subscribedDmUserIds.isNotEmpty()) { if (subscribedDmUserIds.isNotEmpty()) {
statusSubscriptionScope.launch { statusSubscriptionScope.launch {
subscribedDmUserIds.forEach { ApiClient.sendUnsubscribeStatus(it) } subscribedDmUserIds.forEach { ApiClient.sendUnsubscribeStatus(it) }
@@ -169,11 +206,6 @@ fun ChatsSearchScreen(
} }
} }
BackHandler(enabled = true) {
hideIme()
onBack()
}
Scaffold( Scaffold(
modifier = Modifier modifier = Modifier
.imePadding() .imePadding()
@@ -244,16 +276,12 @@ fun ChatsSearchScreen(
SearchConversationsList( SearchConversationsList(
listState = searchListState, listState = searchListState,
conversations = filteredDmConversations, conversations = filteredDmConversations,
remoteUsers = remoteUsers.filter { user ->
filteredDmConversations.none { it.otherUserId == user.id }
},
defaultLastMessage = defaultLastMessage, defaultLastMessage = defaultLastMessage,
statusMap = statusMap, statusMap = statusMap,
modifier = Modifier modifier = Modifier.fillMaxSize(),
.fillMaxSize()
.padding(horizontal = 12.dp),
onOpenProfile = { userId ->
if (userId != 0) {
onOpenProfile(userId)
}
},
onOpenConversation = { userId -> onOpenConversation = { userId ->
if (userId != 0) { if (userId != 0) {
onOpenConversation(userId) onOpenConversation(userId)
@@ -267,16 +295,26 @@ fun ChatsSearchScreen(
} }
} }
private fun matchesSearchConversations(conv: CachedConversation, normalizedQuery: String): Boolean { private fun matchesSearchConversations(conv: CachedConversation, normalizedQuery: String) =
val cached = ProfileCache.get(conv.otherUserId) ProfileCache.get(conv.otherUserId).let { cached ->
val candidates = buildList { buildList {
add(conv.displayName) add(conv.displayName)
cached?.displayName?.let { add(it) } cached?.displayName?.let { add(it) }
cached?.visibleUsername(ApiClient.user?.id)?.let { add(it) } cached?.visibleUsername(ApiClient.user?.id)?.let { add(it) }
}.any { candidate ->
candidate.lowercase().contains(normalizedQuery)
}
} }
return candidates.any { candidate -> private sealed interface SearchResult {
candidate.lowercase().contains(normalizedQuery) 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.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -38,11 +40,14 @@ import org.jetbrains.compose.resources.vectorResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.about import ru.fromchat.about
import ru.fromchat.about_link_max import ru.fromchat.about_link_max
import ru.fromchat.about_link_privacy
import ru.fromchat.about_link_telegram import ru.fromchat.about_link_telegram
import ru.fromchat.about_link_terms
import ru.fromchat.about_link_website import ru.fromchat.about_link_website
import ru.fromchat.about_version import ru.fromchat.about_version
import ru.fromchat.app_desc import ru.fromchat.app_desc
import ru.fromchat.back import ru.fromchat.back
import ru.fromchat.legal.DocumentType
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.components.BrandTitle import ru.fromchat.ui.components.BrandTitle
@@ -146,6 +151,7 @@ fun AboutScreen() {
headline = stringResource(Res.string.about_link_website), headline = stringResource(Res.string.about_link_website),
supportingText = URL_WEBSITE, supportingText = URL_WEBSITE,
onClick = { uriHandler.openUri(URL_WEBSITE) }, onClick = { uriHandler.openUri(URL_WEBSITE) },
divider = true,
leadingContent = { leadingContent = {
Icon( Icon(
imageVector = Icons.Outlined.Website, 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, ProfileOpened,
ProfileClosed, ProfileClosed,
MessageSent, MessageSent,
ContextMenuOpened ContextMenuOpened,
SelectionModeEntered,
} }
@@ -20,6 +20,7 @@ CREATE TABLE conversation (
lastMessagePreview TEXT, lastMessagePreview TEXT,
unreadCount INTEGER NOT NULL DEFAULT 0, unreadCount INTEGER NOT NULL DEFAULT 0,
updatedAt TEXT, updatedAt TEXT,
archived INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (instanceId, id) PRIMARY KEY (instanceId, id)
); );
@@ -157,6 +158,11 @@ deleteMessageById:
DELETE FROM message DELETE FROM message
WHERE instanceId = ? AND conversationId = ? AND id = ?; WHERE instanceId = ? AND conversationId = ? AND id = ?;
updateMessageSendStatusByClientMessageId:
UPDATE message
SET sendStatus = ?
WHERE instanceId = ? AND conversationId = ? AND clientMessageId = ?;
upsertMessage: upsertMessage:
INSERT OR REPLACE INTO message( INSERT OR REPLACE INTO message(
instanceId, instanceId,
@@ -178,6 +184,16 @@ UPDATE message
SET deletedFlag = 1 SET deletedFlag = 1
WHERE instanceId = ? AND id = ? AND conversationId = ?; 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: deleteAllMessagesForInstance:
DELETE FROM message WHERE instanceId = ?; DELETE FROM message WHERE instanceId = ?;
@@ -209,6 +225,67 @@ FROM conversation
WHERE instanceId = ? WHERE instanceId = ?
ORDER BY updatedAt DESC; 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: upsertConversation:
INSERT OR REPLACE INTO conversation( INSERT OR REPLACE INTO conversation(
instanceId, instanceId,
@@ -219,8 +296,9 @@ INSERT OR REPLACE INTO conversation(
lastMessageId, lastMessageId,
lastMessagePreview, lastMessagePreview,
unreadCount, unreadCount,
updatedAt updatedAt,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?); archived
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
-- outbox -- outbox
selectPendingOutboxForInstance: selectPendingOutboxForInstance:
@@ -21,3 +21,20 @@ actual suspend fun wipeFromChatCacheDirectory() {
NSFileManager.defaultManager.removeItemAtPath(path, error = null) 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) { val style: UIImpactFeedbackStyle = when (ordinal) {
HapticFeedbackEvent.MessageSent.ordinal -> UIImpactFeedbackStyle.UIImpactFeedbackStyleMedium HapticFeedbackEvent.MessageSent.ordinal -> UIImpactFeedbackStyle.UIImpactFeedbackStyleMedium
HapticFeedbackEvent.ContextMenuOpened.ordinal -> UIImpactFeedbackStyle.UIImpactFeedbackStyleHeavy HapticFeedbackEvent.ContextMenuOpened.ordinal -> UIImpactFeedbackStyle.UIImpactFeedbackStyleHeavy
HapticFeedbackEvent.SelectionModeEntered.ordinal -> UIImpactFeedbackStyle.UIImpactFeedbackStyleMedium
else -> UIImpactFeedbackStyle.UIImpactFeedbackStyleLight else -> UIImpactFeedbackStyle.UIImpactFeedbackStyleLight
} }
val generator = UIImpactFeedbackGenerator(style) val generator = UIImpactFeedbackGenerator(style)
+2
View File
@@ -44,6 +44,7 @@ krypto = "4.0.10"
sqldelight = "2.3.2" sqldelight = "2.3.2"
livekitAndroid = "2.25.2" livekitAndroid = "2.25.2"
livekitAndroidComposeComponents = "2.3.0" livekitAndroidComposeComponents = "2.3.0"
markdownRendererM3 = "0.41.0"
bouncycastle = "1.79" bouncycastle = "1.79"
[libraries] [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" } sqldelight-driver-native = { module = "app.cash.sqldelight:native-driver", version.ref = "sqldelight" }
livekit-android = { module = "io.livekit:livekit-android", version.ref = "livekitAndroid" } livekit-android = { module = "io.livekit:livekit-android", version.ref = "livekitAndroid" }
livekit-android-compose-components = { module = "io.livekit:livekit-android-compose-components", version.ref = "livekitAndroidComposeComponents" } 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" } bouncycastle-bcprov = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncycastle" }
[plugins] [plugins]
+2 -2
View File
@@ -17,9 +17,9 @@ kotlin {
} }
listOf( listOf(
iosX64(),
iosArm64(), iosArm64(),
iosSimulatorArm64() iosSimulatorArm64(),
iosX64(),
).forEach { ).forEach {
it.binaries.framework { it.binaries.framework {
baseName = "shared" baseName = "shared"
@@ -55,7 +55,12 @@ import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.isImeVisible import androidx.compose.foundation.layout.isImeVisible
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect 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.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -120,13 +125,41 @@ actual fun Modifier.clearFocusOnKeyboardDismiss(): Modifier = composed {
@SuppressLint("ComposableNaming") @SuppressLint("ComposableNaming")
@Composable @Composable
actual fun ToggleNavScrimEffect(enabled: Boolean) { actual fun ToggleNavScrimEffect(enabled: Boolean) {
val context = (LocalContext() as Activity) val context = LocalContext.current as Activity
LaunchedEffect(enabled) { DisposableEffect(enabled) {
runCatching {
val window = context.window 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) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = enabled 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
} }
} }
} }
@@ -27,6 +27,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.utils.conditional
object CategoryDefaults { object CategoryDefaults {
val margin = PaddingValues(start = 16.dp, end = 16.dp, bottom = 20.dp) val margin = PaddingValues(start = 16.dp, end = 16.dp, bottom = 20.dp)
@@ -42,6 +43,7 @@ private fun CategoryBase(
margin: PaddingValues = CategoryDefaults.margin, margin: PaddingValues = CategoryDefaults.margin,
containerColor: Color = CategoryDefaults.containerColor, containerColor: Color = CategoryDefaults.containerColor,
backgroundColor: Color = Color.Transparent, backgroundColor: Color = Color.Transparent,
roundedCorners: Boolean = true,
content: @Composable ColumnScope.() -> Unit content: @Composable ColumnScope.() -> Unit
) { ) {
CompositionLocalProvider( CompositionLocalProvider(
@@ -70,7 +72,7 @@ private fun CategoryBase(
modifier = modifier modifier = modifier
.padding(margin) .padding(margin)
.fillMaxWidth(), .fillMaxWidth(),
shape = CategoryDefaults.shape, shape = if (roundedCorners) CategoryDefaults.shape else RoundedCornerShape(0.dp),
colors = cardColors(containerColor = backgroundColor), colors = cardColors(containerColor = backgroundColor),
content = content content = content
) )
@@ -84,6 +86,7 @@ fun LazyItemScope.Category(
margin: PaddingValues = CategoryDefaults.margin, margin: PaddingValues = CategoryDefaults.margin,
containerColor: Color = CategoryDefaults.containerColor, containerColor: Color = CategoryDefaults.containerColor,
backgroundColor: Color = Color.Transparent, backgroundColor: Color = Color.Transparent,
roundedCorners: Boolean = true,
content: @Composable ColumnScope.() -> Unit content: @Composable ColumnScope.() -> Unit
) { ) {
CategoryBase( CategoryBase(
@@ -92,6 +95,7 @@ fun LazyItemScope.Category(
margin = margin, margin = margin,
containerColor = containerColor, containerColor = containerColor,
backgroundColor = backgroundColor, backgroundColor = backgroundColor,
roundedCorners = roundedCorners,
content = content content = content
) )
} }
@@ -103,6 +107,7 @@ fun ColumnScope.Category(
margin: PaddingValues = CategoryDefaults.margin, margin: PaddingValues = CategoryDefaults.margin,
containerColor: Color = CategoryDefaults.containerColor, containerColor: Color = CategoryDefaults.containerColor,
backgroundColor: Color = Color.Transparent, backgroundColor: Color = Color.Transparent,
roundedCorners: Boolean = true,
content: @Composable ColumnScope.() -> Unit content: @Composable ColumnScope.() -> Unit
) { ) {
CategoryBase( CategoryBase(
@@ -111,6 +116,7 @@ fun ColumnScope.Category(
margin = margin, margin = margin,
containerColor = containerColor, containerColor = containerColor,
backgroundColor = backgroundColor, backgroundColor = backgroundColor,
roundedCorners = roundedCorners,
content = content content = content
) )
} }
@@ -128,6 +134,7 @@ fun LazyListScope.Category(
title: String? = null, title: String? = null,
margin: PaddingValues = CategoryDefaults.margin, margin: PaddingValues = CategoryDefaults.margin,
containerColor: Color? = null, containerColor: Color? = null,
roundedCorners: Boolean = true,
content: CategoryScope.() -> Unit content: CategoryScope.() -> Unit
) { ) {
val scope = CategoryScope().apply(content) val scope = CategoryScope().apply(content)
@@ -165,7 +172,8 @@ fun LazyListScope.Category(
start = margin.calculateStartPadding(LocalLayoutDirection.current), start = margin.calculateStartPadding(LocalLayoutDirection.current),
end = margin.calculateEndPadding(LocalLayoutDirection.current) end = margin.calculateEndPadding(LocalLayoutDirection.current)
) )
.clip( .conditional(roundedCorners) {
clip(
RoundedCornerShape( RoundedCornerShape(
topStart = topStart =
if (index == 0) if (index == 0)
@@ -185,6 +193,7 @@ fun LazyListScope.Category(
else CornerSize(0.dp) else CornerSize(0.dp)
) )
) )
}
) { ) {
composableItem() composableItem()
} }
@@ -3,18 +3,24 @@
package com.pr0gramm3r101.components package com.pr0gramm3r101.components
import androidx.compose.animation.AnimatedVisibility 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.expandVertically
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope 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.selectable
import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.selection.toggleable 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.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Archive import androidx.compose.material.icons.filled.Archive
@@ -66,30 +74,49 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.VerticalDivider import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color 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.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.semantics.Role
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp 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.dp
import androidx.compose.ui.unit.sp 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.conditional
import com.pr0gramm3r101.utils.currentWindowSize
import com.pr0gramm3r101.utils.invoke import com.pr0gramm3r101.utils.invoke
import com.pr0gramm3r101.utils.left import com.pr0gramm3r101.utils.left
import com.pr0gramm3r101.utils.link import com.pr0gramm3r101.utils.link
import com.pr0gramm3r101.utils.plus import com.pr0gramm3r101.utils.plus
import com.pr0gramm3r101.utils.right import com.pr0gramm3r101.utils.right
import com.pr0gramm3r101.utils.scaleOnPress
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import tech.annexflow.constraintlayout.compose.ConstraintLayout import tech.annexflow.constraintlayout.compose.ConstraintLayout
@@ -102,6 +129,12 @@ val LocalDividerThickness = compositionLocalOf<Dp?> { null }
val LocalBeforeDividerRadius = compositionLocalOf<Dp?> { null } val LocalBeforeDividerRadius = compositionLocalOf<Dp?> { null }
val LocalContainerColor = compositionLocalOf<Color?> { null } val LocalContainerColor = compositionLocalOf<Color?> { null }
enum class ListItemPosition {
START,
MIDDLE,
END,
}
object ListItemDefaults { object ListItemDefaults {
val dividerColor @Composable get() = DividerDefaults.color val dividerColor @Composable get() = DividerDefaults.color
val thickness = DividerDefaults.Thickness val thickness = DividerDefaults.Thickness
@@ -109,12 +142,408 @@ object ListItemDefaults {
val containerColor = Color.Transparent 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 @Composable
fun ListItem( fun ListItem(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
bodyModifier: Modifier = Modifier, bodyModifier: Modifier = Modifier,
headline: String, headline: String,
supportingText: String? = null, supportingText: String? = null,
supportingSlot: (@Composable () -> Unit)? = null,
leadingContent: (@Composable () -> Unit)? = null, leadingContent: (@Composable () -> Unit)? = null,
trailingContent: (@Composable ConstraintLayoutScope.() -> Unit)? = null, trailingContent: (@Composable ConstraintLayoutScope.() -> Unit)? = null,
containerColor: Color = LocalContainerColor.current ?: ListItemDefaults.containerColor, containerColor: Color = LocalContainerColor.current ?: ListItemDefaults.containerColor,
@@ -124,12 +553,43 @@ fun ListItem(
dividerThickness: Dp = LocalDividerThickness.current ?: ListItemDefaults.thickness, dividerThickness: Dp = LocalDividerThickness.current ?: ListItemDefaults.thickness,
dividerAnimated: Boolean = false, dividerAnimated: Boolean = false,
beforeDividerRadius: Dp = LocalBeforeDividerRadius.current ?: ListItemDefaults.beforeDividerRadius, beforeDividerRadius: Dp = LocalBeforeDividerRadius.current ?: ListItemDefaults.beforeDividerRadius,
position: ListItemPosition = ListItemPosition.MIDDLE,
groupItemCount: Int? = null,
onClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null,
bodyOnClick: (() -> Unit)? = null, bodyOnClick: (() -> Unit)? = null,
leadingAndBodyShared: Boolean = false, 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 @Composable
fun ProvideStyle( fun ProvideStyle(
content: @Composable BoxScope?.() -> Unit content: @Composable BoxScope?.() -> Unit
@@ -149,12 +609,44 @@ fun ListItem(
} }
ProvideStyle { 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( ConstraintLayout(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(beforeDividerRadius)) .clip(clipShape)
.background(containerColor) .background(containerColor)
.conditional(onClick != null) { .conditional(onClick != null && !hasContextMenu) {
clickable(onClick = onClick!!) clickable(onClick = onClick!!)
} }
.then(modifier) .then(modifier)
@@ -187,7 +679,12 @@ fun ListItem(
ListItem( ListItem(
headlineContent = { Text(headline) }, headlineContent = { Text(headline) },
supportingContent = { if (supportingText != null) Text(supportingText) }, supportingContent = {
when {
supportingSlot != null -> supportingSlot()
supportingText != null -> Text(supportingText)
}
},
modifier = Modifier modifier = Modifier
.constrainAs(listItem) { .constrainAs(listItem) {
top link parent.top top link parent.top
@@ -203,7 +700,7 @@ fun ListItem(
width = Dimension.fillToConstraints width = Dimension.fillToConstraints
} }
.conditional(bodyOnClick != null && !leadingAndBodyShared) { .conditional(bodyOnClick != null && !leadingAndBodyShared && !hasContextMenu) {
Modifier.clickable(onClick = bodyOnClick!!) Modifier.clickable(onClick = bodyOnClick!!)
} }
.then(bodyModifier), .then(bodyModifier),
@@ -227,7 +724,7 @@ fun ListItem(
else parent.right else parent.right
width = Dimension.fillToConstraints width = Dimension.fillToConstraints
} }
.conditional(bodyOnClick != null) { .conditional(bodyOnClick != null && !hasContextMenu) {
Modifier.clickable(onClick = bodyOnClick!!) Modifier.clickable(onClick = bodyOnClick!!)
}, },
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
@@ -274,11 +771,12 @@ fun ListItem(
} }
} }
} }
}
when { when {
dividerAnimated && divider -> { dividerAnimated && showDivider -> {
AnimatedVisibility( AnimatedVisibility(
visible = divider, visible = showDivider,
enter = expandVertically(), enter = expandVertically(),
exit = shrinkVertically() exit = shrinkVertically()
) { ) {
@@ -288,13 +786,23 @@ fun ListItem(
) )
} }
} }
divider -> { showDivider -> {
HorizontalDivider( HorizontalDivider(
color = dividerColor, color = dividerColor,
thickness = dividerThickness 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, dividerColor: Color = LocalDividerColor.current ?: DividerDefaults.color,
dividerThickness: Dp = LocalDividerThickness.current ?: DividerDefaults.Thickness, dividerThickness: Dp = LocalDividerThickness.current ?: DividerDefaults.Thickness,
dividerAnimated: Boolean = false, 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() } val interactionSource = remember { MutableInteractionSource() }
ListItem( ListItem(
@@ -341,7 +853,11 @@ inline fun SwitchListItem(
dividerColor = dividerColor, dividerColor = dividerColor,
dividerThickness = dividerThickness, dividerThickness = dividerThickness,
dividerAnimated = dividerAnimated, 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, dividerColor: Color = LocalDividerColor.current ?: DividerDefaults.color,
dividerThickness: Dp = LocalDividerThickness.current ?: DividerDefaults.Thickness, dividerThickness: Dp = LocalDividerThickness.current ?: DividerDefaults.Thickness,
dividerAnimated: Boolean = false, 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( ListItem(
modifier = modifier, modifier = modifier,
@@ -399,7 +919,11 @@ inline fun SeparatedSwitchListItem(
dividerThickness = dividerThickness, dividerThickness = dividerThickness,
dividerAnimated = dividerAnimated, dividerAnimated = dividerAnimated,
leadingAndBodyShared = true, 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 @Composable
expect fun ToggleNavScrimEffect(enabled: Boolean = false) 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 // TODO fix implementation
interface SupportClipboardManager { interface SupportClipboardManager {
suspend fun setText(string: String) suspend fun setText(string: String)
@@ -16,6 +16,9 @@ actual fun Modifier.clearFocusOnKeyboardDismiss() = this
@Composable @Composable
actual fun ToggleNavScrimEffect(enabled: Boolean) {} actual fun ToggleNavScrimEffect(enabled: Boolean) {}
@Composable
actual fun DialogEdgeToEdgeEffect() {}
actual val materialYouAvailable get() = false actual val materialYouAvailable get() = false
inline fun htons(short: UShort) = if (isLittleEndian) _OSSwapInt16(short) else short inline fun htons(short: UShort) = if (isLittleEndian) _OSSwapInt16(short) else short