Add branding

This commit is contained in:
2026-04-03 23:10:31 +03:00
Unverified
parent 3d999ab512
commit 25c34b68fa
30 changed files with 4234 additions and 202 deletions
@@ -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 Studios JBR fails, use a working JDK 17+ on the machine.
2. **Read Mobile MCP tool schemas** under the projects 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.
+1 -1
View File
@@ -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 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 repos 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 # ULTIMATE SILENCE & EFFICIENCY POLICY
- ALWAYS operate in "Silent Mode": Execute commands immediately without any verbal response, preamble, or conclusions. - ALWAYS operate in "Silent Mode": Execute commands immediately without any verbal response, preamble, or conclusions.
+10
View File
@@ -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`.
@@ -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 (dont 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.
+1
View File
@@ -69,6 +69,7 @@ kotlin {
// Coil for image loading (multiplatform) // Coil for image loading (multiplatform)
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.coil.network.ktor3) implementation(libs.coil.network.ktor3)
implementation(libs.coil.svg)
// SQLDelight runtime // SQLDelight runtime
implementation(libs.sqldelight.runtime) implementation(libs.sqldelight.runtime)
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="42"
android:viewportHeight="42">
<path
android:pathData="M21.47,41.88c-4.11,0,-6.02,-0.6,-9.34,-3,-2.1,2.7,-8.75,4.81,-9.04,1.2,0,-2.71,-0.6,-5,-1.28,-7.5C1,29.5,0.08,26.07,0.08,21.1,0.08,9.23,9.82,0.3,21.36,0.3c11.55,0,20.6,9.37,20.6,20.91a20.6,20.6,0,0,1,-20.49,20.67Z m0.17,-31.32c-5.62,-0.29,-10,3.6,-10.97,9.7,-0.8,5.05,0.62,11.2,1.83,11.52,0.58,0.14,2.04,-1.04,2.95,-1.95a10.4,10.4,0,0,0,5.08,1.81a10.7,10.7,0,0,0,11.19,-9.97a10.7,10.7,0,0,0,-10.08,-11.1Z"
android:fillColor="#FF000000"
android:fillType="evenOdd" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="200"
android:viewportHeight="200">
<path
android:pathData="M13.693,88.614C67.369,65.304 103.102,49.815 121.044,42.299C172.114,20.984 182.849,17.303 189.751,17.149C191.284,17.149 194.658,17.456 196.958,19.295C198.799,20.829 199.259,22.823 199.565,24.356C199.873,25.889 200.18,29.111 199.873,31.565C197.112,60.703 185.15,131.402 179.016,163.916C176.408,177.717 171.347,182.317 166.441,182.778C155.705,183.699 147.577,175.724 137.302,168.976C121.044,158.395 111.997,151.8 96.202,141.371C77.951,129.408 89.76,122.815 100.188,112.08C102.948,109.319 150.031,66.378 150.95,62.545C151.104,62.084 151.104,60.243 150.031,59.323C148.956,58.403 147.423,58.71 146.197,59.016C144.509,59.323 118.745,76.5 68.596,110.391C61.235,115.453 54.64,117.906 48.659,117.754C42.065,117.6 29.49,114.072 19.981,111.005C8.479,107.325 -0.723,105.331 0.045,98.89C0.503,95.516 5.105,92.142 13.693,88.614Z"
android:fillColor="#FF000000"
android:fillType="evenOdd" />
</vector>
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 879 KiB

@@ -1,4 +1,10 @@
<resources> <resources>
<string name="about_version">Версия 1.0</string>
<string name="about_link_telegram">Telegram</string>
<string name="about_link_max">MAX</string>
<string name="about_link_website">Сайт</string>
<string name="app_desc">100% бесплатный и открытый мессенджер. Поддерживает self-hosted установку на своём сервере.</string>
<!-- Typing Indicators --> <!-- Typing Indicators -->
<string name="typing_single">%1$s печатает…</string> <string name="typing_single">%1$s печатает…</string>
<string name="typing_two">%1$s и %2$s печатают…</string> <string name="typing_two">%1$s и %2$s печатают…</string>
@@ -7,7 +7,11 @@
<string name="settings">Settings</string> <string name="settings">Settings</string>
<string name="home">Home</string> <string name="home">Home</string>
<string name="about">About app</string> <string name="about">About app</string>
<string name="app_desc">A 100% free, open source and private messenger.</string> <string name="about_version">Version 1.0</string>
<string name="about_link_telegram">Telegram</string>
<string name="about_link_max">MAX</string>
<string name="about_link_website">Website</string>
<string name="app_desc">100% free and open messenger. Supports self-hosted installation on your own server.</string>
<!-- Authentication --> <!-- Authentication -->
<string name="welcome">Welcome!</string> <string name="welcome">Welcome!</string>
@@ -188,6 +188,14 @@ object ApiClient {
} }
.body() .body()
suspend fun getRegisteredUserCount(): Int =
http
.get("${Config.apiBaseUrl}/user/stats/registered-count") {
contentType(ContentType.Application.Json)
}
.body<RegisteredUserCountResponse>()
.count
suspend fun checkSimilarity(userId: Int): SimilarityResult? = suspend fun checkSimilarity(userId: Int): SimilarityResult? =
runCatching { runCatching {
http http
@@ -30,6 +30,7 @@ data class User(
val last_seen: String, val last_seen: String,
val online: Boolean, val online: Boolean,
val username: String, val username: String,
@SerialName("display_name") val displayName: String? = null,
val admin: Boolean? = null, val admin: Boolean? = null,
val bio: String? = null, val bio: String? = null,
val profile_picture: String? = null val profile_picture: String? = null
@@ -214,6 +215,11 @@ data class DmConversation(
val unreadCount: Int val unreadCount: Int
) )
@Serializable
data class RegisteredUserCountResponse(
val count: Int
)
@Serializable @Serializable
data class DmConversationsResponse( data class DmConversationsResponse(
val conversations: List<DmConversation> = emptyList() val conversations: List<DmConversation> = emptyList()
@@ -33,29 +33,62 @@ object ProfileCache {
fun get(userId: Int): UserProfile? = profiles[userId] fun get(userId: Int): UserProfile? = profiles[userId]
fun put(profile: UserProfile) { 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 val cur = profiles
profiles = cur + (profile.id to profile) profiles = cur + (profile.id to profile)
schedulePersist() schedulePersist()
} }
/** /**
* Fills or refreshes a lightweight profile from a public chat [Message] (username, avatar URL). * Removes one user from the cache and persists. Does not clear full API profiles unless
* Skips when a full API profile is already stored ([UserProfile.isClientPreviewOnly] is false). * 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]. * Seeds or refreshes a lightweight profile from a DM conversations list [User].
* Skips when a full `/user/...` profile is already cached. * 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) { fun mergeFromDmUser(user: User) {
val existing = get(user.id) val existing = get(user.id)
if (existing != null && !existing.isClientPreviewOnly) return 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( put(
UserProfile( UserProfile(
id = user.id, id = user.id,
username = user.username.ifBlank { existing?.username.orEmpty() }, username = incomingUsername,
displayName = existing?.displayName?.takeIf { it.isNotBlank() } displayName = existing?.displayName?.takeIf { it.isNotBlank() } ?: incomingDisplayName,
?: user.username.takeIf { it.isNotBlank() }
?: existing?.username.orEmpty(),
profilePicture = user.profile_picture?.takeIf { it.isNotBlank() } profilePicture = user.profile_picture?.takeIf { it.isNotBlank() }
?: existing?.profilePicture, ?: existing?.profilePicture,
bio = existing?.bio, bio = existing?.bio,
@@ -109,10 +142,42 @@ object ProfileCache {
val raw = settings.getString(SETTINGS_KEY, "").ifBlank { return } val raw = settings.getString(SETTINGS_KEY, "").ifBlank { return }
runCatching { runCatching {
val list = json.decodeFromString(ListSerializer(UserProfile.serializer()), raw) 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 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() { suspend fun clear() {
@@ -30,6 +30,46 @@ object MessageCacheStore {
private fun conversationIdForPublic(): String = "public" private fun conversationIdForPublic(): String = "public"
private fun conversationIdForDm(otherUserId: Int): String = "dm:$otherUserId" 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. * Full history for a conversation (unbounded). Prefer [loadRecentPublicMessages] when opening UI.
*/ */
@@ -92,6 +132,7 @@ object MessageCacheStore {
suspend fun upsertDmMessage(otherUserId: Int, message: Message) { suspend fun upsertDmMessage(otherUserId: Int, message: Message) {
upsertSingle(conversationIdForDm(otherUserId), message) upsertSingle(conversationIdForDm(otherUserId), message)
syncDmConversationPreviewFromCache(otherUserId)
} }
suspend fun deletePublicMessageByClientMessageId(clientMessageId: String) { suspend fun deletePublicMessageByClientMessageId(clientMessageId: String) {
@@ -151,6 +192,7 @@ object MessageCacheStore {
) )
} }
} }
otherUserIdFromDmConversationId(conversationId)?.let { syncDmConversationPreviewFromCache(it) }
} }
private suspend fun loadMessages(conversationId: String): List<Message> = private suspend fun loadMessages(conversationId: String): List<Message> =
@@ -229,6 +271,7 @@ object MessageCacheStore {
} }
} }
} }
otherUserIdFromDmConversationId(conversationId)?.let { syncDmConversationPreviewFromCache(it) }
} }
suspend fun markMessageDeleted(conversationId: String, messageId: Int) { suspend fun markMessageDeleted(conversationId: String, messageId: Int) {
@@ -245,13 +288,23 @@ object MessageCacheStore {
db.messageDatabaseQueries.transaction { db.messageDatabaseQueries.transaction {
conversations.forEach { conv -> conversations.forEach { conv ->
val conversationId = conversationIdForDm(conv.user.id) val conversationId = conversationIdForDm(conv.user.id)
val displayLabel = conv.user.displayName?.trim()?.takeIf { it.isNotEmpty() }
?: 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( db.messageDatabaseQueries.upsertConversation(
id = conversationId, id = conversationId,
type = "dm", type = "dm",
otherUserId = conv.user.id.toLong(), otherUserId = conv.user.id.toLong(),
displayName = conv.user.username, displayName = displayLabel,
lastMessageId = conv.lastMessage.id.toLong(), lastMessageId = conv.lastMessage.id.toLong(),
lastMessagePreview = null, // Encrypted on backend; preview handled in chat UI. lastMessagePreview = preview,
unreadCount = conv.unreadCount.toLong(), unreadCount = conv.unreadCount.toLong(),
updatedAt = conv.lastMessage.timestamp updatedAt = conv.lastMessage.timestamp
) )
@@ -1,8 +1,5 @@
@file:Suppress("NOTHING_TO_INLINE")
package ru.fromchat.ui package ru.fromchat.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -27,24 +24,49 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.nestedscroll.nestedScroll 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.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import 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.stringResource
import org.jetbrains.compose.resources.vectorResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.about import ru.fromchat.about
import ru.fromchat.about_link_max
import ru.fromchat.about_link_telegram
import ru.fromchat.about_link_website
import ru.fromchat.about_version
import ru.fromchat.app_desc import ru.fromchat.app_desc
import ru.fromchat.app_icon
import ru.fromchat.app_name import ru.fromchat.app_name
import ru.fromchat.back 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
inline fun AboutScreen() { fun AboutScreen() {
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
val navController = LocalNavController.current val navController = LocalNavController.current
val uriHandler = LocalUriHandler.current
Scaffold( Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
@@ -69,37 +91,86 @@ inline fun AboutScreen() {
) )
}, },
) { innerPadding -> ) { innerPadding ->
Box(Modifier.padding(innerPadding)) {
Column( Column(
Modifier Modifier
.padding(top = 16.dp, start = 16.dp, end = 16.dp) .fillMaxWidth()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.fillMaxWidth(), .padding(innerPadding)
) {
Column(
Modifier
.fillMaxWidth()
.padding(top = 16.dp, start = 16.dp, end = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Box(Modifier.padding(bottom = 10.dp)) { Box(Modifier.padding(bottom = 12.dp)) {
Image( AsyncImage(
painter = painterResource(Res.drawable.app_icon), model = Res.getUri("drawable/logo_square.svg"),
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier modifier = Modifier
.clip(RoundedCornerShape(20.dp)) .size(88.dp)
.size(70.dp) .clip(RoundedCornerShape(28.dp))
) )
} }
Text( Text(
text = stringResource(Res.string.app_name), text = stringResource(Res.string.app_name),
fontSize = 20.sp, fontSize = 22.sp,
style = MaterialTheme.typography.headlineMedium, 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) modifier = Modifier.padding(bottom = 16.dp)
) )
Text( Text(
text = stringResource(Res.string.app_desc), text = stringResource(Res.string.app_desc),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge, 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,
)
}
) )
} }
} }
} }
} }
@@ -4,6 +4,9 @@ import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.
import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.Start import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.Start
import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.core.tween 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.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
@@ -46,6 +49,14 @@ val LocalNavController = compositionLocalOf<NavController> { error("NavControlle
@Composable @Composable
fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) { fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) {
setSingletonImageLoaderFactory { context ->
ImageLoader.Builder(context)
.components {
add(SvgDecoder.Factory())
}
.build()
}
var startDestination by remember { mutableStateOf<String?>(null) } var startDestination by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -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,
),
),
)
}
@@ -27,7 +27,11 @@ data class ChatPanelState(
val isLoadingMore: Boolean = false, val isLoadingMore: Boolean = false,
val typingUsers: List<TypingUser> = emptyList(), val typingUsers: List<TypingUser> = emptyList(),
val titleAvatar: AvatarInfo? = null, 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 @Serializable
@@ -420,5 +424,9 @@ abstract class ChatPanel(
/** When true, tapping a sender username in a message opens their profile (e.g. public chat). */ /** When true, tapping a sender username in a message opens their profile (e.g. public chat). */
open val supportsNavigateToSenderProfile: Boolean open val supportsNavigateToSenderProfile: Boolean
get() = false get() = false
/** When true, subtitle shows group label / member count instead of DM presence. */
open val usesPublicGroupSubtitle: Boolean
get() = false
} }
@@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -230,7 +229,8 @@ fun ChatScreen(
} }
"newMessage", "messageEdited", "messageDeleted", "newMessage", "messageEdited", "messageDeleted",
"dmNew", "dmEdited", "dmDeleted", "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}") Logger.d("ChatScreen", "handleWebSocketMessage for ${update.type}")
try { try {
panel.handleWebSocketMessage(wsMessage) panel.handleWebSocketMessage(wsMessage)
@@ -252,7 +252,7 @@ fun ChatScreen(
if (userId != null) UserStatusStore.update(userId, online, lastSeen) if (userId != null) UserStatusStore.update(userId, online, lastSeen)
} }
"newMessage", "messageEdited", "messageDeleted", "dmNew", "dmEdited", "dmDeleted", "newMessage", "messageEdited", "messageDeleted", "dmNew", "dmEdited", "dmDeleted",
"dmTyping", "stopDmTyping" -> { "dmTyping", "stopDmTyping", "registeredUserCount" -> {
scope.launch { scope.launch {
panel.handleWebSocketMessage(message) panel.handleWebSocketMessage(message)
} }
@@ -401,6 +401,15 @@ fun ChatScreen(
connectionStatus == ConnectionStatus.UPDATING -> "updating" connectionStatus == ConnectionStatus.UPDATING -> "updating"
connectionStatus != ConnectionStatus.CONNECTED -> "connecting" connectionStatus != ConnectionStatus.CONNECTED -> "connecting"
currentTypingUsers.isNotEmpty() -> "typing" currentTypingUsers.isNotEmpty() -> "typing"
panel.usesPublicGroupSubtitle -> {
if (panelState.publicGroupMetaLoading ||
panelState.publicGroupMemberCount == null
) {
"group"
} else {
"members:${panelState.publicGroupMemberCount}"
}
}
panelState.profileUserId != null -> { panelState.profileUserId != null -> {
val userStatus = statusMap[panelState.profileUserId] val userStatus = statusMap[panelState.profileUserId]
val statusText = userStatus?.let { val statusText = userStatus?.let {
@@ -466,6 +475,23 @@ fun ChatScreen(
modifier = Modifier.padding(top = 2.dp) 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 -> { else -> {
// No subtitle for this state // No subtitle for this state
} }
@@ -4,6 +4,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.Message import ru.fromchat.api.Message
import ru.fromchat.api.MessageDeletedData import ru.fromchat.api.MessageDeletedData
@@ -49,15 +51,35 @@ class PublicChatPanel(
override val supportsNavigateToSenderProfile: Boolean override val supportsNavigateToSenderProfile: Boolean
get() = true get() = true
override val usesPublicGroupSubtitle: Boolean
get() = true
init { init {
updateState { it.copy(title = chatName) } updateState {
// Observe typing users from the handler and update panel state it.copy(
title = chatName,
titleAvatar = AvatarInfo(displayName = chatName, profilePictureUrl = null),
publicGroupMetaLoading = true,
publicGroupMemberCount = null
)
}
scope.launch { scope.launch {
typingHandler.typingUsers.collect { users -> typingHandler.typingUsers.collect { users ->
Logger.d("PublicChatPanel", "Typing users updated in handler: ${users.map { it.username }}") Logger.d("PublicChatPanel", "Typing users updated in handler: ${users.map { it.username }}")
updateState { it.copy(typingUsers = users.filter { it.userId != currentUserId }) } 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) { private fun handleReactionUpdate(reactionUpdate: ReactionUpdateData) {
@@ -274,6 +296,16 @@ class PublicChatPanel(
Logger.d("PublicChatPanel", "Received stopTyping event for user: ${typingData.username}") Logger.d("PublicChatPanel", "Received stopTyping event for user: ${typingData.username}")
typingHandler.handleStopTypingEvent(typingData.userId) 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" -> { "statusUpdate" -> {
// Handled in ChatScreen or by global WebSocketManager listeners // Handled in ChatScreen or by global WebSocketManager listeners
} }
@@ -42,6 +42,7 @@ fun PublicChatScreen(
currentUserId = currentUserId, currentUserId = currentUserId,
scrollToMessageId = scrollToMessageId, scrollToMessageId = scrollToMessageId,
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedContentScope animatedVisibilityScope = animatedContentScope,
sharedAvatarKey = "public-general-chat"
) )
} }
@@ -39,7 +39,7 @@ class DmPanel(
) { ) {
private val typingHandler = DmTypingHandler(coroutineScope, otherUserId) private val typingHandler = DmTypingHandler(coroutineScope, otherUserId)
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
private var otherDisplayName: String = "User $otherUserId" private var otherDisplayName: String = ""
private var otherProfilePicture: String? = null private var otherProfilePicture: String? = null
private val dmEnvelopeMutex = Mutex() private val dmEnvelopeMutex = Mutex()
@@ -59,7 +59,13 @@ class DmPanel(
} }
init { init {
updateState { it.copy(title = "Direct message", profileUserId = otherUserId) } updateState {
it.copy(
title = "",
titleAvatar = null,
profileUserId = otherUserId
)
}
coroutineScope.launch { coroutineScope.launch {
typingHandler.typingUsers.collect { users -> typingHandler.typingUsers.collect { users ->
updateState { it.copy(typingUsers = users) } updateState { it.copy(typingUsers = users) }
@@ -69,6 +75,13 @@ class DmPanel(
runCatching { runCatching {
ApiClient.getProfileById(otherUserId) ApiClient.getProfileById(otherUserId)
}.onSuccess { profile -> }.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) ProfileCache.put(profile)
val displayName = profile.displayName?.takeIf { it.isNotBlank() } ?: profile.username val displayName = profile.displayName?.takeIf { it.isNotBlank() } ?: profile.username
otherDisplayName = displayName otherDisplayName = displayName
@@ -83,6 +96,11 @@ class DmPanel(
profileUserId = otherUserId 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) { val username = if (envelope.senderId == currentUserId) {
"You" "You"
} else { } else {
otherDisplayName otherDisplayName.ifBlank { "User $otherUserId" }
} }
return Message( return Message(
id = envelope.id, id = envelope.id,
@@ -12,16 +12,19 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.MediumTopAppBar import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -31,9 +34,11 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.api.ApiClient 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.CachedConversation
import ru.fromchat.api.db.MessageCacheStore import ru.fromchat.api.db.MessageCacheStore
import ru.fromchat.chat_last_mesaage import ru.fromchat.chat_last_mesaage
import ru.fromchat.public_chat
import ru.fromchat.net.NetworkConnectivity import ru.fromchat.net.NetworkConnectivity
import ru.fromchat.public_chat
import ru.fromchat.ui.ConnectingEllipsis import ru.fromchat.ui.ConnectingEllipsis
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.branding.FromChatBrandTitle
import ru.fromchat.ui.chat.Avatar import ru.fromchat.ui.chat.Avatar
@Composable @Composable
@@ -78,13 +84,9 @@ private fun ChatRowAvatar(
@Composable @Composable
fun ChatsTab() { fun ChatsTab() {
val navController = LocalNavController.current val navController = LocalNavController.current
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
val connectionStatus by ConnectionStateStore.status.collectAsState() val connectionStatus by ConnectionStateStore.status.collectAsState()
val online by NetworkConnectivity.isOnline.collectAsState(initial = true) val online by NetworkConnectivity.isOnline.collectAsState(initial = true)
var dmConversations by remember { mutableStateOf<List<CachedConversation>>(emptyList()) } var dmConversations by remember { mutableStateOf<List<CachedConversation>>(emptyList()) }
var publicListAvatarUserId by remember { mutableStateOf<Int?>(null) }
var publicListAvatarUrl by remember { mutableStateOf<String?>(null) }
var publicListAvatarLabel by remember { mutableStateOf<String?>(null) }
var publicLastMessagePreview by remember { mutableStateOf<String?>(null) } var publicLastMessagePreview by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -96,17 +98,6 @@ fun ChatsTab() {
runCatching { runCatching {
val last = MessageCacheStore.loadRecentPublicMessages(1).lastOrNull() val last = MessageCacheStore.loadRecentPublicMessages(1).lastOrNull()
publicLastMessagePreview = last?.content?.trim()?.takeIf { it.isNotEmpty() } 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. // Then refresh from network and update cache + state.
@@ -129,23 +120,44 @@ fun ChatsTab() {
} }
Scaffold( Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = { topBar = {
MediumTopAppBar( TopAppBar(
title = { 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( AnimatedContent(
targetState = titleKey, targetState = titleKey,
modifier = Modifier.weight(1f),
transitionSpec = { transitionSpec = {
(slideInVertically { it / 2 } + fadeIn()) togetherWith (slideInVertically { it / 2 } + fadeIn()) togetherWith
(slideOutVertically { -it / 2 } + fadeOut()) (slideOutVertically { -it / 2 } + fadeOut())
}, },
label = "chats_title" label = "chats_title"
) { key -> ) { key ->
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.CenterStart
) {
when (key) { when (key) {
"connecting" -> { "connecting" -> {
val style = MaterialTheme.typography.headlineSmall val style = MaterialTheme.typography.titleLarge
val color = MaterialTheme.colorScheme.onSurface val color = MaterialTheme.colorScheme.onSurface
Row( Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start horizontalArrangement = Arrangement.Start
) { ) {
@@ -165,43 +177,33 @@ fun ChatsTab() {
} }
"updating" -> { "updating" -> {
Text( Text(
modifier = Modifier.fillMaxWidth(),
text = "Updating...", text = "Updating...",
style = MaterialTheme.typography.titleLarge,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
} }
else -> { else -> {
Text( FromChatBrandTitle(text = "FromChat")
text = "FromChat", }
maxLines = 1, }
overflow = TextOverflow.Ellipsis }
)
} }
} }
} }
},
scrollBehavior = scrollBehavior
) )
} }
) { innerPadding -> ) { innerPadding ->
val publicChatTitle = stringResource(Res.string.public_chat) val publicChatTitle = stringResource(Res.string.public_chat)
LazyColumn(contentPadding = innerPadding) { LazyColumn(contentPadding = innerPadding) {
item { item {
val publicUid = publicListAvatarUserId
val publicPic = publicListAvatarUrl
val publicLabel = publicListAvatarLabel?.takeIf { it.isNotBlank() } ?: publicChatTitle
ListItem( ListItem(
leadingContent = { leadingContent = {
ChatRowAvatar( ChatRowAvatar(
profilePictureUrl = publicPic, profilePictureUrl = null,
displayNameForInitials = publicLabel, displayNameForInitials = publicChatTitle,
onClick = { onClick = { navController.navigate("chats/publicChat") }
when {
publicUid != null && publicUid > 0 ->
navController.navigate("profile/$publicUid")
else -> navController.navigate("chats/publicChat")
}
}
) )
}, },
headlineContent = { headlineContent = {
@@ -229,15 +231,15 @@ fun ChatsTab() {
val conv = dmConversations[index] val conv = dmConversations[index]
val cached = ProfileCache.get(conv.otherUserId) val cached = ProfileCache.get(conv.otherUserId)
val avatarUrl = cached?.profilePicture val avatarUrl = cached?.profilePicture
val avatarLabel = cached?.displayName?.takeIf { it.isNotBlank() } val peerTitle = cached?.displayName?.takeIf { it.isNotBlank() }
?: cached?.username?.takeIf { it.isNotBlank() } ?: cached?.username?.takeIf { it.isNotBlank() }
?: conv.displayName.ifBlank { "User ${conv.otherUserId}" } ?: conv.displayName.ifBlank { "User ${conv.otherUserId}" }
val preview = conv.lastMessagePreview ?: "Direct messages" val preview = conv.lastMessagePreview?.trim().orEmpty()
ListItem( ListItem(
leadingContent = { leadingContent = {
ChatRowAvatar( ChatRowAvatar(
profilePictureUrl = avatarUrl, profilePictureUrl = avatarUrl,
displayNameForInitials = avatarLabel, displayNameForInitials = peerTitle,
onClick = { onClick = {
if (conv.otherUserId != 0) { if (conv.otherUserId != 0) {
navController.navigate("profile/${conv.otherUserId}") navController.navigate("profile/${conv.otherUserId}")
@@ -247,7 +249,15 @@ fun ChatsTab() {
}, },
headlineContent = { headlineContent = {
Text( 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, maxLines = 2,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
@@ -137,10 +137,20 @@ fun ProfileScreen(
ApiClient.getProfileById(targetUserId) ApiClient.getProfileById(targetUserId)
} }
}.onSuccess { profile -> }.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) ProfileCache.put(profile)
state = latestUi.copy(profile = profile, isLoading = false, error = null) state = latestUi.copy(profile = profile, isLoading = false, error = null)
}.onFailure { err -> }.onFailure { err ->
val fallbackId = targetUserId ?: ownUserId val fallbackId = targetUserId ?: ownUserId
fallbackId?.let { ProfileCache.evictUnusableClientPreview(it) }
val fallback = fallbackId?.let { ProfileCache.get(it) } val fallback = fallbackId?.let { ProfileCache.get(it) }
state = latestUi.copy( state = latestUi.copy(
error = if (latestUi.profile == null && fallback == null) { error = if (latestUi.profile == null && fallback == null) {
@@ -278,9 +288,17 @@ fun ProfileScreen(
) )
} }
profile != null -> { profile != null -> {
val profileLink = profile.username.takeIf { it.isNotBlank() } /** 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" } ?.let { "https://fromchat.ru/@$it" }
?: "https://fromchat.ru/?u=${profile.id}" ?: "https://fromchat.ru/?u=${profile.id}"
}
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val verificationLabel = if (profile.verified == true) "Verified account" else "Click to verify" val verificationLabel = if (profile.verified == true) "Verified account" else "Click to verify"
@@ -299,7 +317,7 @@ fun ProfileScreen(
userId = profile.id userId = profile.id
) )
} }
if (profile.username.isNotBlank()) { if (!compactIdentityForPublicChat && profile.username.isNotBlank()) {
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = "@${profile.username}", text = "@${profile.username}",
@@ -419,26 +437,59 @@ fun ProfileScreen(
) )
} }
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") { Category(Modifier.padding(top = 16.dp), title = "Details") {
if (profile.username.isNotBlank()) { if (showDetailsUsername) {
ListItem( ListItem(
headline = "Username", headline = "Username",
supportingText = profile.username, supportingText = profile.username,
divider = true,
dividerColor = CategoryDefaults.dividerColor,
dividerThickness = CategoryDefaults.dividerThickness,
leadingContent = { leadingContent = {
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) { CompositionLocalProvider(
LocalContentColor provides MaterialTheme.colorScheme.onSurface
) {
Icon( Icon(
imageVector = Icons.Filled.AlternateEmail, imageVector = Icons.Filled.AlternateEmail,
contentDescription = null contentDescription = null
) )
} }
}, }
divider = true,
dividerColor = CategoryDefaults.dividerColor,
dividerThickness = CategoryDefaults.dividerThickness
) )
} }
if (showDetailsMemberSince) {
if (!profile.bio.isNullOrBlank()) { 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 (showDetailsBio) {
ListItem( ListItem(
headline = "Bio", headline = "Bio",
supportingText = profile.bio, supportingText = profile.bio,
@@ -456,23 +507,7 @@ fun ProfileScreen(
) )
} }
ListItem( if (showDetailsVerify) {
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 = { val onVerifyToggle: () -> Unit = {
scope.launch { scope.launch {
val result = withContext(Dispatchers.Default) { val result = withContext(Dispatchers.Default) {
@@ -507,4 +542,5 @@ fun ProfileScreen(
} }
} }
} }
}
} }
+1
View File
@@ -48,6 +48,7 @@ androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } 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-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-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" } constraintlayout = { module = "tech.annexflow.compose:constraintlayout-compose-multiplatform", version.ref = "constraintlayout" }
firebase-messaging = { module = "com.google.firebase:firebase-messaging", version.ref = "firebaseMessaging" } firebase-messaging = { module = "com.google.firebase:firebase-messaging", version.ref = "firebaseMessaging" }
google-services = { module = "com.google.gms:google-services", version.ref = "googleServices" } google-services = { module = "com.google.gms:google-services", version.ref = "googleServices" }