mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Add branding
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
---
|
||||
description: After Android/KMP app changes—build debug APK, install and launch on a real device; emulator only if user asked or no phone is connected
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Build, install, and run on device (after Android-related changes)
|
||||
|
||||
Whenever you change anything under **`app:android`**, **`app:shared`** (`commonMain` / `androidMain`), or **`utils:shared`** in ways that affect the Android app:
|
||||
|
||||
1. **Build** the debug APK: `./gradlew :app:android:assembleDebug` from the Android repo root. If `JAVA_HOME` pointing at Android Studio’s JBR fails, use a working JDK 17+ on the machine.
|
||||
2. **Read Mobile MCP tool schemas** under the project’s MCP descriptors for `user-Mobile MCP` before calling tools (required).
|
||||
3. **`mobile_list_available_devices`** — get all `id`s (and `name` / `type` if present).
|
||||
4. **Use a real device by default.** Do **not** install or launch on an emulator **unless** the user **explicitly** asked to use the emulator, **or** no physical device is connected and **only** emulator(s) are available. Classify devices using `type != "emulator"` when reliable; if `type` is wrong or missing, treat as physical when the **name** does not look like an AVD (e.g. not `sdk_gphone`, not a generic emulator name). When one or more physical devices are available, install and launch **only** on those—**never** also push to emulators in the same step. When **no** physical device is connected, use every returned target (emulators).
|
||||
5. For **each** chosen device `id`: **`mobile_install_app`** with `path` = absolute path to
|
||||
`app/android/build/outputs/apk/debug/android-debug.apk`
|
||||
in this repo.
|
||||
6. For **each** same device: **`mobile_launch_app`** with `packageName` **`ru.fromchat.beta`** (debug `applicationIdSuffix`).
|
||||
|
||||
Also run **`:app:shared:compileKotlinIosArm64`** (and fix errors) when shared Kotlin changes should stay valid for iOS—either with the same Gradle invocation as in `general.mdc` or right after the Android APK build.
|
||||
|
||||
Skipping install/launch when **no** device is online is acceptable; do not skip the **Gradle build** after code changes.
|
||||
@@ -6,7 +6,7 @@ When working with the mobile app:
|
||||
|
||||
- After implementing the solution, run "export JAVA_HOME=/Applications/Android\ Studio.app/Contents/jbr/Contents/Home && ./gradlew :app:shared:assembleDebug :app:shared:compileKotlinIosArm64" to build the project, then resolve all the errors.
|
||||
|
||||
- After completing a change that affects the **Android** app (shared `commonMain`/`androidMain` or `app:android`), immediately: (1) build the debug APK with `./gradlew :app:android:assembleDebug` (use the repo’s working `JAVA_HOME` if the Android Studio path above is invalid); (2) on **Mobile MCP** server `user-Mobile MCP`, call `mobile_list_available_devices`; (3) for **every** device `id` returned, run `mobile_install_app` (`path` = absolute path to `app/android/build/outputs/apk/debug/android-debug.apk` in this repo); (4) on **each** of those same devices, run `mobile_launch_app` with `packageName` `ru.fromchat.beta` (debug uses `applicationIdSuffix` so it can install next to release `ru.fromchat`; install and launch are both required, not optional).
|
||||
- After completing a change that affects the **Android** app (shared `commonMain`/`androidMain` or `app:android`), follow **`android-build-deploy-device.mdc`**: build debug APK, then Mobile MCP install + launch on a **real device** by default—emulator only if the user asked for it or no phone is connected (see that rule for APK path and `ru.fromchat.beta`).
|
||||
|
||||
# ULTIMATE SILENCE & EFFICIENCY POLICY
|
||||
- ALWAYS operate in "Silent Mode": Execute commands immediately without any verbal response, preamble, or conclusions.
|
||||
|
||||
@@ -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 (don’t persist placeholders; use `null`/absent fields; fix the writer). If legacy bad rows exist, use an explicit **schema/version/migration** or a **documented sentinel** agreed with the backend—not substring or equality checks on natural language.
|
||||
- Applies especially to: SQLDelight/cache layers, list previews, and any code that “cleans” strings before show or read.
|
||||
@@ -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 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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) {
|
||||
@@ -507,4 +542,5 @@ fun ProfileScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user