diff --git a/.cursor/rules/code-style.mdc b/.cursor/rules/code-style.mdc new file mode 100644 index 0000000..8bc7553 --- /dev/null +++ b/.cursor/rules/code-style.mdc @@ -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`. diff --git a/.cursor/rules/kotlin-clean-code.mdc b/.cursor/rules/kotlin-clean-code.mdc deleted file mode 100644 index 0cbb131..0000000 --- a/.cursor/rules/kotlin-clean-code.mdc +++ /dev/null @@ -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`). diff --git a/.cursor/skills/adapt-to-style/SKILL.md b/.cursor/skills/adapt-to-style/SKILL.md new file mode 100644 index 0000000..25231eb --- /dev/null +++ b/.cursor/skills/adapt-to-style/SKILL.md @@ -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_.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. diff --git a/CODE_STYLE.md b/CODE_STYLE.md new file mode 100644 index 0000000..889ab9c --- /dev/null +++ b/CODE_STYLE.md @@ -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() +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. + diff --git a/app/android/build.gradle.kts b/app/android/build.gradle.kts index dc3d661..2e1839b 100644 --- a/app/android/build.gradle.kts +++ b/app/android/build.gradle.kts @@ -150,4 +150,9 @@ dependencies { implementation(project(":app:shared")) implementation(project(":utils:shared")) + + testImplementation("junit:junit:4.13.2") + testImplementation(libs.androidx.compose.material3) + testImplementation("androidx.graphics:graphics-shapes:1.0.1") + testImplementation("org.robolectric:robolectric:4.14.1") } \ No newline at end of file diff --git a/app/shared/build.gradle.kts b/app/shared/build.gradle.kts index fad9538..0972b6e 100644 --- a/app/shared/build.gradle.kts +++ b/app/shared/build.gradle.kts @@ -19,9 +19,9 @@ kotlin { } listOf( - iosX64(), iosArm64(), - iosSimulatorArm64() + iosSimulatorArm64(), + iosX64(), ).forEach { iosTarget -> iosTarget.binaries.framework { baseName = "ComposeApp" @@ -84,6 +84,7 @@ kotlin { } androidMain.dependencies { + implementation(libs.markdown.renderer.m3) implementation(libs.bouncycastle.bcprov) implementation(libs.androidx.exifinterface) implementation(libs.ktor.client.okhttp) diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheDirs.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheDirs.android.kt index 47eaad3..649af2d 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheDirs.android.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheDirs.android.kt @@ -10,3 +10,12 @@ actual suspend fun wipeFromChatCacheDirectory() { File(UtilsLibrary.context.cacheDir, "fromchat").deleteRecursively() } } + +actual suspend fun wipeAttachmentCacheDirectories() { + withContext(Dispatchers.IO) { + val cacheDir = UtilsLibrary.context.cacheDir + listOf("decrypted_images", "decrypted_files", "encrypted_downloads").forEach { name -> + File(cacheDir, name).deleteRecursively() + } + } +} diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/send/OutboxSendWorker.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/send/OutboxSendWorker.kt index abf2b0a..2dc3603 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/send/OutboxSendWorker.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/api/local/send/OutboxSendWorker.kt @@ -54,7 +54,7 @@ class OutboxSendWorker( } finally { progressJob?.cancel() } - Result.success() + if (allOk) Result.success() else Result.retry() } companion object { diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/legal/LegalMarkdown.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/legal/LegalMarkdown.android.kt new file mode 100644 index 0000000..ea737d3 --- /dev/null +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/legal/LegalMarkdown.android.kt @@ -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) }, + ) +} diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/components/BackHandler.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/components/BackHandler.android.kt index 0904984..3a9d453 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/components/BackHandler.android.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/components/BackHandler.android.kt @@ -49,6 +49,7 @@ actual fun rememberHapticFeedbackInternal(): (Int) -> Unit { HapticFeedbackEvent.ProfileClosed.ordinal -> HapticFeedbackConstants.CLOCK_TICK HapticFeedbackEvent.MessageSent.ordinal -> HapticFeedbackConstants.CONFIRM HapticFeedbackEvent.ContextMenuOpened.ordinal -> HapticFeedbackConstants.CONFIRM + HapticFeedbackEvent.SelectionModeEntered.ordinal -> HapticFeedbackConstants.CONFIRM else -> HapticFeedbackConstants.CLOCK_TICK } view.performHapticFeedback(constant) diff --git a/app/shared/src/commonMain/composeResources/values-ru/strings.xml b/app/shared/src/commonMain/composeResources/values-ru/strings.xml index 81daac8..ce22703 100644 --- a/app/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/app/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -9,6 +9,14 @@ Telegram MAX Сайт + Политика конфиденциальности + Пользовательское соглашение + Политика конфиденциальности + Пользовательское соглашение + Показана сохранённая копия документа. Содержимое может быть устаревшим. + Не удалось загрузить документ. Проверьте подключение к интернету и попробуйте снова. + Регистрируясь, вы соглашаетесь с + и 100% бесплатный и открытый мессенджер. Поддерживает self-hosted установку на своём сервере. Добро пожаловать! Войти @@ -52,6 +60,17 @@ Список контактов появится здесь, когда функция будет готова. Общий чат Вы: последнее сообщение + Вложение + Прочитано + Выбрать + В архив + Выбрано чатов: %1$d + Удалить чаты? + Сообщения в выбранных чатах будут удалены с устройства и сервера. + Не удалось удалить чатов: %1$d + Закрыть выбор + Выбрано + Ещё действия Найдите пользователя, имя или чат Поиск Ничего не найдено @@ -105,6 +124,9 @@ Удалить Копировать Отменить + Повторить + Не удалось отправить + Сообщение не отправлено %1$d\u0025 %1$s · %2$s Сохранение файла @@ -123,12 +145,24 @@ Отправить Эмодзи Профиль + Изменить + Профиль обновлён + Не более %1$d символов + %1$d %2$s %3$d Не получилось загрузить профиль Профиль не найден Не удалось открыть профиль. Попробуйте снова. Настройки Написать Скопировать ссылку + Написать + Ссылка + Настройки + Позвонить + Видео + Контактная информация + Поиск + Пока не реализовано Ссылка скопирована О человеке Имя пользователя @@ -167,6 +201,18 @@ окт ноя дек + января + февраля + марта + апреля + мая + июня + июля + августа + сентября + октября + ноября + декабря Настройка сервера Подключение к альтернативному серверу FromChat. Это дает больше приватности и контроля над данными. IP или имя сервера diff --git a/app/shared/src/commonMain/composeResources/values/strings.xml b/app/shared/src/commonMain/composeResources/values/strings.xml index 71552f1..3a3684b 100644 --- a/app/shared/src/commonMain/composeResources/values/strings.xml +++ b/app/shared/src/commonMain/composeResources/values/strings.xml @@ -12,6 +12,14 @@ Telegram MAX Website + Privacy policy + Terms of service + Privacy policy + Terms of service + Showing a saved copy. Content may be out of date. + Couldn\'t load the document. Check your connection and try again. + By creating an account you agree to the + and 100% free and open messenger. Supports self-hosted installation on your own server. @@ -60,6 +68,17 @@ Your contacts will appear here when this feature is ready. Main chat You: last message + Attachment + Mark as read + Select + Archive + %1$d chats selected + Delete chats? + Messages in selected chats will be deleted from this device and the server. + Could not delete %1$d chat(s) + Close selection + Selected + More actions Search by name, username or chat Search No results @@ -119,6 +138,9 @@ Delete Copy Cancel + Retry + Couldn\'t send + Message failed to send Save %1$d\u0025 %1$s · %2$s @@ -141,12 +163,24 @@ Profile + Edit + Profile updated + Up to %1$d characters + %1$d %2$s %3$d Couldn’t load this profile This profile could not be found Could not open this profile. Please try again. Settings Chat Copy link + Chat + Link + Settings + Call + Video + Contact info + Search + Not implemented yet Link copied About this person Username @@ -191,6 +225,18 @@ Oct Nov Dec + january + february + march + april + may + june + july + august + september + october + november + december Connect to a server diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt index 107f42e..bad1e21 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt @@ -8,6 +8,8 @@ import com.pr0gramm3r101.utils.settings.settings import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.plugins.ClientRequestException +import io.ktor.client.request.forms.formData +import io.ktor.client.request.forms.submitFormWithBinaryData import io.ktor.client.plugins.HttpResponseValidator import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.defaultRequest @@ -29,7 +31,6 @@ import io.ktor.client.request.setBody import io.ktor.client.statement.HttpResponse import io.ktor.http.ContentType import io.ktor.http.contentType -import io.ktor.http.encodedPath import io.ktor.serialization.kotlinx.json.json import kotlinx.coroutines.MainScope import kotlinx.coroutines.flow.MutableStateFlow @@ -46,18 +47,21 @@ import ru.fromchat.api.crypto.transport.TransportCrypto import ru.fromchat.api.instance.InstanceIdGuard import ru.fromchat.api.instance.InstanceIdResolveResult import ru.fromchat.api.instance.resolveInstanceId -import ru.fromchat.api.local.cache.CacheContext -import ru.fromchat.api.local.send.scheduleOutboxProcessing import ru.fromchat.api.local.WebSocketManager +import ru.fromchat.api.local.db.store.MessageRepository +import ru.fromchat.api.local.send.cancelOutboxProcessing +import ru.fromchat.api.local.cache.CacheContext import ru.fromchat.api.local.cache.readOutboundFileBytes -import ru.fromchat.api.local.db.store.InstanceRegistryStore import ru.fromchat.api.local.db.store.ProfileCache +import ru.fromchat.api.local.db.store.PublicChatProfileCache import ru.fromchat.api.local.download.streamEncryptedFileToDisk +import ru.fromchat.api.local.send.scheduleOutboxProcessing import ru.fromchat.api.schema.calls.CallSignalingLiveKitControl import ru.fromchat.api.schema.calls.CallSignalingLiveKitPayload import ru.fromchat.api.schema.calls.LiveKitTokenRequest import ru.fromchat.api.schema.calls.LiveKitTokenResponse import ru.fromchat.api.schema.core.SimpleStatusResponse +import ru.fromchat.api.schema.messages.MarkReadRequest import ru.fromchat.api.schema.messages.MessagesResponse import ru.fromchat.api.schema.messages.dm.DmConversation import ru.fromchat.api.schema.messages.dm.DmConversationsResponse @@ -71,15 +75,17 @@ import ru.fromchat.api.schema.messages.dm.upload.DmUploadCompleteResponse import ru.fromchat.api.schema.messages.dm.upload.DmUploadInitRequest import ru.fromchat.api.schema.messages.dm.upload.DmUploadInitResponse import ru.fromchat.api.schema.messages.dm.upload.DmUploadStatusResponse +import ru.fromchat.api.schema.chats.publicchat.PublicChatProfile import ru.fromchat.api.schema.messages.publicchat.SendMessageRequest import ru.fromchat.api.schema.server.RegisteredUserCountResponse import ru.fromchat.api.schema.server.ServerInstanceIdResponse import ru.fromchat.api.schema.server.TransportKeyResponse import ru.fromchat.api.schema.user.ChangePasswordApiRequest import ru.fromchat.api.schema.user.DeleteAccountRequest -import ru.fromchat.api.schema.user.VerifyPasswordRequest import ru.fromchat.api.schema.user.FcmTokenRequest import ru.fromchat.api.schema.user.User +import ru.fromchat.api.schema.user.UsersSearchResponse +import ru.fromchat.api.schema.user.VerifyPasswordRequest import ru.fromchat.api.schema.user.auth.CheckAuthResponse import ru.fromchat.api.schema.user.auth.CheckUsernameResponse import ru.fromchat.api.schema.user.auth.LoginRequest @@ -91,6 +97,8 @@ import ru.fromchat.api.schema.user.keys.BackupBlobRequest import ru.fromchat.api.schema.user.keys.BackupBlobResponse import ru.fromchat.api.schema.user.keys.PublicKeyResponse import ru.fromchat.api.schema.user.profile.SimilarityResult +import ru.fromchat.api.schema.user.profile.UpdateProfileRequest +import ru.fromchat.api.schema.user.profile.UpdateProfileResponse import ru.fromchat.api.schema.user.profile.UserProfile import ru.fromchat.api.schema.user.profile.VerifyResponse import ru.fromchat.api.schema.websocket.WebSocketCredentials @@ -296,13 +304,11 @@ object ApiClient { suspend fun probeHttpGet(url: String): Boolean = runCatching { httpProbe.get(url.trim()) - true - }.getOrDefault(false) + }.isSuccess suspend fun checkAuthAt(apiBaseUrl: String, bearer: String): Boolean = runCatching { - val base = apiBaseUrl.trimEnd('/') - httpProbe.get("$base/check_auth") { + httpProbe.get("${apiBaseUrl.trimEnd('/')}/check_auth") { bearerAuth(bearer) }.body().authenticated }.getOrDefault(false) @@ -455,6 +461,11 @@ object ApiClient { } } + suspend fun persistCurrentUser() { + val currentUser = user ?: return + settings.putString("user_info", json.encodeToString(currentUser)) + } + // --- User & profile --- suspend fun getMessages(limit: Int = 50, beforeId: Int? = null) = @@ -466,6 +477,21 @@ object ApiClient { } .body() + suspend fun getNewMessages(): MessagesResponse = + http + .get("${ServerConfig.apiBaseUrl}/messages/new") { + contentType(ContentType.Application.Json) + } + .body() + + suspend fun markMessagesRead(messageIds: List) { + if (messageIds.isEmpty()) return + http.post("${ServerConfig.apiBaseUrl}/messages/read") { + contentType(ContentType.Application.Json) + setBody(MarkReadRequest(messageIds = messageIds)) + } + } + suspend fun getOwnProfile(): UserProfile = http .get("${ServerConfig.apiBaseUrl}/user/profile") { @@ -487,6 +513,54 @@ object ApiClient { } .body() + suspend fun updateProfile( + username: String? = null, + displayName: String? = null, + bio: String? = null, + ): UpdateProfileResponse { + val response = http + .put("${ServerConfig.apiBaseUrl}/user/profile") { + contentType(ContentType.Application.Json) + setBody( + UpdateProfileRequest( + username = username, + displayName = displayName, + description = bio, + ) + ) + } + .body() + + val currentUser = user + if (currentUser != null) { + user = currentUser.copy( + username = response.username, + displayName = response.displayName, + bio = response.bio, + ) + persistCurrentUser() + } + + ProfileCache.get(currentUser?.id ?: 0)?.let { cached -> + ProfileCache.put( + cached.copy( + username = response.username, + displayName = response.displayName, + bio = response.bio, + ) + ) + } + + return response + } + + suspend fun getPublicChatProfile(): PublicChatProfile = + http + .get("${ServerConfig.apiBaseUrl}/public-chat/profile") { + contentType(ContentType.Application.Json) + } + .body() + suspend fun getRegisteredUserCount(): Int = http .get("${ServerConfig.apiBaseUrl}/user/stats/registered-count") { @@ -521,6 +595,18 @@ object ApiClient { .body() .conversations + suspend fun searchUsers(query: String): List { + 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() + .users + } + suspend fun getDmFetch(since: Int? = null): DmHistoryResponse { return http .get("${ServerConfig.apiBaseUrl}/dm/fetch") { @@ -1263,6 +1349,11 @@ object ApiClient { * or together with [logout] after remote logout). */ suspend fun clearLocalSession() { + val instanceId = runCatching { CacheContext.activeInstanceId.value.trim() }.getOrDefault("") + if (instanceId.isNotEmpty()) { + runCatching { cancelOutboxProcessing(instanceId) } + runCatching { MessageRepository.purgeAllPendingForInstance() } + } val uid = user?.id secureSettings.remove("auth_token") settings.remove("user_info") @@ -1277,7 +1368,9 @@ object ApiClient { runCatching { IdentityKeyManager.clearLocalKeys() } runCatching { ProfileCache.clear() } runCatching { DmPanelCache.clearAll() } + runCatching { PublicChatProfileCache.clear() } runCatching { PublicChatPanelCache.clear() } + runCatching { CacheContext.clearActive() } } suspend fun logout() { @@ -1290,10 +1383,15 @@ object ApiClient { suspend fun sendMessageViaHttp(content: String, replyToId: Int? = null) { if (_suspensionState.value.isSuspended) return - http.post("${ServerConfig.apiBaseUrl}/send_message") { - contentType(ContentType.Application.Json) - setBody(SendMessageRequest(content = content, reply_to_id = replyToId)) - } + val payloadJson = json.encodeToString( + SendMessageRequest(content = content.trim(), reply_to_id = replyToId), + ) + http.submitFormWithBinaryData( + url = "${ServerConfig.apiBaseUrl}/send_message", + formData = formData { + append("payload", payloadJson) + }, + ) } // WebSocket send helpers diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/ServerConfigProbe.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/ServerConfigProbe.kt index ea44cc7..eb920c9 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/ServerConfigProbe.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/ServerConfigProbe.kt @@ -7,6 +7,7 @@ import ru.fromchat.api.local.db.store.InstanceRegistryStore import ru.fromchat.config.ServerConfigData import ru.fromchat.api.local.cache.CacheContext import ru.fromchat.config.ServerConfig +import ru.fromchat.legal.DocumentRepository import kotlin.time.TimeSource sealed interface ServerProbeResult { @@ -64,6 +65,7 @@ suspend fun applyServerConfig( ) { val tentative = config.copy(callsEnabled = callsOk) ServerConfig.updateServerConfig(tentative) + DocumentRepository.invalidate() val userId = ApiClient.user?.id InstanceRegistryStore.rebindServerInstance(tentative, instanceId) CacheContext.setActiveInstance(instanceId, userId) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/SessionInstanceBootstrap.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/SessionInstanceBootstrap.kt index b2313d7..ad8ddb6 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/SessionInstanceBootstrap.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/SessionInstanceBootstrap.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import ru.fromchat.api.ApiClient import ru.fromchat.api.local.db.store.InstanceRegistryStore +import ru.fromchat.api.local.db.store.MessageRepository import ru.fromchat.api.local.download.AttachmentDownloadNotifier import ru.fromchat.api.local.send.scheduleOutboxProcessing import ru.fromchat.config.Settings @@ -35,6 +36,11 @@ private fun activateInstance(instanceId: String) { CacheContext.setActiveInstance(instanceId, ApiClient.user?.id) scheduleOutboxProcessing(instanceId) scheduleAttachmentResumeAfterSession() + ApiClient.user?.id?.let { userId -> + bootstrapScope.launch { + runCatching { MessageRepository.purgePendingNotFromUser(userId) } + } + } } /** diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/DecryptedFileCache.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/DecryptedFileCache.kt index 6178848..6db6b49 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/DecryptedFileCache.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/DecryptedFileCache.kt @@ -162,6 +162,19 @@ object DecryptedFileCache { } } + suspend fun invalidateForMessage(messageId: Int) { + if (messageId <= 0) return + val dir = ensureCacheDir() ?: return + withContext(Dispatchers.Default) { + cacheMutex.withLock { + memoryCache.keys.removeAll { it.startsWith("file_${messageId}_") } + } + runCatching { + PlatformFileSystem.deleteFilesWithPrefix(dir, "file_${messageId}_") + } + } + } + suspend fun getOrDecrypt( messageId: Int, fileIndex: Int, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheDirs.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheDirs.kt index da4712d..f6df1e7 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheDirs.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheDirs.kt @@ -1,4 +1,15 @@ package ru.fromchat.api.local.cache +import ru.fromchat.api.local.download.LocalDecodedImageCache + /** Deletes the `cacheDir/fromchat/` tree (blobs, DB file on disk). Call after [ru.fromchat.api.db.MessageRepository.clearAllCache]. */ expect suspend fun wipeFromChatCacheDirectory() + +/** Removes decrypted attachment blobs and partial download state outside `fromchat/`. */ +expect suspend fun wipeAttachmentCacheDirectories() + +suspend fun wipeAllOnDiskAttachmentCaches() { + wipeFromChatCacheDirectory() + wipeAttachmentCacheDirectories() + LocalDecodedImageCache.evictPrefix("img_") +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/LocalCacheWipe.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/LocalCacheWipe.kt index dca4b80..2e8091c 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/LocalCacheWipe.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/LocalCacheWipe.kt @@ -4,10 +4,12 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import ru.fromchat.api.local.db.store.MessageDatabaseProvider import ru.fromchat.api.local.send.cancelOutboxProcessing +import ru.fromchat.api.local.db.store.PublicChatProfileCache import ru.fromchat.api.local.cache.CacheContext +import ru.fromchat.api.local.cache.wipeAttachmentCacheDirectories import ru.fromchat.api.local.cache.wipeFromChatCacheDirectory -import ru.fromchat.ui.chat.utils.PublicChatPanelCache import ru.fromchat.ui.chat.panels.dm.DmPanelCache +import ru.fromchat.ui.chat.utils.PublicChatPanelCache /** * Drops the on-disk FromChat cache tree and reopens SQLite on next access. @@ -22,6 +24,8 @@ suspend fun wipeLocalCacheOnDisk() { MessageDatabaseProvider.closeAndReset() } wipeFromChatCacheDirectory() + wipeAttachmentCacheDirectories() + PublicChatProfileCache.clear() PublicChatPanelCache.clear() DmPanelCache.clearAll() } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/MessageDatabaseExpectedSchema.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/MessageDatabaseExpectedSchema.kt index 9211298..2a88cef 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/MessageDatabaseExpectedSchema.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/MessageDatabaseExpectedSchema.kt @@ -40,6 +40,7 @@ internal object MessageDatabaseExpectedSchema { lastMessagePreview TEXT, unreadCount INTEGER NOT NULL DEFAULT 0, updatedAt TEXT, + archived INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (instanceId, id) ) """.trimIndent(), @@ -122,6 +123,7 @@ internal object MessageDatabaseExpectedSchema { "lastMessagePreview" to "TEXT", "unreadCount" to "INTEGER NOT NULL DEFAULT 0", "updatedAt" to "TEXT", + "archived" to "INTEGER NOT NULL DEFAULT 0", ), "message" to mapOf( "instanceId" to "TEXT NOT NULL", diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/DmConversationListNotifier.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/DmConversationListNotifier.kt new file mode 100644 index 0000000..4f66174 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/DmConversationListNotifier.kt @@ -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(extraBufferCapacity = 8) + val events: SharedFlow = _events.asSharedFlow() + + fun notifyChanged() { + _events.tryEmit(Unit) + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/InstanceRegistryStore.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/InstanceRegistryStore.kt index d15da60..dc86e78 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/InstanceRegistryStore.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/InstanceRegistryStore.kt @@ -6,6 +6,7 @@ import ru.fromchat.api.local.send.cancelOutboxProcessing import ru.fromchat.config.ServerConfigData import ru.fromchat.api.local.cache.CacheContext import ru.fromchat.api.instance.configKey +import ru.fromchat.api.local.db.store.PublicChatProfileCache import ru.fromchat.ui.chat.utils.PublicChatPanelCache import ru.fromchat.ui.chat.panels.dm.DmPanelCache import kotlin.time.Clock @@ -68,6 +69,7 @@ object InstanceRegistryStore { } val userId = CacheContext.activeUserId.value CacheContext.setActiveInstance(newId, userId) + PublicChatProfileCache.clear() PublicChatPanelCache.clear() DmPanelCache.clearAll() } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageCacheStore.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageCacheStore.kt index 769d841..66b8d7f 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageCacheStore.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageCacheStore.kt @@ -19,13 +19,17 @@ import ru.fromchat.api.local.db.parseDmMessageContent import ru.fromchat.api.local.db.resolveLocalPreviewUri import ru.fromchat.api.local.messages.sortMessagesForChatDisplay import ru.fromchat.api.local.send.DmAttachmentOutboxPayload +import ru.fromchat.api.local.send.SEND_ERROR_FAILED import ru.fromchat.api.local.send.OutgoingMessageCoordinator import ru.fromchat.api.schema.messages.Message import ru.fromchat.api.schema.messages.dm.DmConversation +import ru.fromchat.api.schema.messages.dm.DmEnvelope import ru.fromchat.api.local.cache.CacheContext import ru.fromchat.api.local.cache.CacheValidator import ru.fromchat.db.Conversation import ru.fromchat.db.MessageDatabase +import ru.fromchat.api.crypto.decryptEnvelope +import ru.fromchat.api.local.cache.DecryptedFileCache import ru.fromchat.api.local.cache.DecryptedImageCache import ru.fromchat.api.local.download.DownloadedFileRegistry import ru.fromchat.ui.chat.utils.dedupeMessagesByClientId @@ -118,6 +122,7 @@ object MessageCacheStore { purgeSupersededPendingRows(iid, convId, before, merged) } replaceMessages(convId, merged) + pruneEmptyConversations() } private suspend fun filterStillPendingForReplace( @@ -156,6 +161,34 @@ object MessageCacheStore { upsertSingle(conversationIdForPublic(), message) } + suspend fun markSendFailed(conversationId: String, clientMessageId: String) { + val cid = clientMessageId.trim() + if (cid.isEmpty()) return + val iid = instanceId() + withContext(Dispatchers.Default) { + db.messageDatabaseQueries.updateMessageSendStatusByClientMessageId( + sendStatus = "failed", + instanceId = iid, + conversationId = conversationId, + clientMessageId = cid, + ) + } + } + + suspend fun clearSendFailed(conversationId: String, clientMessageId: String) { + val cid = clientMessageId.trim() + if (cid.isEmpty()) return + val iid = instanceId() + withContext(Dispatchers.Default) { + db.messageDatabaseQueries.updateMessageSendStatusByClientMessageId( + sendStatus = "pending", + instanceId = iid, + conversationId = conversationId, + clientMessageId = cid, + ) + } + } + suspend fun upsertDmMessage(otherUserId: Int, message: Message) { upsertSingle(conversationIdForDm(otherUserId), message) syncDmConversationPreviewFromCache(otherUserId) @@ -228,44 +261,257 @@ object MessageCacheStore { } } - suspend fun replaceDmConversations(conversations: List) { + suspend fun replaceDmConversations( + conversations: List, + attachmentOnlyPreview: String, + ) { + val iid = instanceId() + val currentUserId = ApiClient.user?.id + withContext(Dispatchers.Default) { + val upserts = conversations.map { conv -> + val conversationId = conversationIdForDm(conv.user.id) + val displayLabel = conv.user.displayName?.trim()?.takeIf { it.isNotEmpty() } + ?: conv.user.username.trim() + UpsertDmConversationRow( + conversationId = conversationId, + otherUserId = conv.user.id, + displayName = displayLabel, + lastMessageId = conv.lastMessage.id, + lastMessagePreview = buildDmListPreview( + conv.lastMessage, + currentUserId, + attachmentOnlyPreview, + ), + unreadCount = conv.unreadCount, + updatedAt = conv.lastMessage.timestamp, + ) + } + db.messageDatabaseQueries.transaction { + upserts.forEach { row -> + val archived = db.messageDatabaseQueries + .selectConversationById(iid, row.conversationId) + .executeAsOneOrNull() + ?.archived ?: 0L + db.messageDatabaseQueries.upsertConversation( + instanceId = iid, + id = row.conversationId, + type = "dm", + otherUserId = row.otherUserId.toLong(), + displayName = row.displayName, + lastMessageId = row.lastMessageId.toLong(), + lastMessagePreview = row.lastMessagePreview, + unreadCount = row.unreadCount.toLong(), + updatedAt = row.updatedAt, + archived = archived, + ) + } + reconcileDmConversationsLocked(iid, upserts.map { it.otherUserId }.toSet()) + pruneEmptyConversationsLocked(iid) + } + } + } + + private data class UpsertDmConversationRow( + val conversationId: String, + val otherUserId: Int, + val displayName: String, + val lastMessageId: Int, + val lastMessagePreview: String?, + val unreadCount: Int, + val updatedAt: String, + ) + + private suspend fun buildDmListPreview( + envelope: DmEnvelope, + currentUserId: Int?, + attachmentOnlyPreview: String, + ): String? { + val hasFiles = !envelope.files.isNullOrEmpty() + val decrypted = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull() + ?.trim() + ?.takeIf { it.isNotEmpty() } + val previewSource = when { + decrypted != null -> decrypted + hasFiles -> attachmentOnlyPreview + else -> null + } + return previewSource?.let { truncateDmListPreview(it) }?.takeIf { it.isNotEmpty() } + } + + private fun reconcileDmConversationsLocked(instanceId: String, serverOtherUserIds: Set) { + val localDm = db.messageDatabaseQueries + .selectConversationsForInstance(instanceId) + .executeAsList() + .filter { it.type == "dm" } + localDm.forEach { row -> + val otherId = row.otherUserId?.toInt() ?: return@forEach + if (otherId !in serverOtherUserIds) { + db.messageDatabaseQueries.deleteConversationById(instanceId, row.id) + } + } + } + + suspend fun pruneEmptyConversations() { val iid = instanceId() withContext(Dispatchers.Default) { db.messageDatabaseQueries.transaction { - conversations.forEach { conv -> - val conversationId = conversationIdForDm(conv.user.id) - val displayLabel = conv.user.displayName?.trim()?.takeIf { it.isNotEmpty() } - ?: conv.user.username.trim() - val recent = db.messageDatabaseQueries - .selectRecentMessagesByConversation(iid, conversationId, 1) - .executeAsList() - .firstOrNull() - val rawPreview = recent?.content.orEmpty().trim() - val preview = rawPreview.takeIf { it.isNotEmpty() } - ?.let { truncateDmListPreview(it) } - ?.takeIf { it.isNotEmpty() } - db.messageDatabaseQueries.upsertConversation( + pruneEmptyConversationsLocked(iid) + } + } + } + + private fun pruneEmptyConversationsLocked(instanceId: String) { + db.messageDatabaseQueries.deleteEmptyDmConversations(instanceId, instanceId) + } + + suspend fun ensureDmConversationRow(otherUserId: Int, displayName: String? = null) { + val iid = instanceId() + val convId = conversationIdForDm(otherUserId) + withContext(Dispatchers.Default) { + val existing = db.messageDatabaseQueries + .selectConversationById(iid, convId) + .executeAsOneOrNull() + if (existing != null) return@withContext + val label = displayName?.trim()?.takeIf { it.isNotEmpty() } + ?: ProfileCache.get(otherUserId)?.displayName?.trim()?.takeIf { it.isNotEmpty() } + ?: ProfileCache.get(otherUserId)?.username?.trim()?.takeIf { it.isNotEmpty() } + ?: "" + db.messageDatabaseQueries.upsertConversation( + instanceId = iid, + id = convId, + type = "dm", + otherUserId = otherUserId.toLong(), + displayName = label, + lastMessageId = null, + lastMessagePreview = null, + unreadCount = 0L, + updatedAt = null, + archived = 0L, + ) + } + } + + suspend fun markDmConversationRead(otherUserId: Int) { + val iid = instanceId() + val convId = conversationIdForDm(otherUserId) + withContext(Dispatchers.Default) { + db.messageDatabaseQueries.updateConversationUnreadCount( + unreadCount = 0L, + instanceId = iid, + id = convId, + ) + } + } + + suspend fun selectUnreadPublicMessageIds(): List { + val iid = instanceId() + val convId = conversationIdForPublic() + return withContext(Dispatchers.Default) { + db.messageDatabaseQueries + .selectUnreadPublicMessageIds(iid, convId) + .executeAsList() + .map { it.toInt() } + } + } + + suspend fun markPublicMessagesReadLocally() { + val iid = instanceId() + val convId = conversationIdForPublic() + withContext(Dispatchers.Default) { + db.messageDatabaseQueries.markPublicMessagesRead(iid, convId) + } + } + + suspend fun archiveDmConversation(otherUserId: Int) { + val iid = instanceId() + val convId = conversationIdForDm(otherUserId) + withContext(Dispatchers.Default) { + db.messageDatabaseQueries.updateConversationArchived( + archived = 1L, + instanceId = iid, + id = convId, + ) + } + } + + suspend fun deleteDmConversation(otherUserId: Int) { + val iid = instanceId() + val convId = conversationIdForDm(otherUserId) + withContext(Dispatchers.Default) { + val messages = db.messageDatabaseQueries + .selectMessagesByConversation(iid, convId) + .executeAsList() + messages.forEach { row -> + val msgId = row.id.toInt() + DecryptedImageCache.invalidateForMessage(msgId) + DecryptedFileCache.invalidateForMessage(msgId) + row.clientMessageId?.trim()?.takeIf { it.isNotEmpty() }?.let { cid -> + DecryptedImageCache.invalidateForClientMessage(cid) + DecryptedFileCache.invalidateForClientMessage(cid) + } + } + db.messageDatabaseQueries.transaction { + db.messageDatabaseQueries.deleteMessagesForConversation(iid, convId) + val outbox = db.messageDatabaseQueries.selectPendingOutboxForInstance(iid).executeAsList() + outbox.filter { it.conversationId == convId }.forEach { row -> + db.messageDatabaseQueries.deleteOutboxItem(iid, row.clientMessageId) + } + db.messageDatabaseQueries.deleteConversationById(iid, convId) + } + } + } + + suspend fun purgePendingNotFromUser(userId: Int) { + val iid = instanceId() + withContext(Dispatchers.Default) { + val foreign = db.messageDatabaseQueries + .selectForeignPendingMessages(iid, userId.toLong()) + .executeAsList() + foreign.forEach { row -> + val cid = row.clientMessageId?.trim().orEmpty() + if (cid.isNotEmpty()) { + OutgoingMessageCoordinator.cancelOutboundMessage(cid, row.conversationId) + } else { + db.messageDatabaseQueries.deleteMessageById( instanceId = iid, - id = conversationId, - type = "dm", - otherUserId = conv.user.id.toLong(), - displayName = displayLabel, - lastMessageId = conv.lastMessage.id.toLong(), - lastMessagePreview = preview, - unreadCount = conv.unreadCount.toLong(), - updatedAt = conv.lastMessage.timestamp + conversationId = row.conversationId, + id = row.id, ) } } } } + suspend fun purgeAllPendingForInstance() { + val iid = runCatching { instanceId() }.getOrNull()?.trim().orEmpty() + if (iid.isEmpty()) return + withContext(Dispatchers.Default) { + val pending = db.messageDatabaseQueries + .selectAllPendingMessagesForInstance(iid) + .executeAsList() + pending.forEach { row -> + val cid = row.clientMessageId?.trim().orEmpty() + if (cid.isNotEmpty()) { + runCatching { + OutgoingMessageCoordinator.cancelOutboundMessage(cid, row.conversationId) + } + } + } + db.messageDatabaseQueries.transaction { + db.messageDatabaseQueries.deleteAllPendingMessagesForInstance(iid) + val outbox = db.messageDatabaseQueries.selectPendingOutboxForInstance(iid).executeAsList() + outbox.forEach { row -> + db.messageDatabaseQueries.deleteOutboxItem(iid, row.clientMessageId) + } + } + } + } + suspend fun loadCachedDmConversations(): List = withContext(Dispatchers.Default) { db.messageDatabaseQueries - .selectConversationsForInstance(instanceId()) + .selectActiveDmConversationsForInstance(instanceId()) .executeAsList() - .filter { row: Conversation -> row.type == "dm" } .map { row: Conversation -> CachedConversation( id = row.id, @@ -302,7 +548,8 @@ object MessageCacheStore { lastMessageId = row.lastMessageId, lastMessagePreview = preview, unreadCount = row.unreadCount, - updatedAt = row.updatedAt + updatedAt = row.updatedAt, + archived = row.archived, ) } } @@ -374,7 +621,10 @@ object MessageCacheStore { ) } } - dmOtherUserIdFromConversationId(conversationId)?.let { syncDmConversationPreviewFromCache(it) } + dmOtherUserIdFromConversationId(conversationId)?.let { + syncDmConversationPreviewFromCache(it) + pruneEmptyConversations() + } } private suspend fun loadMessages(conversationId: String): List { @@ -558,6 +808,7 @@ object MessageCacheStore { ?: parsed.fileDimensions?.firstOrNull()?.let { (w, h) -> aspectRatioFromDimensionPair(w, h) }, + uploadError = if (sendStatus == "failed") SEND_ERROR_FAILED else null, ) } @@ -610,7 +861,10 @@ object MessageCacheStore { } } } - dmOtherUserIdFromConversationId(conversationId)?.let { syncDmConversationPreviewFromCache(it) } + dmOtherUserIdFromConversationId(conversationId)?.let { + syncDmConversationPreviewFromCache(it) + pruneEmptyConversations() + } } suspend fun hasSentMessageWithClientId(conversationId: String, clientMessageId: String): Boolean { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageRepository.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageRepository.kt index 8e78448..7d820e5 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageRepository.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/MessageRepository.kt @@ -1,6 +1,7 @@ package ru.fromchat.api.local.db.store import kotlinx.coroutines.flow.Flow +import ru.fromchat.api.ApiClient import ru.fromchat.api.local.messages.GENERAL_PUBLIC_GROUP_ID import ru.fromchat.api.local.messages.conversationIdForDm import ru.fromchat.api.local.messages.conversationIdForGroup @@ -63,11 +64,46 @@ object MessageRepository { suspend fun deleteDmMessageById(otherUserId: Int, messageId: Int) = MessageCacheStore.deleteDmMessageById(otherUserId, messageId) - suspend fun replaceDmConversations(conversations: List) = - MessageCacheStore.replaceDmConversations(conversations) + suspend fun replaceDmConversations( + conversations: List, + attachmentOnlyPreview: String, + ) = MessageCacheStore.replaceDmConversations(conversations, attachmentOnlyPreview) suspend fun loadCachedDmConversations(): List = 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() -} \ No newline at end of file +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/ProfileCache.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/ProfileCache.kt index 53276f1..854fcbf 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/ProfileCache.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/ProfileCache.kt @@ -45,6 +45,63 @@ object ProfileCache { fun get(userId: Int): UserProfile? = profiles[userId] + fun findByUsername(username: String): UserProfile? = + username.trim().takeIf { it.isNotEmpty() }?.let { needle -> + profiles.values.firstOrNull { profile -> + profile.username.trim().equals(needle, ignoreCase = true) + } + } + + /** + * Merges minimal identity (name, avatar) from any UI surface that showed this user. + * Skips when a full profile row is already cached. + */ + fun mergePreview( + id: Int, + username: String? = null, + displayName: String? = null, + profilePicture: String? = null, + ) { + if (id <= 0) return + val existing = get(id) + if (existing != null && !existing.isClientPreviewOnly) return + + val incomingUsername = username?.trim()?.takeIf { it.isNotEmpty() } + ?: existing?.username?.trim()?.takeIf { it.isNotEmpty() } + val incomingDisplayName = displayName?.trim()?.takeIf { it.isNotEmpty() } + ?: existing?.displayName?.takeIf { it.isNotBlank() } + ?: incomingUsername + + if (incomingUsername.isNullOrEmpty() && incomingDisplayName.isNullOrBlank()) return + + put( + UserProfile( + id = id, + username = incomingUsername.orEmpty(), + displayName = incomingDisplayName, + profilePicture = profilePicture?.takeIf { it.isNotBlank() } + ?: existing?.profilePicture, + bio = existing?.bio, + online = existing?.online ?: false, + lastSeen = existing?.lastSeen, + createdAt = existing?.createdAt, + verified = existing?.verified, + suspended = existing?.suspended, + suspensionReason = existing?.suspensionReason, + deleted = existing?.deleted, + isClientPreviewOnly = true, + ), + ) + } + + fun mergeFromCachedConversation(conversation: CachedConversation) { + if (conversation.otherUserId <= 0) return + mergePreview( + id = conversation.otherUserId, + displayName = conversation.displayName.takeIf { it.isNotBlank() }, + ) + } + fun put(profile: UserProfile) { if (profile.isClientPreviewOnly) { val hasIdentity = diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/PublicChatProfileCache.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/PublicChatProfileCache.kt new file mode 100644 index 0000000..fd13f07 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/db/store/PublicChatProfileCache.kt @@ -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 + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/EncryptedFileDownloader.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/EncryptedFileDownloader.kt index 020ed24..ca7082e 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/EncryptedFileDownloader.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/download/EncryptedFileDownloader.kt @@ -39,6 +39,9 @@ internal suspend fun streamEncryptedFileToDisk( header(HttpHeaders.Range, "bytes=$rangeOffset-") } }.execute { response -> + if (response.status.value == 404 || response.status.value == 410) { + error("HTTP ${response.status.value} for encrypted file download") + } if (response.status.value !in 200..299) { error("HTTP ${response.status.value} for encrypted file download") } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutboundSendErrors.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutboundSendErrors.kt new file mode 100644 index 0000000..58742e6 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutboundSendErrors.kt @@ -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 diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutboundSendNotifier.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutboundSendNotifier.kt new file mode 100644 index 0000000..8b9f1ae --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutboundSendNotifier.kt @@ -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(extraBufferCapacity = 64) + val progressFlow: SharedFlow = _progressFlow + private val mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + fun emit(progress: OutboundSendProgress) { + mainScope.launch { + _progressFlow.emit(progress) + } + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutgoingMessageCoordinator.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutgoingMessageCoordinator.kt index dc96b0b..8a76b5b 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutgoingMessageCoordinator.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/local/send/OutgoingMessageCoordinator.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import ru.fromchat.api.ApiClient +import ru.fromchat.api.local.workers.AttachmentUploadNotifier import ru.fromchat.api.local.workers.AttachmentUploadProgress import ru.fromchat.api.local.messages.GENERAL_PUBLIC_GROUP_ID import ru.fromchat.api.local.db.store.MessageCacheStore @@ -17,7 +18,11 @@ import ru.fromchat.api.local.db.store.MessageDatabaseProvider import ru.fromchat.api.local.db.store.MessageRepository import ru.fromchat.api.local.messages.conversationIdForDm import ru.fromchat.api.local.messages.conversationIdForGroup -import ru.fromchat.api.local.workers.AttachmentUploadNotifier +import ru.fromchat.api.local.send.OutboundSendNotifier +import ru.fromchat.api.local.send.OutboundSendProgress +import ru.fromchat.api.local.send.isOutboundPermanentFailure +import ru.fromchat.api.local.send.isOutboundTransientFailure +import ru.fromchat.api.local.send.outboundFailureErrorKey import ru.fromchat.api.schema.messages.Message import ru.fromchat.api.schema.messages.dm.SendDmFile import ru.fromchat.api.local.cache.CacheContext @@ -178,6 +183,21 @@ object OutgoingMessageCoordinator { } } + /** Re-queues a failed public / text outbound row (outbox row must still exist). */ + fun retryOutboundMessage(clientMessageId: String, conversationId: String) { + val cid = clientMessageId.trim() + if (cid.isEmpty()) return + val instanceId = CacheContext.activeInstanceId.value.trim() + if (instanceId.isEmpty()) return + drainScope.launch { + withContext(Dispatchers.Default) { + MessageCacheStore.clearSendFailed(conversationId, cid) + } + OutboundSendNotifier.emit(OutboundSendProgress.Pending(cid)) + kickOutboxDrain(instanceId) + } + } + /** Re-queues a failed attachment upload (outbox row must still exist). */ fun retryDmAttachmentUpload(clientMessageId: String) { val cid = clientMessageId.trim() @@ -233,13 +253,34 @@ object OutgoingMessageCoordinator { for (row in rows) { when (row.kind) { KIND_SEND_PUBLIC -> { - runCatching { + val sendResult = runCatching { val payload = json.decodeFromString(row.payloadJson) ApiClient.sendMessageViaHttp(payload.content, payload.replyToId) + } + sendResult.onSuccess { withContext(Dispatchers.Default) { MessageDatabaseProvider.database.messageDatabaseQueries .deleteOutboxItem(id, row.clientMessageId) } + }.onFailure { error -> + when { + error.isOutboundPermanentFailure() -> { + val errorKey = outboundFailureErrorKey(error) + withContext(Dispatchers.Default) { + MessageCacheStore.markSendFailed(row.conversationId, row.clientMessageId) + } + OutboundSendNotifier.emit( + OutboundSendProgress.Failed(row.clientMessageId, errorKey), + ) + } + error.isOutboundTransientFailure() -> { + allOk = false + drainScope.launch { + delay(3_000) + drainOutboxForInstance(id) + } + } + } } } KIND_SEND_DM -> { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/chats/publicchat/PublicChatProfile.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/chats/publicchat/PublicChatProfile.kt new file mode 100644 index 0000000..f958a85 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/chats/publicchat/PublicChatProfile.kt @@ -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, +) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/MarkReadRequest.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/MarkReadRequest.kt new file mode 100644 index 0000000..f4e889c --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/MarkReadRequest.kt @@ -0,0 +1,8 @@ +package ru.fromchat.api.schema.messages + +import kotlinx.serialization.Serializable + +@Serializable +data class MarkReadRequest( + val messageIds: List, +) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/DmArchiveRequest.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/DmArchiveRequest.kt new file mode 100644 index 0000000..5847e0c --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/messages/dm/DmArchiveRequest.kt @@ -0,0 +1,8 @@ +package ru.fromchat.api.schema.messages.dm + +import kotlinx.serialization.Serializable + +@Serializable +data class DmArchiveRequest( + val archived: Boolean, +) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/UsersSearchResponse.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/UsersSearchResponse.kt new file mode 100644 index 0000000..48abe5f --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/UsersSearchResponse.kt @@ -0,0 +1,8 @@ +package ru.fromchat.api.schema.user + +import kotlinx.serialization.Serializable + +@Serializable +data class UsersSearchResponse( + val users: List = emptyList(), +) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/profile/UpdateProfileRequest.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/profile/UpdateProfileRequest.kt new file mode 100644 index 0000000..4a77428 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/profile/UpdateProfileRequest.kt @@ -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, +) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/profile/UpdateProfileResponse.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/profile/UpdateProfileResponse.kt new file mode 100644 index 0000000..bf39ecd --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/schema/user/profile/UpdateProfileResponse.kt @@ -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, +) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/legal/DocumentLoadResult.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/legal/DocumentLoadResult.kt new file mode 100644 index 0000000..3cbfcf0 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/legal/DocumentLoadResult.kt @@ -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() +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/legal/DocumentRepository.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/legal/DocumentRepository.kt new file mode 100644 index 0000000..bb5f852 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/legal/DocumentRepository.kt @@ -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() + + 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 + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/legal/DocumentScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/legal/DocumentScreen.kt new file mode 100644 index 0000000..4a59dc2 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/legal/DocumentScreen.kt @@ -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.Loading) } + var pendingDocument by remember(type) { mutableStateOf(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), + ) + } + } + } + } + } + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/legal/DocumentType.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/legal/DocumentType.kt new file mode 100644 index 0000000..79b2a81 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/legal/DocumentType.kt @@ -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) } + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/legal/Markdown.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/legal/Markdown.kt new file mode 100644 index 0000000..ef04bee --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/legal/Markdown.kt @@ -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? { + 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("""""", 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
, +) + +private data class MarkdownTable( + val header: List, + val rows: List>, +) + +@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() + val sections = mutableListOf
() + + 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(null) } + var pressedLinkRange by remember(text) { mutableStateOf(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): 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), + ) + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt index 216ff3e..daff5b2 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt @@ -1,16 +1,15 @@ package ru.fromchat.ui import androidx.compose.animation.AnimatedContentScope -import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.End -import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.Start +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition import androidx.compose.animation.SharedTransitionLayout -import androidx.compose.animation.core.FiniteAnimationSpec -import androidx.compose.animation.core.spring +import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.SnackbarDuration @@ -25,9 +24,9 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.IntOffset import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver +import androidx.navigation.NamedNavArgument import androidx.navigation.NavBackStackEntry import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder @@ -38,7 +37,9 @@ import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import coil3.ImageLoader import coil3.compose.setSingletonImageLoaderFactory +import coil3.network.ktor3.KtorNetworkFetcherFactory import coil3.svg.SvgDecoder +import ru.fromchat.api.ApiClient import com.pr0gramm3r101.utils.LocalSystemBarsVisibility import com.pr0gramm3r101.utils.navigateAndWipeBackStack import com.pr0gramm3r101.utils.rememberSystemBarsController @@ -54,7 +55,6 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import ru.fromchat.AppForeground import ru.fromchat.Logger -import ru.fromchat.api.ApiClient import ru.fromchat.api.DeferredStartupNetwork import ru.fromchat.api.UpdateSyncManager import ru.fromchat.api.calls.CallStore @@ -70,12 +70,16 @@ import ru.fromchat.api.local.send.scheduleOutboxProcessing import ru.fromchat.api.schema.websocket.WebSocketMessage import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData import ru.fromchat.config.ServerConfig +import ru.fromchat.legal.DocumentScreen +import ru.fromchat.legal.DocumentType import ru.fromchat.ui.auth.AuthScreen import ru.fromchat.ui.calls.CallOverlay import ru.fromchat.ui.chat.panels.dm.DmChatRoute import ru.fromchat.ui.chat.panels.dm.DmNav import ru.fromchat.ui.chat.panels.dm.DmProfileRoute -import ru.fromchat.ui.chat.panels.publicchat.PublicChatScreen +import ru.fromchat.ui.chat.panels.publicchat.PublicChatChatRoute +import ru.fromchat.ui.chat.panels.publicchat.PublicChatNav +import ru.fromchat.ui.chat.panels.publicchat.PublicChatProfileRoute import ru.fromchat.ui.main.MainScreen import ru.fromchat.ui.main.chats.ChatsSearchScreen import ru.fromchat.ui.main.settings.AboutScreen @@ -84,14 +88,43 @@ import ru.fromchat.ui.main.settings.DevicesScreen import ru.fromchat.ui.main.settings.NotificationsScreen import ru.fromchat.ui.main.settings.SettingsRoutes import ru.fromchat.ui.main.settings.account.AccountScreen -import ru.fromchat.ui.main.settings.account.delete.DeleteAccountScreen import ru.fromchat.ui.main.settings.account.changepassword.ChangePasswordScreen +import ru.fromchat.ui.main.settings.account.delete.DeleteAccountScreen import ru.fromchat.ui.main.settings.server.ServerConfigScreen +import ru.fromchat.ui.profile.EditProfileFocusField +import ru.fromchat.ui.profile.EditProfileScreen +import ru.fromchat.ui.profile.ProfileRoutes import ru.fromchat.ui.profile.ProfileScreen import ru.fromchat.utils.NetworkConnectivity val LocalNavController = compositionLocalOf { error("NavController not provided") } +private val rootNavTween = tween(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(durationMillis = 260) + +private fun searchScreenEnterTransition(): EnterTransition = + fadeIn(animationSpec = searchScreenFade) + +private fun searchScreenExitTransition(): ExitTransition = + fadeOut(animationSpec = searchScreenFade) + private fun handlePresenceStatus(data: JsonObject?) { val userId = data?.get("userId")?.jsonPrimitive?.content?.toIntOrNull() ?: return val online = data["online"]?.jsonPrimitive?.booleanOrNull == true @@ -149,25 +182,14 @@ private fun handlePresenceEvent(message: WebSocketMessage) { } } -private fun NavGraphBuilder.settingsSlideComposable( +private fun NavGraphBuilder.settingsComposable( route: String, - animationSpec: FiniteAnimationSpec, + arguments: List = emptyList(), content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit, ) { composable( route = route, - enterTransition = { - slideInHorizontally(animationSpec = animationSpec) { it } - }, - exitTransition = { - slideOutHorizontally(animationSpec = animationSpec) { -it } - }, - popEnterTransition = { - slideInHorizontally(animationSpec = animationSpec) { -it } - }, - popExitTransition = { - slideOutHorizontally(animationSpec = animationSpec) { it } - }, + arguments = arguments, content = content, ) } @@ -186,6 +208,7 @@ fun App( ImageLoader.Builder(context) .components { add(SvgDecoder.Factory()) + add(KtorNetworkFetcherFactory(httpClient = { ApiClient.http })) } .build() } @@ -287,7 +310,6 @@ fun App( SharedTransitionLayout { val navController = rememberNavController() val profileLookupSnackbarHostState = remember { SnackbarHostState() } - LaunchedEffect(profileLookupErrorMessage) { profileLookupErrorMessage?.let { message -> Logger.d("ProfileDeepLink", "showing snackbar for deep-link lookup failure: $message") @@ -359,35 +381,14 @@ fun App( LocalSystemBarsVisibility provides rememberSystemBarsController() ) { if (startDestination != null) { - val rootNavMotion = spring(dampingRatio = 0.88f, stiffness = 420f) Box(Modifier.fillMaxSize()) { NavHost( navController = navController, startDestination = startDestination!!, - enterTransition = { - slideIntoContainer( - Start, - animationSpec = rootNavMotion - ) - }, - exitTransition = { - slideOutOfContainer( - Start, - animationSpec = rootNavMotion - ) - }, - popEnterTransition = { - slideIntoContainer( - End, - animationSpec = rootNavMotion - ) - }, - popExitTransition = { - slideOutOfContainer( - End, - animationSpec = rootNavMotion - ) - } + enterTransition = { rootNavEnterTransition() }, + exitTransition = { rootNavExitTransition() }, + popEnterTransition = { rootNavPopEnterTransition() }, + popExitTransition = { rootNavPopExitTransition() }, ) { composable("serverConfig") { ServerConfigScreen() @@ -426,21 +427,29 @@ fun App( ) } - composable("chats/publicChat") { - PublicChatScreen( + composable(PublicChatNav.CHAT_ROUTE) { + PublicChatChatRoute( scrollToMessageId = scrollToMessageId, + navController = navController, sharedTransitionScope = this@SharedTransitionLayout, - animatedContentScope = this@composable + animatedVisibilityScope = this, + ) + } + + composable(PublicChatNav.PROFILE_ROUTE) { + PublicChatProfileRoute( + navController = navController, + sharedTransitionScope = this@SharedTransitionLayout, + animatedVisibilityScope = this, ) } - val searchScreenFade = tween(260) composable( route = "search/conversations", - enterTransition = { fadeIn(animationSpec = searchScreenFade) }, - exitTransition = { fadeOut(animationSpec = searchScreenFade) }, - popEnterTransition = { fadeIn(animationSpec = searchScreenFade) }, - popExitTransition = { fadeOut(animationSpec = searchScreenFade) } + enterTransition = { searchScreenEnterTransition() }, + exitTransition = { searchScreenExitTransition() }, + popEnterTransition = { searchScreenEnterTransition() }, + popExitTransition = { searchScreenExitTransition() }, ) { ChatsSearchScreen( onBack = { navController.popBackStack() }, @@ -460,21 +469,13 @@ fun App( } composable( - route = "profile/{userId}?fromDeepLink={fromDeepLink}&useSharedElement={useSharedElement}&sourceMessageId={sourceMessageId}", + route = "profile/{userId}?fromDeepLink={fromDeepLink}", arguments = listOf( navArgument("userId") { type = NavType.StringType }, navArgument("fromDeepLink") { type = NavType.BoolType defaultValue = false }, - navArgument("useSharedElement") { - type = NavType.StringType - defaultValue = "false" - }, - navArgument("sourceMessageId") { - type = NavType.StringType - defaultValue = "-1" - } ) ) { backStackEntry -> val args = backStackEntry.savedStateHandle @@ -483,20 +484,8 @@ fun App( val userId = if ((parsedUserId ?: 0) > 0) parsedUserId else null val profileUsername = if (userId == null) { userIdParam?.trim()?.takeIf { it.isNotBlank() } - } else { - null - } - val useSharedElement = when (val rawUseSharedElement = args.get("useSharedElement")) { - is Boolean -> rawUseSharedElement - is String -> rawUseSharedElement == "true" - else -> false - } - val sourceMessageId = when (val rawSourceMessageId = args.get("sourceMessageId")) { - is Int -> rawSourceMessageId - is String -> rawSourceMessageId.toIntOrNull() ?: -1 - is Long -> rawSourceMessageId.toInt() - else -> -1 - } + } else null + val fromDeepLink = when (val rawFromDeepLink = args.get("fromDeepLink")) { is Boolean -> rawFromDeepLink is String -> rawFromDeepLink == "true" @@ -506,24 +495,39 @@ fun App( Logger.d( "ProfileRoute", "profile entry args: rawUserId=$userIdParam parsedUserId=$parsedUserId resolvedUserId=$userId " + - "resolvedUsername=$profileUsername sourceMessageId=$sourceMessageId fromDeepLink=$fromDeepLink " + - "useSharedElement=$useSharedElement currentRoute=${backStackEntry.destination.route}" + "resolvedUsername=$profileUsername fromDeepLink=$fromDeepLink " + + "currentRoute=${backStackEntry.destination.route}" ) ProfileScreen( userId = userId, username = profileUsername, + showBackButton = true, onBack = { navController.navigateUp() }, onChat = { navController.navigate(DmNav.chatRoute(it)) }, sharedTransitionScope = this@SharedTransitionLayout, animatedVisibilityScope = this@composable, - useSharedElementFromNavigation = useSharedElement, - sharedSourceMessageId = sourceMessageId, showErrorAsToast = fromDeepLink ) } - val dmChatProfileFade = tween(durationMillis = 280) + composable( + route = ProfileRoutes.Edit, + arguments = listOf( + navArgument(ProfileRoutes.ARG_FOCUS) { + type = NavType.StringType + defaultValue = "" + }, + ), + ) { entry -> + val focusField = EditProfileFocusField.fromArg( + entry.arguments?.getString(ProfileRoutes.ARG_FOCUS), + ) + EditProfileScreen( + onBack = { navController.navigateUp() }, + initialFocusField = focusField, + ) + } composable( route = DmNav.CHAT_ROUTE, @@ -531,30 +535,6 @@ fun App( navArgument("otherUserId") { type = NavType.StringType }, navArgument("sourceMessageId") { type = NavType.IntType; defaultValue = -1 }, ), - enterTransition = { - when (initialState.destination.route) { - DmNav.PROFILE_ROUTE -> fadeIn(animationSpec = dmChatProfileFade) - else -> slideIntoContainer(Start, animationSpec = rootNavMotion) - } - }, - exitTransition = { - when (targetState.destination.route) { - DmNav.PROFILE_ROUTE -> fadeOut(animationSpec = dmChatProfileFade) - else -> slideOutOfContainer(Start, animationSpec = rootNavMotion) - } - }, - popEnterTransition = { - when (initialState.destination.route) { - DmNav.PROFILE_ROUTE -> fadeIn(animationSpec = dmChatProfileFade) - else -> slideIntoContainer(End, animationSpec = rootNavMotion) - } - }, - popExitTransition = { - when (targetState.destination.route) { - DmNav.PROFILE_ROUTE -> fadeOut(animationSpec = dmChatProfileFade) - else -> slideOutOfContainer(End, animationSpec = rootNavMotion) - } - }, ) { entry -> val otherUserId = entry.savedStateHandle.get("otherUserId")?.toIntOrNull() ?: 0 val sourceMessageId = entry.savedStateHandle.get("sourceMessageId") ?: -1 @@ -571,10 +551,6 @@ fun App( composable( route = DmNav.PROFILE_ROUTE, arguments = listOf(navArgument("otherUserId") { type = NavType.StringType }), - enterTransition = { fadeIn(animationSpec = dmChatProfileFade) }, - exitTransition = { fadeOut(animationSpec = dmChatProfileFade) }, - popEnterTransition = { fadeIn(animationSpec = dmChatProfileFade) }, - popExitTransition = { fadeOut(animationSpec = dmChatProfileFade) }, ) { entry -> val otherUserId = entry.savedStateHandle.get("otherUserId")?.toIntOrNull() ?: 0 if (otherUserId <= 0) return@composable @@ -586,30 +562,51 @@ fun App( ) } - settingsSlideComposable("about", rootNavMotion) { + settingsComposable("about") { AboutScreen() } - settingsSlideComposable(SettingsRoutes.Appearance, rootNavMotion) { + settingsComposable( + route = DocumentType.ROUTE, + arguments = listOf( + navArgument(DocumentType.ARG_DOCUMENT_TYPE) { type = NavType.StringType }, + ), + ) { entry -> + val type = entry.savedStateHandle + .get(DocumentType.ARG_DOCUMENT_TYPE) + ?.let(DocumentType::typeFromArg) + ?: return@settingsComposable + DocumentScreen( + type = type, + onBack = { navController.navigateUp() }, + onOpenLegalDocument = { linkedType -> + navController.navigate(DocumentType.route(linkedType)) { + launchSingleTop = true + } + }, + ) + } + + settingsComposable(SettingsRoutes.Appearance) { AppearanceScreen(onBack = { navController.navigateUp() }) } - settingsSlideComposable(SettingsRoutes.Notifications, rootNavMotion) { + settingsComposable(SettingsRoutes.Notifications) { NotificationsScreen(onBack = { navController.navigateUp() }) } - settingsSlideComposable(SettingsRoutes.Devices, rootNavMotion) { + settingsComposable(SettingsRoutes.Devices) { DevicesScreen(onBack = { navController.navigateUp() }) } - settingsSlideComposable(SettingsRoutes.SecurityPasswordFlow, rootNavMotion) { + settingsComposable(SettingsRoutes.SecurityPasswordFlow) { ChangePasswordScreen( onBack = { navController.navigateUp() }, onDone = { navController.popBackStack() }, ) } - settingsSlideComposable(SettingsRoutes.AccountDeleteFlow, rootNavMotion) { + settingsComposable(SettingsRoutes.AccountDeleteFlow) { DeleteAccountScreen( onBack = { navController.navigateUp() }, onDeleted = { @@ -620,7 +617,7 @@ fun App( ) } - settingsSlideComposable(SettingsRoutes.Account, rootNavMotion) { + settingsComposable(SettingsRoutes.Account) { AccountScreen( onBack = { navController.navigateUp() }, onLogout = { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/register/ProfileStep.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/register/ProfileStep.kt index 6c10e90..6f4b1e2 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/register/ProfileStep.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/register/ProfileStep.kt @@ -1,5 +1,7 @@ package ru.fromchat.ui.auth.register +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -10,6 +12,7 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialShapes import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -17,11 +20,16 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res +import ru.fromchat.about_link_privacy +import ru.fromchat.about_link_terms import ru.fromchat.auth_char_count +import ru.fromchat.auth_legal_notice_and +import ru.fromchat.auth_legal_notice_prefix import ru.fromchat.auth_step_profile_body import ru.fromchat.auth_step_profile_title import ru.fromchat.auth_username_taken @@ -30,6 +38,8 @@ import ru.fromchat.display_name_error import ru.fromchat.error_unexpected import ru.fromchat.profile_headline_bio import ru.fromchat.register_button +import ru.fromchat.legal.DocumentType +import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.auth.RegisterResult import ru.fromchat.ui.auth.register import ru.fromchat.ui.components.ActionButton @@ -60,6 +70,7 @@ internal fun profileStepPage( onSnackbar: (String) -> Unit, ): ExpressiveStepPage { val scope = rememberCoroutineScope() + val navController = LocalNavController.current val fieldColors = expressiveStepFieldColors() val colorScheme = MaterialTheme.colorScheme @@ -123,6 +134,36 @@ internal fun profileStepPage( ) }, button = { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = SettingsStepHorizontalPadding, vertical = 8.dp), + horizontalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(Res.string.auth_legal_notice_prefix), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + TextButton(onClick = { navController.navigate(DocumentType.route(DocumentType.Terms)) }) { + Text( + text = stringResource(Res.string.about_link_terms), + style = MaterialTheme.typography.bodySmall, + ) + } + Text( + text = stringResource(Res.string.auth_legal_notice_and), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + TextButton(onClick = { navController.navigate(DocumentType.route(DocumentType.Privacy)) }) { + Text( + text = stringResource(Res.string.about_link_privacy), + style = MaterialTheme.typography.bodySmall, + ) + } + } ActionButton( onClick = { if (busy) return@ActionButton diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt index 81db430..eb14ec8 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt @@ -80,6 +80,8 @@ import ru.fromchat.api.local.download.resolveSavableMessageImage import ru.fromchat.api.local.messages.generateClientMessageId import ru.fromchat.api.local.messages.nowMessageTimestampIso import ru.fromchat.api.local.messages.optimisticMessageIdForClientMessageId +import ru.fromchat.api.local.send.OutboundSendNotifier +import ru.fromchat.api.local.send.OutboundSendProgress import ru.fromchat.api.local.send.OutgoingMessageCoordinator import ru.fromchat.api.local.send.prepareOutboundFileForSend import ru.fromchat.api.local.send.prepareOutboundImageForSend @@ -94,7 +96,6 @@ import ru.fromchat.chat_group_label import ru.fromchat.status_connecting import ru.fromchat.status_updating import ru.fromchat.ui.LocalNavController -import ru.fromchat.ui.chat.panels.publicchat.publicChatProfileSharedAvatarKey import ru.fromchat.ui.chat.utils.AttachmentDownloadVisibility import ru.fromchat.ui.chat.utils.getImageAspectRatio import ru.fromchat.ui.chat.utils.getImageDimensions @@ -171,13 +172,6 @@ fun ChatScreen( Logger.d("ChatScreen", "currentTypingUsers updated (from panelState): ${currentTypingUsers.map { it.username }}") } - var profileSharedSourceMessageId by remember(scrollToMessageId) { - mutableStateOf(scrollToMessageId?.takeIf { it > 0 }) - } - LaunchedEffect(scrollToMessageId) { - scrollToMessageId?.takeIf { it > 0 }?.let { profileSharedSourceMessageId = it } - } - val subtitleKey = when { !online -> "connecting" connectionStatus == ConnectionStatus.UPDATING -> "updating" @@ -436,6 +430,21 @@ fun ChatScreen( } } + LaunchedEffect(panel) { + OutboundSendNotifier.progressFlow.collect { progress -> + when (progress) { + is OutboundSendProgress.Pending -> + panel.updateMessageByClientMessageId(progress.clientMessageId) { + it.copy(uploadError = null) + } + is OutboundSendProgress.Failed -> + panel.updateMessageByClientMessageId(progress.clientMessageId) { + it.copy(uploadError = progress.error) + } + } + } + } + LaunchedEffect(panel) { if (panel.getRecipientId() != null) { AttachmentUploadNotifier.progressFlow.collect { progress -> @@ -635,7 +644,7 @@ fun ChatScreen( } } ) { - if (panelState.isLoading) { + if (panelState.isLoading && panelState.messages.isEmpty()) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center @@ -698,12 +707,8 @@ fun ChatScreen( message.user_id > 0 ) { { - profileSharedSourceMessageId = message.id ProfileCache.mergePreviewFromPublicMessage(message) - navController.navigate( - "profile/${message.user_id}" + - "?useSharedElement=true&sourceMessageId=${message.id}" - ) + navController.navigate("profile/${message.user_id}") } } else { null @@ -716,8 +721,6 @@ fun ChatScreen( isImageClosing = isImageClosing, showUsername = panel.showUsernamesInMessages, currentUserId = currentUserId, - sharedTransitionScope = sharedTransitionScope, - animatedVisibilityScope = animatedVisibilityScope, onCancelOutboundAttachment = { msg -> scope.launch { panel.cancelQueuedMessage(msg) } }, @@ -731,22 +734,6 @@ fun ChatScreen( OutgoingMessageCoordinator.retryDmAttachmentUpload(cid) } }, - sharedAvatarNavKey = - if ( - panel.supportsNavigateToSenderProfile && - sharedTransitionScope != null && - animatedVisibilityScope != null && - message.user_id != currentUserId && - message.user_id > 0 && - profileSharedSourceMessageId == message.id - ) { - publicChatProfileSharedAvatarKey( - message.user_id, - message.id, - ) - } else { - null - } ) } @@ -837,6 +824,12 @@ fun ChatScreen( onCancelSend = { message -> scope.launch { panel.cancelQueuedMessage(message) } }, + onRetrySend = { message -> + val cid = message.client_message_id?.trim().orEmpty() + if (cid.isNotEmpty()) { + OutgoingMessageCoordinator.retryOutboundMessage(cid, panel.outboxConversationId()) + } + }, ) } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatTopBar.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatTopBar.kt index 0850bfa..6379dc0 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatTopBar.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatTopBar.kt @@ -92,7 +92,7 @@ fun ChatTopBarInner( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, modifier = modifier.conditional( - profileUserId != null && onTitleClick != null + onTitleClick != null ) { Modifier.scaleOnPress( scale = 0.96f, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ImageFullscreenPreview.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ImageFullscreenPreview.kt index 7714fa5..84e3484 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ImageFullscreenPreview.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ImageFullscreenPreview.kt @@ -148,16 +148,20 @@ fun ImageFullscreenPreview( fileIndex = fileIndex, confirmed = message.id > 0, ) - val thumbLayoutAspect = thumbnailBounds?.takeIf { it.width > 0f && it.height > 0f } + var animationBounds by remember(decryptCacheKey) { mutableStateOf(null) } + if (animationBounds == null && thumbnailBounds != null) { + animationBounds = thumbnailBounds + } + val thumbLayoutAspect = animationBounds?.takeIf { it.width > 0f && it.height > 0f } ?.let { bounds -> bounds.width / bounds.height } var menusVisible by remember { mutableStateOf(true) } var dismissRequested by remember { mutableStateOf(false) } val backgroundAlpha = remember { Animatable(1f) } - var hasPlayedOpenAnimation by remember(thumbnailBounds) { mutableStateOf(thumbnailBounds == null) } + var hasPlayedOpenAnimation by remember(decryptCacheKey) { mutableStateOf(false) } var isOpenAnimationPlaying by remember { mutableStateOf(false) } var dismissProgress by remember { mutableStateOf(0f) } - val isInitialOpenState = thumbnailBounds != null && !hasPlayedOpenAnimation + val isInitialOpenState = animationBounds != null && !hasPlayedOpenAnimation val isTransitioning = isOpenAnimationPlaying || dismissRequested val effectiveMenusVisible = if (isInitialOpenState) false else menusVisible && dismissProgress < 0.01f var effectiveBgAlpha by remember { mutableStateOf(1f) } @@ -168,8 +172,10 @@ fun ImageFullscreenPreview( val systemBarsVisibility = LocalSystemBarsVisibility.current - LaunchedEffect(menusVisible) { - systemBarsVisibility?.invoke(menusVisible) + // Keep status bars visible while dismissing so ChatScreen underneath keeps + // correct status-bar insets until the overlay is removed. + LaunchedEffect(menusVisible, dismissRequested) { + systemBarsVisibility?.invoke(menusVisible || dismissRequested) } DisposableEffect(Unit) { onDispose { @@ -237,7 +243,9 @@ fun ImageFullscreenPreview( .background(Color.Black) ) LaunchedEffect(dismissRequested) { - if (dismissRequested) onDismiss() + if (!dismissRequested) return@LaunchedEffect + onClosingChange(true) + onDismiss() } androidx.compose.material3.CircularProgressIndicator( modifier = Modifier.size(48.dp), @@ -255,16 +263,17 @@ fun ImageFullscreenPreview( contentHeightAtScale1 } val initial = remember( - thumbnailBounds, containerWidth, containerHeight, layoutContentHeight, + animationBounds, containerWidth, containerHeight, layoutContentHeight, ) { - if (thumbnailBounds != null && layoutAspect != null) { + val bounds = animationBounds + if (bounds != null && layoutAspect != null) { val fullTop = (containerHeight - layoutContentHeight) / 2f val fullCenter = Offset( x = containerWidth / 2f, y = fullTop + layoutContentHeight / 2f, ) - val thumbCenter = thumbnailBounds.center - val thumbWidth = thumbnailBounds.width + val thumbCenter = bounds.center + val thumbWidth = bounds.width val s = thumbWidth / containerWidth val o = thumbCenter - fullCenter InitialTransform(s, o.x, o.y, 12f, 0f) @@ -288,8 +297,9 @@ fun ImageFullscreenPreview( var lastStableOffsetX by remember { mutableStateOf(initial.offsetX) } var lastStableOffsetY by remember { mutableStateOf(initial.offsetY) } - LaunchedEffect(thumbnailBounds) { - if (thumbnailBounds != null && !hasPlayedOpenAnimation) { + LaunchedEffect(animationBounds) { + val bounds = animationBounds ?: return@LaunchedEffect + if (!hasPlayedOpenAnimation) { hasPlayedOpenAnimation = true isOpenAnimationPlaying = true @@ -298,8 +308,8 @@ fun ImageFullscreenPreview( x = containerWidth / 2f, y = fullTop + layoutContentHeight / 2f, ) - val thumbCenter = thumbnailBounds.center - val thumbWidth = thumbnailBounds.width + val thumbCenter = bounds.center + val thumbWidth = bounds.width val startScale = thumbWidth / containerWidth val startOffset = thumbCenter - fullCenter @@ -342,12 +352,14 @@ fun ImageFullscreenPreview( } LaunchedEffect(dismissRequested) { if (!dismissRequested) return@LaunchedEffect + onClosingChange(true) val hasTransform = lastStableScale != 1f || lastStableOffsetX != 0f || lastStableOffsetY != 0f - if (thumbnailBounds != null) { + val bounds = animationBounds + if (bounds != null) { menusVisible = false val wasOpenAnimating = isOpenAnimationPlaying isOpenAnimationPlaying = false @@ -357,8 +369,8 @@ fun ImageFullscreenPreview( x = containerWidth / 2f, y = fullTop + layoutContentHeight / 2f, ) - val thumbCenter = thumbnailBounds.center - val thumbWidth = thumbnailBounds.width + val thumbCenter = bounds.center + val thumbWidth = bounds.width val targetScale = thumbWidth / containerWidth val targetOffset = thumbCenter - fullCenter @@ -395,9 +407,6 @@ fun ImageFullscreenPreview( } } - onClosingChange(true) - delay(50) - onClosingChange(false) onDismiss() } LaunchedEffect(scale, offset, isTransformInProgress) { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt index 60c1f31..cfabc9f 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt @@ -23,6 +23,7 @@ import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.ContentCopy import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Edit +import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material.icons.rounded.SaveAlt import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -55,6 +56,7 @@ import ru.fromchat.action_copy import ru.fromchat.action_delete import ru.fromchat.action_edit import ru.fromchat.action_reply +import ru.fromchat.action_retry_send import ru.fromchat.action_save import ru.fromchat.api.local.download.resolveSavableMessageFile import ru.fromchat.api.local.download.resolveSavableMessageImage @@ -76,9 +78,11 @@ internal fun messageContextMenuFingerprint( isReadOnly: Boolean, ): String { val isQueued = message.isQueuedOutbound() && isAuthor + val sendFailed = isQueued && !message.uploadError.isNullOrBlank() val corrupted = message.isContentCorrupted return buildString { append("q=").append(isQueued) + append("|failed=").append(sendFailed) append("|copy=").append(!corrupted) append("|save=").append(resolveSavableMessageImage(message) != null) if (!isQueued && !isReadOnly) { @@ -100,6 +104,7 @@ fun MessageContextMenu( onCopy: (Message) -> Unit, onSave: (Message) -> Unit, onCancelSend: (Message) -> Unit, + onRetrySend: (Message) -> Unit = {}, isReadOnly: Boolean = false, screenWidthPx: Int, screenHeightPx: Int, @@ -162,6 +167,7 @@ fun MessageContextMenu( onCopy = {}, onSave = {}, onCancelSend = {}, + onRetrySend = {}, modifier = modifier.graphicsLayer(alpha = 0f), animated = false, withShadow = false, @@ -260,6 +266,10 @@ fun MessageContextMenu( onCancelSend(it) onDismiss() }, + onRetrySend = { + onRetrySend(it) + onDismiss() + }, modifier = modifier, animated = true, scale = scale, @@ -283,6 +293,7 @@ private fun ContextMenuContent( onCopy: (Message) -> Unit, onSave: (Message) -> Unit, onCancelSend: (Message) -> Unit, + onRetrySend: (Message) -> Unit, isReadOnly: Boolean = false, modifier: Modifier, animated: Boolean, @@ -330,7 +341,9 @@ private fun ContextMenuContent( val labelCopy = stringResource(Res.string.action_copy) val labelSave = stringResource(Res.string.action_save) val labelCancelSend = stringResource(Res.string.action_cancel_send) + val labelRetrySend = stringResource(Res.string.action_retry_send) val isQueued = message.isQueuedOutbound() && isAuthor + val sendFailed = isQueued && !message.uploadError.isNullOrBlank() val savableImage = resolveSavableMessageImage(message) val savableFile = resolveSavableMessageFile(message) val canSave = savableImage != null || savableFile != null @@ -357,7 +370,19 @@ private fun ContextMenuContent( onClick = { onSave(message) } ) } - if (isQueued) { + if (sendFailed) { + ContextMenuItem( + icon = Icons.Rounded.Refresh, + text = labelRetrySend, + onClick = { onRetrySend(message) }, + ) + ContextMenuItem( + icon = Icons.Rounded.Close, + text = labelCancelSend, + onClick = { onCancelSend(message) }, + isError = true, + ) + } else if (isQueued) { ContextMenuItem( icon = Icons.Rounded.Close, text = labelCancelSend, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt index 20d23bb..9c74749 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt @@ -1,7 +1,5 @@ package ru.fromchat.ui.chat -import androidx.compose.animation.AnimatedVisibilityScope -import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring @@ -26,7 +24,10 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ErrorOutline import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -47,6 +48,8 @@ import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.pr0gramm3r101.utils.conditional @@ -58,6 +61,7 @@ import ru.fromchat.api.local.messages.isQueuedOutbound import ru.fromchat.api.schema.messages.Message import ru.fromchat.message_corrupted import ru.fromchat.message_edited_suffix +import ru.fromchat.message_send_failed import ru.fromchat.ui.chat.components.getMessageGradient import ru.fromchat.ui.chat.components.getReplyMessageGradient import ru.fromchat.ui.chat.utils.imageAspectRatioForMessage @@ -103,9 +107,6 @@ fun MessageItem( isImageClosing: Boolean = false, isContextMenuOpen: Boolean = false, isContextMenuForThisMessage: Boolean = false, - sharedTransitionScope: SharedTransitionScope? = null, - animatedVisibilityScope: AnimatedVisibilityScope? = null, - sharedAvatarNavKey: String? = null, onCancelOutboundAttachment: ((Message) -> Unit)? = null, onRetryOutboundAttachment: ((Message) -> Unit)? = null, ) { @@ -118,6 +119,7 @@ fun MessageItem( } val corruptedBody = stringResource(Res.string.message_corrupted) val editedSuffix = stringResource(Res.string.message_edited_suffix) + val sendFailedLabel = stringResource(Res.string.message_send_failed) val displayUsername = messageDisplayUsername(message, currentUserId) val replyRef = message.reply_to @@ -178,29 +180,11 @@ fun MessageItem( ) } ) { - val navSharedKey = sharedAvatarNavKey - val navStScope = sharedTransitionScope - val navVisScope = animatedVisibilityScope - if (navSharedKey != null && navStScope != null && navVisScope != null) { - with(navStScope) { - Avatar( - profilePictureUrl = message.profile_picture, - displayName = message.username, - modifier = Modifier - .sharedElement( - rememberSharedContentState(key = navSharedKey), - animatedVisibilityScope = navVisScope - ) - .size(32.dp) - ) - } - } else { - Avatar( - profilePictureUrl = message.profile_picture, - displayName = message.username, - modifier = Modifier.size(32.dp) - ) - } + Avatar( + profilePictureUrl = message.profile_picture, + displayName = message.username, + modifier = Modifier.size(32.dp) + ) } Spacer(modifier = Modifier.width(8.dp)) @@ -608,13 +592,14 @@ fun MessageItem( // Timestamp, sending indicator, and edited indicator val isPendingOutbound = message.id < 0 && message.files.isNullOrEmpty() + val sendFailed = isPendingOutbound && !message.uploadError.isNullOrBlank() Row( modifier = Modifier .padding(start = 12.dp, end = 12.dp, top = 4.dp, bottom = 8.dp), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically ) { - if (isPendingOutbound) { + if (isPendingOutbound && !sendFailed) { CircularProgressIndicator( modifier = Modifier.size(12.dp), strokeWidth = 1.5.dp, @@ -625,6 +610,20 @@ fun MessageItem( } ) Spacer(modifier = Modifier.width(6.dp)) + } else if (sendFailed) { + Icon( + imageVector = Icons.Rounded.ErrorOutline, + contentDescription = sendFailedLabel, + modifier = Modifier + .size(14.dp) + .semantics { contentDescription = sendFailedLabel }, + tint = if (isAuthor) { + Color.White.copy(alpha = 0.85f) + } else { + MaterialTheme.colorScheme.error + }, + ) + Spacer(modifier = Modifier.width(6.dp)) } Text( text = formattedTime, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmNav.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmNav.kt index 758b0ed..a46f3d8 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmNav.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmNav.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.remember import ru.fromchat.api.local.cache.CacheContext import androidx.compose.ui.Modifier import androidx.navigation.NavController +import ru.fromchat.api.local.db.store.ProfileCache import ru.fromchat.utils.haptic.HapticFeedbackEvent import ru.fromchat.ui.profile.ProfileScreen import ru.fromchat.utils.haptic.rememberHapticFeedback @@ -75,9 +76,17 @@ fun DmProfileRoute( val stateSnapshot = panel.getState() val initialDisplayName = stateSnapshot.titleAvatar?.displayName?.takeIf { it.isNotBlank() } ?: stateSnapshot.title.takeIf { it.isNotBlank() } + val initialProfilePictureUrl = stateSnapshot.titleAvatar?.profilePictureUrl + + ProfileCache.mergePreview( + id = otherUserId, + displayName = initialDisplayName, + profilePicture = initialProfilePictureUrl, + ) ProfileScreen( userId = otherUserId, + showBackButton = true, onBack = { haptic(HapticFeedbackEvent.ProfileClosed) navController.popBackStack() @@ -90,6 +99,7 @@ fun DmProfileRoute( sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, sharedAvatarKey = sharedAvatarKey, - initialDisplayName = initialDisplayName + initialDisplayName = initialDisplayName, + initialProfilePictureUrl = initialProfilePictureUrl, ) } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt index f13e191..65eb057 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmPanel.kt @@ -61,6 +61,7 @@ class DmPanel( private var otherDisplayName: String = "" private var otherProfilePicture: String? = null private val dmEnvelopeMutex = Mutex() + private var messagesLoaded = false private data class DmDecryptOutcome(val plaintext: String, val isCorrupted: Boolean) @@ -168,16 +169,28 @@ class DmPanel( } override suspend fun loadMessages() { - setLoading(true) + if (messagesLoaded) return + messagesLoaded = true + + runCatching { MessageRepository.ensureDmConversationRow(otherUserId) } + + // Read cache first. Do not setLoading(true) before this: that forced a 1-frame spinner + // when the chat screen re-entered composition (e.g. pop back from profile). + val cached = runCatching { MessageCacheStore.loadDmMessages(otherUserId) }.getOrDefault(emptyList()) + if (cached.isNotEmpty()) { + batchStateUpdates { + clearMessages() + addMessages(cached) + setLoading(false) + } + } else { + setLoading(true) + } + try { OutgoingMessageCoordinator.pruneStaleAttachmentOutboxForInstance( CacheContext.requireActiveInstanceId(), ) - val cached = runCatching { MessageCacheStore.loadDmMessages(otherUserId) }.getOrDefault(emptyList()) - if (cached.isNotEmpty()) { - clearMessages() - addMessages(cached) - } val historyResult = runCatching { ApiClient.getDmHistory(otherUserId) } if (historyResult.isSuccess) { @@ -512,9 +525,11 @@ class DmPanel( deleteMessageImmediately(messageId) DownloadedFileRegistry.invalidateForMessage(messageId) DecryptedImageCache.invalidateForMessage(messageId) + DecryptedFileCache.invalidateForMessage(messageId) clientId?.trim()?.takeIf { it.isNotEmpty() }?.let { DownloadedFileRegistry.invalidateForClientMessage(it) DecryptedImageCache.invalidateForClientMessage(it) + DecryptedFileCache.invalidateForClientMessage(it) } runCatching { ApiClient.deleteDm(messageId, otherUserId) } withContext(Dispatchers.Default) { @@ -535,9 +550,11 @@ class DmPanel( val clientId = _state.messages.find { it.id == data.id }?.client_message_id DownloadedFileRegistry.invalidateForMessage(data.id) DecryptedImageCache.invalidateForMessage(data.id) + DecryptedFileCache.invalidateForMessage(data.id) clientId?.trim()?.takeIf { it.isNotEmpty() }?.let { DownloadedFileRegistry.invalidateForClientMessage(it) DecryptedImageCache.invalidateForClientMessage(it) + DecryptedFileCache.invalidateForClientMessage(it) } deleteMessageImmediately(data.id) MessageRepository.deleteDmMessageById(otherUserId, data.id) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmScreen.kt index 6f5fead..fa96c1a 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/dm/DmScreen.kt @@ -36,8 +36,13 @@ fun DmScreen( val activeInstanceId by CacheContext.activeInstanceId.collectAsState() val otherUserId = panel.getState().profileUserId - LaunchedEffect(panel, otherUserId) { - if (otherUserId != null && otherUserId > 0) { + LaunchedEffect(panel, activeInstanceId, otherUserId) { + if (activeInstanceId.isBlank()) return@LaunchedEffect + val peerId = otherUserId ?: return@LaunchedEffect + if (peerId <= 0) return@LaunchedEffect + // Panel is retained in [DmPanelCache]; only cold-load when the list is still empty + // (e.g. returning from profile must not call loadMessages and flash the chat spinner). + if (panel.getState().messages.isEmpty()) { panel.loadMessages() } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatNav.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatNav.kt new file mode 100644 index 0000000..3f0e360 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatNav.kt @@ -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, + ) +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatPanel.kt index d452395..4aaeee7 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatPanel.kt @@ -11,10 +11,12 @@ import ru.fromchat.Logger import ru.fromchat.api.ApiClient import ru.fromchat.api.local.cache.DecryptedImageCache import ru.fromchat.api.local.db.store.MessageCacheStore +import ru.fromchat.api.local.db.store.PublicChatProfileCache import ru.fromchat.api.local.db.store.MessageRepository import ru.fromchat.api.local.messages.GENERAL_PUBLIC_GROUP_ID import ru.fromchat.api.local.messages.conversationIdForGroup import ru.fromchat.api.local.send.OutgoingMessageCoordinator +import ru.fromchat.api.schema.chats.publicchat.PublicChatProfile import ru.fromchat.api.schema.messages.Message import ru.fromchat.api.schema.messages.publicchat.SendMessageResponse import ru.fromchat.api.schema.websocket.WebSocketMessage @@ -30,8 +32,6 @@ import ru.fromchat.ui.chat.utils.TypingHandler class PublicChatPanel( /** Stable cache / panel id (not localized; hardcoded in [ru.fromchat.ui.chat.utils.PublicChatPanelCache]). */ panelKey: String, - /** Shown in the app bar and avatars. */ - displayTitle: String, currentUserId: Int?, scope: CoroutineScope ) : ChatPanel( @@ -70,8 +70,8 @@ class PublicChatPanel( init { updateState { it.copy( - title = displayTitle, - titleAvatar = AvatarInfo(displayName = displayTitle, profilePictureUrl = null), + title = "", + titleAvatar = null, publicGroupMetaLoading = true, publicGroupMemberCount = null ) @@ -83,27 +83,30 @@ class PublicChatPanel( } } scope.launch(Dispatchers.Default) { - runCatching { ApiClient.getRegisteredUserCount() } - .onSuccess { n -> - updateState { s -> - s.copy(publicGroupMemberCount = n, publicGroupMetaLoading = false) - } + val cached = PublicChatProfileCache.profile + if (cached != null) { + applyPublicChatProfile(cached) + } + runCatching { ApiClient.getPublicChatProfile() } + .onSuccess { profile -> + PublicChatProfileCache.put(profile) + applyPublicChatProfile(profile) } .onFailure { - updateState { s -> s.copy(publicGroupMetaLoading = false) } + if (cached == null) { + updateState { s -> s.copy(publicGroupMetaLoading = false) } + } } } } - /** When locale changes, keep the same panel but refresh the visible title. */ - fun applyDisplayTitle(title: String) { + private fun applyPublicChatProfile(profile: PublicChatProfile) { updateState { s -> s.copy( - title = title, - titleAvatar = AvatarInfo( - displayName = title, - profilePictureUrl = s.titleAvatar?.profilePictureUrl - ) + title = profile.title, + titleAvatar = AvatarInfo(displayName = profile.title, profilePictureUrl = null), + publicGroupMemberCount = profile.member_count, + publicGroupMetaLoading = false, ) } } @@ -353,6 +356,9 @@ class PublicChatPanel( val obj = data.jsonObject val c = obj["count"]?.jsonPrimitive?.content?.toIntOrNull() if (c != null) { + PublicChatProfileCache.profile?.let { cached -> + PublicChatProfileCache.put(cached.copy(member_count = c)) + } updateState { s -> s.copy(publicGroupMemberCount = c, publicGroupMetaLoading = false) } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatScreen.kt index 9089541..018e31e 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/panels/publicchat/PublicChatScreen.kt @@ -1,19 +1,18 @@ package ru.fromchat.ui.chat.panels.publicchat -import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.SharedTransitionScope +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import org.jetbrains.compose.resources.stringResource -import ru.fromchat.Res +import androidx.compose.ui.Modifier import ru.fromchat.api.ApiClient import ru.fromchat.api.local.cache.CacheContext import ru.fromchat.api.local.db.store.MessageRepository -import ru.fromchat.public_chat import ru.fromchat.ui.chat.ChatScreen import ru.fromchat.ui.chat.utils.PublicChatPanelCache @@ -21,14 +20,15 @@ import ru.fromchat.ui.chat.utils.PublicChatPanelCache fun PublicChatScreen( scrollToMessageId: Int? = null, sharedTransitionScope: SharedTransitionScope? = null, - animatedContentScope: AnimatedContentScope? = null + animatedVisibilityScope: AnimatedVisibilityScope? = null, + sharedAvatarKey: Any? = null, + onTitleClick: (() -> Unit)? = null, + modifier: Modifier = Modifier, ) { val currentUserId = ApiClient.user?.id - val publicChatTitle = stringResource(Res.string.public_chat) - // Reuse one panel for the session (like DM [DmPanelCache]); avoids full reload on every visit. - val panel = remember(currentUserId, publicChatTitle) { - PublicChatPanelCache.getOrCreateGeneralChat(publicChatTitle, currentUserId) + val panel = remember(currentUserId) { + PublicChatPanelCache.getOrCreateGeneralChat(currentUserId) } val activeInstanceId by CacheContext.activeInstanceId.collectAsState() @@ -47,7 +47,6 @@ fun PublicChatScreen( } } - // Track visibility for notifications DisposableEffect(Unit) { isPublicChatVisible = true onDispose { @@ -55,14 +54,15 @@ fun PublicChatScreen( } } - // Render with ChatScreen ChatScreen( panel = panel, currentUserId = currentUserId, scrollToMessageId = scrollToMessageId, sharedTransitionScope = sharedTransitionScope, - animatedVisibilityScope = animatedContentScope, - sharedAvatarKey = null, + animatedVisibilityScope = animatedVisibilityScope, + sharedAvatarKey = sharedAvatarKey, + onTitleClick = onTitleClick, + modifier = modifier.fillMaxSize(), ) } @@ -74,4 +74,4 @@ fun PublicChatScreen( fun publicChatProfileSharedAvatarKey(userId: Int, sourceMessageId: Int): String = "public-chat-profile-avatar-$userId-$sourceMessageId" -var isPublicChatVisible = false \ No newline at end of file +var isPublicChatVisible = false diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/PublicChatPanelCache.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/PublicChatPanelCache.kt index 26cc8f1..ca61495 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/PublicChatPanelCache.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/utils/PublicChatPanelCache.kt @@ -24,7 +24,6 @@ object PublicChatPanelCache { private var panel: PublicChatPanel? = null private var cachedPanelKey: String? = null - private var cachedDisplayTitle: String? = null private var cachedUserId: Int? = null private var cachedInstanceId: String = "" @@ -42,10 +41,7 @@ object PublicChatPanelCache { } } - /** - * @param displayTitle Localized title from e.g. [ru.fromchat.Res.string.public_chat]. - */ - fun getOrCreateGeneralChat(displayTitle: String, currentUserId: Int?): PublicChatPanel { + fun getOrCreateGeneralChat(currentUserId: Int?): PublicChatPanel { ensureScope() val instanceId = CacheContext.activeInstanceId.value.trim() if (instanceId.isNotEmpty() && cachedInstanceId.isNotEmpty() && cachedInstanceId != instanceId) { @@ -58,21 +54,15 @@ object PublicChatPanelCache { cachedUserId == currentUserId && (instanceId.isEmpty() || cachedInstanceId == instanceId) ) { - if (cachedDisplayTitle != displayTitle) { - cachedDisplayTitle = displayTitle - panel!!.applyDisplayTitle(displayTitle) - } return panel!! } panel?.destroy() panel = PublicChatPanel( panelKey = GeneralPublicPanelKey, - displayTitle = displayTitle, currentUserId = currentUserId, scope = panelScope ) cachedPanelKey = GeneralPublicPanelKey - cachedDisplayTitle = displayTitle cachedUserId = currentUserId return panel!! } @@ -81,8 +71,6 @@ object PublicChatPanelCache { panel?.destroy() panel = null cachedPanelKey = null - cachedDisplayTitle = null cachedUserId = null - supervisorJob.cancel() } -} \ No newline at end of file +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/ExpressiveStepFlow.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/ExpressiveStepFlow.kt index 8218cc1..0f07d06 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/ExpressiveStepFlow.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/ExpressiveStepFlow.kt @@ -1,3 +1,7 @@ +/** + * Мне лень чистить этот файл, я устал. + * TODO Почищу когда-нибудь потом. + */ package ru.fromchat.ui.components import androidx.compose.animation.ExperimentalAnimationApi @@ -344,7 +348,6 @@ fun ExpressiveStepFlowScaffold( val scope = flowState.scope val pageCount = pages.size.coerceAtLeast(1) val heroSpecs = remember(pages) { pages.map { it.hero } } - val isHazeLazyMode = hazeScaffold val pageOffset by derivedStateOf { pagerState.currentPageOffsetFraction } val predictiveThreshold = 0.15f @@ -352,6 +355,7 @@ fun ExpressiveStepFlowScaffold( enabled = pagerState.currentPage > 0, onProgress = { p -> val clamped = p.coerceIn(0f, 1f) + if (clamped <= 0f) { flowState.resetPredictiveState() } else { @@ -368,6 +372,7 @@ fun ExpressiveStepFlowScaffold( val fromPageSnapshot = flowState.predictiveFromPage val toPageSnapshot = flowState.predictiveToPage val lastProgress = flowState.predictiveProgress.coerceIn(0f, 1f) + if (lastProgress < predictiveThreshold || fromPageSnapshot == null || toPageSnapshot == null) { scope.launch { finishPredictiveMorph( @@ -426,6 +431,7 @@ fun ExpressiveStepFlowScaffold( val fromIndex: Int val toIndex: Int val morphProgress: Float + if (flowState.predictiveFromPage != null && flowState.predictiveToPage != null && flowState.predictiveProgress > 0f) { fromIndex = flowState.predictiveFromPage!! toIndex = flowState.predictiveToPage!! @@ -443,6 +449,7 @@ fun ExpressiveStepFlowScaffold( toIndex = fromIndex morphProgress = 0f } + val effectiveMorphProgress = morphProgress.coerceIn(0f, 1f) val currentPage = pagerState.currentPage val morphing = fromIndex != toIndex @@ -592,65 +599,139 @@ fun ExpressiveStepFlowScaffold( LocalExpressiveStepFocusEnabled provides isPageTransitionSettled, LocalExpressiveStepAutoFocusPrimary provides autoFocusPrimaryField, ) { - if (isHazeLazyMode) { - val hazeState = rememberHazeState() - val listState = rememberLazyListState() - val imeScrollState = rememberLazyListImeScrollState() - var listViewportBounds by remember { mutableStateOf(null) } + if (hazeScaffold) { + val hazeState = rememberHazeState() + val listState = rememberLazyListState() + val imeScrollState = rememberLazyListImeScrollState() + var listViewportBounds by remember { mutableStateOf(null) } - Box( - Modifier - .fillMaxSize() - .onGloballyPositioned { snackbarOverlayBounds = it.boundsInWindow() }, - ) { - Scaffold( - modifier = Modifier.fillMaxSize(), - contentWindowInsets = WindowInsets.navigationBars, - containerColor = Color.Transparent, - contentColor = scheme.onSurface, - topBar = { HazeTopBar(hazeState = hazeState) }, - bottomBar = { - HazeBottomBar(hazeState = hazeState) { - Box( - Modifier.trackExpressiveStepSnackbarAnchor( - anchors = snackbarAnchors, - role = ExpressiveStepSnackbarAnchorRole.PrimaryCta, - ), + Box( + Modifier + .fillMaxSize() + .onGloballyPositioned { snackbarOverlayBounds = it.boundsInWindow() }, + ) { + Scaffold( + modifier = Modifier.fillMaxSize(), + contentWindowInsets = WindowInsets.navigationBars, + containerColor = Color.Transparent, + contentColor = scheme.onSurface, + topBar = { HazeTopBar(hazeState = hazeState) }, + bottomBar = { + HazeBottomBar(hazeState = hazeState) { + Box( + Modifier.trackExpressiveStepSnackbarAnchor( + anchors = snackbarAnchors, + role = ExpressiveStepSnackbarAnchorRole.PrimaryCta, + ), + ) { + ExpressiveStepBottomBar() + } + } + }, + ) { innerPadding -> + LazyListImeScrollEffect( + listState = listState, + scrollState = imeScrollState, + viewportBoundsInWindow = listViewportBounds, + contentPaddingTop = innerPadding.calculateTopPadding(), + contentPaddingBottom = innerPadding.calculateBottomPadding(), + predictiveBackProgress = { flowState.predictiveProgress }, + ) + + DisabledBringIntoViewSpec { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .background(scheme.background) + .hazeSource(hazeState) + .onGloballyPositioned { + listViewportBounds = it.boundsInWindow() + listViewportBoundsForSnackbar = it.boundsInWindow() + }, + contentPadding = innerPadding, + verticalArrangement = remember { LastAnchoredBottomArrangement(space = 4.dp) }, ) { - ExpressiveStepBottomBar() + item { Spacer(Modifier.height(8.dp)) } + + item { + Column(modifier = Modifier.fillMaxWidth()) { + ExpressiveStepHeroSection() + Spacer(Modifier.height(ExpressiveStepHeroTitleSpacing)) + HorizontalPager( + state = pagerState, + userScrollEnabled = false, + beyondViewportPageCount = 1, + pageSpacing = 0.dp, + verticalAlignment = Alignment.Top, + modifier = Modifier.fillMaxWidth(), + ) { page -> + Column(modifier = Modifier.pagerPageFullWidth()) { + pages[page].content(imeScrollState) + } + } + } + } + + val currentListFooter = pages.getOrNull(currentPage)?.listFooter + if (currentListFooter != null) { + item(key = "expressive_step_footer_$currentPage") { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp, bottom = 4.dp) + .trackExpressiveStepSnackbarAnchor( + anchors = snackbarAnchors, + role = ExpressiveStepSnackbarAnchorRole.SecondaryCta, + ), + ) { + currentListFooter() + } + } + } else { + // Absorbs [LastAnchoredBottomArrangement] slack so hero/content stays at the top. + item(key = "expressive_step_bottom_anchor") { + Spacer(Modifier.height(1.dp)) + } + } } } - }, - ) { innerPadding -> - LazyListImeScrollEffect( - listState = listState, - scrollState = imeScrollState, - viewportBoundsInWindow = listViewportBounds, - contentPaddingTop = innerPadding.calculateTopPadding(), - contentPaddingBottom = innerPadding.calculateBottomPadding(), - predictiveBackProgress = { flowState.predictiveProgress }, - ) + } - DisabledBringIntoViewSpec { - LazyColumn( - state = listState, - modifier = Modifier - .fillMaxSize() - .background(scheme.background) - .hazeSource(hazeState) - .onGloballyPositioned { - listViewportBounds = it.boundsInWindow() - listViewportBoundsForSnackbar = it.boundsInWindow() - }, - contentPadding = innerPadding, - verticalArrangement = remember { LastAnchoredBottomArrangement(space = 4.dp) }, - ) { - item { Spacer(Modifier.height(8.dp)) } + ExpressiveStepSnackbarHost() + } + } else { + val imeScrollState = rememberLazyListImeScrollState() - item { - Column(modifier = Modifier.fillMaxWidth()) { + Surface( + modifier = Modifier.fillMaxSize(), + color = scheme.surface, + contentColor = scheme.onSurface, + ) { + Box( + modifier = Modifier + .fillMaxSize() + .imePadding() + .onGloballyPositioned { snackbarOverlayBounds = it.boundsInWindow() }, + ) { + Column(modifier = Modifier.fillMaxSize()) { + BackButtonRow() + + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + ) { + Column( + modifier = Modifier + .align(Alignment.TopStart) + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + ) { ExpressiveStepHeroSection() Spacer(Modifier.height(ExpressiveStepHeroTitleSpacing)) + HorizontalPager( state = pagerState, userScrollEnabled = false, @@ -659,108 +740,35 @@ fun ExpressiveStepFlowScaffold( verticalAlignment = Alignment.Top, modifier = Modifier.fillMaxWidth(), ) { page -> - Column(modifier = Modifier.pagerPageFullWidth()) { + Column( + modifier = Modifier.pagerPageFullWidth(), + ) { pages[page].content(imeScrollState) } } } } - val currentListFooter = pages.getOrNull(currentPage)?.listFooter - if (currentListFooter != null) { - item(key = "expressive_step_footer_$currentPage") { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(top = 16.dp, bottom = 4.dp) - .trackExpressiveStepSnackbarAnchor( - anchors = snackbarAnchors, - role = ExpressiveStepSnackbarAnchorRole.SecondaryCta, - ), - ) { - currentListFooter() - } - } - } else { - // Absorbs [LastAnchoredBottomArrangement] slack so hero/content stays at the top. - item(key = "expressive_step_bottom_anchor") { - Spacer(Modifier.height(1.dp)) - } - } - } - } - } - - ExpressiveStepSnackbarHost() - } - } else { - val imeScrollState = rememberLazyListImeScrollState() - Surface( - modifier = Modifier.fillMaxSize(), - color = scheme.surface, - contentColor = scheme.onSurface, - ) { - Box( - modifier = Modifier - .fillMaxSize() - .imePadding() - .onGloballyPositioned { snackbarOverlayBounds = it.boundsInWindow() }, - ) { - Column(modifier = Modifier.fillMaxSize()) { - BackButtonRow() - - Box( - modifier = Modifier - .weight(1f) - .fillMaxWidth(), - ) { - Column( + Box( modifier = Modifier - .align(Alignment.TopStart) .fillMaxWidth() - .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally, + .navigationBarsPadding() + .padding(horizontal = SettingsStepHorizontalPadding) + .padding(top = 12.dp, bottom = 16.dp) + .trackExpressiveStepSnackbarAnchor( + anchors = snackbarAnchors, + role = ExpressiveStepSnackbarAnchorRole.PrimaryCta, + ), ) { - ExpressiveStepHeroSection() - Spacer(Modifier.height(ExpressiveStepHeroTitleSpacing)) - - HorizontalPager( - state = pagerState, - userScrollEnabled = false, - beyondViewportPageCount = 1, - pageSpacing = 0.dp, - verticalAlignment = Alignment.Top, - modifier = Modifier.fillMaxWidth(), - ) { page -> - Column( - modifier = Modifier.pagerPageFullWidth(), - ) { - pages[page].content(imeScrollState) - } - } + ExpressiveStepBottomBar() } } - Box( - modifier = Modifier - .fillMaxWidth() - .navigationBarsPadding() - .padding(horizontal = SettingsStepHorizontalPadding) - .padding(top = 12.dp, bottom = 16.dp) - .trackExpressiveStepSnackbarAnchor( - anchors = snackbarAnchors, - role = ExpressiveStepSnackbarAnchorRole.PrimaryCta, - ), - ) { - ExpressiveStepBottomBar() - } + ExpressiveStepSnackbarHost() } - - ExpressiveStepSnackbarHost() } } } - } } @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalAnimationApi::class) @@ -791,6 +799,7 @@ fun MorphedExpressiveHero( ) { Canvas(Modifier.fillMaxSize()) { val unit = minOf(size.width, size.height) * 0.94f + translate(left = size.width / 2f, top = size.height / 2f) { scale(scaleX = unit, scaleY = unit, pivot = Offset.Zero) { translate(left = -0.5f, top = -0.5f) { @@ -800,7 +809,7 @@ fun MorphedExpressiveHero( colors = listOf(light, deep), start = Offset.Zero, end = Offset(1f, 1f), - ), + ) ) } } @@ -820,6 +829,7 @@ fun MorphedExpressiveHero( .graphicsLayer { alpha = 1f - p }, tint = contentColor, ) + Icon( imageVector = toSpec.icon, contentDescription = null, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/HazeActionButton.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/HazeActionButton.kt index b41f9f5..6dacbc8 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/HazeActionButton.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/HazeActionButton.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import dev.chrisbanes.haze.HazeProgressive import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.HazeStyle import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.HazeMaterials @@ -24,18 +25,24 @@ import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding fun HazeBottomBar( hazeState: HazeState, modifier: Modifier = Modifier, + hazeStyle: HazeStyle? = null, content: @Composable () -> Unit, ) { + val effectModifier = if (hazeStyle != null) { + Modifier.hazeEffect(state = hazeState, style = hazeStyle) + } else { + Modifier.hazeEffect(state = hazeState, style = HazeMaterials.thin()) { + progressive = HazeProgressive.verticalGradient( + startIntensity = 0f, + endIntensity = 1f, + ) + } + } Column( modifier = Modifier .windowInsetsPadding(WindowInsets.ime) .fillMaxWidth() - .hazeEffect(state = hazeState, style = HazeMaterials.thin()) { - progressive = HazeProgressive.verticalGradient( - startIntensity = 0f, - endIntensity = 1f, - ) - } + .then(effectModifier) .then(modifier), ) { Column( @@ -57,12 +64,13 @@ fun HazeActionButton( hazeState: HazeState, modifier: Modifier = Modifier, innerModifier: Modifier = Modifier, + hazeStyle: HazeStyle? = null, enabled: Boolean = true, loading: Boolean = false, interactionSource: MutableInteractionSource? = null, content: @Composable (RowScope.() -> Unit) ) { - HazeBottomBar(hazeState = hazeState, modifier = modifier) { + HazeBottomBar(hazeState = hazeState, modifier = modifier, hazeStyle = hazeStyle) { ActionButton( onClick = onClick, modifier = innerModifier, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/SearchBar.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/SearchBar.kt index e55ca09..9a36f61 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/SearchBar.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/SearchBar.kt @@ -18,7 +18,6 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface -import ru.fromchat.ui.components.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember @@ -63,16 +62,6 @@ fun SearchBar( Surface( modifier = modifier - .conditional( - readOnly && onReadOnlyActivate != null, - `if` = { - clickable( - interactionSource = interactionSource, - indication = null, - onClick = onReadOnlyActivate!! - ) - } - ) .conditional( ( sharedTransitionScope != null && @@ -98,49 +87,78 @@ fun SearchBar( modifier = Modifier .fillMaxWidth() .height(56.dp) - .padding(horizontal = 14.dp), + .padding(horizontal = 14.dp) + .conditional( + readOnly && onReadOnlyActivate != null, + `if` = { + clickable( + interactionSource = interactionSource, + indication = null, + onClick = onReadOnlyActivate!!, + ) + }, + ), verticalAlignment = Alignment.CenterVertically ) { Box(modifier = Modifier.size(24.dp)) { leadingIcon() } - BasicTextField( - value = query, - onValueChange = onQueryChange, - singleLine = true, - textStyle = typography.copy( - color = MaterialTheme.colorScheme.onSurface - ), - enabled = !readOnly, - readOnly = readOnly, - cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), - keyboardActions = KeyboardActions( - onSearch = { onSearch() } - ), - decorationBox = { innerTextField -> - Box( - modifier = Modifier - .fillMaxHeight() - .fillMaxWidth() - .padding(start = 12.dp, end = 8.dp), - contentAlignment = Alignment.CenterStart - ) { - if (query.isEmpty()) { - Text( - text = placeholder, - style = typography.copy(color = MaterialTheme.colorScheme.onSurfaceVariant) - ) + if (readOnly) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .padding(start = 12.dp, end = 8.dp), + contentAlignment = Alignment.CenterStart, + ) { + Text( + text = query.ifEmpty { placeholder }, + style = typography.copy( + color = if (query.isEmpty()) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.onSurface + }, + ), + ) + } + } else { + BasicTextField( + value = query, + onValueChange = onQueryChange, + singleLine = true, + textStyle = typography.copy( + color = MaterialTheme.colorScheme.onSurface + ), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions( + onSearch = { onSearch() } + ), + decorationBox = { innerTextField -> + Box( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth() + .padding(start = 12.dp, end = 8.dp), + contentAlignment = Alignment.CenterStart + ) { + if (query.isEmpty()) { + Text( + text = placeholder, + style = typography.copy(color = MaterialTheme.colorScheme.onSurfaceVariant) + ) + } + innerTextField() } - innerTextField() - } - }, - modifier = Modifier - .weight(1f) - .padding(start = 4.dp) - .focusRequester(focusRequester) - ) + }, + modifier = Modifier + .weight(1f) + .padding(start = 4.dp) + .focusRequester(focusRequester) + ) + } Box(modifier = Modifier.width(6.dp)) Box(modifier = Modifier.size(24.dp)) { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt index ce8f6e0..f6eff4a 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt @@ -2,13 +2,18 @@ package ru.fromchat.ui.main import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.SharedTransitionScope +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons @@ -18,15 +23,27 @@ import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHostState import ru.fromchat.ui.components.Text +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.HazeStyle +import dev.chrisbanes.haze.HazeTint +import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import dev.chrisbanes.haze.rememberHazeState import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res @@ -36,7 +53,10 @@ import ru.fromchat.contacts import ru.fromchat.profile import ru.fromchat.settings import ru.fromchat.ui.LocalNavController +import ru.fromchat.ui.chat.rememberChatSurfaceContainerHazeStyle import ru.fromchat.ui.components.FromChatSnackbarHost +import ru.fromchat.ui.main.chats.ChatContextMenuOverlayController +import ru.fromchat.ui.main.chats.ChatContextMenuOverlayHost import ru.fromchat.ui.main.chats.ChatsTab import ru.fromchat.ui.main.settings.SettingsTab import ru.fromchat.ui.profile.ProfileScreen @@ -48,7 +68,7 @@ private const val PAGE_SETTINGS = 2 private const val PAGE_PROFILE = 3 private const val PAGE_COUNT = 4 -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @Composable fun MainScreen( sharedTransitionScope: SharedTransitionScope? = null, @@ -62,87 +82,163 @@ fun MainScreen( pageCount = { PAGE_COUNT }, ) val scope = rememberCoroutineScope() + val navBarHazeState = rememberHazeState(blurEnabled = true) + val contextMenuHazeState = rememberHazeState(blurEnabled = true) + val chatContextMenuOverlay = remember { ChatContextMenuOverlayController() } // Pager is the single source of truth; tabs only call animateScrollToPage (no write/read loop). val selectedPage = pagerState.currentPage val isChatsPage = selectedPage == PAGE_CHATS + val chatMenuBlurProgress = chatContextMenuOverlay.blurProgress - Scaffold( - snackbarHost = { FromChatSnackbarHost(hostState = effectiveSnackbarHostState) }, - bottomBar = { - NavigationBar { - NavigationBarItem( - selected = selectedPage == PAGE_CHATS, - onClick = { - scope.launch { pagerState.animateScrollToPage(PAGE_CHATS) } - }, - label = { Text(stringResource(Res.string.chats)) }, - icon = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null) } - ) - NavigationBarItem( - selected = selectedPage == PAGE_CONTACTS, - onClick = { - scope.launch { pagerState.animateScrollToPage(PAGE_CONTACTS) } - }, - label = { Text(stringResource(Res.string.contacts)) }, - icon = { Icon(Icons.Filled.Contacts, contentDescription = null) } - ) - NavigationBarItem( - selected = selectedPage == PAGE_SETTINGS, - onClick = { - scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) } - }, - label = { Text(stringResource(Res.string.settings)) }, - icon = { Icon(Icons.Filled.Settings, contentDescription = null) } - ) - NavigationBarItem( - selected = selectedPage == PAGE_PROFILE, - onClick = { - scope.launch { pagerState.animateScrollToPage(PAGE_PROFILE) } - }, - label = { Text(stringResource(Res.string.profile)) }, - icon = { Icon(Icons.Filled.Person, contentDescription = null) } - ) - } - }, - contentWindowInsets = WindowInsets.safeDrawing.exclude(WindowInsetsSides.Top), - modifier = Modifier.imePadding() - ) { innerPadding -> + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { Box( modifier = Modifier .fillMaxSize() - .padding(innerPadding) + .hazeSource(contextMenuHazeState), ) { - HorizontalPager( - state = pagerState, - modifier = Modifier.fillMaxSize(), - beyondViewportPageCount = 1, - ) { page -> - when (page) { - PAGE_CHATS -> ChatsTab( - isVisible = isChatsPage, - onOpenSearch = { - navController.navigate("search/conversations") - }, - sharedTransitionScope = sharedTransitionScope, - animatedVisibilityScope = animatedVisibilityScope, - ) - PAGE_CONTACTS -> ContactsTab() - PAGE_SETTINGS -> SettingsTab() - PAGE_PROFILE -> { - ProfileScreen( - userId = ApiClient.user?.id, - onBack = {}, - onChat = { _ -> }, - modifier = Modifier.fillMaxSize(), - onOpenSettings = { + Scaffold( + snackbarHost = { FromChatSnackbarHost(hostState = effectiveSnackbarHostState) }, + bottomBar = { + Column( + modifier = Modifier + .windowInsetsPadding(WindowInsets.ime) + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceContainer) + .hazeEffect( + state = navBarHazeState, + style = rememberChatSurfaceContainerHazeStyle(), + ), + ) { + NavigationBar( + containerColor = Color.Transparent, + tonalElevation = 0.dp, + ) { + NavigationBarItem( + selected = selectedPage == PAGE_CHATS, + onClick = { + scope.launch { pagerState.animateScrollToPage(PAGE_CHATS) } + }, + label = { Text(stringResource(Res.string.chats)) }, + icon = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null) } + ) + NavigationBarItem( + selected = selectedPage == PAGE_CONTACTS, + onClick = { + scope.launch { pagerState.animateScrollToPage(PAGE_CONTACTS) } + }, + label = { Text(stringResource(Res.string.contacts)) }, + icon = { Icon(Icons.Filled.Contacts, contentDescription = null) } + ) + NavigationBarItem( + selected = selectedPage == PAGE_SETTINGS, + onClick = { scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) } - } + }, + label = { Text(stringResource(Res.string.settings)) }, + icon = { Icon(Icons.Filled.Settings, contentDescription = null) } + ) + NavigationBarItem( + selected = selectedPage == PAGE_PROFILE, + onClick = { + scope.launch { pagerState.animateScrollToPage(PAGE_PROFILE) } + }, + label = { Text(stringResource(Res.string.profile)) }, + icon = { Icon(Icons.Filled.Person, contentDescription = null) } ) } - else -> Unit + } + }, + contentWindowInsets = WindowInsets.safeDrawing.exclude(WindowInsetsSides.Top), + modifier = Modifier.imePadding(), + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .hazeSource(navBarHazeState), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + beyondViewportPageCount = 1, + ) { page -> + when (page) { + PAGE_CHATS -> ChatsTab( + isVisible = isChatsPage, + onOpenSearch = { + navController.navigate("search/conversations") + }, + chatContextMenuOverlay = chatContextMenuOverlay, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + ) + PAGE_CONTACTS -> ContactsTab() + PAGE_SETTINGS -> SettingsTab() + PAGE_PROFILE -> { + ProfileScreen( + userId = ApiClient.user?.id, + onBack = {}, + onChat = { _ -> }, + modifier = Modifier.fillMaxSize(), + onOpenSettings = { + scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) } + } + ) + } + else -> Unit + } + } + } } } } + + if (chatMenuBlurProgress > 0f) { + ChatContextMenuBlurLayer( + hazeState = contextMenuHazeState, + blurProgress = chatMenuBlurProgress, + modifier = Modifier + .fillMaxSize() + .zIndex(1f), + ) + } + + ChatContextMenuOverlayHost( + controller = chatContextMenuOverlay, + screenWidthPx = constraints.maxWidth, + screenHeightPx = constraints.maxHeight, + modifier = Modifier + .fillMaxSize() + .zIndex(2f), + ) } } + +@OptIn(ExperimentalHazeMaterialsApi::class) +@Composable +private fun ChatContextMenuBlurLayer( + hazeState: HazeState, + blurProgress: Float, + modifier: Modifier = Modifier, +) { + val blurRadius = 12.dp * blurProgress + if (blurRadius <= 0.dp) return + + Box( + modifier = modifier.hazeEffect( + state = hazeState, + style = HazeStyle( + blurRadius = blurRadius, + tints = emptyList(), + backgroundColor = Color.Transparent, + noiseFactor = 0f, + fallbackTint = HazeTint(Color.Transparent), + ), + ), + ) +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatContextMenu.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatContextMenu.kt new file mode 100644 index 0000000..260eecf --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatContextMenu.kt @@ -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, + ) + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt new file mode 100644 index 0000000..a438acd --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt @@ -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, + publicChatTitle: String?, + publicLastMessagePreview: String?, + defaultLastMessage: String, + statusMap: Map, + listMode: ChatsListMode, + selectionTransitionProgress: Float, + publicChatSelected: Boolean, + selectedOtherUserIds: Set, + 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, + defaultLastMessage: String, + statusMap: Map, + modifier: Modifier = Modifier, + remoteUsers: List = 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, + 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, + 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( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, +) +internal val ChatContextMenuRevealSpring = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, +) +internal val ChatContextMenuOpenSpring = spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = Spring.StiffnessMediumLow, +) +internal val ChatSelectionTransitionSpring: SpringSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, +) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsSearchScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsSearchScreen.kt index deb9b80..23d7d4f 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsSearchScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsSearchScreen.kt @@ -38,7 +38,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -52,6 +51,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.pr0gramm3r101.utils.resetFocus +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource @@ -59,15 +59,17 @@ import ru.fromchat.Res import ru.fromchat.api.ApiClient import ru.fromchat.api.local.db.store.CachedConversation import ru.fromchat.api.local.db.store.MessageCacheStore +import ru.fromchat.api.local.db.store.MessageRepository import ru.fromchat.api.local.db.store.ProfileCache import ru.fromchat.api.local.db.store.UserStatusStore import ru.fromchat.api.local.db.store.visibleUsername +import ru.fromchat.api.schema.user.User import ru.fromchat.chat_last_mesaage +import ru.fromchat.chat_preview_attachment import ru.fromchat.search_hint import ru.fromchat.search_not_found import ru.fromchat.search_not_found_message import ru.fromchat.search_title -import ru.fromchat.ui.components.BackHandler import ru.fromchat.ui.components.SearchBar import ru.fromchat.ui.components.SearchBarSharedElement import ru.fromchat.ui.components.Text @@ -85,9 +87,11 @@ fun ChatsSearchScreen( val searchListState = rememberLazyListState() val statusMap by UserStatusStore.status.collectAsState() val defaultLastMessage = stringResource(Res.string.chat_last_mesaage) + val attachmentOnlyPreview = stringResource(Res.string.chat_preview_attachment) val searchHint = stringResource(Res.string.search_hint) val searchBarHint = stringResource(Res.string.search_title) var dmConversations by remember { mutableStateOf>(emptyList()) } + var remoteUsers by remember { mutableStateOf>(emptyList()) } val statusSubscriptionScope = rememberCoroutineScope() var subscribedDmUserIds by remember { mutableStateOf>(emptySet()) } val keyboardController = LocalSoftwareKeyboardController.current @@ -99,7 +103,7 @@ fun ChatsSearchScreen( val searchQuery = searchText.trim().lowercase().trimStart('@') val filteredDmConversations = remember(dmConversations, searchQuery) { if (searchQuery.isBlank()) { - dmConversations + emptyList() } else { dmConversations.filter { conv -> matchesSearchConversations(conv, searchQuery) @@ -107,19 +111,50 @@ fun ChatsSearchScreen( } } - val searchUiState by remember(searchText, filteredDmConversations) { - derivedStateOf { - when { - searchText.isBlank() -> SearchScreenState.EmptyQuery - filteredDmConversations.isEmpty() -> SearchScreenState.NotFound - else -> SearchScreenState.Results + val searchResults = remember(filteredDmConversations, remoteUsers, searchQuery) { + if (searchQuery.isBlank()) { + emptyList() + } else { + val dmUserIds = filteredDmConversations.map { it.otherUserId }.toSet() + val remoteOnly = remoteUsers.filter { it.id !in dmUserIds } + filteredDmConversations.map { SearchResult.Conversation(it) } + + remoteOnly.map { SearchResult.UserResult(it) } + } + } + + val searchUiState = when { + searchText.isBlank() -> SearchScreenState.EmptyQuery + searchResults.isEmpty() -> SearchScreenState.NotFound + else -> SearchScreenState.Results + } + LaunchedEffect(searchQuery) { + if (searchQuery.length < 2) { + remoteUsers = emptyList() + return@LaunchedEffect + } + delay(300) + val querySnapshot = searchQuery + runCatching { + ApiClient.searchUsers(querySnapshot) + }.onSuccess { users -> + if (querySnapshot == searchText.trim().lowercase().trimStart('@')) { + users.forEach { ProfileCache.mergeFromDmUser(it) } + remoteUsers = users + } + }.onFailure { + if (querySnapshot == searchText.trim().lowercase().trimStart('@')) { + remoteUsers = emptyList() } } } + LaunchedEffect(Unit) { // Load cached DM conversations first for immediate display. runCatching { - dmConversations = MessageCacheStore.loadCachedDmConversations() + MessageCacheStore.loadCachedDmConversations() + }.onSuccess { conversations -> + conversations.forEach { ProfileCache.mergeFromCachedConversation(it) } + dmConversations = conversations } // Then refresh from network and update cache + state. @@ -128,17 +163,18 @@ fun ChatsSearchScreen( }.onSuccess { conversations -> runCatching { conversations.forEach { ProfileCache.mergeFromDmUser(it.user) } - MessageCacheStore.replaceDmConversations(conversations) - dmConversations = MessageCacheStore.loadCachedDmConversations() + MessageRepository.replaceDmConversations(conversations, attachmentOnlyPreview) + dmConversations = MessageRepository.loadCachedDmConversations() } } } - LaunchedEffect(filteredDmConversations, searchListState) { + LaunchedEffect(searchResults, searchListState) { snapshotFlow { searchListState.layoutInfo.visibleItemsInfo .mapNotNull { item -> - filteredDmConversations.getOrNull(item.index)?.otherUserId + SearchListIndices.resultIndexFromLazy(item.index) + ?.let { searchResults.getOrNull(it)?.userId } } .filter { it > 0 } .toSet() @@ -160,6 +196,7 @@ fun ChatsSearchScreen( DisposableEffect(Unit) { onDispose { + hideIme() if (subscribedDmUserIds.isNotEmpty()) { statusSubscriptionScope.launch { subscribedDmUserIds.forEach { ApiClient.sendUnsubscribeStatus(it) } @@ -169,11 +206,6 @@ fun ChatsSearchScreen( } } - BackHandler(enabled = true) { - hideIme() - onBack() - } - Scaffold( modifier = Modifier .imePadding() @@ -244,16 +276,12 @@ fun ChatsSearchScreen( SearchConversationsList( listState = searchListState, conversations = filteredDmConversations, + remoteUsers = remoteUsers.filter { user -> + filteredDmConversations.none { it.otherUserId == user.id } + }, defaultLastMessage = defaultLastMessage, statusMap = statusMap, - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 12.dp), - onOpenProfile = { userId -> - if (userId != 0) { - onOpenProfile(userId) - } - }, + modifier = Modifier.fillMaxSize(), onOpenConversation = { userId -> if (userId != 0) { onOpenConversation(userId) @@ -267,16 +295,26 @@ fun ChatsSearchScreen( } } -private fun matchesSearchConversations(conv: CachedConversation, normalizedQuery: String): Boolean { - val cached = ProfileCache.get(conv.otherUserId) - val candidates = buildList { - add(conv.displayName) - cached?.displayName?.let { add(it) } - cached?.visibleUsername(ApiClient.user?.id)?.let { add(it) } +private fun matchesSearchConversations(conv: CachedConversation, normalizedQuery: String) = + ProfileCache.get(conv.otherUserId).let { cached -> + buildList { + add(conv.displayName) + cached?.displayName?.let { add(it) } + cached?.visibleUsername(ApiClient.user?.id)?.let { add(it) } + }.any { candidate -> + candidate.lowercase().contains(normalizedQuery) + } } - return candidates.any { candidate -> - candidate.lowercase().contains(normalizedQuery) +private sealed interface SearchResult { + val userId: Int + + data class Conversation(val conversation: CachedConversation) : SearchResult { + override val userId: Int = conversation.otherUserId + } + + data class UserResult(val user: User) : SearchResult { + override val userId: Int = user.id } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsSelectionState.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsSelectionState.kt new file mode 100644 index 0000000..8b87218 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsSelectionState.kt @@ -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 = 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 = emptyList(), + val statusMap: Map = emptyMap(), + val listMode: ChatsListMode = ChatsListMode.Normal, + val selectionTransitionProgress: Float = 0f, + val publicChatSelected: Boolean = false, + val selectedOtherUserIds: Set = emptySet(), + val isReadOnly: Boolean = false, + val callsEnabled: Boolean = false, + val publicHasUnread: Boolean = false, +) + +class ChatContextMenuOverlayController { + var uiState by mutableStateOf(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 + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt index d0ad335..a8529ad 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt @@ -3,42 +3,52 @@ package ru.fromchat.ui.main.chats import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibilityScope import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.animate +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.LocalIndication -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer 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.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.ListItem +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -47,71 +57,212 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res +import ru.fromchat.action_delete +import ru.fromchat.action_mark_read import ru.fromchat.api.ApiClient +import ru.fromchat.api.calls.CallStore import ru.fromchat.api.local.cache.CacheContext import ru.fromchat.api.local.db.store.CachedConversation import ru.fromchat.api.local.db.store.ConnectionStateStore import ru.fromchat.api.local.db.store.ConnectionStatus import ru.fromchat.api.local.db.store.MessageRepository import ru.fromchat.api.local.db.store.ProfileCache -import ru.fromchat.api.local.db.store.UserStatus +import ru.fromchat.api.local.db.store.PublicChatProfileCache import ru.fromchat.api.local.db.store.UserStatusStore import ru.fromchat.api.local.db.store.visibleUsername +import ru.fromchat.cancel +import ru.fromchat.cd_close_selection +import ru.fromchat.cd_selection_more +import ru.fromchat.chat_delete_confirm_body +import ru.fromchat.chat_delete_confirm_title import ru.fromchat.chat_last_mesaage +import ru.fromchat.chat_preview_attachment +import ru.fromchat.chats_selected_count import ru.fromchat.config.ServerConfig -import ru.fromchat.presence_online -import ru.fromchat.public_chat import ru.fromchat.search_title import ru.fromchat.status_connecting import ru.fromchat.status_updating import ru.fromchat.suspend_chat_banner_message import ru.fromchat.suspended_default_reason import ru.fromchat.ui.LocalNavController -import ru.fromchat.ui.chat.Avatar -import ru.fromchat.ui.chat.TypingIndicator import ru.fromchat.ui.chat.panels.dm.DmNav +import ru.fromchat.ui.components.BackHandler import ru.fromchat.ui.components.BrandTitle import ru.fromchat.ui.components.ConnectingEllipsis +import ru.fromchat.ui.components.PredictiveBackHandler import ru.fromchat.ui.components.SearchBar import ru.fromchat.ui.components.SearchBarSharedElement import ru.fromchat.ui.components.SuspendedAccountBannerStyle import ru.fromchat.ui.components.SuspendedAccountNoticeHost import ru.fromchat.ui.components.Text -import ru.fromchat.unread_count -import ru.fromchat.user_fallback import ru.fromchat.utils.NetworkConnectivity +import ru.fromchat.utils.haptic.HapticFeedbackEvent +import ru.fromchat.utils.haptic.rememberHapticFeedback +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.TimeSource +private const val ChatContextMenuHoldGateMs = 250L + +@OptIn(ExperimentalMaterial3Api::class) @Composable -private fun ChatRowAvatar( - profilePictureUrl: String?, - displayNameForInitials: String, - onClick: () -> Unit, - modifier: Modifier = Modifier +private fun ChatsNormalTopBar( + titleKey: String, + connectingTitle: String, + updatingTitle: String, + modifier: Modifier = Modifier, ) { - val interaction = remember { MutableInteractionSource() } - Box( - modifier - .size(40.dp) - .clickable( - interactionSource = interaction, - indication = LocalIndication.current, - onClick = onClick + TopAppBar( + modifier = modifier, + title = { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + ) { + AsyncImage( + model = Res.getUri("drawable/logo_square.svg"), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier + .size(32.dp) + .clip(RoundedCornerShape(10.dp)), + ) + Spacer(modifier = Modifier.width(10.dp)) + AnimatedContent( + targetState = titleKey, + modifier = Modifier.weight(1f), + transitionSpec = { + (slideInVertically { it / 2 } + fadeIn()) togetherWith + (slideOutVertically { -it / 2 } + fadeOut()) + }, + label = "chats_title", + ) { key -> + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.CenterStart, + ) { + when (key) { + "connecting", "updating" -> { + val style = MaterialTheme.typography.titleLarge + val color = MaterialTheme.colorScheme.onSurface + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = if (key == "connecting") connectingTitle else updatingTitle, + style = style, + color = color, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + ConnectingEllipsis( + fontSize = style.fontSize, + color = color, + baseStyle = style, + ) + } + } + + else -> BrandTitle() + } + } + } + } + }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ChatsSelectionTopBar( + selectedCountTitle: String, + closeSelectionCd: String, + bulkActions: ChatsBulkActions, + deleteCd: String, + moreActionsCd: String, + markReadLabel: String, + onClose: () -> Unit, + onDelete: () -> Unit, + onMarkRead: () -> Unit, + modifier: Modifier = Modifier, +) { + var overflowOpen by remember { mutableStateOf(false) } + + TopAppBar( + modifier = modifier, + navigationIcon = { + IconButton(onClick = onClose) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = closeSelectionCd, + ) + } + }, + title = { + Text( + text = selectedCountTitle, + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) - ) { - Avatar( - profilePictureUrl = profilePictureUrl, - displayName = displayNameForInitials, - modifier = Modifier.fillMaxSize() - ) - } + }, + actions = { + if (bulkActions.canDelete) { + IconButton(onClick = onDelete) { + Icon( + imageVector = Icons.Rounded.Delete, + contentDescription = deleteCd, + ) + } + } + if (bulkActions.canMarkRead) { + Box { + IconButton(onClick = { overflowOpen = true }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = moreActionsCd, + ) + } + DropdownMenu( + expanded = overflowOpen, + onDismissRequest = { overflowOpen = false }, + ) { + DropdownMenuItem( + text = { Text(markReadLabel) }, + onClick = { + overflowOpen = false + onMarkRead() + }, + ) + } + } + } + }, + ) } @OptIn(ExperimentalMaterial3Api::class) @@ -119,21 +270,134 @@ private fun ChatRowAvatar( fun ChatsTab( isVisible: Boolean = true, onOpenSearch: () -> Unit, + chatContextMenuOverlay: ChatContextMenuOverlayController, sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, ) { val navController = LocalNavController.current + val clipboardManager = LocalClipboardManager.current + val haptic = rememberHapticFeedback() + val scope = rememberCoroutineScope() val connectionStatus by ConnectionStateStore.status.collectAsState() val online by NetworkConnectivity.isOnline.collectAsState(initial = true) var dmConversations by remember { mutableStateOf>(emptyList()) } var publicLastMessagePreview by remember { mutableStateOf(null) } + var publicChatProfile by remember { mutableStateOf(PublicChatProfileCache.profile) } val searchBarHint = stringResource(Res.string.search_title) val tabListState = rememberLazyListState() val statusMap by UserStatusStore.status.collectAsState() var subscribedDmUserIds by remember { mutableStateOf>(emptySet()) } val statusSubscriptionScope = rememberCoroutineScope() val suspensionState by ApiClient.suspensionState.collectAsState() - val publicConversationsOffset = 1 + val attachmentOnlyPreview = stringResource(Res.string.chat_preview_attachment) + val defaultLastMessage = stringResource(Res.string.chat_last_mesaage) + + var listMode by remember { mutableStateOf(ChatsListMode.Normal) } + var publicChatSelected by remember { mutableStateOf(false) } + var selectedOtherUserIds by remember { mutableStateOf>(emptySet()) } + var contextMenuState by remember { mutableStateOf(ChatContextMenuState()) } + var avatarPressMark by remember { mutableStateOf(null) } + var pendingSelectAfterMenuDismiss by remember { + mutableStateOf?>(null) + } + val selectionTransitionProgress = remember { Animatable(0f) } + + BackHandler( + enabled = contextMenuState.phase == ChatContextMenuPhase.Pressing, + ) { + contextMenuState = ChatContextMenuState() + } + + var showDeleteConfirm by remember { mutableStateOf(false) } + var pendingDeleteUserIds by remember { mutableStateOf>(emptySet()) } + + fun enterSelectionModeFor(target: ChatContextMenuTarget, userId: Int?) { + haptic(HapticFeedbackEvent.SelectionModeEntered) + scope.launch { selectionTransitionProgress.snapTo(0f) } + listMode = ChatsListMode.Selecting + publicChatSelected = false + selectedOtherUserIds = emptySet() + when (target) { + ChatContextMenuTarget.Public -> publicChatSelected = true + ChatContextMenuTarget.Dm -> userId?.let { selectedOtherUserIds = setOf(it) } + } + } + + fun exitSelectionMode() { + scope.launch { selectionTransitionProgress.snapTo(0f) } + listMode = ChatsListMode.Normal + publicChatSelected = false + selectedOtherUserIds = emptySet() + contextMenuState = ChatContextMenuState() + chatContextMenuOverlay.clear() + } + + fun requestExitSelectionMode() { + scope.launch { + selectionTransitionProgress.animateTo(0f, ChatSelectionTransitionSpring) + exitSelectionMode() + } + } + + fun refreshDmList() { + scope.launch { + runCatching { + dmConversations = MessageRepository.loadCachedDmConversations() + } + } + } + + fun deleteChats(userIds: Set) { + scope.launch { + var failures = 0 + + userIds.forEach { otherUserId -> + var ok = true + + val messages = runCatching { MessageRepository.loadDmMessages(otherUserId) } + .getOrDefault(emptyList()) + .filter { it.id > 0 } + + messages.forEach { msg -> + runCatching { + ApiClient.deleteDm(msg.id, otherUserId) + }.onFailure { + ok = false + } + } + runCatching { + MessageRepository.deleteDmConversation(otherUserId) + }.onFailure { ok = false } + + if (!ok) failures++ + } + + refreshDmList() + exitSelectionMode() + } + } + + LaunchedEffect(listMode) { + if (listMode == ChatsListMode.Selecting) { + selectionTransitionProgress.animateTo(1f, ChatSelectionTransitionSpring) + } + } + + LaunchedEffect(publicChatSelected, selectedOtherUserIds) { + if (listMode == ChatsListMode.Selecting && !publicChatSelected && selectedOtherUserIds.isEmpty()) { + requestExitSelectionMode() + } + } + + DisposableEffect(isVisible) { + onDispose { + if (!isVisible) exitSelectionMode() + } + } + + DisposableEffect(Unit) { + onDispose { chatContextMenuOverlay.clear() } + } LaunchedEffect(dmConversations, tabListState, isVisible, onOpenSearch) { snapshotFlow { @@ -142,8 +406,8 @@ fun ChatsTab( } else { tabListState.layoutInfo.visibleItemsInfo .mapNotNull { item -> - val dmRowIndex = item.index - publicConversationsOffset - dmConversations.getOrNull(dmRowIndex)?.otherUserId + ChatListLayout.dmIndexFromLazy(item.index) + ?.let { dmConversations.getOrNull(it)?.otherUserId } } .filter { it > 0 } .toSet() @@ -151,15 +415,14 @@ fun ChatsTab( } .distinctUntilChanged() .collect { visibleIds -> - val toSubscribe = visibleIds - subscribedDmUserIds - val toUnsubscribe = subscribedDmUserIds - visibleIds - - toSubscribe.forEach { userId -> + (visibleIds - subscribedDmUserIds).forEach { userId -> runCatching { ApiClient.sendSubscribeStatus(userId) } } - toUnsubscribe.forEach { userId -> + + (subscribedDmUserIds - visibleIds).forEach { userId -> runCatching { ApiClient.sendUnsubscribeStatus(userId) } } + subscribedDmUserIds = visibleIds } } @@ -180,22 +443,40 @@ fun ChatsTab( LaunchedEffect(serverConfig, activeInstanceId) { if (activeInstanceId.isBlank()) return@LaunchedEffect + runCatching { - dmConversations = MessageRepository.loadCachedDmConversations() + MessageRepository.loadCachedDmConversations() + }.onSuccess { conversations -> + conversations.forEach { ProfileCache.mergeFromCachedConversation(it) } + dmConversations = conversations } + runCatching { - val last = MessageRepository.loadRecentPublicMessages(1).lastOrNull() - publicLastMessagePreview = last?.content?.trim()?.takeIf { it.isNotEmpty() } + publicLastMessagePreview = MessageRepository + .loadRecentPublicMessages(1) + .lastOrNull() + ?.content + ?.trim() + ?.takeIf { it.isNotEmpty() } } + runCatching { ApiClient.getDmConversations() }.onSuccess { conversations -> runCatching { conversations.forEach { ProfileCache.mergeFromDmUser(it.user) } - MessageRepository.replaceDmConversations(conversations) + MessageRepository.replaceDmConversations(conversations, attachmentOnlyPreview) dmConversations = MessageRepository.loadCachedDmConversations() } } + + publicChatProfile = PublicChatProfileCache.profile + + runCatching { ApiClient.getPublicChatProfile() } + .onSuccess { profile -> + PublicChatProfileCache.put(profile) + publicChatProfile = profile + } } val titleKey = when { @@ -207,319 +488,820 @@ fun ChatsTab( val connectingTitle = stringResource(Res.string.status_connecting) val updatingTitle = stringResource(Res.string.status_updating) - val defaultLastMessage = stringResource(Res.string.chat_last_mesaage) + val selectedCount = selectedOtherUserIds.size + if (publicChatSelected) 1 else 0 + val selectedCountTitle = stringResource(Res.string.chats_selected_count, selectedCount) val suspendBannerTitle = stringResource(Res.string.suspend_chat_banner_message) val suspendDefaultReason = stringResource(Res.string.suspended_default_reason) - val publicChatTitle = stringResource(Res.string.public_chat) + val publicChatTitle = publicChatProfile?.title + val publicChatLink = publicChatProfile?.let { "https://fromchat.ru/chats/${it.id}" } + val deleteConfirmTitle = stringResource(Res.string.chat_delete_confirm_title) + val deleteConfirmBody = stringResource(Res.string.chat_delete_confirm_body) + val deleteLabel = stringResource(Res.string.action_delete) + val markReadLabel = stringResource(Res.string.action_mark_read) + val closeSelectionCd = stringResource(Res.string.cd_close_selection) + val moreActionsCd = stringResource(Res.string.cd_selection_more) + val selectionMode = listMode == ChatsListMode.Selecting + val selectionProgress = selectionTransitionProgress.value + val bulkActions = resolveChatsBulkActions( + ChatsSelection( + publicChatSelected = publicChatSelected, + selectedOtherUserIds = selectedOtherUserIds, + ), + ) + val rowRevealProgress = chatContextMenuOverlay.rowRevealProgress + val overlayCloneReady = chatContextMenuOverlay.overlayCloneReady + val callsEnabled = ServerConfig.callsEnabled - Scaffold( - topBar = { - TopAppBar( - title = { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - AsyncImage( - model = Res.getUri("drawable/logo_square.svg"), - contentDescription = null, - contentScale = ContentScale.Fit, - modifier = Modifier - .size(32.dp) - .clip(RoundedCornerShape(10.dp)) - ) + fun markSelectedChatsRead() { + scope.launch { + if (publicChatSelected) { + runCatching { MessageRepository.markPublicConversationRead() } + } + selectedOtherUserIds.forEach { userId -> + runCatching { MessageRepository.markDmConversationRead(userId) } + } + refreshDmList() + exitSelectionMode() + } + } - Spacer(modifier = Modifier.width(10.dp)) + if (showDeleteConfirm) { + AlertDialog( + onDismissRequest = { showDeleteConfirm = false }, + title = { Text(deleteConfirmTitle) }, + text = { Text(deleteConfirmBody) }, + confirmButton = { + TextButton( + onClick = { + showDeleteConfirm = false + deleteChats(pendingDeleteUserIds) + pendingDeleteUserIds = emptySet() + }, + ) { + Text(deleteLabel) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteConfirm = false }) { + Text(stringResource(Res.string.cancel)) + } + }, + ) + } - AnimatedContent( - targetState = titleKey, - modifier = Modifier.weight(1f), - transitionSpec = { - (slideInVertically { it / 2 } + fadeIn()) togetherWith - (slideOutVertically { -it / 2 } + fadeOut()) + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + BackHandler(enabled = selectionMode) { + requestExitSelectionMode() + } + + PredictiveBackHandler( + enabled = selectionMode, + onProgress = { backProgress -> + scope.launch { + selectionTransitionProgress.snapTo((1f - backProgress).coerceIn(0f, 1f)) + } + }, + onCommit = { requestExitSelectionMode() }, + onCancel = { + if (selectionMode) { + scope.launch { + selectionTransitionProgress.animateTo(1f, ChatSelectionTransitionSpring) + } + } + }, + ) + + Scaffold( + topBar = { + Box { + ChatsNormalTopBar( + titleKey = titleKey, + connectingTitle = connectingTitle, + updatingTitle = updatingTitle, + modifier = Modifier.graphicsLayer { alpha = 1f - selectionProgress }, + ) + if (selectionMode || selectionProgress > 0f) { + ChatsSelectionTopBar( + selectedCountTitle = selectedCountTitle, + closeSelectionCd = closeSelectionCd, + bulkActions = bulkActions, + deleteCd = deleteLabel, + moreActionsCd = moreActionsCd, + markReadLabel = markReadLabel, + onClose = { requestExitSelectionMode() }, + onDelete = { + pendingDeleteUserIds = selectedOtherUserIds + showDeleteConfirm = true }, - label = "chats_title" - ) { key -> - Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.CenterStart - ) { - when (key) { - "connecting" -> { - val style = MaterialTheme.typography.titleLarge - val color = MaterialTheme.colorScheme.onSurface - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - Text( - text = connectingTitle, - style = style, - color = color, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - ConnectingEllipsis( - fontSize = style.fontSize, - color = color, - baseStyle = style - ) - } - } - "updating" -> { - val style = MaterialTheme.typography.titleLarge - val color = MaterialTheme.colorScheme.onSurface - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - Text( - text = updatingTitle, - style = style, - color = color, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - ConnectingEllipsis( - fontSize = style.fontSize, - color = color, - baseStyle = style - ) - } - } - else -> { - BrandTitle() - } - } + onMarkRead = { markSelectedChatsRead() }, + modifier = Modifier.graphicsLayer { alpha = selectionProgress }, + ) + } + } + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + val searchBarReveal = 1f - selectionProgress + Box( + modifier = Modifier + .fillMaxWidth() + .graphicsLayer { alpha = searchBarReveal } + .height((56.dp * searchBarReveal).coerceAtLeast(0.dp)) + .clip(RectangleShape), + ) { + if (searchBarReveal > 0f) { + SearchBar( + query = "", + onQueryChange = {}, + onSearch = {}, + placeholder = searchBarHint, + readOnly = true, + onReadOnlyActivate = onOpenSearch, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp), + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + sharedElementKey = SearchBarSharedElement, + ) + } + } + + SuspendedAccountNoticeHost( + isSuspended = suspensionState.isSuspended, + reason = suspensionState.reason, + fallbackReason = suspendDefaultReason, + bannerTitle = suspendBannerTitle, + style = SuspendedAccountBannerStyle.Tabs, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + ) + + ChatConversationsList( + listState = tabListState, + listFilter = ChatListFilter.Active, + conversations = dmConversations, + publicChatTitle = publicChatTitle, + publicLastMessagePreview = publicLastMessagePreview, + defaultLastMessage = defaultLastMessage, + statusMap = statusMap, + listMode = listMode, + selectionTransitionProgress = selectionProgress, + publicChatSelected = publicChatSelected, + selectedOtherUserIds = selectedOtherUserIds, + contextMenuState = contextMenuState, + overlayCloneReady = overlayCloneReady, + rowRevealProgress = rowRevealProgress, + modifier = Modifier.fillMaxSize(), + onOpenPublic = { + when { + selectionMode && publicChatSelected -> publicChatSelected = false + selectionMode -> publicChatSelected = true + else -> navController.navigate("chats/publicChat") + } + }, + onOpenConversation = { userId -> + when { + selectionMode && userId in selectedOtherUserIds -> { + selectedOtherUserIds -= userId + } + + selectionMode -> selectedOtherUserIds += userId + + userId != 0 -> navController.navigate(DmNav.chatRoute(userId)) + } + }, + onAvatarContextMenuPressStart = { lazyIndex, target, userId, rowOffset, rowSize, position, groupCount -> + if (suspensionState.isSuspended) return@ChatConversationsList + avatarPressMark = TimeSource.Monotonic.markNow() + contextMenuState = ChatContextMenuState( + phase = ChatContextMenuPhase.Pressing, + target = target, + otherUserId = userId, + listIndex = lazyIndex, + rowOffset = rowOffset, + rowSize = rowSize, + listItemPosition = position, + groupItemCount = groupCount, + ) + }, + onAvatarContextMenuPressEnd = { + avatarPressMark = null + if (contextMenuState.phase == ChatContextMenuPhase.Pressing) { + contextMenuState = ChatContextMenuState() + } + }, + onAvatarContextMenuOpen = { lazyIndex, target, userId, _, rowOffset, rowSize, position, groupCount -> + if (suspensionState.isSuspended) return@ChatConversationsList + val pressMark = avatarPressMark + if (pressMark == null || + pressMark.elapsedNow() < ChatContextMenuHoldGateMs.milliseconds + ) { + return@ChatConversationsList + } + if (contextMenuState.phase != ChatContextMenuPhase.Pressing) return@ChatConversationsList + haptic(HapticFeedbackEvent.ContextMenuOpened) + chatContextMenuOverlay.overlayCloneReady = false + contextMenuState = ChatContextMenuState( + phase = ChatContextMenuPhase.Animating, + target = target, + otherUserId = userId, + listIndex = lazyIndex, + rowOffset = rowOffset, + rowSize = rowSize, + listItemPosition = position, + groupItemCount = groupCount, + ) + }, + onEnterSelectionMode = { _, target, userId -> + if (suspensionState.isSuspended) return@ChatConversationsList + enterSelectionModeFor(target, userId) + }, + onRowPositioned = { lazyIndex, offset, size -> + if ( + contextMenuState.listIndex == lazyIndex && + contextMenuState.phase != ChatContextMenuPhase.Closed + ) { + contextMenuState = contextMenuState.copy( + rowOffset = offset, + rowSize = size, + ) + } + }, + ) + } + } + + chatContextMenuOverlay.onStateChange = { contextMenuState = it } + chatContextMenuOverlay.onDismiss = { + val pending = pendingSelectAfterMenuDismiss + pendingSelectAfterMenuDismiss = null + contextMenuState = ChatContextMenuState() + chatContextMenuOverlay.clear() + if (pending != null) { + enterSelectionModeFor(pending.first, pending.second) + } + } + chatContextMenuOverlay.onMessage = { + when (contextMenuState.target) { + ChatContextMenuTarget.Public -> navController.navigate("chats/publicChat") + ChatContextMenuTarget.Dm -> { + contextMenuState.otherUserId?.let { navController.navigate(DmNav.chatRoute(it)) } + } + } + } + chatContextMenuOverlay.onCall = { userId -> + scope.launch { CallStore.startOutgoingCall(userId) } + } + chatContextMenuOverlay.onLink = { + when (contextMenuState.target) { + ChatContextMenuTarget.Public -> { + publicChatLink?.let { clipboardManager.setText(AnnotatedString(it)) } + } + ChatContextMenuTarget.Dm -> { + val link = contextMenuState.otherUserId?.let { userId -> + val cached = ProfileCache.get(userId) + val username = cached?.visibleUsername(ApiClient.user?.id) ?: cached?.username + username?.let { "https://fromchat.ru/@$it" } ?: "https://fromchat.ru/?u=$userId" + } + link?.let { clipboardManager.setText(AnnotatedString(it)) } + } + } + } + chatContextMenuOverlay.onMarkRead = { userId -> + scope.launch { + runCatching { MessageRepository.markDmConversationRead(userId) } + refreshDmList() + } + } + chatContextMenuOverlay.onMarkPublicRead = { + scope.launch { + runCatching { MessageRepository.markPublicConversationRead() } + } + } + chatContextMenuOverlay.onDelete = { userId -> + pendingDeleteUserIds = setOf(userId) + showDeleteConfirm = true + } + chatContextMenuOverlay.onSelect = { + pendingSelectAfterMenuDismiss = contextMenuState.target to contextMenuState.otherUserId + contextMenuState = contextMenuState.copy( + phase = ChatContextMenuPhase.Animating, + animatingOut = true, + ) + } + chatContextMenuOverlay.onOverlayCloneReady = { + chatContextMenuOverlay.overlayCloneReady = true + } + + val shouldPublishOverlay = isVisible && + contextMenuState.isOverlayReplicaActive && + contextMenuState.rowSize != IntSize.Zero + val overlayUiState = if (shouldPublishOverlay) { + ChatContextMenuOverlayUiState( + contextMenuState = contextMenuState, + blurProgress = chatContextMenuOverlay.blurProgress, + listFilter = ChatListFilter.Active, + publicChatTitle = publicChatTitle, + publicLastMessagePreview = publicLastMessagePreview, + publicChatLink = publicChatLink, + defaultLastMessage = defaultLastMessage, + conversations = dmConversations, + statusMap = statusMap, + listMode = listMode, + selectionTransitionProgress = selectionProgress, + publicChatSelected = publicChatSelected, + selectedOtherUserIds = selectedOtherUserIds, + isReadOnly = suspensionState.isSuspended, + callsEnabled = callsEnabled, + publicHasUnread = false, + ) + } else { + null + } + if (chatContextMenuOverlay.uiState != overlayUiState) { + val previousOverlay = chatContextMenuOverlay.uiState + chatContextMenuOverlay.uiState = overlayUiState + if (overlayUiState == null) { + chatContextMenuOverlay.blurProgress = 0f + chatContextMenuOverlay.rowRevealProgress = 0f + chatContextMenuOverlay.overlayCloneReady = false + } else { + val isNewOverlaySession = previousOverlay == null || + previousOverlay.contextMenuState.listIndex != overlayUiState.contextMenuState.listIndex || + !previousOverlay.contextMenuState.isOverlayReplicaActive + if (isNewOverlaySession) { + chatContextMenuOverlay.overlayCloneReady = false + } + } + } + } +} + +@Composable +internal fun ChatContextMenuOverlayHost( + controller: ChatContextMenuOverlayController, + screenWidthPx: Int, + screenHeightPx: Int, + modifier: Modifier = Modifier, +) { + val uiState = controller.uiState ?: return + if ( + uiState.contextMenuState.phase == ChatContextMenuPhase.Pressing || + !uiState.contextMenuState.isOverlayReplicaActive || + uiState.contextMenuState.rowSize == IntSize.Zero + ) { + return + } + + ChatContextMenuOverlay( + uiState = uiState, + screenWidthPx = screenWidthPx, + screenHeightPx = screenHeightPx, + onBlurProgressChange = { controller.blurProgress = it }, + onRowRevealProgressChange = { controller.rowRevealProgress = it }, + onStateChange = controller.onStateChange, + onDismiss = controller.onDismiss, + onMessage = controller.onMessage, + onCall = { + uiState.contextMenuState.otherUserId?.let(controller.onCall) + }, + onLink = controller.onLink, + onMarkRead = { + when (uiState.contextMenuState.target) { + ChatContextMenuTarget.Public -> controller.onMarkPublicRead() + ChatContextMenuTarget.Dm -> uiState.contextMenuState.otherUserId?.let(controller.onMarkRead) + } + }, + onDelete = { + uiState.contextMenuState.otherUserId?.let(controller.onDelete) + }, + onSelect = controller.onSelect, + onOverlayCloneReady = controller.onOverlayCloneReady, + modifier = modifier, + ) +} + +@Composable +private fun ChatContextMenuOverlay( + uiState: ChatContextMenuOverlayUiState, + screenWidthPx: Int, + screenHeightPx: Int, + onBlurProgressChange: (Float) -> Unit, + onRowRevealProgressChange: (Float) -> Unit, + onStateChange: (ChatContextMenuState) -> Unit, + onDismiss: () -> Unit, + onMessage: () -> Unit, + onCall: () -> Unit, + onLink: () -> Unit, + onMarkRead: () -> Unit, + onDelete: () -> Unit, + onSelect: () -> Unit, + onOverlayCloneReady: () -> Unit, + modifier: Modifier = Modifier, +) { + val contextMenuState = uiState.contextMenuState + val density = LocalDensity.current + val menuGapPx = with(density) { 8.dp.toPx() } + val paddingPx = with(density) { 16.dp.toPx() } + var overlayOriginInRoot by remember { mutableStateOf(Offset.Zero) } + var overlaySize by remember { mutableStateOf(IntSize.Zero) } + var menuSize by remember(contextMenuState.target, contextMenuState.otherUserId) { + mutableStateOf(IntSize.Zero) + } + val revealProgress = remember { Animatable(0f) } + var menuOpenProgress by remember { mutableFloatStateOf(0f) } + val overlayScope = rememberCoroutineScope() + val dmUnreadCount = contextMenuState.otherUserId?.let { userId -> + uiState.conversations.find { it.otherUserId == userId }?.unreadCount ?: 0 + } ?: 0 + + val rowSize = contextMenuState.rowSize + + var sessionBlockTopY by remember( + contextMenuState.listIndex, + contextMenuState.target, + contextMenuState.otherUserId, + ) { + mutableStateOf(null) + } + var sessionBlockLeftX by remember( + contextMenuState.listIndex, + contextMenuState.target, + contextMenuState.otherUserId, + ) { + mutableStateOf(null) + } + var sessionBlockWidthPx by remember( + contextMenuState.listIndex, + contextMenuState.target, + contextMenuState.otherUserId, + ) { + mutableStateOf(null) + } + + ChatContextMenuMeasurer( + state = contextMenuState, + listFilter = uiState.listFilter, + callsEnabled = uiState.callsEnabled, + dmUnreadCount = dmUnreadCount, + showPublicMarkRead = uiState.publicHasUnread, + hasPublicLink = !uiState.publicChatLink.isNullOrBlank(), + isReadOnly = uiState.isReadOnly, + screenWidthPx = screenWidthPx, + screenHeightPx = screenHeightPx, + onMeasured = { size -> + if (menuSize != size) menuSize = size + }, + ) + + val layoutReady = menuSize != IntSize.Zero && + rowSize != IntSize.Zero && + overlaySize != IntSize.Zero + + LaunchedEffect( + contextMenuState.listIndex, + contextMenuState.target, + contextMenuState.otherUserId, + layoutReady, + ) { + if (!layoutReady) return@LaunchedEffect + if (sessionBlockTopY == null || sessionBlockLeftX == null || sessionBlockWidthPx == null) { + val blockWidth = maxOf( + rowSize.width, + if (menuSize == IntSize.Zero) 0 else menuSize.width, + ) + val blockHeightPx = if (menuSize == IntSize.Zero) { + rowSize.height.toFloat() + } else { + rowSize.height + menuGapPx + menuSize.height + } + + val centeredBlockTopY = chatContextMenuCenteredBlockTopY( + rowOffsetY = contextMenuState.rowOffset.y, + blockHeightPx = blockHeightPx, + overlayOriginY = overlayOriginInRoot.y, + overlayHeightPx = overlaySize.height, + paddingPx = paddingPx, + ) + + val clampedLeftX = if (menuSize == IntSize.Zero || overlaySize == IntSize.Zero) { + contextMenuState.rowOffset.x.toInt() + } else { + chatContextMenuClampedMenuX( + preferredLeftX = contextMenuState.rowOffset.x, + menuWidth = blockWidth, + overlayOriginX = overlayOriginInRoot.x, + overlayWidth = overlaySize.width, + paddingPx = paddingPx, + ) + } + + sessionBlockTopY = centeredBlockTopY + sessionBlockLeftX = clampedLeftX.toFloat() + sessionBlockWidthPx = blockWidth + } + } + + val blockWidthPx = sessionBlockWidthPx + ?: maxOf(rowSize.width, if (menuSize == IntSize.Zero) 0 else menuSize.width) + + val targetTopY = sessionBlockTopY ?: contextMenuState.rowOffset.y + val targetLeftX = sessionBlockLeftX ?: contextMenuState.rowOffset.x + + val progress = when (contextMenuState.phase) { + ChatContextMenuPhase.Animating, ChatContextMenuPhase.Open -> revealProgress.value + else -> 0f + } + + val animatedBlockTopY = contextMenuState.rowOffset.y + + (targetTopY - contextMenuState.rowOffset.y) * progress + val animatedBlockLeftX = contextMenuState.rowOffset.x + + (targetLeftX - contextMenuState.rowOffset.x) * progress + val applyPressScale = contextMenuState.phase == ChatContextMenuPhase.Animating && + !contextMenuState.animatingOut + val scale = chatRowContextMenuScale(progress, applyPressScale) + val shadowElevationPx = with(density) { 12.dp.toPx() * progress } + val blurProgress = if (contextMenuState.isBlurActive) progress else 0f + + SideEffect { + onBlurProgressChange(blurProgress) + onRowRevealProgressChange(progress) + } + + LaunchedEffect(contextMenuState.phase, contextMenuState.animatingOut) { + when (contextMenuState.phase) { + ChatContextMenuPhase.Animating -> { + if (contextMenuState.animatingOut) { + coroutineScope { + val menuJob = launch { + animate( + initialValue = menuOpenProgress, + targetValue = 0f, + animationSpec = ChatContextMenuOpenSpring, + ) { value, _ -> + menuOpenProgress = value + } + } + val revealJob = launch { + revealProgress.animateTo(0f, ChatContextMenuRevealSpring) + } + menuJob.join() + revealJob.join() + } + onDismiss() + } else { + revealProgress.snapTo(0f) + menuOpenProgress = 0f + snapshotFlow { + menuSize != IntSize.Zero && + rowSize != IntSize.Zero && + overlaySize != IntSize.Zero + } + .distinctUntilChanged() + .first { it } + coroutineScope { + val revealJob = launch { + revealProgress.animateTo(1f, ChatContextMenuRevealSpring) + } + val menuJob = launch { + animate( + initialValue = 0f, + targetValue = 1f, + animationSpec = ChatContextMenuOpenSpring, + ) { value, _ -> + menuOpenProgress = value + } + } + revealJob.join() + menuJob.join() + } + onStateChange(contextMenuState.copy(phase = ChatContextMenuPhase.Open)) + } + } + ChatContextMenuPhase.Pressing -> { + revealProgress.snapTo(0f) + menuOpenProgress = 0f + } + else -> Unit + } + } + + LaunchedEffect(contextMenuState.phase, menuSize, layoutReady) { + if (contextMenuState.phase != ChatContextMenuPhase.Open || !layoutReady) return@LaunchedEffect + if (revealProgress.value < 1f) { + revealProgress.snapTo(1f) + } + if (menuOpenProgress < 1f) { + animate( + initialValue = menuOpenProgress, + targetValue = 1f, + animationSpec = ChatContextMenuOpenSpring, + ) { value, _ -> + menuOpenProgress = value + } + } + } + + LaunchedEffect( + layoutReady, + contextMenuState.listIndex, + contextMenuState.target, + contextMenuState.otherUserId, + ) { + if (layoutReady && contextMenuState.isOverlayReplicaActive) { + onOverlayCloneReady() + } + } + + fun requestDismiss() { + when (contextMenuState.phase) { + ChatContextMenuPhase.Open -> { + onStateChange(contextMenuState.copy(phase = ChatContextMenuPhase.Animating, animatingOut = true)) + } + ChatContextMenuPhase.Animating -> { + if (!contextMenuState.animatingOut) { + onStateChange(contextMenuState.copy(animatingOut = true)) + } + } + ChatContextMenuPhase.Pressing -> onDismiss() + else -> Unit + } + } + + val relativeBlockOffset = IntOffset( + (animatedBlockLeftX - overlayOriginInRoot.x).toInt(), + (animatedBlockTopY - overlayOriginInRoot.y).toInt(), + ) + + val touchBlockActive = contextMenuState.phase == ChatContextMenuPhase.Animating || + contextMenuState.phase == ChatContextMenuPhase.Open + val menuScale = 0.5f + 0.5f * menuOpenProgress + val menuAlpha = menuOpenProgress + val menuCornerRadius = 16.dp * menuOpenProgress + + BackHandler( + enabled = contextMenuState.isOverlayReplicaActive, + ) { + requestDismiss() + } + + PredictiveBackHandler( + enabled = contextMenuState.isOverlayReplicaActive && !contextMenuState.animatingOut, + onProgress = { backProgress -> + if (contextMenuState.phase != ChatContextMenuPhase.Open) return@PredictiveBackHandler + val preview = (1f - backProgress).coerceIn(0f, 1f) + overlayScope.launch { + revealProgress.snapTo(preview) + menuOpenProgress = preview + } + }, + onCommit = { requestDismiss() }, + onCancel = { + if (contextMenuState.phase == ChatContextMenuPhase.Open && !contextMenuState.animatingOut) { + overlayScope.launch { + coroutineScope { + launch { revealProgress.animateTo(1f, ChatContextMenuRevealSpring) } + launch { + animate( + initialValue = menuOpenProgress, + targetValue = 1f, + animationSpec = ChatContextMenuOpenSpring, + ) { value, _ -> + menuOpenProgress = value } } } } - ) - } - ) { innerPadding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - ) { - SearchBar( - query = "", - onQueryChange = {}, - onSearch = {}, - placeholder = searchBarHint, - readOnly = true, - onReadOnlyActivate = onOpenSearch, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp), - leadingIcon = { - Icon( - imageVector = Icons.Default.Search, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - }, - sharedTransitionScope = sharedTransitionScope, - animatedVisibilityScope = animatedVisibilityScope, - sharedElementKey = SearchBarSharedElement - ) - - SuspendedAccountNoticeHost( - isSuspended = suspensionState.isSuspended, - reason = suspensionState.reason, - fallbackReason = suspendDefaultReason, - bannerTitle = suspendBannerTitle, - style = SuspendedAccountBannerStyle.Tabs, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp) - ) - - ChatConversationsList( - listState = tabListState, - conversations = dmConversations, - publicChatTitle = publicChatTitle, - publicLastMessagePreview = publicLastMessagePreview, - defaultLastMessage = defaultLastMessage, - statusMap = statusMap, - modifier = Modifier.fillMaxSize(), - onOpenPublic = { navController.navigate("chats/publicChat") }, - onOpenProfile = { userId -> - if (userId != 0) { - navController.navigate("profile/$userId") - } - }, - onOpenConversation = { userId -> - if (userId != 0) { - navController.navigate(DmNav.chatRoute(userId)) - } - } - ) - } - } - -} - -@Composable -private fun ChatConversationsList( - listState: LazyListState, - conversations: List, - publicChatTitle: String, - publicLastMessagePreview: String?, - defaultLastMessage: String, - statusMap: Map, - modifier: Modifier = Modifier, - onOpenPublic: () -> Unit, - onOpenProfile: (Int) -> Unit, - onOpenConversation: (Int) -> Unit -) { - LazyColumn( - state = listState, - modifier = modifier, - contentPadding = PaddingValues(bottom = 12.dp) - ) { - item { - ListItem( - leadingContent = { - ChatRowAvatar( - profilePictureUrl = null, - displayNameForInitials = publicChatTitle, - onClick = onOpenPublic - ) - }, - headlineContent = { - Text( - text = publicChatTitle, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - }, - supportingContent = { - Text( - text = publicLastMessagePreview ?: defaultLastMessage, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - }, - modifier = Modifier.clickable { onOpenPublic() } - ) - } - - items(conversations.size) { index -> - DmConversationRow( - conversation = conversations[index], - defaultLastMessage = defaultLastMessage, - statusMap = statusMap, - onOpenProfile = onOpenProfile, - onOpenConversation = onOpenConversation - ) - } - } -} - -@Composable -internal fun SearchConversationsList( - listState: LazyListState, - conversations: List, - defaultLastMessage: String, - statusMap: Map, - modifier: Modifier = Modifier, - onOpenProfile: (Int) -> Unit, - onOpenConversation: (Int) -> Unit -) { - LazyColumn( - state = listState, - modifier = modifier, - contentPadding = PaddingValues(bottom = 12.dp) - ) { - items(conversations.size) { index -> - DmConversationRow( - conversation = conversations[index], - defaultLastMessage = defaultLastMessage, - statusMap = statusMap, - onOpenProfile = onOpenProfile, - onOpenConversation = onOpenConversation - ) - } - } -} - -@Composable -private fun DmConversationRow( - conversation: CachedConversation, - defaultLastMessage: String, - statusMap: Map, - onOpenProfile: (Int) -> Unit, - onOpenConversation: (Int) -> Unit -) { - 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( - leadingContent = { - ChatRowAvatar( - profilePictureUrl = avatarUrl, - displayNameForInitials = peerTitle, - onClick = { onOpenProfile(conversation.otherUserId) } - ) - }, - headlineContent = { - Text( - text = peerTitle, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - }, - supportingContent = { - 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 - ) - } } }, - trailingContent = { - if (conversation.unreadCount > 0) { - Text(stringResource(Res.string.unread_count, conversation.unreadCount)) - } - }, - modifier = Modifier.clickable { - onOpenConversation(conversation.otherUserId) - } ) -} \ No newline at end of file + + Box( + modifier = modifier + .fillMaxSize() + .onGloballyPositioned { coords -> + overlayOriginInRoot = coords.positionInRoot() + overlaySize = coords.size + }, + ) { + if (touchBlockActive) { + Box( + modifier = Modifier + .fillMaxSize() + .pointerInput(Unit) { + detectTapGestures(onTap = { requestDismiss() }) + }, + ) + } + + Box( + modifier = Modifier + .offset { relativeBlockOffset } + .width(with(density) { blockWidthPx.toDp() }), + ) { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + ChatRowScaleContainer( + listItemPosition = contextMenuState.listItemPosition, + groupItemCount = contextMenuState.groupItemCount, + pressScale = 1f, + shadowElevationPx = shadowElevationPx, + modifier = Modifier + .height(with(density) { rowSize.height.toDp() }) + .graphicsLayer { + scaleX = scale + scaleY = scale + transformOrigin = TransformOrigin(0.5f, 0.5f) + } + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + event.changes.forEach { it.consume() } + } + } + }, + ) { + when (contextMenuState.target) { + ChatContextMenuTarget.Public -> { + PublicChatRowContent( + publicChatTitle = uiState.publicChatTitle, + publicLastMessagePreview = uiState.publicLastMessagePreview, + defaultLastMessage = uiState.defaultLastMessage, + listMode = uiState.listMode, + selectionTransitionProgress = uiState.selectionTransitionProgress, + isSelected = uiState.publicChatSelected, + listItemPosition = contextMenuState.listItemPosition, + groupItemCount = contextMenuState.groupItemCount, + avatarEnabled = false, + onOpenPublic = {}, + onAvatarPressStart = {}, + onAvatarPressEnd = {}, + onAvatarLongPress = {}, + onBodyLongPress = {}, + ) + } + + ChatContextMenuTarget.Dm -> { + val userId = contextMenuState.otherUserId + val conversation = uiState.conversations.find { it.otherUserId == userId } + if (conversation != null) { + DmConversationRowContent( + conversation = conversation, + defaultLastMessage = uiState.defaultLastMessage, + statusMap = uiState.statusMap, + listMode = uiState.listMode, + selectionTransitionProgress = uiState.selectionTransitionProgress, + isSelected = userId in uiState.selectedOtherUserIds, + listItemPosition = contextMenuState.listItemPosition, + groupItemCount = contextMenuState.groupItemCount, + avatarEnabled = false, + onOpenConversation = {}, + onAvatarPressStart = {}, + onAvatarPressEnd = {}, + onAvatarLongPress = {}, + onBodyLongPress = {}, + ) + } + } + } + } + + if (menuSize != IntSize.Zero && menuOpenProgress > 0f) { + Spacer(Modifier.height(8.dp)) + ChatContextMenuPanel( + state = contextMenuState, + listFilter = uiState.listFilter, + callsEnabled = uiState.callsEnabled, + dmUnreadCount = dmUnreadCount, + showPublicMarkRead = uiState.publicHasUnread, + hasPublicLink = !uiState.publicChatLink.isNullOrBlank(), + onDismiss = { requestDismiss() }, + onMessage = onMessage, + onCall = onCall, + onLink = onLink, + onMarkRead = onMarkRead, + onDelete = onDelete, + onSelect = onSelect, + isReadOnly = uiState.isReadOnly, + scale = menuScale, + alpha = menuAlpha, + cornerRadius = menuCornerRadius, + ) + } + } + } + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTabBannerHost.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTabBannerHost.kt deleted file mode 100644 index 0469ed7..0000000 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTabBannerHost.kt +++ /dev/null @@ -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, - modifier: Modifier = Modifier -) { - val dismissedByUser = remember { mutableStateMapOf() } - - 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" - ) - } - } - } - } -} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/AboutScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/AboutScreen.kt index e28a0c0..93691ef 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/AboutScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/AboutScreen.kt @@ -10,6 +10,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.outlined.Description +import androidx.compose.material.icons.outlined.Shield import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -38,11 +40,14 @@ import org.jetbrains.compose.resources.vectorResource import ru.fromchat.Res import ru.fromchat.about import ru.fromchat.about_link_max +import ru.fromchat.about_link_privacy import ru.fromchat.about_link_telegram +import ru.fromchat.about_link_terms import ru.fromchat.about_link_website import ru.fromchat.about_version import ru.fromchat.app_desc import ru.fromchat.back +import ru.fromchat.legal.DocumentType import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.components.BrandTitle @@ -146,6 +151,7 @@ fun AboutScreen() { headline = stringResource(Res.string.about_link_website), supportingText = URL_WEBSITE, onClick = { uriHandler.openUri(URL_WEBSITE) }, + divider = true, leadingContent = { Icon( imageVector = Icons.Outlined.Website, @@ -155,6 +161,33 @@ fun AboutScreen() { ) } ) + + ListItem( + headline = stringResource(Res.string.about_link_privacy), + onClick = { navController.navigate(DocumentType.route(DocumentType.Privacy)) }, + divider = true, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Shield, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + ) + + ListItem( + headline = stringResource(Res.string.about_link_terms), + onClick = { navController.navigate(DocumentType.route(DocumentType.Terms)) }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Description, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + ) } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/EditProfileScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/EditProfileScreen.kt new file mode 100644 index 0000000..30aa455 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/EditProfileScreen.kt @@ -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(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().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, + ) + } + } + } + } + } + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileRoutes.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileRoutes.kt new file mode 100644 index 0000000..21c0a3d --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileRoutes.kt @@ -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}" +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt index 5c572a6..67c0c1d 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/profile/ProfileScreen.kt @@ -3,48 +3,82 @@ package ru.fromchat.ui.profile import androidx.compose.animation.AnimatedContent 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.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring import androidx.compose.foundation.LocalIndication -import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.indication import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.heightIn +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.Surface +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import com.pr0gramm3r101.components.ListItemPosition +import dev.chrisbanes.haze.HazeProgressive +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import dev.chrisbanes.haze.materials.HazeMaterials +import kotlinx.coroutines.delay +import ru.fromchat.api.local.db.store.PublicChatProfileCache +import ru.fromchat.api.schema.chats.publicchat.PublicChatProfile +import ru.fromchat.back +import ru.fromchat.chat_members_count +import ru.fromchat.legal.MarkdownPlain +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.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets 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.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.Chat import androidx.compose.material.icons.filled.AlternateEmail import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Link +import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Verified -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults +import androidx.compose.material.icons.rounded.Call +import androidx.compose.material.icons.rounded.ContentCopy +import androidx.compose.material.icons.rounded.Edit import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.MediumTopAppBar -import androidx.compose.material3.Scaffold -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -57,19 +91,20 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.compose.LocalLifecycleOwner -import androidx.lifecycle.repeatOnLifecycle import com.pr0gramm3r101.components.Category +import com.pr0gramm3r101.components.ContextMenuPressable import com.pr0gramm3r101.components.ListItem +import com.pr0gramm3r101.components.listItemPositionInGroup +import com.pr0gramm3r101.utils.supportClipboardManagerImpl +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState import io.ktor.client.plugins.ClientRequestException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -77,36 +112,47 @@ import kotlinx.coroutines.withContext import org.jetbrains.compose.resources.stringResource import ru.fromchat.Logger import ru.fromchat.Res -import ru.fromchat.action_chat -import ru.fromchat.action_copy_link -import ru.fromchat.action_open_settings +import ru.fromchat.action_copy +import ru.fromchat.action_edit import ru.fromchat.api.ApiClient +import ru.fromchat.api.calls.CallStore import ru.fromchat.api.local.db.store.ProfileCache import ru.fromchat.api.local.db.store.UserStatus import ru.fromchat.api.local.db.store.UserStatusStore import ru.fromchat.api.local.db.store.visibleDisplayName import ru.fromchat.api.local.db.store.visibleUsername import ru.fromchat.api.schema.user.profile.UserProfile -import ru.fromchat.back +import ru.fromchat.config.ServerConfig +import ru.fromchat.feature_not_implemented import ru.fromchat.presence_online import ru.fromchat.presence_recently +import ru.fromchat.profile_action_call +import ru.fromchat.profile_action_chat +import ru.fromchat.profile_action_link +import ru.fromchat.profile_action_search +import ru.fromchat.profile_action_settings import ru.fromchat.profile_headline_bio import ru.fromchat.profile_headline_member_since import ru.fromchat.profile_headline_username import ru.fromchat.profile_headline_verification import ru.fromchat.profile_load_failed import ru.fromchat.profile_not_found -import ru.fromchat.profile_title import ru.fromchat.profile_verified_support import ru.fromchat.profile_verify_prompt_support import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.chat.Avatar import ru.fromchat.ui.chat.TypingIndicator -import ru.fromchat.ui.chat.panels.publicchat.publicChatProfileSharedAvatarKey +import ru.fromchat.ui.components.FromChatSnackbarHost import ru.fromchat.ui.components.Text +import ru.fromchat.ui.components.showReplacingSnackbar import ru.fromchat.utils.formatLastSeen +import ru.fromchat.utils.formatProfileRegistrationDate +import ru.fromchat.utils.haptic.HapticFeedbackEvent +import ru.fromchat.utils.haptic.rememberHapticFeedback import ru.fromchat.utils.rememberLastSeenFormatStrings -import com.pr0gramm3r101.utils.scaleOnPress +import ru.fromchat.utils.rememberRegistrationDateFormatStrings + +private const val ProfileDisplayNamePressScale = 0.92f private sealed interface ProfileLoadError { data object Generic : ProfileLoadError @@ -115,15 +161,30 @@ private sealed interface ProfileLoadError { private data class ProfileUiState( val profile: UserProfile? = null, - val isLoading: Boolean = true, + val isLoading: Boolean = false, val error: ProfileLoadError? = null ) -private val profileActionCardPressSpring = spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessLow, - visibilityThreshold = 0.001f -) +private fun resolveCachedProfile( + targetUserId: Int?, + targetUsername: String?, + ownUserId: Int?, +): UserProfile? { + targetUserId?.takeIf { it > 0 }?.let { ProfileCache.get(it) }?.let { return it } + targetUsername?.trim()?.takeIf { it.isNotBlank() }?.let { ProfileCache.findByUsername(it) }?.let { return it } + ownUserId?.takeIf { it > 0 }?.let { ProfileCache.get(it) }?.let { return it } + return null +} + +private fun hasDisplayableProfile( + profile: UserProfile?, + initialDisplayName: String?, + currentUserId: Int?, +) = !initialDisplayName.isNullOrBlank() || + (profile != null && profile.id > 0 && ( + !profile.visibleDisplayName(currentUserId).isNullOrBlank() || + profile.username.isNotBlank() + )) @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -138,24 +199,47 @@ fun ProfileScreen( sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, sharedAvatarKey: Any? = null, - useSharedElementFromNavigation: Boolean = false, - sharedSourceMessageId: Int = -1, initialDisplayName: String? = null, + initialProfilePictureUrl: String? = null, showErrorAsToast: Boolean = false, - onOpenSettings: () -> Unit = {} + onOpenSettings: () -> Unit = {}, + showBackButton: Boolean = false, ) { val clipboardManager: ClipboardManager = LocalClipboardManager.current + val clipboard = supportClipboardManagerImpl val navController = LocalNavController.current - val lifecycleOwner = LocalLifecycleOwner.current val scope = rememberCoroutineScope() + val density = LocalDensity.current + val statusBarTopDp = with(density) { WindowInsets.statusBars.getTop(this).toDp() } + val profileAvatarTop = statusBarTopDp + 24.dp val profileLoadFailed = stringResource(Res.string.profile_load_failed) val profileNotFound = stringResource(Res.string.profile_not_found) + val labelCopy = stringResource(Res.string.action_copy) + val labelEdit = stringResource(Res.string.action_edit) + val labelSettings = stringResource(Res.string.profile_action_settings) + val labelLink = stringResource(Res.string.profile_action_link) + val labelChat = stringResource(Res.string.profile_action_chat) + val labelCall = stringResource(Res.string.profile_action_call) + val labelSearch = stringResource(Res.string.profile_action_search) + val notImplementedMessage = stringResource(Res.string.feature_not_implemented) + + val snackbarHostState = remember { SnackbarHostState() } + val haptic = rememberHapticFeedback() + val openContextMenuHaptic: () -> Unit = { haptic(HapticFeedbackEvent.ContextMenuOpened) } val targetUserId = userId.takeIf { it != null && it > 0 } val targetUsername = username?.trim()?.takeIf { it.isNotBlank() } val ownUserId = ApiClient.user?.id?.takeIf { it > 0 } + targetUserId?.let { id -> + ProfileCache.mergePreview( + id = id, + displayName = initialDisplayName?.takeIf { it.isNotBlank() }, + profilePicture = initialProfilePictureUrl?.takeIf { it.isNotBlank() }, + ) + } + val cacheLookupId = when { targetUserId != null -> targetUserId targetUsername != null -> null @@ -174,492 +258,608 @@ fun ProfileScreen( else -> "self" } - var state by remember(targetUserId, targetUsername, ownUserId) { + val lookupKey = listOfNotNull(targetUserId, targetUsername, ownUserId).joinToString("|") + + var state by remember(lookupKey) { + val cached = resolveCachedProfile(targetUserId, targetUsername, ownUserId) mutableStateOf( - cacheLookupId?.let { ProfileCache.get(it) }.let { - ProfileUiState( - profile = it, - isLoading = it == null, - error = null - ) - } + ProfileUiState( + profile = cached, + isLoading = !hasDisplayableProfile(cached, initialDisplayName, ownUserId), + error = null + ) ) } + val hasShownContent = remember(lookupKey) { + mutableStateOf(hasDisplayableProfile(state.profile, initialDisplayName, ownUserId)) + } + val latestUi by rememberUpdatedState(state) - LaunchedEffect(cacheLookupId, targetUserId, targetUsername, lifecycleOwner) { - Logger.d( - "ProfileScreen", - "load start: mode=$lookupMode identifier=$lookupIdentifier cacheLookupId=$cacheLookupId ownUserId=$ownUserId " + - "lifecycleStarted=${lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)}" - ) - - lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + val backStackEntry = navController.currentBackStackEntry + LaunchedEffect(backStackEntry, lookupKey) { + val handle = backStackEntry?.savedStateHandle ?: return@LaunchedEffect + handle.getStateFlow(ProfileRoutes.REFRESH_KEY, false).collect { shouldRefresh -> + if (!shouldRefresh) return@collect + handle[ProfileRoutes.REFRESH_KEY] = false + if (targetUserId != null || targetUsername != null) return@collect try { - val profile = when { - targetUserId == null && targetUsername == null -> ApiClient.getOwnProfile() - targetUsername != null -> ApiClient.getProfileByUsername(targetUsername) - else -> ApiClient.getProfileById(targetUserId!!) + val refreshed = ApiClient.getOwnProfile() + ProfileCache.put(refreshed) + state = latestUi.copy(profile = refreshed, error = null) + } catch (_: Exception) { + ownUserId?.let { ProfileCache.get(it) }?.let { cached -> + state = latestUi.copy(profile = cached) } - - if (profile.username.isBlank() && profile.displayName.isNullOrBlank()) { - cacheLookupId?.let { ProfileCache.evictUnusableClientPreview(it) } - - Logger.d( - "ProfileScreen", - "load success but blank identity for id=${profile.id}, dropping " + - "as unusable preview" - ) - - state = latestUi.copy( - profile = null, - isLoading = false, - error = ProfileLoadError.Generic - ) - - return@repeatOnLifecycle - } - - Logger.d( - "ProfileScreen", - "load success: mode=$lookupMode identifier=$lookupIdentifier -> " + - "id=${profile.id}, username='${profile.username}', " + - "display='${profile.displayName}', deleted=${profile.deleted}, " + - "suspended=${profile.suspended}" - ) - - ProfileCache.put(profile) - state = latestUi.copy(profile = profile, isLoading = false, error = null) - } catch (err: Exception) { - val fallbackId = ( - if (targetUsername != null) null else targetUserId ?: ownUserId - )?.also { - ProfileCache.evictUnusableClientPreview(it) - } - - val fallback = fallbackId?.let { ProfileCache.get(it) } - - Logger.d( - "ProfileScreen", - "load failure fallback lookup: fallbackId=$fallbackId " + - "fallbackFound=${fallback != null}" - ) - - val resolvedErrorMessage = when { - err is ClientRequestException && err.response.status.value == 404 -> profileNotFound - else -> err.message?.takeIf { it.isNotBlank() } ?: profileLoadFailed - } - - if (err is ClientRequestException) { - Logger.d( - "ProfileScreen", - "load failed: mode=$lookupMode identifier=$lookupIdentifier " + - "status=${err.response.status.value} fallbackId=$fallbackId error=${err.message}" - ) - } else { - Logger.d( - "ProfileScreen", - "load failed: mode=$lookupMode identifier=$lookupIdentifier " + - "errorType=${err::class.simpleName} message=${err.message}" - ) - } - - if ( - showErrorAsToast && - (targetUserId != null || targetUsername != null) && - latestUi.profile == null && - fallback == null - ) { - showProfileLoadErrorMessage(resolvedErrorMessage) - } - - state = latestUi.copy( - error = if (latestUi.profile == null && fallback == null) { - if (resolvedErrorMessage == profileLoadFailed) { - ProfileLoadError.Generic - } else { - ProfileLoadError.Message(resolvedErrorMessage) - } - } else { - null - }, - profile = latestUi.profile ?: fallback, - isLoading = false - ) } } } - val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) + LaunchedEffect(lookupMode, lookupIdentifier) { + Logger.d( + "ProfileScreen", + "load start: mode=$lookupMode identifier=$lookupIdentifier cacheLookupId=$cacheLookupId ownUserId=$ownUserId" + ) - Scaffold( - modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - topBar = { - MediumTopAppBar( - title = { Text(stringResource(Res.string.profile_title)) }, - navigationIcon = { - if (navController.currentDestination?.route != "chat") { - IconButton(onClick = onBack) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(Res.string.back), - modifier = Modifier.size(24.dp) - ) - } - } - }, - scrollBehavior = scrollBehavior + try { + val profile = when { + targetUserId == null && targetUsername == null -> ApiClient.getOwnProfile() + targetUsername != null -> ApiClient.getProfileByUsername(targetUsername) + else -> ApiClient.getProfileById(targetUserId!!) + } + + if (profile.username.isBlank() && profile.displayName.isNullOrBlank()) { + cacheLookupId?.let { ProfileCache.evictUnusableClientPreview(it) } + + Logger.d( + "ProfileScreen", + "load success but blank identity for id=${profile.id}, dropping " + + "as unusable preview" + ) + + state = latestUi.copy( + profile = null, + isLoading = false, + error = ProfileLoadError.Generic + ) + + return@LaunchedEffect + } + + Logger.d( + "ProfileScreen", + "load success: mode=$lookupMode identifier=$lookupIdentifier -> " + + "id=${profile.id}, username='${profile.username}', " + + "display='${profile.displayName}', deleted=${profile.deleted}, " + + "suspended=${profile.suspended}" ) - } - ) { innerPadding -> - Box( - modifier = Modifier.fillMaxSize() - ) { - val loadError = state.error - val profile = state.profile - val currentProfileUserId = targetUserId ?: ownUserId ?: profile?.id - val displayName = - profile?.visibleDisplayName(currentProfileUserId) - ?: initialDisplayName?.takeIf { it.isNotBlank() } - ?: "?" - val usernameForLinks = profile?.visibleUsername(currentProfileUserId) + ProfileCache.put(profile) + state = latestUi.copy(profile = profile, isLoading = false, error = null) + } catch (err: Exception) { + val fallbackId = ( + if (targetUsername != null) null else targetUserId ?: ownUserId + )?.also { + ProfileCache.evictUnusableClientPreview(it) + } - val navSharedAvatarKey = - if (useSharedElementFromNavigation && targetUserId != null && sharedSourceMessageId != -1) { - publicChatProfileSharedAvatarKey(targetUserId, sharedSourceMessageId) + val fallback = fallbackId?.let { ProfileCache.get(it) } + ?: resolveCachedProfile(targetUserId, targetUsername, ownUserId) + + Logger.d( + "ProfileScreen", + "load failure fallback lookup: fallbackId=$fallbackId " + + "fallbackFound=${fallback != null}" + ) + + val resolvedErrorMessage = when { + err is ClientRequestException && err.response.status.value == 404 -> profileNotFound + else -> err.message?.takeIf { it.isNotBlank() } ?: profileLoadFailed + } + + if (err is ClientRequestException) { + Logger.d( + "ProfileScreen", + "load failed: mode=$lookupMode identifier=$lookupIdentifier " + + "status=${err.response.status.value} fallbackId=$fallbackId error=${err.message}" + ) + } else { + Logger.d( + "ProfileScreen", + "load failed: mode=$lookupMode identifier=$lookupIdentifier " + + "errorType=${err::class.simpleName} message=${err.message}" + ) + } + + val resolvedProfile = latestUi.profile ?: fallback + + if ( + showErrorAsToast && + (targetUserId != null || targetUsername != null) && + !hasDisplayableProfile(resolvedProfile, initialDisplayName, ownUserId) + ) { + showProfileLoadErrorMessage(resolvedErrorMessage) + } + + state = latestUi.copy( + error = if (!hasDisplayableProfile(resolvedProfile, initialDisplayName, ownUserId)) { + if (resolvedErrorMessage == profileLoadFailed) { + ProfileLoadError.Generic + } else { + ProfileLoadError.Message(resolvedErrorMessage) + } } else { null + }, + profile = resolvedProfile, + isLoading = false + ) + } + } + + val hazeState = rememberHazeState() + val detailsBringIntoView = remember { BringIntoViewRequester() } + val registrationDateStrings = rememberRegistrationDateFormatStrings() + + val presenceOnline = stringResource(Res.string.presence_online) + val presenceRecently = stringResource(Res.string.presence_recently) + val headlineUsername = stringResource(Res.string.profile_headline_username) + val headlineMemberSince = stringResource(Res.string.profile_headline_member_since) + val headlineBio = stringResource(Res.string.profile_headline_bio) + val headlineVerification = stringResource(Res.string.profile_headline_verification) + val verifiedSupport = stringResource(Res.string.profile_verified_support) + val verifyPromptSupport = stringResource(Res.string.profile_verify_prompt_support) + + val profile = state.profile ?: resolveCachedProfile(targetUserId, targetUsername, ownUserId) + if (hasDisplayableProfile(profile, initialDisplayName, ownUserId)) { + hasShownContent.value = true + } + val statusMap by UserStatusStore.status.collectAsState() + val lastSeenFormatStrings = rememberLastSeenFormatStrings() + val loadError = state.error + val showLoadingSpinner = state.isLoading && !hasShownContent.value + val currentProfileUserId = targetUserId ?: ownUserId ?: profile?.id + val displayName = + profile?.visibleDisplayName(currentProfileUserId) + ?: initialDisplayName?.takeIf { it.isNotBlank() } + ?: "?" + val usernameForLinks = profile?.visibleUsername(currentProfileUserId) + + val resolvedProfile = profile?.takeIf { loadError == null && !showLoadingSpinner } + val profileLink = resolvedProfile?.let { + usernameForLinks?.let { name -> "https://fromchat.ru/@$name" } + ?: "https://fromchat.ru/?u=${it.id}" + } + val isOwnProfile = resolvedProfile?.let { ApiClient.user?.id == it.id } == true + val statusState = resolvedProfile?.let { p -> + statusMap[p.id] ?: UserStatus(online = p.online, lastSeen = p.lastSeen) + } + val typingUsers = statusState?.typingUsernames.orEmpty() + val statusText = when { + statusState?.online == true -> presenceOnline + statusState != null -> formatLastSeen( + false, + statusState.lastSeen, + lastSeenFormatStrings, + ).ifEmpty { presenceRecently } + else -> "" + } + val listItemIconTint = MaterialTheme.colorScheme.onSurfaceVariant + val profileActions = resolvedProfile?.let { p -> + if (isOwnProfile) { + listOf( + ProfileAction( + label = labelEdit, + icon = Icons.Filled.Edit, + holdsExpansionOnNavigate = true, + onClick = { navController.navigate(ProfileRoutes.editRoute()) }, + ), + ProfileAction( + label = labelLink, + icon = Icons.Filled.Link, + onClick = { clipboardManager.setText(AnnotatedString(profileLink.orEmpty())) }, + ), + ProfileAction( + label = labelSettings, + icon = Icons.Filled.Settings, + holdsExpansionOnNavigate = true, + onClick = onOpenSettings, + ), + ) + } else { + buildList { + add( + ProfileAction( + label = labelChat, + icon = Icons.AutoMirrored.Filled.Chat, + holdsExpansionOnNavigate = true, + onClick = { onChat(p.id) }, + ) + ) + add( + ProfileAction( + label = labelLink, + icon = Icons.Filled.Link, + onClick = { clipboardManager.setText(AnnotatedString(profileLink.orEmpty())) }, + ) + ) + if (ServerConfig.callsEnabled) { + add( + ProfileAction( + label = labelCall, + icon = Icons.Rounded.Call, + onClick = { scope.launch { CallStore.startOutgoingCall(p.id) } }, + ) + ) } + add( + ProfileAction( + label = labelSearch, + icon = Icons.Filled.Search, + onClick = { + scope.launch { + snackbarHostState.showReplacingSnackbar( + message = notImplementedMessage, + withDismissAction = false, + duration = SnackbarDuration.Short, + ) + } + }, + ) + ) + } + } + } + val showDetailsUsername = usernameForLinks != null + val showDetailsMemberSince = !resolvedProfile?.createdAt.isNullOrBlank() + val showDetailsBio = !resolvedProfile?.bio.isNullOrBlank() + val showDetailsVerify = resolvedProfile?.verified == true || ApiClient.user?.id == 1 + val showDetailsSection = resolvedProfile != null && ( + showDetailsUsername || showDetailsMemberSince || showDetailsBio || showDetailsVerify + ) - val effectiveSharedAvatarKey: Any? = sharedAvatarKey ?: navSharedAvatarKey - val useSharedAvatar = sharedTransitionScope != null && - animatedVisibilityScope != null && - effectiveSharedAvatarKey != null + Box( + modifier = modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.navigationBars), + ) { + val useSharedAvatar = sharedTransitionScope != null && + animatedVisibilityScope != null && + sharedAvatarKey != null - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(innerPadding), - horizontalAlignment = Alignment.CenterHorizontally + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .hazeSource(hazeState), + ) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, ) { when { useSharedAvatar -> { - with(sharedTransitionScope) { + item { + with(sharedTransitionScope) { + Avatar( + profilePictureUrl = profile?.profilePicture, + displayName = displayName, + modifier = Modifier + .padding(top = profileAvatarTop) + .sharedElement( + rememberSharedContentState(key = sharedAvatarKey), + animatedVisibilityScope = animatedVisibilityScope + ) + .size(104.dp) + ) + } + } + item { Spacer(Modifier.height(12.dp)) } + } + + !hideAvatar -> { + item { Avatar( profilePictureUrl = profile?.profilePicture, displayName = displayName, modifier = Modifier - .padding(top = 16.dp) - .sharedElement( - rememberSharedContentState( - key = effectiveSharedAvatarKey - ), - animatedVisibilityScope = animatedVisibilityScope - ) - .size(128.dp) + .padding(top = profileAvatarTop) + .size(104.dp) ) } - - Spacer(modifier = Modifier.height(12.dp)) - } - - !hideAvatar -> { - Avatar( - profilePictureUrl = profile?.profilePicture, - displayName = displayName, - modifier = Modifier - .padding(top = 16.dp) - .size(128.dp) - ) - - Spacer(modifier = Modifier.height(12.dp)) + item { Spacer(Modifier.height(12.dp)) } } onAvatarSlotBounds != null -> { - Box( - modifier = Modifier - .padding(top = 16.dp) - .size(128.dp) - .onGloballyPositioned { coords -> - onAvatarSlotBounds( - Rect( - coords.positionInRoot().x, - coords.positionInRoot().y, - coords.positionInRoot().x + coords.size.width.toFloat(), - coords.positionInRoot().y + coords.size.height.toFloat() + item { + Box( + modifier = Modifier + .padding(top = profileAvatarTop) + .size(104.dp) + .onGloballyPositioned { coords -> + onAvatarSlotBounds( + Rect( + coords.positionInRoot().x, + coords.positionInRoot().y, + coords.positionInRoot().x + coords.size.width.toFloat(), + coords.positionInRoot().y + coords.size.height.toFloat() + ) ) - ) - } - ) - - Spacer(modifier = Modifier.height(12.dp)) + } + ) + } + item { Spacer(Modifier.height(12.dp)) } } else -> { - Spacer(modifier = Modifier.height(16.dp + 128.dp + 12.dp)) + item { Spacer(Modifier.height(profileAvatarTop + 104.dp + 12.dp)) } } } when { - state.isLoading -> { - CircularProgressIndicator(modifier = Modifier.padding(top = 24.dp)) + showLoadingSpinner -> { + item { + CircularProgressIndicator(modifier = Modifier.padding(top = 24.dp)) + } } loadError != null -> { - Text( - text = when (loadError) { - ProfileLoadError.Generic -> profileLoadFailed - is ProfileLoadError.Message -> loadError.text - }, - color = MaterialTheme.colorScheme.error, - modifier = Modifier.padding(top = 24.dp) - ) + item { + Text( + text = when (loadError) { + ProfileLoadError.Generic -> profileLoadFailed + is ProfileLoadError.Message -> loadError.text + }, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(top = 24.dp) + ) + } } - profile != null -> { - val compactIdentityForPublicChat = - useSharedElementFromNavigation && sharedSourceMessageId > 0 - val profileLink = - if (compactIdentityForPublicChat) { - "https://fromchat.ru/?u=${profile.id}" - } else { - usernameForLinks - ?.let { "https://fromchat.ru/@$it" } - ?: "https://fromchat.ru/?u=${profile.id}" - } - - val isOwnProfile = ApiClient.user?.id == profile.id - val statusState = UserStatusStore - .status - .collectAsState() - .value[profile.id] ?: UserStatus( - online = profile.online, - lastSeen = profile.lastSeen - ) - - val typingUsers = statusState.typingUsernames - val statusText = if (statusState.online) { - stringResource(Res.string.presence_online) - } else { - formatLastSeen( - false, - statusState.lastSeen, - rememberLastSeenFormatStrings() - ).ifEmpty { stringResource(Res.string.presence_recently) } - } - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - Text( - text = displayName, - style = MaterialTheme.typography.titleLarge - ) - - StatusBadge( - verified = profile.verified, - userId = profile.id - ) - } - - Spacer(modifier = Modifier.height(4.dp)) - - AnimatedContent( - targetState = when { - typingUsers.isNotEmpty() -> "typing:${typingUsers.joinToString("|")}" - statusState.online ->"online" - else -> "offline" - }, - transitionSpec = { - (slideInVertically { it / 2 } + fadeIn()) togetherWith - (slideOutVertically { -it / 2 } + fadeOut()) - }, - label = "profile_status_${profile.id}" - ) { state -> - if (state.startsWith("typing:")) { - TypingIndicator( - typingUsers = typingUsers - ) - } else { - Text( - text = statusText, - style = MaterialTheme.typography.bodyMedium, - color = if (state == "online") - MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - Spacer(modifier = Modifier.height(36.dp)) - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, bottom = 20.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - @Composable - fun Item(label: String, icon: ImageVector, onClick: () -> Unit) { - val interactionSource = remember { MutableInteractionSource() } - - Card( - modifier = Modifier - .weight(1f) - .scaleOnPress( - scale = 0.90f, - interactionSource = interactionSource, - clipShape = MaterialTheme.shapes.extraLarge, - animationSpec = profileActionCardPressSpring - ), - shape = MaterialTheme.shapes.extraLarge, - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer) - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .clickable( - interactionSource = interactionSource, - indication = LocalIndication.current, - onClick = onClick - ), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(vertical = 16.dp) - ) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(28.dp) - ) - - Spacer(modifier = Modifier.height(6.dp)) - - Text( - text = label, - style = MaterialTheme.typography.bodyMedium - ) - } - } - } - } - - if (isOwnProfile) { - Item( - stringResource(Res.string.action_open_settings), - Icons.Filled.Settings, - onOpenSettings - ) - } else { - Item( - stringResource(Res.string.action_chat), - Icons.AutoMirrored.Filled.Chat - ) { - onChat(profile.id) - } - } - - Item( - stringResource(Res.string.action_copy_link), - Icons.Filled.Link + resolvedProfile != null -> { + item { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), ) { - clipboardManager.setText(AnnotatedString(profileLink)) + ContextMenuPressable( + pressScale = ProfileDisplayNamePressScale, + onContextMenuOpen = openContextMenuHaptic, + contextMenu = { + item(Icons.Rounded.ContentCopy, labelCopy) { + clipboardManager.setText(AnnotatedString(displayName)) + } + if (isOwnProfile) { + item(Icons.Rounded.Edit, labelEdit) { + navController.navigate( + ProfileRoutes.editRoute(EditProfileFocusField.DisplayName), + ) + } + } + }, + ) { + Text( + text = displayName, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } + StatusBadge( + verified = resolvedProfile.verified, + userId = resolvedProfile.id, + ) } } - val showDetailsUsername = - !compactIdentityForPublicChat && usernameForLinks != null - val showDetailsMemberSince = - !compactIdentityForPublicChat && - !profile.createdAt.isNullOrBlank() - val showDetailsBio = !profile.bio.isNullOrBlank() - val showDetailsVerify = profile.verified == true || ApiClient.user?.id == 1 + item { Spacer(Modifier.height(4.dp)) } - if ( - showDetailsUsername || - showDetailsMemberSince || - showDetailsBio || - showDetailsVerify - ) { - Category(Modifier.padding(top = 16.dp)) { - if (showDetailsUsername) { - ListItem( - headline = stringResource(Res.string.profile_headline_username), - supportingText = usernameForLinks, - divider = true, - leadingContent = { - Icon( - imageVector = Icons.Filled.AlternateEmail, - contentDescription = null - ) + item { + AnimatedContent( + targetState = when { + typingUsers.isNotEmpty() -> "typing:${typingUsers.joinToString("|")}" + statusState?.online == true -> "online" + else -> "offline" + }, + transitionSpec = { + (slideInVertically { it / 2 } + fadeIn()) togetherWith + (slideOutVertically { -it / 2 } + fadeOut()) + }, + label = "profile_status_${resolvedProfile.id}" + ) { animatedState -> + if (animatedState.startsWith("typing:")) { + TypingIndicator(typingUsers = typingUsers) + } else { + Text( + text = statusText, + style = MaterialTheme.typography.bodyMedium, + color = if (animatedState == "online") { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant } ) } + } + } + + item { Spacer(Modifier.height(24.dp)) } + + item { + ProfileActionButtonRow( + actions = profileActions.orEmpty(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + + if (showDetailsSection) { + val detailCount = listOf( + showDetailsUsername, + showDetailsMemberSince, + showDetailsBio, + showDetailsVerify, + ).count { it } + var detailIndex = 0 + + Category( + modifier = Modifier.bringIntoViewRequester(detailsBringIntoView), + margin = PaddingValues( + start = 16.dp, + end = 16.dp, + top = 28.dp, + bottom = 20.dp, + ), + roundedCorners = false, + ) { + if (showDetailsUsername) { + val position = listItemPositionInGroup(detailIndex, detailCount) + detailIndex++ + item { + ListItem( + headline = headlineUsername, + supportingText = usernameForLinks.orEmpty(), + divider = true, + position = position, + groupItemCount = detailCount, + onContextMenuOpen = openContextMenuHaptic, + leadingContent = { + Icon( + imageVector = Icons.Filled.AlternateEmail, + contentDescription = null, + tint = listItemIconTint, + ) + }, + contextMenu = { + item(Icons.Rounded.ContentCopy, labelCopy) { + clipboardManager.setText( + AnnotatedString(usernameForLinks.orEmpty()), + ) + } + if (isOwnProfile) { + item(Icons.Rounded.Edit, labelEdit) { + navController.navigate( + ProfileRoutes.editRoute(EditProfileFocusField.Username), + ) + } + } + }, + ) + } + } if (showDetailsMemberSince) { - ListItem( - headline = stringResource(Res.string.profile_headline_member_since), - supportingText = profile.createdAt, - divider = true, - leadingContent = { - Icon( - imageVector = Icons.Filled.CalendarMonth, - contentDescription = null - ) - } - ) + val memberSinceText = formatProfileRegistrationDate( + resolvedProfile.createdAt, + registrationDateStrings, + ).orEmpty() + val position = listItemPositionInGroup(detailIndex, detailCount) + detailIndex++ + item { + ListItem( + headline = headlineMemberSince, + supportingText = memberSinceText, + divider = true, + position = position, + groupItemCount = detailCount, + onContextMenuOpen = openContextMenuHaptic, + leadingContent = { + Icon( + imageVector = Icons.Filled.CalendarMonth, + contentDescription = null, + tint = listItemIconTint, + ) + }, + contextMenu = { + item(Icons.Rounded.ContentCopy, labelCopy) { + clipboardManager.setText(AnnotatedString(memberSinceText)) + } + }, + ) + } } if (showDetailsBio) { - ListItem( - headline = stringResource(Res.string.profile_headline_bio), - supportingText = profile.bio, - divider = true, - leadingContent = { - Icon( - imageVector = Icons.Filled.Info, - contentDescription = null - ) - } - ) + val position = listItemPositionInGroup(detailIndex, detailCount) + detailIndex++ + item { + ListItem( + headline = headlineBio, + supportingSlot = { + ProfileBioMarkdown( + content = resolvedProfile.bio.orEmpty(), + ) + }, + divider = true, + position = position, + groupItemCount = detailCount, + onContextMenuOpen = openContextMenuHaptic, + leadingContent = { + Icon( + imageVector = Icons.Filled.Info, + contentDescription = null, + tint = listItemIconTint, + ) + }, + contextMenu = { + item(Icons.Rounded.ContentCopy, labelCopy) { + scope.launch { + clipboard.setText(resolvedProfile.bio.orEmpty()) + } + } + if (isOwnProfile) { + item(Icons.Rounded.Edit, labelEdit) { + navController.navigate( + ProfileRoutes.editRoute(EditProfileFocusField.Bio), + ) + } + } + }, + ) + } } if (showDetailsVerify) { - ListItem( - headline = stringResource(Res.string.profile_headline_verification), - supportingText = - if (profile.verified == true) - stringResource(Res.string.profile_verified_support) - else stringResource(Res.string.profile_verify_prompt_support), - leadingContent = { - Icon( - imageVector = Icons.Filled.Verified, - contentDescription = null - ) - }, - divider = true, - onClick = if (ApiClient.user?.id == 1) { - { - scope.launch { - val result = withContext(Dispatchers.Default) { - runCatching { ApiClient.verifyUser(profile.id) }.getOrNull() - } - result?.verified?.let { newVerified -> - val updated = - state.profile?.copy(verified = newVerified) - state = state.copy(profile = updated) - updated?.let { ProfileCache.put(it) } + val position = listItemPositionInGroup(detailIndex, detailCount) + detailIndex++ + item { + ListItem( + headline = headlineVerification, + supportingText = if (resolvedProfile.verified == true) { + verifiedSupport + } else { + verifyPromptSupport + }, + divider = true, + position = position, + groupItemCount = detailCount, + leadingContent = { + Icon( + imageVector = Icons.Filled.Verified, + contentDescription = null, + tint = listItemIconTint, + ) + }, + onClick = if (ApiClient.user?.id == 1) { + { + scope.launch { + val result = withContext(Dispatchers.Default) { + runCatching { + ApiClient.verifyUser(resolvedProfile.id) + }.getOrNull() + } + result?.verified?.let { newVerified -> + val updated = + state.profile?.copy(verified = newVerified) + state = state.copy(profile = updated) + updated?.let { ProfileCache.put(it) } + } } } - } - } else null - ) + } else null, + ) + } } } } @@ -667,7 +867,540 @@ fun ProfileScreen( } } } + + ProfileFloatingBackBar( + visible = showBackButton, + onBack = onBack, + hazeState = hazeState, + blurHeight = profileAvatarTop, + modifier = Modifier.align(Alignment.TopCenter), + ) + + FromChatSnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp) + .fillMaxWidth(), + ) + } +} +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PublicChatProfileScreen( + onBack: () -> Unit, + onChat: () -> Unit, + modifier: Modifier = Modifier, + sharedTransitionScope: SharedTransitionScope? = null, + animatedVisibilityScope: AnimatedVisibilityScope? = null, + sharedAvatarKey: Any? = null, + initialDisplayName: String? = null, + showBackButton: Boolean = false, +) { + val clipboardManager = LocalClipboardManager.current + val clipboard = supportClipboardManagerImpl + val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + val density = LocalDensity.current + val statusBarTopDp = with(density) { WindowInsets.statusBars.getTop(this).toDp() } + val profileAvatarTop = statusBarTopDp + 24.dp + val profileLoadFailed = stringResource(Res.string.profile_load_failed) + val profileNotFound = stringResource(Res.string.profile_not_found) + val labelChat = stringResource(Res.string.profile_action_chat) + val labelLink = stringResource(Res.string.profile_action_link) + val labelSearch = stringResource(Res.string.profile_action_search) + val notImplementedMessage = stringResource(Res.string.feature_not_implemented) + val headlineBio = stringResource(Res.string.profile_headline_bio) + val labelCopy = stringResource(Res.string.action_copy) + val listItemIconTint = MaterialTheme.colorScheme.onSurfaceVariant + + var state by remember { + mutableStateOf( + PublicChatProfileUiState( + profile = PublicChatProfileCache.profile, + isLoading = PublicChatProfileCache.profile == null, + error = null, + ) + ) + } + + val hasShownContent = remember { + mutableStateOf(PublicChatProfileCache.profile != null) + } + + val latestUi by rememberUpdatedState(state) + + LaunchedEffect(Unit) { + try { + val profile = ApiClient.getPublicChatProfile() + PublicChatProfileCache.put(profile) + state = latestUi.copy(profile = profile, isLoading = false, error = null) + } catch (err: Exception) { + val cached = PublicChatProfileCache.profile + val resolvedErrorMessage = when { + err is ClientRequestException && err.response.status.value == 404 -> profileNotFound + else -> err.message?.takeIf { it.isNotBlank() } ?: profileLoadFailed + } + state = latestUi.copy( + profile = latestUi.profile ?: cached, + isLoading = false, + error = if (latestUi.profile == null && cached == null) { + if (resolvedErrorMessage == profileLoadFailed) { + PublicChatProfileLoadError.Generic + } else { + PublicChatProfileLoadError.Message(resolvedErrorMessage) + } + } else { + null + }, + ) + } + } + + val hazeState = rememberHazeState() + + val profile = state.profile + if (profile != null) { + hasShownContent.value = true + } + val loadError = state.error + val showLoadingSpinner = state.isLoading && !hasShownContent.value + val displayName = profile?.title?.takeIf { it.isNotBlank() } + ?: initialDisplayName?.takeIf { it.isNotBlank() } + ?: "" + val resolvedProfile = profile?.takeIf { loadError == null && !showLoadingSpinner } + val profileLink = resolvedProfile?.let { "https://fromchat.ru/chats/${it.id}" } + val profileActions = resolvedProfile?.let { + listOf( + ProfileAction( + label = labelChat, + icon = Icons.AutoMirrored.Filled.Chat, + holdsExpansionOnNavigate = true, + onClick = onChat, + ), + ProfileAction( + label = labelLink, + icon = Icons.Filled.Link, + onClick = { clipboardManager.setText(AnnotatedString(profileLink.orEmpty())) }, + ), + ProfileAction( + label = labelSearch, + icon = Icons.Filled.Search, + onClick = { + scope.launch { + snackbarHostState.showReplacingSnackbar( + message = notImplementedMessage, + withDismissAction = false, + duration = SnackbarDuration.Short, + ) + } + }, + ), + ) + } + val showMemberCount = (resolvedProfile?.member_count ?: -1) >= 0 + val membersCountText = resolvedProfile?.takeIf { showMemberCount }?.let { + stringResource(Res.string.chat_members_count, it.member_count) + } + val showBio = !resolvedProfile?.bio.isNullOrBlank() + val useSharedAvatar = sharedTransitionScope != null && + animatedVisibilityScope != null && + sharedAvatarKey != null + + Box( + modifier = modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.navigationBars), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .hazeSource(hazeState), + ) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + when { + showLoadingSpinner -> { + item { Spacer(Modifier.height(profileAvatarTop + 104.dp + 12.dp)) } + item { + CircularProgressIndicator(modifier = Modifier.padding(top = 24.dp)) + } + } + + loadError != null -> { + item { Spacer(Modifier.height(profileAvatarTop + 104.dp + 12.dp)) } + item { + Text( + text = when (loadError) { + PublicChatProfileLoadError.Generic -> profileLoadFailed + is PublicChatProfileLoadError.Message -> loadError.text + }, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(top = 24.dp), + ) + } + } + + resolvedProfile != null || initialDisplayName != null -> { + item { + if (useSharedAvatar) { + with(sharedTransitionScope!!) { + Avatar( + profilePictureUrl = null, + displayName = displayName, + modifier = Modifier + .padding(top = profileAvatarTop) + .sharedElement( + rememberSharedContentState(key = sharedAvatarKey!!), + animatedVisibilityScope = animatedVisibilityScope!!, + ) + .size(104.dp), + ) + } + } else { + Avatar( + profilePictureUrl = null, + displayName = displayName, + modifier = Modifier + .padding(top = profileAvatarTop) + .size(104.dp), + ) + } + } + + item { Spacer(Modifier.height(12.dp)) } + + if (resolvedProfile != null) { + item { + Text( + text = resolvedProfile.title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + item { Spacer(Modifier.height(4.dp)) } + + if (showMemberCount) { + item { + Text( + text = membersCountText.orEmpty(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + item { Spacer(Modifier.height(24.dp)) } + + item { + ProfileActionButtonRow( + actions = profileActions.orEmpty(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + + if (showBio) { + Category( + margin = PaddingValues( + start = 16.dp, + end = 16.dp, + top = 28.dp, + bottom = 20.dp, + ), + roundedCorners = false, + ) { + item { + ListItem( + headline = headlineBio, + supportingSlot = { + ProfileBioMarkdown( + content = resolvedProfile.bio.orEmpty(), + ) + }, + position = ListItemPosition.START, + groupItemCount = 1, + leadingContent = { + Icon( + imageVector = Icons.Filled.Info, + contentDescription = null, + tint = listItemIconTint, + ) + }, + contextMenu = { + item(Icons.Rounded.ContentCopy, labelCopy) { + scope.launch { + clipboard.setText(resolvedProfile.bio.orEmpty()) + } + } + }, + ) + } + } + } + } + } + } + } + } + + ProfileFloatingBackBar( + visible = showBackButton, + onBack = onBack, + hazeState = hazeState, + blurHeight = profileAvatarTop, + modifier = Modifier.align(Alignment.TopCenter), + ) + + FromChatSnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp) + .fillMaxWidth(), + ) } } -expect fun showProfileLoadErrorMessage(message: String) \ No newline at end of file +expect fun showProfileLoadErrorMessage(message: String) + +private sealed interface PublicChatProfileLoadError { + data object Generic : PublicChatProfileLoadError + data class Message(val text: String) : PublicChatProfileLoadError +} + +private data class PublicChatProfileUiState( + val profile: PublicChatProfile? = null, + val isLoading: Boolean = false, + val error: PublicChatProfileLoadError? = null, +) + +private data class ProfileAction( + val label: String, + val icon: ImageVector, + val onClick: () -> Unit, + val enabled: Boolean = true, + val holdsExpansionOnNavigate: Boolean = false, +) + +private const val ProfileActionPressExpansionRatio = 1.15f +private const val ProfileActionDefaultWeight = 1f +private const val ProfileActionNavigationPressHoldMs = 400L +private const val ProfileActionMinPressVisibleMs = 80L + +private val profileActionPressSpring = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessLow, + visibilityThreshold = 0.001f, +) + +private fun targetWeightForPress( + index: Int, + pressedIndex: Int, + count: Int, +): Float { + if (pressedIndex < 0 || count <= 1) return ProfileActionDefaultWeight + if (index == pressedIndex) return ProfileActionPressExpansionRatio + return (count * ProfileActionDefaultWeight - ProfileActionPressExpansionRatio) / (count - 1) +} + +@Composable +private fun ProfileBioMarkdown( + content: String, + modifier: Modifier = Modifier, +) { + val uriHandler = LocalUriHandler.current + + MarkdownPlain( + content = content, + modifier = modifier, + onLinkClick = { uriHandler.openUri(it) }, + ) +} + +@Composable +private fun ProfileActionButtonRow( + actions: List, + modifier: Modifier = Modifier, +) { + if (actions.isEmpty()) return + + var pressedIndex by remember(actions) { mutableIntStateOf(-1) } + var latchedPressIndex by remember(actions) { mutableIntStateOf(-1) } + + LaunchedEffect(latchedPressIndex) { + if (latchedPressIndex >= 0) { + delay(ProfileActionNavigationPressHoldMs) + latchedPressIndex = -1 + pressedIndex = -1 + } + } + + DisposableEffect(Unit) { + onDispose { + latchedPressIndex = -1 + pressedIndex = -1 + } + } + + val effectivePressedIndex = latchedPressIndex.takeIf { it >= 0 } ?: pressedIndex + + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + val count = actions.size + + actions.forEachIndexed { index, action -> + val targetWeight = targetWeightForPress(index, effectivePressedIndex, count) + val animatedWeight by animateFloatAsState( + targetValue = targetWeight, + animationSpec = profileActionPressSpring, + label = "profileActionWeight$index", + ) + + ExpressiveProfileActionButton( + action = action, + modifier = Modifier.weight(animatedWeight), + onPressedChange = { pressed -> + if (pressed) { + pressedIndex = index + } else if (latchedPressIndex != index) { + pressedIndex = -1 + } + }, + onClick = { + if (action.holdsExpansionOnNavigate) { + latchedPressIndex = index + pressedIndex = index + } + action.onClick() + }, + ) + } + } +} + +@Composable +private fun ExpressiveProfileActionButton( + action: ProfileAction, + onPressedChange: (Boolean) -> Unit, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val interactionSource = remember { MutableInteractionSource() } + + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + val pillShape = MaterialTheme.shapes.extraLarge + Surface( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 52.dp) + .clip(pillShape) + .indication(interactionSource, LocalIndication.current) + .pointerInput(action.enabled, onClick) { + if (!action.enabled) return@pointerInput + detectTapGestures( + onPress = { offset -> + val press = PressInteraction.Press(offset) + interactionSource.emit(press) + onPressedChange(true) + try { + awaitRelease() + } finally { + delay(ProfileActionMinPressVisibleMs) + interactionSource.emit(PressInteraction.Release(press)) + onPressedChange(false) + } + }, + onTap = { onClick() }, + ) + }, + shape = pillShape, + color = MaterialTheme.colorScheme.secondaryContainer.copy( + alpha = if (action.enabled) 1f else 0.38f, + ), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = action.icon, + contentDescription = action.label, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer.copy( + alpha = if (action.enabled) 1f else 0.38f, + ), + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = action.label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface.copy( + alpha = if (action.enabled) 1f else 0.38f, + ), + textAlign = TextAlign.Center, + maxLines = 2, + modifier = Modifier.padding(horizontal = 2.dp), + ) + } +} + +@OptIn(ExperimentalHazeMaterialsApi::class) +@Composable +private fun ProfileFloatingBackBar( + visible: Boolean, + onBack: () -> Unit, + hazeState: HazeState, + blurHeight: Dp, + modifier: Modifier = Modifier, +) { + if (!visible) return + + Box(modifier = modifier.fillMaxWidth()) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(blurHeight) + .hazeEffect(state = hazeState, style = HazeMaterials.thin()) { + progressive = HazeProgressive.verticalGradient( + startIntensity = 1f, + endIntensity = 0f, + ) + }, + ) + IconButton( + onClick = onBack, + modifier = Modifier + .align(Alignment.TopStart) + .windowInsetsPadding(WindowInsets.statusBars), + colors = IconButtonDefaults.iconButtonColors( + contentColor = MaterialTheme.colorScheme.onSurface, + ), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(Res.string.back), + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/utils/ProfileRegistrationDateFormat.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/utils/ProfileRegistrationDateFormat.kt new file mode 100644 index 0000000..24bbe0e --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/utils/ProfileRegistrationDateFormat.kt @@ -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, + ) +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/utils/haptic/HapticFeedbackEvent.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/utils/haptic/HapticFeedbackEvent.kt index 9108cae..d784459 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/utils/haptic/HapticFeedbackEvent.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/utils/haptic/HapticFeedbackEvent.kt @@ -4,5 +4,6 @@ enum class HapticFeedbackEvent { ProfileOpened, ProfileClosed, MessageSent, - ContextMenuOpened + ContextMenuOpened, + SelectionModeEntered, } \ No newline at end of file diff --git a/app/shared/src/commonMain/sqldelight/ru/fromchat/db/MessageDatabase.sq b/app/shared/src/commonMain/sqldelight/ru/fromchat/db/MessageDatabase.sq index b040c24..9edaac7 100644 --- a/app/shared/src/commonMain/sqldelight/ru/fromchat/db/MessageDatabase.sq +++ b/app/shared/src/commonMain/sqldelight/ru/fromchat/db/MessageDatabase.sq @@ -20,6 +20,7 @@ CREATE TABLE conversation ( lastMessagePreview TEXT, unreadCount INTEGER NOT NULL DEFAULT 0, updatedAt TEXT, + archived INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (instanceId, id) ); @@ -157,6 +158,11 @@ deleteMessageById: DELETE FROM message WHERE instanceId = ? AND conversationId = ? AND id = ?; +updateMessageSendStatusByClientMessageId: +UPDATE message +SET sendStatus = ? +WHERE instanceId = ? AND conversationId = ? AND clientMessageId = ?; + upsertMessage: INSERT OR REPLACE INTO message( instanceId, @@ -178,6 +184,16 @@ UPDATE message SET deletedFlag = 1 WHERE instanceId = ? AND id = ? AND conversationId = ?; +selectUnreadPublicMessageIds: +SELECT id +FROM message +WHERE instanceId = ? AND conversationId = ? AND isRead = 0 AND id > 0 AND deletedFlag = 0; + +markPublicMessagesRead: +UPDATE message +SET isRead = 1 +WHERE instanceId = ? AND conversationId = ? AND isRead = 0 AND deletedFlag = 0; + deleteAllMessagesForInstance: DELETE FROM message WHERE instanceId = ?; @@ -209,6 +225,67 @@ FROM conversation WHERE instanceId = ? ORDER BY updatedAt DESC; +selectActiveDmConversationsForInstance: +SELECT * +FROM conversation +WHERE instanceId = ? AND type = 'dm' AND archived = 0 +ORDER BY updatedAt DESC; + +selectConversationById: +SELECT * +FROM conversation +WHERE instanceId = ? AND id = ? +LIMIT 1; + +countNonDeletedMessagesForConversation: +SELECT COUNT(*) +FROM message +WHERE instanceId = ? AND conversationId = ? AND deletedFlag = 0; + +countPendingMessagesForConversation: +SELECT COUNT(*) +FROM message +WHERE instanceId = ? AND conversationId = ? AND deletedFlag = 0 AND id < 0; + +deleteConversationById: +DELETE FROM conversation +WHERE instanceId = ? AND id = ?; + +deleteEmptyDmConversations: +DELETE FROM conversation +WHERE instanceId = ? AND type = 'dm' AND id NOT IN ( + SELECT DISTINCT conversationId FROM message + WHERE instanceId = ? AND deletedFlag = 0 +); + +selectForeignPendingMessages: +SELECT * +FROM message +WHERE instanceId = ? AND id < 0 AND deletedFlag = 0 AND userId != ?; + +selectAllPendingMessagesForInstance: +SELECT * +FROM message +WHERE instanceId = ? AND id < 0 AND deletedFlag = 0; + +deleteAllPendingMessagesForInstance: +DELETE FROM message +WHERE instanceId = ? AND id < 0; + +deletePendingMessagesNotFromUser: +DELETE FROM message +WHERE instanceId = ? AND id < 0 AND userId != ?; + +updateConversationArchived: +UPDATE conversation +SET archived = ? +WHERE instanceId = ? AND id = ?; + +updateConversationUnreadCount: +UPDATE conversation +SET unreadCount = ? +WHERE instanceId = ? AND id = ?; + upsertConversation: INSERT OR REPLACE INTO conversation( instanceId, @@ -219,8 +296,9 @@ INSERT OR REPLACE INTO conversation( lastMessageId, lastMessagePreview, unreadCount, - updatedAt -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?); + updatedAt, + archived +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?); -- outbox selectPendingOutboxForInstance: diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheDirs.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheDirs.ios.kt index 944b25b..6ebc89a 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheDirs.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/api/local/cache/FromChatCacheDirs.ios.kt @@ -21,3 +21,20 @@ actual suspend fun wipeFromChatCacheDirectory() { NSFileManager.defaultManager.removeItemAtPath(path, error = null) } } + +@OptIn(ExperimentalForeignApi::class) +actual suspend fun wipeAttachmentCacheDirectories() { + withContext(Dispatchers.Default) { + val url = NSFileManager.defaultManager.URLForDirectory( + directory = NSCachesDirectory, + inDomain = NSUserDomainMask, + appropriateForURL = null, + create = false, + error = null, + ) ?: return@withContext + val base = url.path ?: return@withContext + listOf("decrypted_images", "decrypted_files", "encrypted_downloads").forEach { name -> + NSFileManager.defaultManager.removeItemAtPath("$base/$name", error = null) + } + } +} diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/legal/LegalMarkdown.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/legal/LegalMarkdown.ios.kt new file mode 100644 index 0000000..ea737d3 --- /dev/null +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/legal/LegalMarkdown.ios.kt @@ -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) }, + ) +} diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/BackHandler.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/BackHandler.ios.kt index 750095d..31299fd 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/BackHandler.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/BackHandler.ios.kt @@ -20,6 +20,7 @@ actual fun rememberHapticFeedbackInternal(): (Int) -> Unit { val style: UIImpactFeedbackStyle = when (ordinal) { HapticFeedbackEvent.MessageSent.ordinal -> UIImpactFeedbackStyle.UIImpactFeedbackStyleMedium HapticFeedbackEvent.ContextMenuOpened.ordinal -> UIImpactFeedbackStyle.UIImpactFeedbackStyleHeavy + HapticFeedbackEvent.SelectionModeEntered.ordinal -> UIImpactFeedbackStyle.UIImpactFeedbackStyleMedium else -> UIImpactFeedbackStyle.UIImpactFeedbackStyleLight } val generator = UIImpactFeedbackGenerator(style) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index feed544..66de5e0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -44,6 +44,7 @@ krypto = "4.0.10" sqldelight = "2.3.2" livekitAndroid = "2.25.2" livekitAndroidComposeComponents = "2.3.0" +markdownRendererM3 = "0.41.0" bouncycastle = "1.79" [libraries] @@ -115,6 +116,7 @@ sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", ver sqldelight-driver-native = { module = "app.cash.sqldelight:native-driver", version.ref = "sqldelight" } livekit-android = { module = "io.livekit:livekit-android", version.ref = "livekitAndroid" } livekit-android-compose-components = { module = "io.livekit:livekit-android-compose-components", version.ref = "livekitAndroidComposeComponents" } +markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "markdownRendererM3" } bouncycastle-bcprov = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncycastle" } [plugins] diff --git a/utils/shared/build.gradle.kts b/utils/shared/build.gradle.kts index 322df89..3101e87 100644 --- a/utils/shared/build.gradle.kts +++ b/utils/shared/build.gradle.kts @@ -17,9 +17,9 @@ kotlin { } listOf( - iosX64(), iosArm64(), - iosSimulatorArm64() + iosSimulatorArm64(), + iosX64(), ).forEach { it.binaries.framework { baseName = "shared" diff --git a/utils/shared/src/androidMain/kotlin/com/pr0gramm3r101/utils/Utils.android.kt b/utils/shared/src/androidMain/kotlin/com/pr0gramm3r101/utils/Utils.android.kt index 54b394d..4cb3228 100644 --- a/utils/shared/src/androidMain/kotlin/com/pr0gramm3r101/utils/Utils.android.kt +++ b/utils/shared/src/androidMain/kotlin/com/pr0gramm3r101/utils/Utils.android.kt @@ -55,7 +55,12 @@ import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.isImeVisible import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.window.DialogWindowProvider +import androidx.core.view.WindowCompat import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -120,17 +125,45 @@ actual fun Modifier.clearFocusOnKeyboardDismiss(): Modifier = composed { @SuppressLint("ComposableNaming") @Composable actual fun ToggleNavScrimEffect(enabled: Boolean) { - val context = (LocalContext() as Activity) - LaunchedEffect(enabled) { - runCatching { - val window = context.window - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - window.isNavigationBarContrastEnforced = enabled + val context = LocalContext.current as Activity + DisposableEffect(enabled) { + val window = context.window + val previousContrastEnforced = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + window.isNavigationBarContrastEnforced + } else { + null + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + window.isNavigationBarContrastEnforced = enabled + } + + onDispose { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && previousContrastEnforced != null) { + window.isNavigationBarContrastEnforced = previousContrastEnforced } } } } +@Composable +actual fun DialogEdgeToEdgeEffect() { + val view = LocalView.current + SideEffect { + val window = (view.parent as? DialogWindowProvider)?.window ?: return@SideEffect + runCatching { + WindowCompat.setDecorFitsSystemWindows(window, false) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + window.isNavigationBarContrastEnforced = false + } + @Suppress("DEPRECATION") + window.navigationBarColor = android.graphics.Color.TRANSPARENT + @Suppress("DEPRECATION") + window.statusBarColor = android.graphics.Color.TRANSPARENT + } + } +} + val screenWidth: Int inline get() = Resources.getSystem().displayMetrics.widthPixels @Suppress("unused") val screenHeight: Int inline get() = Resources.getSystem().displayMetrics.heightPixels diff --git a/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/components/Category.kt b/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/components/Category.kt index 62017d7..73bea78 100644 --- a/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/components/Category.kt +++ b/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/components/Category.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.pr0gramm3r101.utils.conditional object CategoryDefaults { val margin = PaddingValues(start = 16.dp, end = 16.dp, bottom = 20.dp) @@ -42,6 +43,7 @@ private fun CategoryBase( margin: PaddingValues = CategoryDefaults.margin, containerColor: Color = CategoryDefaults.containerColor, backgroundColor: Color = Color.Transparent, + roundedCorners: Boolean = true, content: @Composable ColumnScope.() -> Unit ) { CompositionLocalProvider( @@ -70,7 +72,7 @@ private fun CategoryBase( modifier = modifier .padding(margin) .fillMaxWidth(), - shape = CategoryDefaults.shape, + shape = if (roundedCorners) CategoryDefaults.shape else RoundedCornerShape(0.dp), colors = cardColors(containerColor = backgroundColor), content = content ) @@ -84,6 +86,7 @@ fun LazyItemScope.Category( margin: PaddingValues = CategoryDefaults.margin, containerColor: Color = CategoryDefaults.containerColor, backgroundColor: Color = Color.Transparent, + roundedCorners: Boolean = true, content: @Composable ColumnScope.() -> Unit ) { CategoryBase( @@ -92,6 +95,7 @@ fun LazyItemScope.Category( margin = margin, containerColor = containerColor, backgroundColor = backgroundColor, + roundedCorners = roundedCorners, content = content ) } @@ -103,6 +107,7 @@ fun ColumnScope.Category( margin: PaddingValues = CategoryDefaults.margin, containerColor: Color = CategoryDefaults.containerColor, backgroundColor: Color = Color.Transparent, + roundedCorners: Boolean = true, content: @Composable ColumnScope.() -> Unit ) { CategoryBase( @@ -111,6 +116,7 @@ fun ColumnScope.Category( margin = margin, containerColor = containerColor, backgroundColor = backgroundColor, + roundedCorners = roundedCorners, content = content ) } @@ -128,6 +134,7 @@ fun LazyListScope.Category( title: String? = null, margin: PaddingValues = CategoryDefaults.margin, containerColor: Color? = null, + roundedCorners: Boolean = true, content: CategoryScope.() -> Unit ) { val scope = CategoryScope().apply(content) @@ -165,26 +172,28 @@ fun LazyListScope.Category( start = margin.calculateStartPadding(LocalLayoutDirection.current), end = margin.calculateEndPadding(LocalLayoutDirection.current) ) - .clip( - RoundedCornerShape( - topStart = - if (index == 0) - CategoryDefaults.shape.topStart - else CornerSize(0.dp), - topEnd = - if (index == 0) - CategoryDefaults.shape.topEnd - else CornerSize(0.dp), - bottomStart = - if (index == items.lastIndex) - CategoryDefaults.shape.bottomStart - else CornerSize(0.dp), - bottomEnd = - if (index == items.lastIndex) - CategoryDefaults.shape.bottomEnd - else CornerSize(0.dp) + .conditional(roundedCorners) { + clip( + RoundedCornerShape( + topStart = + if (index == 0) + CategoryDefaults.shape.topStart + else CornerSize(0.dp), + topEnd = + if (index == 0) + CategoryDefaults.shape.topEnd + else CornerSize(0.dp), + bottomStart = + if (index == items.lastIndex) + CategoryDefaults.shape.bottomStart + else CornerSize(0.dp), + bottomEnd = + if (index == items.lastIndex) + CategoryDefaults.shape.bottomEnd + else CornerSize(0.dp) + ) ) - ) + } ) { composableItem() } diff --git a/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/components/Components.kt b/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/components/Components.kt index 73b24c1..8dcdfde 100644 --- a/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/components/Components.kt +++ b/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/components/Components.kt @@ -3,18 +3,24 @@ package com.pr0gramm3r101.components import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animate +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope @@ -28,6 +34,8 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.CornerBasedShape +import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Archive @@ -66,30 +74,49 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties import com.pr0gramm3r101.utils.conditional +import com.pr0gramm3r101.utils.currentWindowSize import com.pr0gramm3r101.utils.invoke import com.pr0gramm3r101.utils.left import com.pr0gramm3r101.utils.link import com.pr0gramm3r101.utils.plus import com.pr0gramm3r101.utils.right +import com.pr0gramm3r101.utils.scaleOnPress import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import tech.annexflow.constraintlayout.compose.ConstraintLayout @@ -102,6 +129,12 @@ val LocalDividerThickness = compositionLocalOf { null } val LocalBeforeDividerRadius = compositionLocalOf { null } val LocalContainerColor = compositionLocalOf { null } +enum class ListItemPosition { + START, + MIDDLE, + END, +} + object ListItemDefaults { val dividerColor @Composable get() = DividerDefaults.color val thickness = DividerDefaults.Thickness @@ -109,12 +142,408 @@ object ListItemDefaults { val containerColor = Color.Transparent } +@Composable +fun listItemClipShape( + position: ListItemPosition, + groupItemCount: Int? = null, + cornerShape: CornerBasedShape = CategoryDefaults.shape, + beforeDividerRadius: Dp = ListItemDefaults.beforeDividerRadius, +): Shape { + val useGroupShape = position != ListItemPosition.MIDDLE || groupItemCount != null + if (!useGroupShape) { + return RoundedCornerShape(beforeDividerRadius) + } + val solo = groupItemCount == 1 + val roundTop = position == ListItemPosition.START || solo + val roundBottom = position == ListItemPosition.END || solo + val innerRadius = CornerSize(beforeDividerRadius) + return RoundedCornerShape( + topStart = if (roundTop) cornerShape.topStart else innerRadius, + topEnd = if (roundTop) cornerShape.topEnd else innerRadius, + bottomStart = if (roundBottom) cornerShape.bottomStart else innerRadius, + bottomEnd = if (roundBottom) cornerShape.bottomEnd else innerRadius, + ) +} + +fun listItemPositionInGroup(index: Int, count: Int): ListItemPosition { + require(count > 0) + return when { + index == 0 -> ListItemPosition.START + index == count - 1 -> ListItemPosition.END + else -> ListItemPosition.MIDDLE + } +} + +class ListItemContextMenuScope internal constructor( + private val dismiss: () -> Unit, +) { + internal val items = mutableListOf() + + 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, + animated: Boolean, + withShadow: Boolean = true, + scale: Float = 1f, + alpha: Float = 1f, + transformOriginX: Float = 0f, + transformOriginY: Float = 0f, + modifier: Modifier = Modifier, + onItemClick: (ListItemContextMenuEntry) -> Unit = { it.onClick() }, +) { + val density = LocalDensity.current + val shadowElevationPx = if (withShadow) with(density) { 12.dp.toPx() } else 0f + val menuColor = MaterialTheme.colorScheme.surfaceContainerHighest + val baseModifier = modifier.width(IntrinsicSize.Max) + val containerModifier = if (animated) { + baseModifier.graphicsLayer( + scaleX = scale, + scaleY = scale, + alpha = alpha, + transformOrigin = TransformOrigin(transformOriginX, transformOriginY), + shadowElevation = shadowElevationPx, + shape = listItemContextMenuShape, + clip = true, + ) + } else { + baseModifier.graphicsLayer( + shadowElevation = shadowElevationPx, + shape = listItemContextMenuShape, + clip = true, + ) + } + + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) { + Box(modifier = containerModifier) { + Box(modifier = Modifier.matchParentSize().background(menuColor, listItemContextMenuShape)) + Column( + modifier = Modifier.padding(8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + items.forEach { entry -> + ListItemContextMenuRow( + icon = entry.icon, + label = entry.label, + onClick = { onItemClick(entry) }, + ) + } + } + } + } +} + +@Composable +private fun ListItemContextMenuRow( + icon: ImageVector, + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxWidth() + .padding(4.dp) + .clip(listItemContextMenuItemShape) + .scaleOnPress( + scale = 0.96f, + onClick = onClick, + indication = LocalIndication.current, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium, + ), + ) + .padding(horizontal = 12.dp, vertical = 8.dp), + contentAlignment = Alignment.CenterStart, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = label, + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(20.dp), + ) + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } +} + +@Composable +fun ContextMenuPressable( + modifier: Modifier = Modifier, + enabled: Boolean = true, + openMenuOnTap: Boolean = false, + pressScale: Float = 0.96f, + onContextMenuOpen: (() -> Unit)? = null, + contextMenu: ListItemContextMenuScope.() -> Unit, + content: @Composable () -> Unit, +) { + if (!enabled) { + Box(modifier = modifier) { content() } + return + } + + var isPressed by remember { mutableStateOf(false) } + var menuOpen by remember { mutableStateOf(false) } + var menuPosition by remember { mutableStateOf(IntOffset.Zero) } + var anchorPositionInRoot by remember { mutableStateOf(Offset.Zero) } + val latestContextMenu by rememberUpdatedState(contextMenu) + val scaleTarget = if (isPressed && !menuOpen) pressScale else 1f + val scale by animateFloatAsState( + targetValue = scaleTarget, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + visibilityThreshold = 0.001f, + label = "contextMenuPressableScale", + ) + + Column( + modifier = Modifier.onGloballyPositioned { coordinates -> + anchorPositionInRoot = coordinates.positionInRoot() + }, + ) { + Box( + modifier = modifier + .graphicsLayer( + scaleX = scale, + scaleY = scale, + transformOrigin = TransformOrigin.Center, + ) + .pointerInput(latestContextMenu, openMenuOnTap) { + detectTapGestures( + onPress = { + isPressed = true + try { + awaitRelease() + } finally { + isPressed = false + } + }, + onTap = { + if (openMenuOnTap) { + onContextMenuOpen?.invoke() + menuPosition = IntOffset.Zero + menuOpen = true + } + }, + onLongPress = { localOffset -> + onContextMenuOpen?.invoke() + menuPosition = IntOffset( + localOffset.x.toInt(), + localOffset.y.toInt(), + ) + menuOpen = true + }, + ) + }, + ) { + content() + } + + ListItemContextMenuPopup( + open = menuOpen, + position = menuPosition, + anchorPositionInRoot = anchorPositionInRoot, + onDismiss = { menuOpen = false }, + menuContent = latestContextMenu, + ) + } +} + @Composable fun ListItem( modifier: Modifier = Modifier, bodyModifier: Modifier = Modifier, headline: String, supportingText: String? = null, + supportingSlot: (@Composable () -> Unit)? = null, leadingContent: (@Composable () -> Unit)? = null, trailingContent: (@Composable ConstraintLayoutScope.() -> Unit)? = null, containerColor: Color = LocalContainerColor.current ?: ListItemDefaults.containerColor, @@ -124,12 +553,43 @@ fun ListItem( dividerThickness: Dp = LocalDividerThickness.current ?: ListItemDefaults.thickness, dividerAnimated: Boolean = false, beforeDividerRadius: Dp = LocalBeforeDividerRadius.current ?: ListItemDefaults.beforeDividerRadius, + position: ListItemPosition = ListItemPosition.MIDDLE, + groupItemCount: Int? = null, onClick: (() -> Unit)? = null, bodyOnClick: (() -> Unit)? = null, leadingAndBodyShared: Boolean = false, - bottomContent: (@Composable () -> Unit)? = null + bottomContent: (@Composable () -> Unit)? = null, + onContextMenuOpen: (() -> Unit)? = null, + contextMenu: (ListItemContextMenuScope.() -> Unit)? = null, ) { - Column { + val showDivider = divider && position != ListItemPosition.END + val clipShape = listItemClipShape( + position = position, + groupItemCount = groupItemCount, + beforeDividerRadius = beforeDividerRadius, + ) + var isPressed by remember { mutableStateOf(false) } + var menuOpen by remember { mutableStateOf(false) } + var menuPosition by remember { mutableStateOf(IntOffset.Zero) } + var anchorPositionInRoot by remember { mutableStateOf(Offset.Zero) } + val latestContextMenu by rememberUpdatedState(contextMenu) + val hasContextMenu = contextMenu != null + val scaleTarget = if (isPressed && !menuOpen) 0.96f else 1f + val scale by animateFloatAsState( + targetValue = scaleTarget, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + visibilityThreshold = 0.001f, + label = "listItemScale", + ) + + Column( + modifier = Modifier.onGloballyPositioned { coordinates -> + anchorPositionInRoot = coordinates.positionInRoot() + }, + ) { @Composable fun ProvideStyle( content: @Composable BoxScope?.() -> Unit @@ -149,12 +609,44 @@ fun ListItem( } ProvideStyle { + Box( + modifier = Modifier + .fillMaxWidth() + .graphicsLayer( + scaleX = scale, + scaleY = scale, + transformOrigin = TransformOrigin.Center, + ) + .conditional(hasContextMenu) { + pointerInput(latestContextMenu, onClick) { + detectTapGestures( + onPress = { + isPressed = true + try { + awaitRelease() + } finally { + isPressed = false + } + }, + onTap = { onClick?.invoke() }, + onLongPress = { localOffset -> + onContextMenuOpen?.invoke() + menuPosition = IntOffset( + localOffset.x.toInt(), + localOffset.y.toInt(), + ) + menuOpen = true + }, + ) + } + }, + ) { ConstraintLayout( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(beforeDividerRadius)) + .clip(clipShape) .background(containerColor) - .conditional(onClick != null) { + .conditional(onClick != null && !hasContextMenu) { clickable(onClick = onClick!!) } .then(modifier) @@ -187,7 +679,12 @@ fun ListItem( ListItem( headlineContent = { Text(headline) }, - supportingContent = { if (supportingText != null) Text(supportingText) }, + supportingContent = { + when { + supportingSlot != null -> supportingSlot() + supportingText != null -> Text(supportingText) + } + }, modifier = Modifier .constrainAs(listItem) { top link parent.top @@ -203,7 +700,7 @@ fun ListItem( width = Dimension.fillToConstraints } - .conditional(bodyOnClick != null && !leadingAndBodyShared) { + .conditional(bodyOnClick != null && !leadingAndBodyShared && !hasContextMenu) { Modifier.clickable(onClick = bodyOnClick!!) } .then(bodyModifier), @@ -227,7 +724,7 @@ fun ListItem( else parent.right width = Dimension.fillToConstraints } - .conditional(bodyOnClick != null) { + .conditional(bodyOnClick != null && !hasContextMenu) { Modifier.clickable(onClick = bodyOnClick!!) }, verticalAlignment = Alignment.CenterVertically @@ -273,12 +770,13 @@ fun ListItem( } } } + } } when { - dividerAnimated && divider -> { + dividerAnimated && showDivider -> { AnimatedVisibility( - visible = divider, + visible = showDivider, enter = expandVertically(), exit = shrinkVertically() ) { @@ -288,13 +786,23 @@ fun ListItem( ) } } - divider -> { + showDivider -> { HorizontalDivider( color = dividerColor, thickness = dividerThickness ) } } + + if (hasContextMenu) { + ListItemContextMenuPopup( + open = menuOpen, + position = menuPosition, + anchorPositionInRoot = anchorPositionInRoot, + onDismiss = { menuOpen = false }, + menuContent = { latestContextMenu?.invoke(this) }, + ) + } } } @@ -311,7 +819,11 @@ inline fun SwitchListItem( dividerColor: Color = LocalDividerColor.current ?: DividerDefaults.color, dividerThickness: Dp = LocalDividerThickness.current ?: DividerDefaults.Thickness, dividerAnimated: Boolean = false, - enabled: Boolean = true + enabled: Boolean = true, + position: ListItemPosition = ListItemPosition.MIDDLE, + groupItemCount: Int? = null, + noinline onContextMenuOpen: (() -> Unit)? = null, + noinline contextMenu: (ListItemContextMenuScope.() -> Unit)? = null, ) { val interactionSource = remember { MutableInteractionSource() } ListItem( @@ -341,7 +853,11 @@ inline fun SwitchListItem( dividerColor = dividerColor, dividerThickness = dividerThickness, dividerAnimated = dividerAnimated, - enabled = enabled + enabled = enabled, + position = position, + groupItemCount = groupItemCount, + onContextMenuOpen = onContextMenuOpen, + contextMenu = contextMenu, ) } @@ -359,7 +875,11 @@ inline fun SeparatedSwitchListItem( dividerColor: Color = LocalDividerColor.current ?: DividerDefaults.color, dividerThickness: Dp = LocalDividerThickness.current ?: DividerDefaults.Thickness, dividerAnimated: Boolean = false, - enabled: Boolean = true + enabled: Boolean = true, + position: ListItemPosition = ListItemPosition.MIDDLE, + groupItemCount: Int? = null, + noinline onContextMenuOpen: (() -> Unit)? = null, + noinline contextMenu: (ListItemContextMenuScope.() -> Unit)? = null, ) { ListItem( modifier = modifier, @@ -399,7 +919,11 @@ inline fun SeparatedSwitchListItem( dividerThickness = dividerThickness, dividerAnimated = dividerAnimated, leadingAndBodyShared = true, - enabled = enabled + enabled = enabled, + position = position, + groupItemCount = groupItemCount, + onContextMenuOpen = onContextMenuOpen, + contextMenu = contextMenu, ) } diff --git a/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/utils/Utils.kt b/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/utils/Utils.kt index 32dc7d2..b11b455 100644 --- a/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/utils/Utils.kt +++ b/utils/shared/src/commonMain/kotlin/com/pr0gramm3r101/utils/Utils.kt @@ -70,6 +70,10 @@ operator fun Modifier.plus(other: Modifier) = then(other) @Composable expect fun ToggleNavScrimEffect(enabled: Boolean = false) +/** Configures the hosting dialog window for edge-to-edge bottom sheets (Android). */ +@Composable +expect fun DialogEdgeToEdgeEffect() + // TODO fix implementation interface SupportClipboardManager { suspend fun setText(string: String) diff --git a/utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/Utils.ios.kt b/utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/Utils.ios.kt index b9b984a..05ca0a1 100644 --- a/utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/Utils.ios.kt +++ b/utils/shared/src/iosMain/kotlin/com/pr0gramm3r101/utils/Utils.ios.kt @@ -16,6 +16,9 @@ actual fun Modifier.clearFocusOnKeyboardDismiss() = this @Composable actual fun ToggleNavScrimEffect(enabled: Boolean) {} + +@Composable +actual fun DialogEdgeToEdgeEffect() {} actual val materialYouAvailable get() = false inline fun htons(short: UShort) = if (isLittleEndian) _OSSwapInt16(short) else short