diff --git a/.cursor/rules/android-build-deploy-device.mdc b/.cursor/rules/android-build-deploy-device.mdc
new file mode 100644
index 0000000..80dd976
--- /dev/null
+++ b/.cursor/rules/android-build-deploy-device.mdc
@@ -0,0 +1,21 @@
+---
+description: After Android/KMP app changes—build debug APK, install and launch on a real device; emulator only if user asked or no phone is connected
+alwaysApply: true
+---
+
+# Build, install, and run on device (after Android-related changes)
+
+Whenever you change anything under **`app:android`**, **`app:shared`** (`commonMain` / `androidMain`), or **`utils:shared`** in ways that affect the Android app:
+
+1. **Build** the debug APK: `./gradlew :app:android:assembleDebug` from the Android repo root. If `JAVA_HOME` pointing at Android Studio’s JBR fails, use a working JDK 17+ on the machine.
+2. **Read Mobile MCP tool schemas** under the project’s MCP descriptors for `user-Mobile MCP` before calling tools (required).
+3. **`mobile_list_available_devices`** — get all `id`s (and `name` / `type` if present).
+4. **Use a real device by default.** Do **not** install or launch on an emulator **unless** the user **explicitly** asked to use the emulator, **or** no physical device is connected and **only** emulator(s) are available. Classify devices using `type != "emulator"` when reliable; if `type` is wrong or missing, treat as physical when the **name** does not look like an AVD (e.g. not `sdk_gphone`, not a generic emulator name). When one or more physical devices are available, install and launch **only** on those—**never** also push to emulators in the same step. When **no** physical device is connected, use every returned target (emulators).
+5. For **each** chosen device `id`: **`mobile_install_app`** with `path` = absolute path to
+ `app/android/build/outputs/apk/debug/android-debug.apk`
+ in this repo.
+6. For **each** same device: **`mobile_launch_app`** with `packageName` **`ru.fromchat.beta`** (debug `applicationIdSuffix`).
+
+Also run **`:app:shared:compileKotlinIosArm64`** (and fix errors) when shared Kotlin changes should stay valid for iOS—either with the same Gradle invocation as in `general.mdc` or right after the Android APK build.
+
+Skipping install/launch when **no** device is online is acceptable; do not skip the **Gradle build** after code changes.
diff --git a/.cursor/rules/general.mdc b/.cursor/rules/general.mdc
index 1c4b199..df73b7d 100644
--- a/.cursor/rules/general.mdc
+++ b/.cursor/rules/general.mdc
@@ -6,7 +6,7 @@ When working with the mobile app:
- After implementing the solution, run "export JAVA_HOME=/Applications/Android\ Studio.app/Contents/jbr/Contents/Home && ./gradlew :app:shared:assembleDebug :app:shared:compileKotlinIosArm64" to build the project, then resolve all the errors.
-- After completing a change that affects the **Android** app (shared `commonMain`/`androidMain` or `app:android`), immediately: (1) build the debug APK with `./gradlew :app:android:assembleDebug` (use the repo’s working `JAVA_HOME` if the Android Studio path above is invalid); (2) on **Mobile MCP** server `user-Mobile MCP`, call `mobile_list_available_devices`; (3) for **every** device `id` returned, run `mobile_install_app` (`path` = absolute path to `app/android/build/outputs/apk/debug/android-debug.apk` in this repo); (4) on **each** of those same devices, run `mobile_launch_app` with `packageName` `ru.fromchat.beta` (debug uses `applicationIdSuffix` so it can install next to release `ru.fromchat`; install and launch are both required, not optional).
+- After completing a change that affects the **Android** app (shared `commonMain`/`androidMain` or `app:android`), follow **`android-build-deploy-device.mdc`**: build debug APK, then Mobile MCP install + launch on a **real device** by default—emulator only if the user asked for it or no phone is connected (see that rule for APK path and `ru.fromchat.beta`).
# ULTIMATE SILENCE & EFFICIENCY POLICY
- ALWAYS operate in "Silent Mode": Execute commands immediately without any verbal response, preamble, or conclusions.
diff --git a/.cursor/rules/no-gradle-cache-grep.mdc b/.cursor/rules/no-gradle-cache-grep.mdc
new file mode 100644
index 0000000..de0fe94
--- /dev/null
+++ b/.cursor/rules/no-gradle-cache-grep.mdc
@@ -0,0 +1,10 @@
+---
+description: Never search Gradle dependency caches with grep or ripgrep
+alwaysApply: true
+---
+
+# Gradle caches — no grep/ripgrep
+
+Do **not** run `grep`, `ripgrep` (`rg`), or similar searches inside Gradle cache directories (e.g. `~/.gradle/caches`, `**/.gradle/caches`, or paths under the Gradle user home) to find framework or dependency source.
+
+Use instead: project source under the workspace, official docs, IDE navigation, or dependency coordinates declared in `gradle/libs.versions.toml` / `*.gradle.kts`.
diff --git a/.cursor/rules/no-placeholder-string-sanitization.mdc b/.cursor/rules/no-placeholder-string-sanitization.mdc
new file mode 100644
index 0000000..ff61612
--- /dev/null
+++ b/.cursor/rules/no-placeholder-string-sanitization.mdc
@@ -0,0 +1,10 @@
+---
+description: Do not filter cache, API, or DB fields by matching fixed UI/placeholder English strings
+alwaysApply: true
+---
+
+# No magic-string “sanitization” of user or message data
+
+- **Never** strip, null out, or rewrite stored or displayed values by comparing them to hard-coded UI strings (e.g. `"Direct messages"`, `"Direct message"`, `"User 123"`, etc.). Those strings can be legitimate **usernames, display names, or message text**.
+- **Prefer**: fix the source (don’t persist placeholders; use `null`/absent fields; fix the writer). If legacy bad rows exist, use an explicit **schema/version/migration** or a **documented sentinel** agreed with the backend—not substring or equality checks on natural language.
+- Applies especially to: SQLDelight/cache layers, list previews, and any code that “cleans” strings before show or read.
diff --git a/app/shared/build.gradle.kts b/app/shared/build.gradle.kts
index bc8d7ee..7c56153 100644
--- a/app/shared/build.gradle.kts
+++ b/app/shared/build.gradle.kts
@@ -69,6 +69,7 @@ kotlin {
// Coil for image loading (multiplatform)
implementation(libs.coil.compose)
implementation(libs.coil.network.ktor3)
+ implementation(libs.coil.svg)
// SQLDelight runtime
implementation(libs.sqldelight.runtime)
diff --git a/app/shared/src/commonMain/composeResources/drawable/about_link_max.xml b/app/shared/src/commonMain/composeResources/drawable/about_link_max.xml
new file mode 100644
index 0000000..3707d91
--- /dev/null
+++ b/app/shared/src/commonMain/composeResources/drawable/about_link_max.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/shared/src/commonMain/composeResources/drawable/about_link_telegram.xml b/app/shared/src/commonMain/composeResources/drawable/about_link_telegram.xml
new file mode 100644
index 0000000..dcfc5df
--- /dev/null
+++ b/app/shared/src/commonMain/composeResources/drawable/about_link_telegram.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/shared/src/commonMain/composeResources/drawable/logo_square.svg b/app/shared/src/commonMain/composeResources/drawable/logo_square.svg
new file mode 100644
index 0000000..24c8655
--- /dev/null
+++ b/app/shared/src/commonMain/composeResources/drawable/logo_square.svg
@@ -0,0 +1,3507 @@
+
+
\ No newline at end of file
diff --git a/app/shared/src/commonMain/composeResources/font/montserrat_cyrillic.ttf b/app/shared/src/commonMain/composeResources/font/montserrat_cyrillic.ttf
new file mode 100644
index 0000000..57728fe
Binary files /dev/null and b/app/shared/src/commonMain/composeResources/font/montserrat_cyrillic.ttf differ
diff --git a/app/shared/src/commonMain/composeResources/font/montserrat_cyrillic_ext.ttf b/app/shared/src/commonMain/composeResources/font/montserrat_cyrillic_ext.ttf
new file mode 100644
index 0000000..a7044c1
Binary files /dev/null and b/app/shared/src/commonMain/composeResources/font/montserrat_cyrillic_ext.ttf differ
diff --git a/app/shared/src/commonMain/composeResources/font/montserrat_latin.ttf b/app/shared/src/commonMain/composeResources/font/montserrat_latin.ttf
new file mode 100644
index 0000000..b99369a
Binary files /dev/null and b/app/shared/src/commonMain/composeResources/font/montserrat_latin.ttf differ
diff --git a/app/shared/src/commonMain/composeResources/font/montserrat_latin_ext.ttf b/app/shared/src/commonMain/composeResources/font/montserrat_latin_ext.ttf
new file mode 100644
index 0000000..6cef0bb
Binary files /dev/null and b/app/shared/src/commonMain/composeResources/font/montserrat_latin_ext.ttf differ
diff --git a/app/shared/src/commonMain/composeResources/font/montserrat_vietnamese.ttf b/app/shared/src/commonMain/composeResources/font/montserrat_vietnamese.ttf
new file mode 100644
index 0000000..4a22655
Binary files /dev/null and b/app/shared/src/commonMain/composeResources/font/montserrat_vietnamese.ttf differ
diff --git a/app/shared/src/commonMain/composeResources/values-ru/strings.xml b/app/shared/src/commonMain/composeResources/values-ru/strings.xml
index 699e502..da00d2d 100644
--- a/app/shared/src/commonMain/composeResources/values-ru/strings.xml
+++ b/app/shared/src/commonMain/composeResources/values-ru/strings.xml
@@ -1,4 +1,10 @@
+ Версия 1.0
+ Telegram
+ MAX
+ Сайт
+ 100% бесплатный и открытый мессенджер. Поддерживает self-hosted установку на своём сервере.
+
%1$s печатает…
%1$s и %2$s печатают…
diff --git a/app/shared/src/commonMain/composeResources/values/strings.xml b/app/shared/src/commonMain/composeResources/values/strings.xml
index 88a8039..a097f82 100644
--- a/app/shared/src/commonMain/composeResources/values/strings.xml
+++ b/app/shared/src/commonMain/composeResources/values/strings.xml
@@ -7,7 +7,11 @@
Settings
Home
About app
- A 100% free, open source and private messenger.
+ Version 1.0
+ Telegram
+ MAX
+ Website
+ 100% free and open messenger. Supports self-hosted installation on your own server.
Welcome!
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 1af7500..0574fb0 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt
@@ -188,6 +188,14 @@ object ApiClient {
}
.body()
+ suspend fun getRegisteredUserCount(): Int =
+ http
+ .get("${Config.apiBaseUrl}/user/stats/registered-count") {
+ contentType(ContentType.Application.Json)
+ }
+ .body()
+ .count
+
suspend fun checkSimilarity(userId: Int): SimilarityResult? =
runCatching {
http
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt
index 86bd459..c3a07e8 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt
@@ -30,6 +30,7 @@ data class User(
val last_seen: String,
val online: Boolean,
val username: String,
+ @SerialName("display_name") val displayName: String? = null,
val admin: Boolean? = null,
val bio: String? = null,
val profile_picture: String? = null
@@ -214,6 +215,11 @@ data class DmConversation(
val unreadCount: Int
)
+@Serializable
+data class RegisteredUserCountResponse(
+ val count: Int
+)
+
@Serializable
data class DmConversationsResponse(
val conversations: List = emptyList()
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ProfileCache.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ProfileCache.kt
index 753bb1d..dfe6615 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ProfileCache.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ProfileCache.kt
@@ -33,29 +33,62 @@ object ProfileCache {
fun get(userId: Int): UserProfile? = profiles[userId]
fun put(profile: UserProfile) {
+ if (profile.isClientPreviewOnly) {
+ val hasIdentity =
+ profile.username.trim().isNotEmpty() || !profile.displayName.isNullOrBlank()
+ if (!hasIdentity) {
+ remove(profile.id)
+ return
+ }
+ }
val cur = profiles
profiles = cur + (profile.id to profile)
schedulePersist()
}
/**
- * Fills or refreshes a lightweight profile from a public chat [Message] (username, avatar URL).
- * Skips when a full API profile is already stored ([UserProfile.isClientPreviewOnly] is false).
+ * Removes one user from the cache and persists. Does not clear full API profiles unless
+ * the caller only invokes this for preview cleanup flows.
*/
+ fun remove(userId: Int) {
+ val cur = profiles
+ if (userId !in cur) return
+ profiles = cur - userId
+ schedulePersist()
+ }
+
+ /**
+ * Drops [isClientPreviewOnly] entries that have no usable identity (blank username and
+ * display name). Avoids persisting or showing stale rows from failed loads / bad merges.
+ */
+ fun evictUnusableClientPreview(userId: Int) {
+ val p = get(userId) ?: return
+ if (!p.isClientPreviewOnly) return
+ val hasIdentity =
+ p.username.trim().isNotEmpty() || !p.displayName.isNullOrBlank()
+ if (!hasIdentity) remove(userId)
+ }
+
/**
* Seeds or refreshes a lightweight profile from a DM conversations list [User].
* Skips when a full `/user/...` profile is already cached.
+ * Does not write a preview when the API user has no non-blank username (avoids caching empty identity).
*/
fun mergeFromDmUser(user: User) {
val existing = get(user.id)
if (existing != null && !existing.isClientPreviewOnly) return
+
+ val incomingUsername = user.username.trim()
+ if (incomingUsername.isEmpty()) return
+
+ val incomingDisplayName =
+ user.displayName?.trim()?.takeIf { it.isNotEmpty() } ?: incomingUsername
+
put(
UserProfile(
id = user.id,
- username = user.username.ifBlank { existing?.username.orEmpty() },
- displayName = existing?.displayName?.takeIf { it.isNotBlank() }
- ?: user.username.takeIf { it.isNotBlank() }
- ?: existing?.username.orEmpty(),
+ username = incomingUsername,
+ displayName = existing?.displayName?.takeIf { it.isNotBlank() } ?: incomingDisplayName,
profilePicture = user.profile_picture?.takeIf { it.isNotBlank() }
?: existing?.profilePicture,
bio = existing?.bio,
@@ -109,12 +142,44 @@ object ProfileCache {
val raw = settings.getString(SETTINGS_KEY, "").ifBlank { return }
runCatching {
val list = json.decodeFromString(ListSerializer(UserProfile.serializer()), raw)
- val fromDisk = list.associateBy { it.id }
+ val usable = list.filter { p ->
+ when {
+ !p.isClientPreviewOnly -> true
+ else ->
+ p.username.trim().isNotEmpty() ||
+ !p.displayName.isNullOrBlank()
+ }
+ }
+ val fromDisk = usable.associateBy { it.id }
profiles = fromDisk + profiles
+ if (usable.size < list.size) {
+ schedulePersist()
+ }
}
+ pruneUnusableClientPreviewsLocked()
}
}
+ /**
+ * Drops in-memory preview-only rows with no identity (e.g. bad runtime state after a failed merge).
+ * Call only while holding [persistMutex] or from [hydrateFromDisk] inside the lock.
+ */
+ private fun pruneUnusableClientPreviewsLocked() {
+ val snap = profiles
+ val toRemove = snap.filter { (_, p) ->
+ p.isClientPreviewOnly &&
+ p.username.trim().isEmpty() &&
+ p.displayName.isNullOrBlank()
+ }.keys
+ if (toRemove.isEmpty()) return
+ var cur = profiles
+ for (id in toRemove) {
+ cur = cur - id
+ }
+ profiles = cur
+ schedulePersist()
+ }
+
suspend fun clear() {
persistMutex.withLock {
profiles = emptyMap()
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageCacheStore.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageCacheStore.kt
index bfdfd06..5d65b3e 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageCacheStore.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/db/MessageCacheStore.kt
@@ -30,6 +30,46 @@ object MessageCacheStore {
private fun conversationIdForPublic(): String = "public"
private fun conversationIdForDm(otherUserId: Int): String = "dm:$otherUserId"
+ private fun otherUserIdFromDmConversationId(conversationId: String): Int? =
+ if (conversationId.startsWith("dm:")) {
+ conversationId.removePrefix("dm:").toIntOrNull()
+ } else {
+ null
+ }
+
+ private fun truncateDmListPreview(text: String, maxLen: Int = 120): String {
+ val t = text.trim()
+ if (t.isEmpty()) return ""
+ return if (t.length > maxLen) t.take(maxLen) + "\u2026" else t
+ }
+
+ /** Sets [Conversation.lastMessagePreview] from the latest cached plaintext row (DMs are encrypted on the API). */
+ private suspend fun syncDmConversationPreviewFromCache(otherUserId: Int) {
+ val convId = conversationIdForDm(otherUserId)
+ withContext(Dispatchers.Default) {
+ val row = db.messageDatabaseQueries.selectConversations().executeAsList()
+ .find { it.id == convId } ?: return@withContext
+ val recent = db.messageDatabaseQueries
+ .selectRecentMessagesByConversation(convId, 1)
+ .executeAsList()
+ .firstOrNull()
+ val rawPreview = recent?.content?.orEmpty()?.trim().orEmpty()
+ val preview = rawPreview.takeIf { it.isNotEmpty() }
+ ?.let { truncateDmListPreview(it) }
+ ?.takeIf { it.isNotEmpty() }
+ db.messageDatabaseQueries.upsertConversation(
+ id = row.id,
+ type = row.type,
+ otherUserId = row.otherUserId,
+ displayName = row.displayName,
+ lastMessageId = row.lastMessageId,
+ lastMessagePreview = preview,
+ unreadCount = row.unreadCount,
+ updatedAt = row.updatedAt
+ )
+ }
+ }
+
/**
* Full history for a conversation (unbounded). Prefer [loadRecentPublicMessages] when opening UI.
*/
@@ -92,6 +132,7 @@ object MessageCacheStore {
suspend fun upsertDmMessage(otherUserId: Int, message: Message) {
upsertSingle(conversationIdForDm(otherUserId), message)
+ syncDmConversationPreviewFromCache(otherUserId)
}
suspend fun deletePublicMessageByClientMessageId(clientMessageId: String) {
@@ -151,6 +192,7 @@ object MessageCacheStore {
)
}
}
+ otherUserIdFromDmConversationId(conversationId)?.let { syncDmConversationPreviewFromCache(it) }
}
private suspend fun loadMessages(conversationId: String): List =
@@ -229,6 +271,7 @@ object MessageCacheStore {
}
}
}
+ otherUserIdFromDmConversationId(conversationId)?.let { syncDmConversationPreviewFromCache(it) }
}
suspend fun markMessageDeleted(conversationId: String, messageId: Int) {
@@ -245,13 +288,23 @@ object MessageCacheStore {
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(conversationId, 1)
+ .executeAsList()
+ .firstOrNull()
+ val rawPreview = recent?.content?.orEmpty()?.trim().orEmpty()
+ val preview = rawPreview.takeIf { it.isNotEmpty() }
+ ?.let { truncateDmListPreview(it) }
+ ?.takeIf { it.isNotEmpty() }
db.messageDatabaseQueries.upsertConversation(
id = conversationId,
type = "dm",
otherUserId = conv.user.id.toLong(),
- displayName = conv.user.username,
+ displayName = displayLabel,
lastMessageId = conv.lastMessage.id.toLong(),
- lastMessagePreview = null, // Encrypted on backend; preview handled in chat UI.
+ lastMessagePreview = preview,
unreadCount = conv.unreadCount.toLong(),
updatedAt = conv.lastMessage.timestamp
)
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/AboutScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/AboutScreen.kt
index cbf77ca..b7b95d4 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/AboutScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/AboutScreen.kt
@@ -1,8 +1,5 @@
-@file:Suppress("NOTHING_TO_INLINE")
-
package ru.fromchat.ui
-import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
@@ -27,25 +24,50 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalUriHandler
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.sp
-import org.jetbrains.compose.resources.painterResource
+import coil3.compose.AsyncImage
+import com.pr0gramm3r101.components.Category
+import com.pr0gramm3r101.components.ListItem
+import com.pr0gramm3r101.ui.Website
+import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.stringResource
+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_telegram
+import ru.fromchat.about_link_website
+import ru.fromchat.about_version
import ru.fromchat.app_desc
-import ru.fromchat.app_icon
import ru.fromchat.app_name
import ru.fromchat.back
+private const val URL_TELEGRAM = "https://t.me/fromchat_ch"
+private const val URL_MAX = "https://maxgate.io/fromchat_ch"
+private const val URL_WEBSITE = "https://fromchat.ru"
+
+@Composable
+private fun AboutBrandListIcon(drawable: DrawableResource) {
+ Icon(
+ imageVector = vectorResource(drawable),
+ contentDescription = null,
+ modifier = Modifier.size(24.dp),
+ tint = MaterialTheme.colorScheme.onSurface,
+ )
+}
+
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-inline fun AboutScreen() {
+fun AboutScreen() {
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
val navController = LocalNavController.current
-
+ val uriHandler = LocalUriHandler.current
+
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
@@ -69,37 +91,86 @@ inline fun AboutScreen() {
)
},
) { innerPadding ->
- Box(Modifier.padding(innerPadding)) {
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState())
+ .padding(innerPadding)
+ ) {
Column(
Modifier
- .padding(top = 16.dp, start = 16.dp, end = 16.dp)
- .verticalScroll(rememberScrollState())
- .fillMaxWidth(),
+ .fillMaxWidth()
+ .padding(top = 16.dp, start = 16.dp, end = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
- Box(Modifier.padding(bottom = 10.dp)) {
- Image(
- painter = painterResource(Res.drawable.app_icon),
+ Box(Modifier.padding(bottom = 12.dp)) {
+ AsyncImage(
+ model = Res.getUri("drawable/logo_square.svg"),
contentDescription = null,
+ contentScale = ContentScale.Fit,
modifier = Modifier
- .clip(RoundedCornerShape(20.dp))
- .size(70.dp)
+ .size(88.dp)
+ .clip(RoundedCornerShape(28.dp))
)
}
Text(
text = stringResource(Res.string.app_name),
- fontSize = 20.sp,
+ fontSize = 22.sp,
style = MaterialTheme.typography.headlineMedium,
+ modifier = Modifier.padding(bottom = 4.dp)
+ )
+ Text(
+ text = stringResource(Res.string.about_version),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 16.dp)
)
Text(
text = stringResource(Res.string.app_desc),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge,
- color = MaterialTheme.colorScheme.onSurfaceVariant
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+
+ Category(Modifier.padding(top = 16.dp)) {
+ ListItem(
+ headline = stringResource(Res.string.about_link_telegram),
+ supportingText = URL_TELEGRAM,
+ onClick = { uriHandler.openUri(URL_TELEGRAM) },
+ divider = true,
+ dividerColor = MaterialTheme.colorScheme.surface,
+ dividerThickness = 2.dp,
+ leadingContent = {
+ AboutBrandListIcon(Res.drawable.about_link_telegram)
+ }
+ )
+ ListItem(
+ headline = stringResource(Res.string.about_link_max),
+ supportingText = URL_MAX,
+ onClick = { uriHandler.openUri(URL_MAX) },
+ divider = true,
+ dividerColor = MaterialTheme.colorScheme.surface,
+ dividerThickness = 2.dp,
+ leadingContent = {
+ AboutBrandListIcon(Res.drawable.about_link_max)
+ }
+ )
+ ListItem(
+ headline = stringResource(Res.string.about_link_website),
+ supportingText = URL_WEBSITE,
+ onClick = { uriHandler.openUri(URL_WEBSITE) },
+ leadingContent = {
+ Icon(
+ imageVector = Icons.Outlined.Website,
+ contentDescription = null,
+ modifier = Modifier.size(24.dp),
+ tint = MaterialTheme.colorScheme.onSurface,
+ )
+ }
)
}
}
}
}
-
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 aef0011..3a92415 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt
@@ -4,6 +4,9 @@ import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.
import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.Start
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.core.tween
+import coil3.ImageLoader
+import coil3.compose.setSingletonImageLoaderFactory
+import coil3.svg.SvgDecoder
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
@@ -46,6 +49,14 @@ val LocalNavController = compositionLocalOf { error("NavControlle
@Composable
fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) {
+ setSingletonImageLoaderFactory { context ->
+ ImageLoader.Builder(context)
+ .components {
+ add(SvgDecoder.Factory())
+ }
+ .build()
+ }
+
var startDestination by remember { mutableStateOf(null) }
LaunchedEffect(Unit) {
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/branding/FromChatBrandTitle.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/branding/FromChatBrandTitle.kt
new file mode 100644
index 0000000..2cc786d
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/branding/FromChatBrandTitle.kt
@@ -0,0 +1,107 @@
+package ru.fromchat.ui.branding
+
+import androidx.compose.foundation.layout.wrapContentWidth
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shadow
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import org.jetbrains.compose.resources.Font
+import ru.fromchat.Res
+import ru.fromchat.montserrat_cyrillic
+import ru.fromchat.montserrat_cyrillic_ext
+import ru.fromchat.montserrat_latin
+import ru.fromchat.montserrat_latin_ext
+import ru.fromchat.montserrat_vietnamese
+import kotlin.math.abs
+
+/**
+ * Left → right gradient (like the web header reference), not stretched to parent width:
+ * [Modifier.wrapContentWidth] + brush [end] = measured text width from [onTextLayout].
+ *
+ * Palette aligned with [Web/frontend/src/pages/chat/css/left-panel.module.scss] `.productName`.
+ */
+private val fromChatTitleGradientStops = arrayOf(
+ // 0f to Color(0xFF6366F1),
+ // 0.18f to Color(0xFF3B82F6),
+ // 0.38f to Color(0xFF9333EA),
+ // 0.55f to Color(0xFFA855F7),
+ // 0.72f to Color(0xFFD946EF),
+ // 0.88f to Color(0xFFEC4899),
+ // 1f to Color(0xFFC084FC),
+ 0f to Color(0xFF6366F1),
+ 0.2f to Color(0xFF3B82F6),
+ 0.4f to Color(0xFF9333EA),
+ 0.6f to Color(0xFFA855F7),
+ 0.8f to Color(0xFFD946EF),
+ 1f to Color(0xFFEC4899),
+)
+
+@Composable
+private fun montserratBoldBrandFamily(): FontFamily = FontFamily(
+ // Latin first: Cyrillic-only subsets have no ASCII glyphs, so "FromChat" never used Montserrat.
+ Font(Res.font.montserrat_latin, FontWeight.Bold, FontStyle.Normal),
+ Font(Res.font.montserrat_latin_ext, FontWeight.Bold, FontStyle.Normal),
+ Font(Res.font.montserrat_cyrillic, FontWeight.Bold, FontStyle.Normal),
+ Font(Res.font.montserrat_cyrillic_ext, FontWeight.Bold, FontStyle.Normal),
+ Font(Res.font.montserrat_vietnamese, FontWeight.Bold, FontStyle.Normal),
+)
+
+@Composable
+fun FromChatBrandTitle(
+ text: String = "FromChat",
+ modifier: Modifier = Modifier,
+) {
+ val density = LocalDensity.current
+ val fontFamily = montserratBoldBrandFamily()
+ var textDrawWidthPx by remember(text) { mutableFloatStateOf(0f) }
+ val gradientBrush = remember(text, textDrawWidthPx) {
+ val w = if (textDrawWidthPx > 1f) textDrawWidthPx else with(density) { 180.dp.toPx() }
+ Brush.linearGradient(
+ colorStops = fromChatTitleGradientStops,
+ start = Offset.Zero,
+ end = Offset(w, 0f),
+ )
+ }
+ val shadowBlur = with(density) { 20.dp.toPx() }
+ Text(
+ text = text,
+ modifier = modifier.wrapContentWidth(align = Alignment.Start),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ softWrap = false,
+ onTextLayout = { layout ->
+ val w = layout.size.width.toFloat()
+ if (abs(w - textDrawWidthPx) > 0.5f) {
+ textDrawWidthPx = w
+ }
+ },
+ style = TextStyle(
+ fontFamily = fontFamily,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 29.sp,
+ lineHeight = 34.sp,
+ brush = gradientBrush,
+ shadow = Shadow(
+ color = Color(0x809333EA),
+ offset = Offset.Zero,
+ blurRadius = shadowBlur,
+ ),
+ ),
+ )
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt
index b50589a..e6543ae 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt
@@ -27,7 +27,11 @@ data class ChatPanelState(
val isLoadingMore: Boolean = false,
val typingUsers: List = emptyList(),
val titleAvatar: AvatarInfo? = null,
- val profileUserId: Int? = null
+ val profileUserId: Int? = null,
+ /** Public chat: registered user count; null until first successful load or WS update. */
+ val publicGroupMemberCount: Int? = null,
+ /** Public chat: true until first count response (HTTP or WebSocket). */
+ val publicGroupMetaLoading: Boolean = false
)
@Serializable
@@ -420,5 +424,9 @@ abstract class ChatPanel(
/** When true, tapping a sender username in a message opens their profile (e.g. public chat). */
open val supportsNavigateToSenderProfile: Boolean
get() = false
+
+ /** When true, subtitle shows group label / member count instead of DM presence. */
+ open val usesPublicGroupSubtitle: Boolean
+ get() = false
}
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 ac24247..a5557e3 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
@@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
@@ -230,7 +229,8 @@ fun ChatScreen(
}
"newMessage", "messageEdited", "messageDeleted",
"dmNew", "dmEdited", "dmDeleted",
- "typing", "stopTyping", "dmTyping", "stopDmTyping", "suspended", "account_deleted" -> {
+ "typing", "stopTyping", "dmTyping", "stopDmTyping",
+ "suspended", "account_deleted", "registeredUserCount" -> {
Logger.d("ChatScreen", "handleWebSocketMessage for ${update.type}")
try {
panel.handleWebSocketMessage(wsMessage)
@@ -252,7 +252,7 @@ fun ChatScreen(
if (userId != null) UserStatusStore.update(userId, online, lastSeen)
}
"newMessage", "messageEdited", "messageDeleted", "dmNew", "dmEdited", "dmDeleted",
- "dmTyping", "stopDmTyping" -> {
+ "dmTyping", "stopDmTyping", "registeredUserCount" -> {
scope.launch {
panel.handleWebSocketMessage(message)
}
@@ -401,6 +401,15 @@ fun ChatScreen(
connectionStatus == ConnectionStatus.UPDATING -> "updating"
connectionStatus != ConnectionStatus.CONNECTED -> "connecting"
currentTypingUsers.isNotEmpty() -> "typing"
+ panel.usesPublicGroupSubtitle -> {
+ if (panelState.publicGroupMetaLoading ||
+ panelState.publicGroupMemberCount == null
+ ) {
+ "group"
+ } else {
+ "members:${panelState.publicGroupMemberCount}"
+ }
+ }
panelState.profileUserId != null -> {
val userStatus = statusMap[panelState.profileUserId]
val statusText = userStatus?.let {
@@ -466,6 +475,23 @@ fun ChatScreen(
modifier = Modifier.padding(top = 2.dp)
)
}
+ key == "group" -> {
+ Text(
+ text = "group",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 2.dp)
+ )
+ }
+ key.startsWith("members:") -> {
+ val n = key.removePrefix("members:").toIntOrNull() ?: 0
+ Text(
+ text = "$n members",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 2.dp)
+ )
+ }
else -> {
// No subtitle for this state
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanel.kt
index c0b8b6e..5a7303a 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanel.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanel.kt
@@ -4,6 +4,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
import ru.fromchat.api.ApiClient
import ru.fromchat.api.Message
import ru.fromchat.api.MessageDeletedData
@@ -49,15 +51,35 @@ class PublicChatPanel(
override val supportsNavigateToSenderProfile: Boolean
get() = true
+ override val usesPublicGroupSubtitle: Boolean
+ get() = true
+
init {
- updateState { it.copy(title = chatName) }
- // Observe typing users from the handler and update panel state
+ updateState {
+ it.copy(
+ title = chatName,
+ titleAvatar = AvatarInfo(displayName = chatName, profilePictureUrl = null),
+ publicGroupMetaLoading = true,
+ publicGroupMemberCount = null
+ )
+ }
scope.launch {
typingHandler.typingUsers.collect { users ->
Logger.d("PublicChatPanel", "Typing users updated in handler: ${users.map { it.username }}")
updateState { it.copy(typingUsers = users.filter { it.userId != currentUserId }) }
}
}
+ scope.launch(Dispatchers.Default) {
+ runCatching { ApiClient.getRegisteredUserCount() }
+ .onSuccess { n ->
+ updateState { s ->
+ s.copy(publicGroupMemberCount = n, publicGroupMetaLoading = false)
+ }
+ }
+ .onFailure {
+ updateState { s -> s.copy(publicGroupMetaLoading = false) }
+ }
+ }
}
private fun handleReactionUpdate(reactionUpdate: ReactionUpdateData) {
@@ -274,6 +296,16 @@ class PublicChatPanel(
Logger.d("PublicChatPanel", "Received stopTyping event for user: ${typingData.username}")
typingHandler.handleStopTypingEvent(typingData.userId)
}
+ "registeredUserCount" -> {
+ val data = updateMessage.data ?: return
+ val obj = data.jsonObject
+ val c = obj["count"]?.jsonPrimitive?.content?.toIntOrNull()
+ if (c != null) {
+ updateState { s ->
+ s.copy(publicGroupMemberCount = c, publicGroupMetaLoading = false)
+ }
+ }
+ }
"statusUpdate" -> {
// Handled in ChatScreen or by global WebSocketManager listeners
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatScreen.kt
index e8c9a3f..d242967 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatScreen.kt
@@ -42,6 +42,7 @@ fun PublicChatScreen(
currentUserId = currentUserId,
scrollToMessageId = scrollToMessageId,
sharedTransitionScope = sharedTransitionScope,
- animatedVisibilityScope = animatedContentScope
+ animatedVisibilityScope = animatedContentScope,
+ sharedAvatarKey = "public-general-chat"
)
}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt
index 6c0703a..2e52558 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt
@@ -39,7 +39,7 @@ class DmPanel(
) {
private val typingHandler = DmTypingHandler(coroutineScope, otherUserId)
private val json = Json { ignoreUnknownKeys = true }
- private var otherDisplayName: String = "User $otherUserId"
+ private var otherDisplayName: String = ""
private var otherProfilePicture: String? = null
private val dmEnvelopeMutex = Mutex()
@@ -59,7 +59,13 @@ class DmPanel(
}
init {
- updateState { it.copy(title = "Direct message", profileUserId = otherUserId) }
+ updateState {
+ it.copy(
+ title = "",
+ titleAvatar = null,
+ profileUserId = otherUserId
+ )
+ }
coroutineScope.launch {
typingHandler.typingUsers.collect { users ->
updateState { it.copy(typingUsers = users) }
@@ -69,6 +75,13 @@ class DmPanel(
runCatching {
ApiClient.getProfileById(otherUserId)
}.onSuccess { profile ->
+ if (profile.username.isBlank() && profile.displayName.isNullOrBlank()) {
+ ProfileCache.evictUnusableClientPreview(otherUserId)
+ updateState {
+ it.copy(title = "", titleAvatar = null, profileUserId = otherUserId)
+ }
+ return@onSuccess
+ }
ProfileCache.put(profile)
val displayName = profile.displayName?.takeIf { it.isNotBlank() } ?: profile.username
otherDisplayName = displayName
@@ -83,6 +96,11 @@ class DmPanel(
profileUserId = otherUserId
)
}
+ }.onFailure {
+ ProfileCache.evictUnusableClientPreview(otherUserId)
+ updateState {
+ it.copy(title = "", titleAvatar = null, profileUserId = otherUserId)
+ }
}
}
}
@@ -359,7 +377,7 @@ class DmPanel(
val username = if (envelope.senderId == currentUserId) {
"You"
} else {
- otherDisplayName
+ otherDisplayName.ifBlank { "User $otherUserId" }
}
return Message(
id = envelope.id,
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsTab.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsTab.kt
index a31c4cc..da3b605 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsTab.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ChatsTab.kt
@@ -12,16 +12,19 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.size
+import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ListItem
-import androidx.compose.material3.MediumTopAppBar
+import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
-import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -31,9 +34,11 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import coil3.compose.AsyncImage
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.api.ApiClient
@@ -43,10 +48,11 @@ import ru.fromchat.api.ProfileCache
import ru.fromchat.api.db.CachedConversation
import ru.fromchat.api.db.MessageCacheStore
import ru.fromchat.chat_last_mesaage
-import ru.fromchat.public_chat
import ru.fromchat.net.NetworkConnectivity
+import ru.fromchat.public_chat
import ru.fromchat.ui.ConnectingEllipsis
import ru.fromchat.ui.LocalNavController
+import ru.fromchat.ui.branding.FromChatBrandTitle
import ru.fromchat.ui.chat.Avatar
@Composable
@@ -78,13 +84,9 @@ private fun ChatRowAvatar(
@Composable
fun ChatsTab() {
val navController = LocalNavController.current
- val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
val connectionStatus by ConnectionStateStore.status.collectAsState()
val online by NetworkConnectivity.isOnline.collectAsState(initial = true)
var dmConversations by remember { mutableStateOf>(emptyList()) }
- var publicListAvatarUserId by remember { mutableStateOf(null) }
- var publicListAvatarUrl by remember { mutableStateOf(null) }
- var publicListAvatarLabel by remember { mutableStateOf(null) }
var publicLastMessagePreview by remember { mutableStateOf(null) }
LaunchedEffect(Unit) {
@@ -96,17 +98,6 @@ fun ChatsTab() {
runCatching {
val last = MessageCacheStore.loadRecentPublicMessages(1).lastOrNull()
publicLastMessagePreview = last?.content?.trim()?.takeIf { it.isNotEmpty() }
- if (last != null && last.user_id > 0) {
- ProfileCache.mergePreviewFromPublicMessage(last)
- publicListAvatarUserId = last.user_id
- publicListAvatarUrl = last.profile_picture
- publicListAvatarLabel = last.username.takeIf { it.isNotBlank() }
- ?: ProfileCache.get(last.user_id)?.displayName?.takeIf { it.isNotBlank() }
- } else {
- publicListAvatarUserId = null
- publicListAvatarUrl = null
- publicListAvatarLabel = null
- }
}
// Then refresh from network and update cache + state.
@@ -129,79 +120,90 @@ fun ChatsTab() {
}
Scaffold(
- modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
- MediumTopAppBar(
+ TopAppBar(
title = {
- AnimatedContent(
- targetState = titleKey,
- transitionSpec = {
- (slideInVertically { it / 2 } + fadeIn()) togetherWith
- (slideOutVertically { -it / 2 } + fadeOut())
- },
- label = "chats_title"
- ) { key ->
- when (key) {
- "connecting" -> {
- val style = MaterialTheme.typography.headlineSmall
- val color = MaterialTheme.colorScheme.onSurface
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.Start
- ) {
- Text(
- text = "Connecting",
- style = style,
- color = color,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
- ConnectingEllipsis(
- fontSize = style.fontSize,
- color = color,
- baseStyle = style
- )
+ 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" -> {
+ val style = MaterialTheme.typography.titleLarge
+ val color = MaterialTheme.colorScheme.onSurface
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Start
+ ) {
+ Text(
+ text = "Connecting",
+ style = style,
+ color = color,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ ConnectingEllipsis(
+ fontSize = style.fontSize,
+ color = color,
+ baseStyle = style
+ )
+ }
+ }
+ "updating" -> {
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = "Updating...",
+ style = MaterialTheme.typography.titleLarge,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ else -> {
+ FromChatBrandTitle(text = "FromChat")
+ }
}
}
- "updating" -> {
- Text(
- text = "Updating...",
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
- }
- else -> {
- Text(
- text = "FromChat",
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
- }
}
}
- },
- scrollBehavior = scrollBehavior
+ }
)
}
) { innerPadding ->
val publicChatTitle = stringResource(Res.string.public_chat)
LazyColumn(contentPadding = innerPadding) {
item {
- val publicUid = publicListAvatarUserId
- val publicPic = publicListAvatarUrl
- val publicLabel = publicListAvatarLabel?.takeIf { it.isNotBlank() } ?: publicChatTitle
ListItem(
leadingContent = {
ChatRowAvatar(
- profilePictureUrl = publicPic,
- displayNameForInitials = publicLabel,
- onClick = {
- when {
- publicUid != null && publicUid > 0 ->
- navController.navigate("profile/$publicUid")
- else -> navController.navigate("chats/publicChat")
- }
- }
+ profilePictureUrl = null,
+ displayNameForInitials = publicChatTitle,
+ onClick = { navController.navigate("chats/publicChat") }
)
},
headlineContent = {
@@ -229,15 +231,15 @@ fun ChatsTab() {
val conv = dmConversations[index]
val cached = ProfileCache.get(conv.otherUserId)
val avatarUrl = cached?.profilePicture
- val avatarLabel = cached?.displayName?.takeIf { it.isNotBlank() }
+ val peerTitle = cached?.displayName?.takeIf { it.isNotBlank() }
?: cached?.username?.takeIf { it.isNotBlank() }
?: conv.displayName.ifBlank { "User ${conv.otherUserId}" }
- val preview = conv.lastMessagePreview ?: "Direct messages"
+ val preview = conv.lastMessagePreview?.trim().orEmpty()
ListItem(
leadingContent = {
ChatRowAvatar(
profilePictureUrl = avatarUrl,
- displayNameForInitials = avatarLabel,
+ displayNameForInitials = peerTitle,
onClick = {
if (conv.otherUserId != 0) {
navController.navigate("profile/${conv.otherUserId}")
@@ -247,7 +249,15 @@ fun ChatsTab() {
},
headlineContent = {
Text(
- text = preview,
+ text = peerTitle,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ },
+ supportingContent = {
+ Text(
+ text = preview.takeIf { it.isNotEmpty() }
+ ?: stringResource(Res.string.chat_last_mesaage),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
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 62a8fe0..e4e1e8b 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
@@ -137,10 +137,20 @@ fun ProfileScreen(
ApiClient.getProfileById(targetUserId)
}
}.onSuccess { profile ->
+ if (profile.username.isBlank() && profile.displayName.isNullOrBlank()) {
+ cacheLookupId?.let { ProfileCache.evictUnusableClientPreview(it) }
+ state = latestUi.copy(
+ profile = null,
+ isLoading = false,
+ error = "Unable to load profile"
+ )
+ return@onSuccess
+ }
ProfileCache.put(profile)
state = latestUi.copy(profile = profile, isLoading = false, error = null)
}.onFailure { err ->
val fallbackId = targetUserId ?: ownUserId
+ fallbackId?.let { ProfileCache.evictUnusableClientPreview(it) }
val fallback = fallbackId?.let { ProfileCache.get(it) }
state = latestUi.copy(
error = if (latestUi.profile == null && fallback == null) {
@@ -278,9 +288,17 @@ fun ProfileScreen(
)
}
profile != null -> {
- val profileLink = profile.username.takeIf { it.isNotBlank() }
- ?.let { "https://fromchat.ru/@$it" }
- ?: "https://fromchat.ru/?u=${profile.id}"
+ /** Public chat message → profile: minimal identity (no @ handle, username row, member since). */
+ val compactIdentityForPublicChat =
+ useSharedElementFromNavigation && sharedSourceMessageId > 0
+ val profileLink =
+ if (compactIdentityForPublicChat) {
+ "https://fromchat.ru/?u=${profile.id}"
+ } else {
+ profile.username.takeIf { it.isNotBlank() }
+ ?.let { "https://fromchat.ru/@$it" }
+ ?: "https://fromchat.ru/?u=${profile.id}"
+ }
val scope = rememberCoroutineScope()
val verificationLabel = if (profile.verified == true) "Verified account" else "Click to verify"
@@ -299,7 +317,7 @@ fun ProfileScreen(
userId = profile.id
)
}
- if (profile.username.isNotBlank()) {
+ if (!compactIdentityForPublicChat && profile.username.isNotBlank()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "@${profile.username}",
@@ -419,87 +437,105 @@ fun ProfileScreen(
)
}
- Category(Modifier.padding(top = 16.dp), title = "Details") {
- if (profile.username.isNotBlank()) {
- ListItem(
- headline = "Username",
- supportingText = profile.username,
- leadingContent = {
- CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
- Icon(
- imageVector = Icons.Filled.AlternateEmail,
- contentDescription = null
- )
+ val showDetailsUsername =
+ !compactIdentityForPublicChat && profile.username.isNotBlank()
+ val showDetailsMemberSince =
+ !compactIdentityForPublicChat &&
+ !profile.createdAt.isNullOrBlank()
+ val showDetailsBio = !profile.bio.isNullOrBlank()
+ val showDetailsVerify = profile.verified == true || ApiClient.user?.id == 1
+ if (
+ showDetailsUsername ||
+ showDetailsMemberSince ||
+ showDetailsBio ||
+ showDetailsVerify
+ ) {
+ Category(Modifier.padding(top = 16.dp), title = "Details") {
+ if (showDetailsUsername) {
+ ListItem(
+ headline = "Username",
+ supportingText = profile.username,
+ divider = true,
+ dividerColor = CategoryDefaults.dividerColor,
+ dividerThickness = CategoryDefaults.dividerThickness,
+ leadingContent = {
+ CompositionLocalProvider(
+ LocalContentColor provides MaterialTheme.colorScheme.onSurface
+ ) {
+ Icon(
+ imageVector = Icons.Filled.AlternateEmail,
+ contentDescription = null
+ )
+ }
}
- },
- divider = true,
- dividerColor = CategoryDefaults.dividerColor,
- dividerThickness = CategoryDefaults.dividerThickness
- )
- }
-
- if (!profile.bio.isNullOrBlank()) {
- ListItem(
- headline = "Bio",
- supportingText = profile.bio,
- divider = true,
- dividerColor = CategoryDefaults.dividerColor,
- dividerThickness = CategoryDefaults.dividerThickness,
- leadingContent = {
- CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
- Icon(
- imageVector = Icons.Filled.Info,
- contentDescription = null
- )
- }
- }
- )
- }
-
- ListItem(
- headline = "Member since",
- supportingText = profile.createdAt,
- divider = true,
- dividerColor = CategoryDefaults.dividerColor,
- dividerThickness = CategoryDefaults.dividerThickness,
- leadingContent = {
- CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
- Icon(
- imageVector = Icons.Filled.CalendarMonth,
- contentDescription = null
- )
- }
+ )
}
- )
-
- if (profile.verified == true || ApiClient.user?.id == 1) {
- val onVerifyToggle: () -> Unit = {
- scope.launch {
- val result = withContext(Dispatchers.Default) {
- runCatching { ApiClient.verifyUser(profile.id) }.getOrNull()
+ if (showDetailsMemberSince) {
+ ListItem(
+ headline = "Member since",
+ supportingText = profile.createdAt,
+ divider = true,
+ dividerColor = CategoryDefaults.dividerColor,
+ dividerThickness = CategoryDefaults.dividerThickness,
+ leadingContent = {
+ CompositionLocalProvider(
+ LocalContentColor provides MaterialTheme.colorScheme.onSurface
+ ) {
+ Icon(
+ imageVector = Icons.Filled.CalendarMonth,
+ contentDescription = null
+ )
+ }
}
- result?.verified?.let { newVerified ->
- val updated = state.profile?.copy(verified = newVerified)
- state = state.copy(profile = updated)
- updated?.let { ProfileCache.put(it) }
+ )
+ }
+ if (showDetailsBio) {
+ ListItem(
+ headline = "Bio",
+ supportingText = profile.bio,
+ divider = true,
+ dividerColor = CategoryDefaults.dividerColor,
+ dividerThickness = CategoryDefaults.dividerThickness,
+ leadingContent = {
+ CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
+ Icon(
+ imageVector = Icons.Filled.Info,
+ contentDescription = null
+ )
+ }
}
- }
+ )
}
- ListItem(
- headline = "Verification",
- supportingText = verificationLabel,
- leadingContent = {
- Icon(
- imageVector = Icons.Filled.Verified,
- contentDescription = null
- )
- },
- divider = true,
- dividerColor = CategoryDefaults.dividerColor,
- dividerThickness = CategoryDefaults.dividerThickness,
- onClick = if (ApiClient.user?.id == 1) onVerifyToggle else null
- )
+ if (showDetailsVerify) {
+ val onVerifyToggle: () -> Unit = {
+ 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) }
+ }
+ }
+ }
+
+ ListItem(
+ headline = "Verification",
+ supportingText = verificationLabel,
+ leadingContent = {
+ Icon(
+ imageVector = Icons.Filled.Verified,
+ contentDescription = null
+ )
+ },
+ divider = true,
+ dividerColor = CategoryDefaults.dividerColor,
+ dividerThickness = CategoryDefaults.dividerThickness,
+ onClick = if (ApiClient.user?.id == 1) onVerifyToggle else null
+ )
+ }
}
}
}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index f7f1f9b..ddb8064 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -48,6 +48,7 @@ androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" }
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilCompose" }
coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coilCompose" }
+coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coilCompose" }
constraintlayout = { module = "tech.annexflow.compose:constraintlayout-compose-multiplatform", version.ref = "constraintlayout" }
firebase-messaging = { module = "com.google.firebase:firebase-messaging", version.ref = "firebaseMessaging" }
google-services = { module = "com.google.gms:google-services", version.ref = "googleServices" }