mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-23 19:45:04 +03:00
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:
@@ -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`.
|
||||||
@@ -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`).
|
|
||||||
@@ -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
@@ -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 it’s 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.
|
||||||
|
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
+9
@@ -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">Couldn’t load this profile</string>
|
<string name="profile_load_failed">Couldn’t 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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+13
@@ -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,
|
||||||
|
|||||||
+11
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -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",
|
||||||
|
|||||||
+14
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
+277
-23
@@ -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 {
|
||||||
|
|||||||
+38
-2
@@ -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 =
|
||||||
|
|||||||
+19
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+43
-2
@@ -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 -> {
|
||||||
|
|||||||
+11
@@ -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>,
|
||||||
|
)
|
||||||
+8
@@ -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(),
|
||||||
|
)
|
||||||
+11
@@ -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,
|
||||||
|
)
|
||||||
+12
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+81
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
+21
-15
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-13
@@ -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,
|
||||||
)
|
)
|
||||||
@@ -146,3 +196,49 @@ 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:
|
||||||
|
|||||||
+17
@@ -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)
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
) {
|
||||||
|
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()
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
Column {
|
|
||||||
@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
|
||||||
|
|||||||
Reference in New Issue
Block a user