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 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
- 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)
implementation(libs.coil.compose)
implementation(libs.coil.network.ktor3)
implementation(libs.coil.svg)
// 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>
<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 -->
<string name="typing_single">%1$s печатает…</string>
<string name="typing_two">%1$s и %2$s печатают…</string>
@@ -7,7 +7,11 @@
<string name="settings">Settings</string>
<string name="home">Home</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 -->
<string name="welcome">Welcome!</string>
@@ -188,6 +188,14 @@ object ApiClient {
}
.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? =
runCatching {
http
@@ -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<DmConversation> = emptyList()
@@ -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,10 +142,42 @@ 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() {
@@ -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<Message> =
@@ -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
)
@@ -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,24 +24,49 @@ 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),
@@ -69,37 +91,86 @@ inline fun AboutScreen() {
)
},
) { innerPadding ->
Box(Modifier.padding(innerPadding)) {
Column(
Modifier
.padding(top = 16.dp, start = 16.dp, end = 16.dp)
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.fillMaxWidth(),
.padding(innerPadding)
) {
Column(
Modifier
.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,
)
}
)
}
}
}
}
@@ -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<NavController> { 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<String?>(null) }
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 typingUsers: List<TypingUser> = 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
}
@@ -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
}
@@ -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
}
@@ -42,6 +42,7 @@ fun PublicChatScreen(
currentUserId = currentUserId,
scrollToMessageId = scrollToMessageId,
sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedContentScope
animatedVisibilityScope = animatedContentScope,
sharedAvatarKey = "public-general-chat"
)
}
@@ -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,
@@ -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<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) }
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,23 +120,44 @@ fun ChatsTab() {
}
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
MediumTopAppBar(
TopAppBar(
title = {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
AsyncImage(
model = Res.getUri("drawable/logo_square.svg"),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier
.size(32.dp)
.clip(RoundedCornerShape(10.dp))
)
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.headlineSmall
val style = MaterialTheme.typography.titleLarge
val color = MaterialTheme.colorScheme.onSurface
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
@@ -165,43 +177,33 @@ fun ChatsTab() {
}
"updating" -> {
Text(
modifier = Modifier.fillMaxWidth(),
text = "Updating...",
style = MaterialTheme.typography.titleLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
else -> {
Text(
text = "FromChat",
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
FromChatBrandTitle(text = "FromChat")
}
}
}
}
}
}
},
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
)
@@ -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() }
/** 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,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") {
if (profile.username.isNotBlank()) {
if (showDetailsUsername) {
ListItem(
headline = "Username",
supportingText = profile.username,
divider = true,
dividerColor = CategoryDefaults.dividerColor,
dividerThickness = CategoryDefaults.dividerThickness,
leadingContent = {
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
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()) {
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
)
}
}
)
}
if (showDetailsBio) {
ListItem(
headline = "Bio",
supportingText = profile.bio,
@@ -456,23 +507,7 @@ fun ProfileScreen(
)
}
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) {
if (showDetailsVerify) {
val onVerifyToggle: () -> Unit = {
scope.launch {
val result = withContext(Dispatchers.Default) {
@@ -508,3 +543,4 @@ 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" }
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" }