More desktop and mobile fixes

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-08-15 16:17:53 +03:00
Unverified
parent 3f17419539
commit 7a553ff02d
33 changed files with 1231 additions and 327 deletions
+3 -1
View File
@@ -1,6 +1,8 @@
<component name="ArtifactManager">
<artifact type="jar" name="shared">
<output-path>$PROJECT_DIR$/utils/shared/build/libs</output-path>
<root id="archive" name="shared.jar" />
<root id="archive" name="shared.jar">
<element id="module-output" name="FromChat.utils.shared.androidMain" />
</root>
</artifact>
</component>
+3 -1
View File
@@ -1,6 +1,8 @@
<component name="ArtifactManager">
<artifact type="jar" name="shared-jvm">
<output-path>$PROJECT_DIR$/utils/shared/build/libs</output-path>
<root id="archive" name="shared-jvm.jar" />
<root id="archive" name="shared-jvm.jar">
<element id="module-output" name="FromChat.utils.shared.jvmMain" />
</root>
</artifact>
</component>
-1
View File
@@ -6,7 +6,6 @@
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="jbr-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
+5
View File
@@ -1,5 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CidrRootsConfiguration">
<excludeRoots>
<file path="$PROJECT_DIR$/app/android/keys" />
</excludeRoots>
</component>
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
+19 -16
View File
@@ -18,14 +18,14 @@ FromChat is a 100% free and open-source messenger. This repository is the cross-
## 📊 Client Comparison
| Feature | Android | Desktop | Web | iOS |
| --- | --- | --- | --- | --- |
| **Messaging and profiles** | ✅ | ✅ | ✅ | ✅ |
| **Voice/video calls** | ✅ | ✅ | ✅ | ✅ |
| **Screen sharing** | ✅ | ❌ | ✅ | ❌ |
| **Push when backgrounded** | ✅ (FCM) | ✅ (persistent WS + tray) | ✅ | ❌ (WS while open) |
| **Message reactions** | ❌ | ❌ | ✅ | ❌ |
| **Rich attachment support** | ✅ | ✅ | ❌ | ✅ |
| Feature | Android | Desktop | Web | iOS |
|-----------------------------|---------|--------------------------|-----|-------------------|
| **Messaging and profiles** | ✅ | ✅ | ✅ | ✅ |
| **Voice/video calls** | ✅ | ✅ | ✅ | ✅ |
| **Screen sharing** | ✅ | ❌ | ✅ | ❌ |
| **Push when backgrounded** | ✅ (FCM) | ✅ (persistent WS + tray) | ✅ | ❌ (WS while open) |
| **Message reactions** | ❌ | ❌ | ✅ | ❌ |
| **Rich attachment support** | ✅ | ✅ | ❌ | ✅ |
Desktop replaces the former Electron client in the Web repo. Run with `./gradlew :app:desktop:run`.
@@ -70,22 +70,25 @@ Desktop replaces the former Electron client in the Web repo. Run with `./gradlew
mkdir -p app/android/keys
# Quote passwords: characters like & * ? ! break unquoted shell args.
keytool -genkey -v -keystore app/android/keys/debug.jks \
-keyalg RSA -keysize 2048 -validity 10000 \
-alias key0 -storepass $DEBUG_STORE_PASS -keypass $DEBUG_KEY_PASS \
-alias key0 -storepass "$DEBUG_STORE_PASS" -keypass "$DEBUG_KEY_PASS" \
-dname "CN=Debug, O=FromChat, C=RU"
keytool -genkey -v -keystore app/android/keys/release.jks \
-keyalg RSA -keysize 2048 -validity 10000 \
-alias key0 -storepass $RELEASE_STORE_PASS -keypass $RELEASE_KEY_PASS \
-alias key0 -storepass "$RELEASE_STORE_PASS" -keypass "$RELEASE_KEY_PASS" \
-dname "CN=Release, O=FromChat, C=RU"
cat > app/android/keystore.properties << EOF
releaseStorePassword=$RELEASE_STORE_PASS
releaseKeyPassword=$RELEASE_KEY_PASS
debugStorePassword=$DEBUG_STORE_PASS
debugKeyPassword=$DEBUG_KEY_PASS
EOF
# Path must be app/android/keys/keystore.properties (matches build.gradle.kts).
# Outer "…" / '…' around values are optional; Gradle strips them.
cat > app/android/keys/keystore.properties << EOF
releaseStorePassword=$RELEASE_STORE_PASS
releaseKeyPassword=$RELEASE_KEY_PASS
debugStorePassword=$DEBUG_STORE_PASS
debugKeyPassword=$DEBUG_KEY_PASS
EOF
```
3. **Open in Android Studio:** `File → Open` → repo root. Gradle syncs dependencies automatically.
+15 -15
View File
@@ -18,14 +18,14 @@ FromChat — 100% бесплатный и открытый мессенджер.
## 📊 Сравнение клиентов
| Возможность | Android | Desktop | Web | iOS |
| --- | --- | --- | --- | --- |
| **Обмен сообщениями и профили** | ✅ | ✅ | ✅ | ✅ |
| **Голосовые/видеозвонки** | ✅ | ✅ | ✅ | ✅ |
| **Демонстрация экрана** | ✅ | ❌ | ✅ | ❌ |
| **Push в фоне** | ✅ (FCM) | ✅ (постоянный WS + tray) | ✅ | ❌ (WS пока открыто) |
| **Реакции на сообщения** | ❌ | ❌ | ✅ | ❌ |
| **Расширенная поддержка вложений** | ✅ | ✅ | ❌ | ✅ |
| Возможность | Android | Desktop | Web | iOS |
|------------------------------------|---------|--------------------------|-----|---------------------|
| **Обмен сообщениями и профили** | ✅ | ✅ | ✅ | ✅ |
| **Голосовые/видеозвонки** | ✅ | ✅ | ✅ | ✅ |
| **Демонстрация экрана** | ✅ | ❌ | ✅ | ❌ |
| **Push в фоне** | ✅ (FCM) | ✅ (постоянный WS + tray) | ✅ | ❌ (WS пока открыто) |
| **Реакции на сообщения** | ❌ | ❌ | ✅ | ❌ |
| **Расширенная поддержка вложений** | ✅ | ✅ | ❌ | ✅ |
Desktop заменяет бывший Electron-клиент в репозитории Web. Запуск: `./gradlew :app:desktop:run`.
@@ -63,24 +63,24 @@ Desktop заменяет бывший Electron-клиент в репозито
2. **Сгенерируйте ключи (Debug & Release):**
```bash
DEBUG_STORE_PASS=CHANGEME
DEBUG_KEY_PASS=CHANGEME
RELEASE_STORE_PASS=CHANGEME
RELEASE_KEY_PASS=CHANGEME
DEBUG_STORE_PASS="CHANGEME"
DEBUG_KEY_PASS="CHANGEME"
RELEASE_STORE_PASS="CHANGEME"
RELEASE_KEY_PASS="CHANGEME"
mkdir -p app/android/keys
keytool -genkey -v -keystore app/android/keys/debug.jks \
-keyalg RSA -keysize 2048 -validity 10000 \
-alias key0 -storepass $DEBUG_STORE_PASS -keypass $DEBUG_KEY_PASS \
-alias key0 -storepass "$DEBUG_STORE_PASS" -keypass "$DEBUG_KEY_PASS" \
-dname "CN=Debug, O=FromChat, C=RU"
keytool -genkey -v -keystore app/android/keys/release.jks \
-keyalg RSA -keysize 2048 -validity 10000 \
-alias key0 -storepass $RELEASE_STORE_PASS -keypass $RELEASE_KEY_PASS \
-alias key0 -storepass "$RELEASE_STORE_PASS" -keypass "$RELEASE_KEY_PASS" \
-dname "CN=Release, O=FromChat, C=RU"
cat > app/android/keystore.properties << EOF
cat > app/android/keys/keystore.properties << EOF
releaseStorePassword=$RELEASE_STORE_PASS
releaseKeyPassword=$RELEASE_KEY_PASS
debugStorePassword=$DEBUG_STORE_PASS
+11 -5
View File
@@ -75,19 +75,25 @@ extensions.configure<ApplicationExtension> {
load(file("keys/keystore.properties").inputStream())
}
fun Properties.password(key: String): String {
val raw = getProperty(key)
?: error("Missing $key in keys/keystore.properties")
return raw.trim().removeSurrounding("\"").removeSurrounding("'")
}
create("release") {
storeFile = file("keys/release.jks")
keyAlias = "key0"
storePassword = keystoreProperties["releaseStorePassword"].toString()
keyPassword = keystoreProperties["releaseKeyPassword"].toString()
storePassword = keystoreProperties.password("releaseStorePassword")
keyPassword = keystoreProperties.password("releaseKeyPassword")
enableV3Signing = true
}
getByName("debug") {
storeFile = file("keys/debug.jks")
keyAlias = "debug"
storePassword = keystoreProperties["debugStorePassword"].toString()
keyPassword = keystoreProperties["debugKeyPassword"].toString()
keyAlias = "key0"
storePassword = keystoreProperties.password("debugStorePassword")
keyPassword = keystoreProperties.password("debugKeyPassword")
enableV3Signing = true
}
}
@@ -0,0 +1,25 @@
package ru.fromchat.ui.chat
import androidx.compose.runtime.Composable
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
@Composable
internal actual fun MessageContextMenuPopup(
onDismissRequest: () -> Unit,
positionProvider: PopupPositionProvider,
content: @Composable () -> Unit,
) {
Popup(
onDismissRequest = onDismissRequest,
popupPositionProvider = positionProvider,
properties = PopupProperties(
focusable = true,
dismissOnBackPress = true,
dismissOnClickOutside = true,
clippingEnabled = true,
),
content = content,
)
}
@@ -0,0 +1,132 @@
package ru.fromchat.ui.chat.utils
import android.app.Activity
import android.content.ClipDescription
import android.content.Context
import android.content.ContextWrapper
import android.net.Uri
import android.provider.OpenableColumns
import android.view.DragEvent
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.draganddrop.DragAndDropEvent
import androidx.compose.ui.draganddrop.mimeTypes
import androidx.compose.ui.draganddrop.toAndroidDragEvent
import androidx.compose.ui.platform.LocalContext
import androidx.core.app.ActivityCompat
import androidx.core.net.toUri
import java.io.File
import java.io.FileOutputStream
actual class AttachmentDropPermissionsHost(
internal val activity: Activity?,
internal val appContext: Context,
)
@Composable
actual fun rememberAttachmentDropPermissionsHost(): AttachmentDropPermissionsHost {
val context = LocalContext.current
return remember(context) {
AttachmentDropPermissionsHost(
activity = findActivity(context),
appContext = context.applicationContext,
)
}
}
actual fun acceptsAttachmentDrop(event: DragAndDropEvent): Boolean {
val mimeTypes = event.mimeTypes()
if (mimeTypes.any { it == ClipDescription.MIMETYPE_TEXT_URILIST }) return true
return mimeTypes.any { mime ->
mime == "*/*" ||
mime.startsWith("image/") ||
mime.startsWith("video/") ||
mime.startsWith("application/") ||
mime.startsWith("audio/") ||
mime.startsWith("text/")
}
}
actual fun handleAttachmentDrop(
host: AttachmentDropPermissionsHost,
event: DragAndDropEvent,
onUris: (List<String>) -> Unit,
): Boolean {
val androidEvent = event.toAndroidDragEvent()
// Temporary read access lasts only until release(); copy into app cache first.
val permission = host.activity?.let {
ActivityCompat.requestDragAndDropPermissions(it, androidEvent)
}
return try {
val sourceUris = extractAttachmentUris(androidEvent)
if (sourceUris.isEmpty()) {
false
} else {
val durableUris = sourceUris.mapNotNull { copyDropUriToCache(host.appContext, it) }
if (durableUris.isEmpty()) {
false
} else {
onUris(durableUris)
true
}
}
} finally {
permission?.release()
}
}
private fun extractAttachmentUris(androidEvent: DragEvent): List<String> {
val clipData = androidEvent.clipData ?: return emptyList()
val uris = mutableListOf<String>()
for (index in 0 until clipData.itemCount) {
val item = clipData.getItemAt(index)
item.uri?.toString()?.takeIf { it.isNotBlank() }?.let { uris.add(it) }
val text = item.text?.toString()?.trim().orEmpty()
if (text.startsWith("content:") || text.startsWith("file:")) {
uris.add(text)
}
}
return uris.distinct()
}
private fun copyDropUriToCache(context: Context, uriString: String): String? {
val uri = uriString.toUri()
if (uri.scheme == "file") {
val path = uri.path?.takeIf { it.isNotBlank() } ?: return null
return if (File(path).isFile) uriString else null
}
val displayName = resolveDisplayName(context, uri) ?: uri.lastPathSegment ?: "drop_file"
val safeName = displayName.replace(Regex("[^A-Za-z0-9._-]"), "_").ifBlank { "drop_file" }
val dir = File(context.cacheDir, "drag_drop").apply { mkdirs() }
val dest = File(dir, "${System.currentTimeMillis()}_$safeName")
return try {
context.contentResolver.openInputStream(uri)?.use { input ->
FileOutputStream(dest).use { output ->
input.copyTo(output)
output.flush()
}
} ?: return null
Uri.fromFile(dest).toString()
} catch (_: Exception) {
dest.delete()
null
}
}
private fun resolveDisplayName(context: Context, uri: Uri): String? =
runCatching {
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (nameIndex >= 0 && cursor.moveToFirst()) {
cursor.getString(nameIndex)?.takeIf { it.isNotBlank() }
} else {
null
}
}
}.getOrNull()
private tailrec fun findActivity(ctx: Context?): Activity? = when (ctx) {
is Activity -> ctx
is ContextWrapper -> findActivity(ctx.baseContext)
else -> null
}
@@ -110,6 +110,7 @@
<string name="search_not_found">Ничего не найдено</string>
<string name="search_not_found_message">Ничего не найдено. Попробуйте переформулировать запрос.</string>
<string name="message_placeholder">Напишите сообщение…</string>
<string name="chat_drop_attachment_hint">Отпустите файл здесь</string>
<string name="status_connecting">Соединение</string>
<string name="status_connected">Подключено</string>
<string name="status_disconnected">Отключено</string>
@@ -120,6 +120,7 @@
<string name="search_not_found">No results</string>
<string name="search_not_found_message">Nothing found here. Try rephrasing your query.</string>
<string name="message_placeholder">Write a message…</string>
<string name="chat_drop_attachment_hint">Drop file here</string>
<!-- Connection / chat chrome -->
<string name="status_connecting">Connecting</string>
@@ -27,6 +27,7 @@ import ru.fromchat.api.schema.websocket.requests.AckUpdatesRequest
import ru.fromchat.api.schema.websocket.requests.GetUpdatesRequest
import ru.fromchat.api.schema.websocket.requests.GetUpdatesResponse
import kotlin.concurrent.Volatile
import kotlin.coroutines.cancellation.CancellationException
/**
* Per-device update cursor: advance + ack only after batches are applied successfully.
@@ -51,9 +52,10 @@ object UpdateSyncManager {
val userId = currentUserId ?: return
val key = KEY_UPDATES_LAST_SEQ_PREFIX + userId
val stored = runCatching { settings.getInt(key, 0) }.getOrDefault(0)
Logger.d("UpdateSyncManager", "Loaded lastSeq=$stored for userId=$userId")
_lastSeq.value = stored
ConnectionStateStore.updateSeqAndMissed(lastSeq = stored, missedCount = null)
val next = maxOf(_lastSeq.value, stored)
Logger.d("UpdateSyncManager", "Loaded lastSeq=$stored for userId=$userId (inMemory=${_lastSeq.value}$next)")
_lastSeq.value = next
ConnectionStateStore.updateSeqAndMissed(lastSeq = next, missedCount = _lastMissedCount.value)
}
/**
@@ -122,6 +124,7 @@ object UpdateSyncManager {
ConnectionStateStore.onUpdating(start = true)
try {
initializeFromStorage(ApiClient.user?.id)
var rounds = 0
var consecutiveFailures = 0
while (rounds < 100) {
@@ -160,6 +163,16 @@ object UpdateSyncManager {
when (response.status) {
"tooLong" -> {
initializeFromStorage(ApiClient.user?.id)
if (_lastSeq.value >= response.lastSeq) {
Logger.i(
"UpdateSyncManager",
"tooLong ignored — cursor already caught up " +
"lastSeq=${_lastSeq.value} serverSeq=${response.lastSeq}",
)
sendAckFireAndForget(response.lastSeq)
break
}
val ok = rebuildStateFromHistory()
if (ok) {
persistLastSeq(response.lastSeq)
@@ -207,6 +220,8 @@ object UpdateSyncManager {
}
}
}
} catch (t: CancellationException) {
throw t
} catch (t: Throwable) {
Logger.w("UpdateSyncManager", "Gap detection failed: ${t.message}", t)
} finally {
@@ -88,6 +88,7 @@ import ru.fromchat.ui.calls.CallOverlay
import ru.fromchat.ui.chat.panels.dm.navigateToDmChat
import ru.fromchat.ui.chat.panels.publicchat.PublicChatNav
import ru.fromchat.ui.chat.panels.publicchat.navigateToPublicChat
import ru.fromchat.ui.chat.utils.appRootAttachmentDropTarget
import ru.fromchat.ui.main.ConversationListDetailShell
import ru.fromchat.ui.main.DesktopChatsDetailNavHost
import ru.fromchat.ui.main.DesktopContactsDetailNavHost
@@ -242,6 +243,7 @@ fun App(
runCatching { ensureFromChatCacheGeneration() }
runCatching { NetworkConnectivity.ensureStarted() }
runCatching { ApiClient.loadPersistedData() }
runCatching { UpdateSyncManager.initializeFromStorage(ApiClient.user?.id) }
Logger.i("App", "FromChat started")
}
@@ -265,10 +267,6 @@ fun App(
}
onContentReady()
runCatching {
UpdateSyncManager.initializeFromStorage(ApiClient.user?.id)
}
DeferredStartupNetwork.scheduleAfterUiVisible()
if (!hasToken) return@LaunchedEffect
@@ -525,7 +523,11 @@ fun App(
}
ScreenSurface {
Box(Modifier.fillMaxSize()) {
Box(
Modifier
.fillMaxSize()
.appRootAttachmentDropTarget(),
) {
@Composable
fun AppNavHost(modifier: Modifier = Modifier) {
NavHost(
@@ -3,10 +3,14 @@ package ru.fromchat.ui.auth
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.MoreVert
@@ -30,6 +34,7 @@ import ru.fromchat.logs_title
import ru.fromchat.more
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.components.Text
import ru.fromchat.ui.extraStatusBars
import ru.fromchat.ui.main.settings.SettingsRoutes
/** Margin between the window edge and the wide [AuthContentFrame] panel. */
@@ -37,7 +42,8 @@ val AuthWidePanelOuterPadding = 32.dp
/**
* Centers auth content on wide layouts (max 600×800.dp, [AuthWidePanelOuterPadding] margin).
* Narrow layouts fill the window ([widePanel] = false).
* Narrow layouts fill the window edge-to-edge ([widePanel] = false) so top-bar haze can
* draw under the status bar; wide layouts clear system bars outside the panel.
*/
@Composable
fun AuthContentFrame(
@@ -49,12 +55,18 @@ fun AuthContentFrame(
if (widePanel) {
Box(
Modifier
.align(Alignment.Center)
.padding(AuthWidePanelOuterPadding)
.widthIn(max = 600.dp)
.heightIn(max = 800.dp),
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing.union(WindowInsets.extraStatusBars)),
) {
content(true)
Box(
Modifier
.align(Alignment.Center)
.padding(AuthWidePanelOuterPadding)
.widthIn(max = 600.dp)
.heightIn(max = 800.dp),
) {
content(true)
}
}
} else {
Box(Modifier.fillMaxSize()) {
@@ -1,12 +1,7 @@
package ru.fromchat.ui.auth
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material3.MaterialTheme
@@ -442,45 +437,77 @@ fun AuthScreen(
}
val yandexStep = yandexParams
// Clear title bar / system bars outside the rounded panel (same as WelcomeScreen).
Box(
Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing.union(WindowInsets.extraStatusBars)),
) {
AuthContentFrame { _ ->
ExpressiveStepFlowScaffold(
flowState = flowState,
pages = listOf(
usernameStepPage(
username = username,
onUsernameChange = { username = it },
onContinue = {
flowState.pagerState.animateScrollToPage(AuthFlowStep.Password.ordinal)
AuthContentFrame { widePanel ->
ExpressiveStepFlowScaffold(
flowState = flowState,
pages = listOf(
usernameStepPage(
username = username,
onUsernameChange = { username = it },
onContinue = {
flowState.pagerState.animateScrollToPage(AuthFlowStep.Password.ordinal)
},
onSnackbar = ::snackbar,
),
passwordStepPage(
username = username,
password = password,
onPasswordChange = { password = it },
onLoginSuccess = wrappedAuthSuccess,
onNeedsRegister = { required, params, captchaReq, captcha ->
Logger.i(
SmartCaptchaLog.TAG,
"onNeedsRegister yandexRequired=$required captchaRequired=$captchaReq " +
"clientKey=${SmartCaptchaLog.redactKey(captcha?.client_key)}",
)
yandexRequired = required
yandexParams = params
captchaRequired = captchaReq
captchaParams = captcha
registrationProof = null
captchaToken = null
flowState.pagerState.animateScrollToPage(AuthFlowStep.ConfirmPassword.ordinal)
},
onSnackbar = ::snackbar,
),
confirmPasswordStepPage(
confirmPassword = confirmPassword,
onConfirmPasswordChange = { confirmPassword = it },
password = password,
onContinue = {
val captchaKey = captchaParams?.client_key?.trim().orEmpty()
when {
yandexRequired && yandexParams != null -> {
Logger.i(SmartCaptchaLog.TAG, "confirm → YandexId (captcha skipped)")
flowState.pagerState.animateScrollToPage(AuthFlowStep.YandexId.ordinal)
}
captchaRequired &&
captchaToken.isNullOrBlank() &&
captchaKey.isNotEmpty() -> {
openCaptchaRoute(captchaKey)
}
else -> {
Logger.i(
SmartCaptchaLog.TAG,
"confirm → Profile captchaRequired=$captchaRequired " +
"hasToken=${!captchaToken.isNullOrBlank()}",
)
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
}
}
},
onSnackbar = ::snackbar,
),
if (yandexStep != null) {
yandexIdStepPage(
yandex = yandexStep,
onProof = { proof ->
registrationProof = proof
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
},
onSnackbar = ::snackbar,
),
passwordStepPage(
username = username,
password = password,
onPasswordChange = { password = it },
onLoginSuccess = wrappedAuthSuccess,
onNeedsRegister = { required, params, captchaReq, captcha ->
Logger.i(
SmartCaptchaLog.TAG,
"onNeedsRegister yandexRequired=$required captchaRequired=$captchaReq " +
"clientKey=${SmartCaptchaLog.redactKey(captcha?.client_key)}",
)
yandexRequired = required
yandexParams = params
captchaRequired = captchaReq
captchaParams = captcha
registrationProof = null
captchaToken = null
flowState.pagerState.animateScrollToPage(AuthFlowStep.ConfirmPassword.ordinal)
},
onSnackbar = ::snackbar,
),
)
} else {
confirmPasswordStepPage(
confirmPassword = confirmPassword,
onConfirmPasswordChange = { confirmPassword = it },
@@ -488,10 +515,6 @@ fun AuthScreen(
onContinue = {
val captchaKey = captchaParams?.client_key?.trim().orEmpty()
when {
yandexRequired && yandexParams != null -> {
Logger.i(SmartCaptchaLog.TAG, "confirm → YandexId (captcha skipped)")
flowState.pagerState.animateScrollToPage(AuthFlowStep.YandexId.ordinal)
}
captchaRequired &&
captchaToken.isNullOrBlank() &&
captchaKey.isNotEmpty() -> {
@@ -500,69 +523,39 @@ fun AuthScreen(
else -> {
Logger.i(
SmartCaptchaLog.TAG,
"confirm → Profile captchaRequired=$captchaRequired " +
"hasToken=${!captchaToken.isNullOrBlank()}",
"yandex-placeholder confirm → Profile",
)
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
}
}
},
onSnackbar = ::snackbar,
),
if (yandexStep != null) {
yandexIdStepPage(
yandex = yandexStep,
onProof = { proof ->
registrationProof = proof
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
},
onSnackbar = ::snackbar,
)
} else {
confirmPasswordStepPage(
confirmPassword = confirmPassword,
onConfirmPasswordChange = { confirmPassword = it },
password = password,
onContinue = {
val captchaKey = captchaParams?.client_key?.trim().orEmpty()
when {
captchaRequired &&
captchaToken.isNullOrBlank() &&
captchaKey.isNotEmpty() -> {
openCaptchaRoute(captchaKey)
}
else -> {
Logger.i(
SmartCaptchaLog.TAG,
"yandex-placeholder confirm → Profile",
)
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
}
}
},
onSnackbar = ::snackbar,
)
},
profileStepPage(
username = username,
displayName = displayName,
onDisplayNameChange = { displayName = it },
bio = bio,
onBioChange = { bio = it },
password = password,
registrationProof = registrationProof,
captchaToken = captchaToken,
onRegisterSuccess = wrappedAuthSuccess,
onUsernameTaken = resetToUsername,
onSnackbar = ::snackbar,
),
)
},
profileStepPage(
username = username,
displayName = displayName,
onDisplayNameChange = { displayName = it },
bio = bio,
onBioChange = { bio = it },
password = password,
registrationProof = registrationProof,
captchaToken = captchaToken,
onRegisterSuccess = wrappedAuthSuccess,
onUsernameTaken = resetToUsername,
onSnackbar = ::snackbar,
),
snackbarHostState = snackbarHostState,
onBackAtFirstPage = wrappedBackToWelcome,
trailingTopBarActions = { PreAuthOverflowMenu() },
contentBackground = MaterialTheme.colorScheme.background,
topBarWindowInsets = WindowInsets(0, 0, 0, 0),
)
}
),
snackbarHostState = snackbarHostState,
onBackAtFirstPage = wrappedBackToWelcome,
trailingTopBarActions = { PreAuthOverflowMenu() },
contentBackground = MaterialTheme.colorScheme.background,
// Wide panel: system bars cleared by [AuthContentFrame]. Compact: haze under status bar.
topBarWindowInsets = if (widePanel) {
WindowInsets(0, 0, 0, 0)
} else {
WindowInsets.extraStatusBars
},
)
}
}
@@ -4,7 +4,10 @@ import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
@@ -53,17 +56,25 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import ru.fromchat.ui.components.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.BlurredEdgeTreatment
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
@@ -72,6 +83,7 @@ import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.layout
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
@@ -89,6 +101,7 @@ import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.api.schema.messages.Message
import ru.fromchat.chat_drop_attachment_hint
import ru.fromchat.cd_close
import ru.fromchat.cd_emoji
import ru.fromchat.cd_pick_file
@@ -104,10 +117,16 @@ import ru.fromchat.message_placeholder
import ru.fromchat.message_replying_to
import ru.fromchat.suspend_chat_banner_message
import ru.fromchat.ui.chat.utils.SelectedAttachment
import ru.fromchat.ui.chat.utils.AttachmentDragSession
import ru.fromchat.ui.chat.utils.TypingHandler
import ru.fromchat.ui.chat.utils.AttachmentDropBridge
import ru.fromchat.ui.chat.utils.GlobalAttachmentDropRouter
import ru.fromchat.ui.chat.utils.chatAttachmentDropTarget
import ru.fromchat.ui.chat.utils.getFilenameFromUri
import ru.fromchat.ui.chat.utils.rememberFilePicker
import ru.fromchat.ui.chat.utils.rememberImagePicker
import ru.fromchat.ui.chat.utils.urisToSelectedAttachments
import kotlin.math.roundToInt
import kotlin.time.Clock
private val ChatInputChromeHeight = 54.dp
@@ -118,6 +137,23 @@ private val ChatInputIconSlotSize = 36.dp
private val ChatInputIconSlotVerticalInset =
(ChatInputChromeHeight - ChatInputIconSlotSize) / 2f
private fun Modifier.scaleFadeLayout(scale: Float): Modifier = layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
val width = (placeable.width * scale).roundToInt().coerceAtLeast(0)
val height = (placeable.height * scale).roundToInt().coerceAtLeast(0)
layout(width, height) {
placeable.placeWithLayer(
x = ((width - placeable.width) / 2f).roundToInt(),
y = ((height - placeable.height) / 2f).roundToInt(),
) {
scaleX = scale
scaleY = scale
alpha = scale
transformOrigin = TransformOrigin.Center
}
}
}
@Composable
private fun <T> AnimatedPreviewBar(
state: T?,
@@ -194,12 +230,29 @@ private fun PreviewBar(
@Composable
private fun AttachmentChip(
attachment: SelectedAttachment,
onRemoveStart: () -> Unit,
onRemove: () -> Unit,
removeContentDescription: String,
modifier: Modifier = Modifier,
) {
val onRemoveState = rememberUpdatedState(onRemove)
val onRemoveStartState = rememberUpdatedState(onRemoveStart)
val scale = remember { Animatable(0f) }
var removing by remember { mutableStateOf(false) }
LaunchedEffect(removing) {
if (removing) {
scale.animateTo(0f, tween(durationMillis = 220, easing = FastOutSlowInEasing))
onRemoveState.value()
} else {
scale.animateTo(1f, tween(durationMillis = 220, easing = FastOutSlowInEasing))
}
}
Row(
modifier = modifier
modifier = Modifier
.scaleFadeLayout(scale.value)
.then(modifier)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 8.dp, vertical = 6.dp),
@@ -231,7 +284,15 @@ private fun AttachmentChip(
overflow = TextOverflow.Ellipsis,
modifier = Modifier.widthIn(max = 120.dp),
)
IconButton(onClick = onRemove, modifier = Modifier.size(24.dp)) {
IconButton(
onClick = {
if (removing) return@IconButton
removing = true
onRemoveStartState.value()
},
enabled = !removing,
modifier = Modifier.size(24.dp),
) {
Icon(
imageVector = Icons.Rounded.Close,
contentDescription = removeContentDescription,
@@ -259,11 +320,36 @@ fun ChatInput(
onReadOnlyMessageClick: () -> Unit = {},
snackbarHostState: SnackbarHostState? = null,
hazeBlurEnabled: Boolean = true,
attachmentDropBridge: AttachmentDropBridge,
pendingDropUris: List<String> = emptyList(),
) {
val composerHazeStyle = rememberChatSurfaceContainerHazeStyle()
val dropScrimColor = lerp(MaterialTheme.colorScheme.primary, Color.Black, 0.62f)
val attachmentDropEnabled = supportsAttachments && !isReadOnly
val dropHighlightActive =
attachmentDropEnabled &&
(attachmentDropBridge.dropHighlightActive || AttachmentDragSession.isActive)
val dropAnimMs = 220
val dropScale by animateFloatAsState(
targetValue = if (dropHighlightActive) 0.96f else 1f,
animationSpec = tween(durationMillis = dropAnimMs, easing = FastOutSlowInEasing),
label = "chat_input_drop_scale",
)
val dropOverlayAlpha by animateFloatAsState(
targetValue = if (dropHighlightActive) 1f else 0f,
animationSpec = tween(durationMillis = dropAnimMs, easing = FastOutSlowInEasing),
label = "chat_input_drop_overlay_alpha",
)
val dropBlurRadius by animateDpAsState(
targetValue = if (dropHighlightActive) 18.dp else 0.dp,
animationSpec = tween(durationMillis = dropAnimMs, easing = FastOutSlowInEasing),
label = "chat_input_drop_blur",
)
val scope = rememberCoroutineScope()
var typingJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
var attachments by remember { mutableStateOf<List<SelectedAttachment>>(emptyList()) }
var exitingAttachmentIds by remember { mutableStateOf(setOf<String>()) }
var pendingDropSeeded by remember { mutableStateOf(false) }
val launchImagePicker = rememberImagePicker { uris ->
attachments = attachments + uris.map { uri ->
@@ -288,6 +374,27 @@ fun ChatInput(
}
}
DisposableEffect(attachmentDropBridge, attachmentDropEnabled) {
fun acceptUris(uris: List<String>) {
if (!attachmentDropEnabled || uris.isEmpty()) return
attachments = attachments + urisToSelectedAttachments(uris, attachments.size)
}
attachmentDropBridge.consumer = ::acceptUris
GlobalAttachmentDropRouter.setConsumer(::acceptUris)
onDispose {
attachmentDropBridge.consumer = null
GlobalAttachmentDropRouter.setConsumer(null)
}
}
LaunchedEffect(pendingDropUris, attachmentDropEnabled) {
if (pendingDropSeeded || pendingDropUris.isEmpty() || !attachmentDropEnabled) {
return@LaunchedEffect
}
pendingDropSeeded = true
attachments = attachments + urisToSelectedAttachments(pendingDropUris, attachments.size)
}
LaunchedEffect(text) {
if (text.isNotBlank()) {
typingJob?.cancel()
@@ -302,7 +409,9 @@ fun ChatInput(
}
}
val canSend = !isReadOnly && (text.isNotBlank() || attachments.isNotEmpty())
val canSend = !isReadOnly && (
text.isNotBlank() || attachments.any { it.id !in exitingAttachmentIds }
)
val enterToSend = Settings.enterToSend
val cdClose = stringResource(Res.string.cd_close)
val cdRemove = stringResource(Res.string.cd_remove)
@@ -313,14 +422,16 @@ fun ChatInput(
val corruptedShort = stringResource(Res.string.message_corrupted_short)
val editingTitle = stringResource(Res.string.message_editing_title)
val blockedMessage = stringResource(Res.string.suspend_chat_banner_message)
val dropAttachmentHint = stringResource(Res.string.chat_drop_attachment_hint)
val cdVoiceUnavailable = "Функция пока не готова. Следите за обновлениями!"
fun sendMessage() {
if (!canSend) return
val plaintext = text.trim().ifBlank { "" }
onSend(plaintext, attachments)
onSend(plaintext, attachments.filter { it.id !in exitingAttachmentIds })
onTextChange("")
attachments = emptyList()
exitingAttachmentIds = emptySet()
typingHandler.stopTyping()
}
@@ -360,7 +471,12 @@ fun ChatInput(
}
} else {
Row(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.chatAttachmentDropTarget(
enabled = attachmentDropEnabled,
bridge = attachmentDropBridge,
),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
@@ -371,18 +487,29 @@ fun ChatInput(
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Column(
Box(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainer, pillShape)
.clip(pillShape)
.hazeEffect(state = hazeState) {
blurEffect {
blurEnabled = hazeBlurEnabled
style = composerHazeStyle
}
},
.graphicsLayer {
scaleX = dropScale
scaleY = dropScale
}
.clip(pillShape),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainer, pillShape)
.hazeEffect(state = hazeState) {
blurEffect {
blurEnabled = hazeBlurEnabled
style = composerHazeStyle
}
}
.conditional(dropBlurRadius > 0.dp) {
blur(dropBlurRadius, BlurredEdgeTreatment.Unbounded)
},
) {
AnimatedPreviewBar(replyTo) { reply ->
val replySubtitle = if (reply.isContentCorrupted) {
corruptedShort
@@ -414,24 +541,33 @@ fun ChatInput(
)
}
AnimatedVisibility(
visible = attachments.isNotEmpty(),
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
if (attachments.isNotEmpty()) {
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(horizontal = 10.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
.padding(start = 10.dp, end = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
attachments.forEach { attachment ->
AttachmentChip(
attachment = attachment,
onRemove = { attachments = attachments.filter { it.id != attachment.id } },
removeContentDescription = cdRemove,
)
key(attachment.id) {
AttachmentChip(
attachment = attachment,
onRemoveStart = {
exitingAttachmentIds = exitingAttachmentIds + attachment.id
},
onRemove = {
attachments = attachments.filter { it.id != attachment.id }
exitingAttachmentIds = exitingAttachmentIds - attachment.id
},
removeContentDescription = cdRemove,
modifier = Modifier.padding(
end = 8.dp,
top = 4.dp,
bottom = 4.dp,
),
)
}
}
}
}
@@ -562,6 +698,24 @@ fun ChatInput(
}
}
}
}
Box(
modifier = Modifier
.matchParentSize()
.graphicsLayer { alpha = dropOverlayAlpha }
.clip(pillShape)
.background(dropScrimColor.copy(alpha = 0.78f)),
contentAlignment = Alignment.Center,
) {
Text(
text = dropAttachmentHint,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
}
}
@@ -147,9 +147,12 @@ import ru.fromchat.status_connecting
import ru.fromchat.status_updating
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.chat.utils.AttachmentDownloadVisibility
import ru.fromchat.ui.chat.utils.PendingChatAttachmentDrops
import ru.fromchat.ui.chat.utils.chatAttachmentDropTarget
import ru.fromchat.ui.chat.utils.getImageAspectRatio
import ru.fromchat.ui.chat.utils.getImageDimensions
import ru.fromchat.ui.chat.utils.imageAttachmentKey
import ru.fromchat.ui.chat.utils.rememberAttachmentDropBridge
import ru.fromchat.ui.chat.utils.visibleMessageIdsInChatList
import ru.fromchat.ui.components.Text
import ru.fromchat.ui.components.SuspendedAccountSupportSheet
@@ -443,6 +446,13 @@ fun ChatScreen(
val online by NetworkConnectivity.isOnline.collectAsState(initial = true)
val suspensionState by ApiClient.suspensionState.collectAsState()
val isReadOnly = suspensionState.isSuspended
val attachmentDropBridge = rememberAttachmentDropBridge()
val attachmentDropEnabled = panel.supportsAttachments && !isReadOnly
val attachmentDropChatKey = panel.getRecipientId()?.let { PendingChatAttachmentDrops.dmKey(it) }
?: PendingChatAttachmentDrops.publicKey()
val pendingDropUris = remember(attachmentDropChatKey) {
PendingChatAttachmentDrops.consume(attachmentDropChatKey)
}
val dmRecipientId = panel.getRecipientId()
var peerDeleted by remember(dmRecipientId) { mutableStateOf(false) }
val deleteChatLabel = stringResource(Res.string.action_delete_chat)
@@ -875,6 +885,8 @@ fun ChatScreen(
text = inputText,
onTextChange = { inputText = it },
currentUserId = currentUserId,
attachmentDropBridge = attachmentDropBridge,
pendingDropUris = pendingDropUris,
onSend = { text, attachments ->
if (editingMessage != null) {
scope.launch {
@@ -1086,6 +1098,10 @@ fun ChatScreen(
Box(
modifier = Modifier
.fillMaxSize()
.chatAttachmentDropTarget(
enabled = attachmentDropEnabled,
bridge = attachmentDropBridge,
)
.then(
if (contextMenuState.isOpen) {
Modifier.pointerInput(Unit) {
@@ -411,10 +411,6 @@ fun ChatTopBar(
blurEffect {
blurEnabled = hazeBlurEnabled
style = hazeStyle
progressive = HazeProgressive.verticalGradient(
startIntensity = 1f,
endIntensity = 0f,
)
}
},
)
@@ -453,10 +449,10 @@ private fun ChatTopBarPill(
val pillShape = RoundedCornerShape(28.dp)
Box(modifier = modifier.fillMaxWidth()) {
// Progressive blur only — not part of the pill chrome.
Box(
modifier = Modifier
.matchParentSize()
.background(chromeColor)
.hazeEffect(state = hazeState) {
blurEffect {
blurEnabled = hazeBlurEnabled
@@ -53,9 +53,7 @@ import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.action_cancel_send
@@ -72,6 +70,14 @@ import ru.fromchat.api.schema.messages.Message
import ru.fromchat.supportsMouseMessageInteraction
import ru.fromchat.ui.components.Text
/** Platform host for the message context menu (Popup on mobile, OS window on desktop). */
@Composable
internal expect fun MessageContextMenuPopup(
onDismissRequest: () -> Unit,
positionProvider: PopupPositionProvider,
content: @Composable () -> Unit,
)
data class ContextMenuState(
val isOpen: Boolean = false,
val message: Message? = null,
@@ -102,8 +108,8 @@ internal fun messageContextMenuFingerprint(
}
private val contextMenuEnterSpec = spring<Float>(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMedium,
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessMediumLow,
)
private fun clampContextMenuOffset(
@@ -226,20 +232,17 @@ fun MessageContextMenu(
}
}
val scale = 0.8f + 0.2f * animationProgress.floatValue
val alpha = animationProgress.floatValue
// Match list-item menus (0.5→1) so spring overshoot past 1 is visible.
val scale = 0.5f + 0.5f * animationProgress.floatValue
val alpha = animationProgress.floatValue.coerceIn(0f, 1f)
// [state.position] is window coordinates (see MessageItem localToWindow).
// Alignment+offset would add the popup parents window origin again (double offset
// in listdetail panes). Place with an absolute provider instead.
Popup(
// Desktop uses a real OS window so the menu can sit outside the app frame.
MessageContextMenuPopup(
onDismissRequest = onDismiss,
popupPositionProvider = positionProvider,
properties = PopupProperties(
dismissOnBackPress = true,
dismissOnClickOutside = true,
clippingEnabled = false,
),
positionProvider = positionProvider,
) {
ContextMenuContent(
message = state.message,
@@ -72,6 +72,7 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.util.fastAll
import kotlin.math.roundToInt
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Logger
@@ -246,24 +247,6 @@ fun MessageItem(
val isCorrupted = remember(message.files, message.fileThumbnails, message.dmEnvelope) {
isMessageCorrupted(message)
}
val primaryIsImageMessage = remember(
message.pendingFileUri,
message.pendingFilename,
message.files,
) {
val pendingIsImage = when {
message.pendingFileUri != null &&
DecryptedImageCache.isDecryptedImageCacheUri(message.pendingFileUri) -> true
message.pendingFilename?.isNotBlank() == true ->
isImageFilename(message.pendingFilename)
message.pendingFileUri != null -> isImageFilename(
message.pendingFileUri.substringAfterLast('/').substringBefore('?')
)
else -> false
}
pendingIsImage ||
message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true
}
val formattedTime = remember(message.timestamp) {
formatMessageTimeLocal(message.timestamp)
}
@@ -289,13 +272,16 @@ fun MessageItem(
var rowLayoutCoords by remember(message.id) { mutableStateOf<LayoutCoordinates?>(null) }
var bubbleContentCoords by remember(message.id) { mutableStateOf<LayoutCoordinates?>(null) }
LaunchedEffect(isContextMenuForThisMessage) {
if (!isContextMenuForThisMessage) isPressed = false
}
val scaleTarget = when {
isContextMenuForThisMessage -> 0.96f
isPressed && !isContextMenuOpen -> 0.96f
else -> 1f
if (isContextMenuForThisMessage) {
// Keep the press squash briefly so the bubble can animate down, then release
// so it springs back up as the menu opens.
delay(48)
isPressed = false
} else {
isPressed = false
}
}
val scaleTarget = if (isPressed) 0.96f else 1f
val avatarScaleTarget = if (avatarPressed && !isContextMenuOpen) 0.96f else 1f
val replyScaleTarget = if (replyPressed && !isContextMenuOpen) 0.96f else 1f
val scale by animateFloatAsState(
@@ -371,22 +357,17 @@ fun MessageItem(
"enterFinished=$enterFinished scale=${enterScale.value}",
)
}
LaunchedEffect(enterIdentity, isNewEnterRole, primaryIsImageMessage) {
if (isNewEnterRole && primaryIsImageMessage) {
enterStarted = true
enterFinished = true
enterScale.snapTo(1f)
} else if (isNewEnterRole && !enterStarted) {
LaunchedEffect(enterIdentity, isNewEnterRole) {
if (isNewEnterRole && !enterStarted) {
enterStarted = true
enterFinished = false
enterScale.snapTo(0f)
}
}
val runEnterAnimation =
enterStarted && !enterFinished && !isExiting && !primaryIsImageMessage
val runEnterAnimation = enterStarted && !enterFinished && !isExiting
// Single effect: start the spring as soon as this bubble is marked for enter.
LaunchedEffect(enterIdentity, runEnterAnimation, primaryIsImageMessage) {
if (primaryIsImageMessage || !runEnterAnimation) return@LaunchedEffect
LaunchedEffect(enterIdentity, runEnterAnimation) {
if (!runEnterAnimation) return@LaunchedEffect
Logger.d(
"EnterAnim",
"spring_start identity=${enterIdentity.take(12)} " +
@@ -37,12 +37,17 @@ object DmNav {
/**
* Opens a DM chat, replacing any prior conversation/profile above the main `chat` root
* so large-screen switches do not stack previous chats.
*
* No-ops when that DM chat is already the top destination (avoids NavHost enter/exit
* for re-selecting the same conversation in listdetail).
*/
fun NavController.navigateToDmChat(
otherUserId: Int,
sourceMessageId: Int? = null,
builder: NavOptionsBuilder.() -> Unit = {},
) {
if (isCurrentDmChat(otherUserId)) return
navigate(DmNav.chatRoute(otherUserId, sourceMessageId)) {
popUpTo(graph.startDestinationId) { saveState = true }
launchSingleTop = true
@@ -50,6 +55,14 @@ fun NavController.navigateToDmChat(
}
}
/** True when the back-stack top is already [DmNav.CHAT_ROUTE] for [otherUserId]. */
fun NavController.isCurrentDmChat(otherUserId: Int): Boolean {
val entry = currentBackStackEntry ?: return false
if (entry.destination.route != DmNav.CHAT_ROUTE) return false
val currentId = entry.savedStateHandle.get<String>("otherUserId")?.toIntOrNull()
return currentId == otherUserId
}
private const val DM_AVATAR_KEY_PREFIX = "dm-avatar-"
@Composable
@@ -26,10 +26,15 @@ object PublicChatNav {
/**
* Opens the public chat, replacing any prior conversation/profile above the main `chat` root.
*
* No-ops when public chat is already the top destination (avoids NavHost enter/exit
* for re-selecting the same conversation in listdetail).
*/
fun NavController.navigateToPublicChat(
builder: NavOptionsBuilder.() -> Unit = {},
) {
if (currentBackStackEntry?.destination?.route == PublicChatNav.CHAT_ROUTE) return
navigate(PublicChatNav.CHAT_ROUTE) {
popUpTo(graph.startDestinationId) { saveState = true }
launchSingleTop = true
@@ -0,0 +1,275 @@
package ru.fromchat.ui.chat.utils
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.draganddrop.dragAndDropTarget
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draganddrop.DragAndDropEvent
import androidx.compose.ui.draganddrop.DragAndDropTarget
import androidx.compose.ui.draw.BlurredEdgeTreatment
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import kotlin.time.Clock
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.chat_drop_attachment_hint
import ru.fromchat.ui.chat.isImageFilename
import ru.fromchat.ui.components.Text
import com.pr0gramm3r101.utils.conditional
/** True while a file drag that this process accepted is still in progress. */
object AttachmentDragSession {
var isActive by mutableStateOf(false)
internal set
}
/**
* Delivers drops from the always-mounted app-root target to the visible chat composer.
* Needed when a chat opens after drag start (Compose targets miss ACTION_DRAG_STARTED).
*/
object GlobalAttachmentDropRouter {
private var consumer: ((List<String>) -> Unit)? = null
fun setConsumer(onUris: ((List<String>) -> Unit)?) {
consumer = onUris
}
fun deliver(uris: List<String>) {
if (uris.isNotEmpty()) consumer?.invoke(uris)
}
}
/** Holds drop URIs until the destination chat composer mounts. */
object PendingChatAttachmentDrops {
private var pendingKey by mutableStateOf<String?>(null)
private var pendingUris by mutableStateOf<List<String>>(emptyList())
fun dmKey(otherUserId: Int) = "dm:$otherUserId"
fun publicKey() = "public"
fun offer(chatKey: String, uris: List<String>) {
if (uris.isEmpty()) return
pendingKey = chatKey
pendingUris = uris
}
fun consume(chatKey: String): List<String> {
if (pendingKey != chatKey) return emptyList()
val uris = pendingUris
pendingKey = null
pendingUris = emptyList()
return uris
}
}
@Stable
class AttachmentDropBridge {
internal var consumer: ((List<String>) -> Unit)? = null
private var dropHighlightDepth by mutableIntStateOf(0)
var dropHighlightActive by mutableStateOf(false)
private set
fun deliver(uris: List<String>) {
if (uris.isNotEmpty()) consumer?.invoke(uris)
}
internal fun enterDropHighlight() {
dropHighlightDepth++
dropHighlightActive = dropHighlightDepth > 0
}
internal fun exitDropHighlight() {
if (dropHighlightDepth > 0) dropHighlightDepth--
dropHighlightActive = dropHighlightDepth > 0
}
internal fun endDropHighlight() {
dropHighlightDepth = 0
dropHighlightActive = false
}
}
@Composable
fun rememberAttachmentDropBridge(): AttachmentDropBridge = remember { AttachmentDropBridge() }
fun urisToSelectedAttachments(
uris: List<String>,
existingAttachmentCount: Int,
): List<SelectedAttachment> =
uris.mapIndexed { index, uri ->
val filename = getFilenameFromUri(uri)
SelectedAttachment(
id = "drop_${Clock.System.now().toEpochMilliseconds()}_${existingAttachmentCount + index}",
uri = uri,
filename = filename,
sizeBytes = null,
isImage = isImageFilename(filename),
)
}
expect class AttachmentDropPermissionsHost
@Composable
expect fun rememberAttachmentDropPermissionsHost(): AttachmentDropPermissionsHost
expect fun acceptsAttachmentDrop(event: DragAndDropEvent): Boolean
expect fun handleAttachmentDrop(
host: AttachmentDropPermissionsHost,
event: DragAndDropEvent,
onUris: (List<String>) -> Unit,
): Boolean
@OptIn(ExperimentalFoundationApi::class)
fun Modifier.chatAttachmentDropTarget(
enabled: Boolean,
bridge: AttachmentDropBridge,
): Modifier = composed {
if (!enabled) return@composed Modifier
val permissionsHost = rememberAttachmentDropPermissionsHost()
val target = remember(bridge, permissionsHost) {
object : DragAndDropTarget {
override fun onStarted(event: DragAndDropEvent) {
AttachmentDragSession.isActive = true
}
override fun onEntered(event: DragAndDropEvent) {
bridge.enterDropHighlight()
}
override fun onExited(event: DragAndDropEvent) {
bridge.exitDropHighlight()
}
override fun onEnded(event: DragAndDropEvent) {
bridge.endDropHighlight()
AttachmentDragSession.isActive = false
}
override fun onDrop(event: DragAndDropEvent): Boolean {
bridge.endDropHighlight()
AttachmentDragSession.isActive = false
return handleAttachmentDrop(permissionsHost, event) { bridge.deliver(it) }
}
}
}
dragAndDropTarget(
shouldStartDragAndDrop = { acceptsAttachmentDrop(it) },
target = target,
)
}
/**
* Always-mounted root target so a drag started on the chats list (or elsewhere) still
* receives DROP after navigating into a chat that was not composed at drag start.
*/
@OptIn(ExperimentalFoundationApi::class)
fun Modifier.appRootAttachmentDropTarget(): Modifier = composed {
val permissionsHost = rememberAttachmentDropPermissionsHost()
val target = remember(permissionsHost) {
object : DragAndDropTarget {
override fun onStarted(event: DragAndDropEvent) {
AttachmentDragSession.isActive = true
}
override fun onEnded(event: DragAndDropEvent) {
AttachmentDragSession.isActive = false
}
override fun onDrop(event: DragAndDropEvent): Boolean {
AttachmentDragSession.isActive = false
return handleAttachmentDrop(permissionsHost, event) {
GlobalAttachmentDropRouter.deliver(it)
}
}
}
}
dragAndDropTarget(
shouldStartDragAndDrop = { acceptsAttachmentDrop(it) },
target = target,
)
}
@Composable
fun AttachmentDropHighlightBox(
active: Boolean,
shape: Shape,
modifier: Modifier = Modifier,
content: @Composable BoxScope.() -> Unit,
) {
val dropAnimMs = 220
val dropScale by animateFloatAsState(
targetValue = if (active) 0.96f else 1f,
animationSpec = tween(durationMillis = dropAnimMs, easing = FastOutSlowInEasing),
label = "attachment_drop_scale",
)
val dropOverlayAlpha by animateFloatAsState(
targetValue = if (active) 1f else 0f,
animationSpec = tween(durationMillis = dropAnimMs, easing = FastOutSlowInEasing),
label = "attachment_drop_overlay_alpha",
)
val dropBlurRadius by animateDpAsState(
targetValue = if (active) 18.dp else 0.dp,
animationSpec = tween(durationMillis = dropAnimMs, easing = FastOutSlowInEasing),
label = "attachment_drop_blur",
)
val dropScrimColor = lerp(MaterialTheme.colorScheme.primary, Color.Black, 0.62f)
val dropAttachmentHint = stringResource(Res.string.chat_drop_attachment_hint)
Box(
modifier = modifier
.graphicsLayer {
scaleX = dropScale
scaleY = dropScale
}
.clip(shape),
) {
Box(
modifier = Modifier.conditional(dropBlurRadius > 0.dp) {
blur(dropBlurRadius, BlurredEdgeTreatment.Unbounded)
},
content = content,
)
Box(
modifier = Modifier
.matchParentSize()
.graphicsLayer { alpha = dropOverlayAlpha }
.clip(shape)
.background(dropScrimColor.copy(alpha = 0.78f)),
contentAlignment = Alignment.Center,
) {
Text(
text = dropAttachmentHint,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
}
}
@@ -14,11 +14,11 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.focus.FocusRequester
@@ -141,6 +141,27 @@ private fun effectiveImeBottomPx(
return (reportedImeBottomPx * (1f - progress)).roundToInt()
}
/**
* True while IME / page predictive-back is still moving dismiss-follow must stay latched
* so reverse gestures keep scrolling content back up.
*/
private fun imeDismissFollowActive(
phase: ImeKeyboardPhase,
imeAnimating: Boolean,
predictiveBackProgress: Float,
reportedImeBottomPx: Int,
settledImeBottomPx: Int,
): Boolean {
if (phase == ImeKeyboardPhase.Hidden && !imeAnimating && predictiveBackProgress <= 0f) {
return false
}
return predictiveBackProgress > 0f ||
imeAnimating ||
phase == ImeKeyboardPhase.Closing ||
phase == ImeKeyboardPhase.ReopeningPartial ||
(settledImeBottomPx > 0 && reportedImeBottomPx in 1 until settledImeBottomPx)
}
/** Scrolls the list when the IME would cover the focused field; skips if already fully visible. */
@Composable
fun LazyListImeScrollEffect(
@@ -176,13 +197,14 @@ fun LazyListImeScrollEffect(
}
}
// Do not key on focusedBoundsInWindow: scrollBy updates those bounds and would restart
// this effect every frame (cancel/re-apply), which oscillates two scroll targets.
LaunchedEffect(
imeMotion.currentBottomPx,
imeMotion.sourceBottomPx,
imeMotion.targetBottomPx,
predictiveBackProgress(),
scrollState.focusedItemIndex,
scrollState.focusedBoundsInWindow,
viewportBoundsInWindow,
contentPaddingTop,
contentPaddingBottom,
@@ -208,12 +230,18 @@ fun LazyListImeScrollEffect(
val reportedImeBottomPx = imeMotion.currentBottomPx
val previousReported = previousReportedImeBottom.intValue
val progress = currentPredictiveProgress.value().coerceIn(0f, 1f)
val phase = imeMotion.keyboardPhase(
settledImeBottomPx = settledImeBottom.intValue,
previousReportedImeBottomPx = previousReported,
)
if (!imeMotion.isAnimating) {
// Keep the last fully-open height while dismiss-follow is latched; predictive IME
// can report source==target at a partial inset and would otherwise overwrite this.
if (!imeMotion.isAnimating &&
!wasFollowingKeyboardDismiss.value &&
progress <= 0f
) {
settledImeBottom.intValue = reportedImeBottomPx
}
@@ -225,7 +253,6 @@ fun LazyListImeScrollEffect(
else -> Unit
}
val progress = currentPredictiveProgress.value().coerceIn(0f, 1f)
val followImeBottom = if (progress > 0f) {
effectiveImeBottomPx(reportedImeBottomPx, progress)
} else {
@@ -260,9 +287,27 @@ fun LazyListImeScrollEffect(
wasFollowingKeyboardDismiss.value = true
previousFollowImeBottom.intValue = followImeBottom
previousReportedImeBottom.intValue = reportedImeBottomPx
if (phase == ImeKeyboardPhase.Open || phase == ImeKeyboardPhase.Hidden) {
val followStillActive = imeDismissFollowActive(
phase = phase,
imeAnimating = imeMotion.isAnimating,
predictiveBackProgress = progress,
reportedImeBottomPx = reportedImeBottomPx,
settledImeBottomPx = settledImeBottom.intValue,
)
if (!followStillActive &&
(phase == ImeKeyboardPhase.Open || phase == ImeKeyboardPhase.Hidden)
) {
// Reverse/abort already kept the gap locked while the IME moved back.
// Do not animateScrollBy here — that fights bring-into-view and glitches.
wasFollowingKeyboardDismiss.value = false
dismissFollowKeyboardTopGap.value = null
if (phase == ImeKeyboardPhase.Open) {
skipStableAnchorAfterReopen.value = true
}
if (!imeMotion.isAnimating) {
settledImeBottom.intValue = reportedImeBottomPx
}
}
return@LaunchedEffect
}
@@ -45,6 +45,7 @@ import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -76,6 +77,9 @@ import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastAll
import kotlinx.coroutines.withTimeout
import ru.fromchat.ui.chat.utils.AttachmentDropHighlightBox
import ru.fromchat.ui.chat.utils.chatAttachmentDropTarget
import ru.fromchat.ui.chat.utils.rememberAttachmentDropBridge
import ru.fromchat.ui.components.SearchBar
import ru.fromchat.ui.components.SearchBarSharedElement
import com.pr0gramm3r101.components.Category
@@ -313,6 +317,9 @@ internal fun ChatConversationsList(
) -> Unit,
onEnterSelectionMode: (lazyIndex: Int, target: ChatContextMenuTarget, userId: Int?) -> Unit,
onRowPositioned: (lazyIndex: Int, offset: Offset, size: IntSize) -> Unit,
onDropAttachmentsOnPublic: (uris: List<String>) -> Unit = {},
onDropAttachmentsOnConversation: (userId: Int, uris: List<String>) -> Unit = { _, _ -> },
attachmentDropEnabled: Boolean = true,
) {
val scrollBlocked = contextMenuState.isOverlayActive
val showPublicChat = listFilter == ChatListFilter.Active && !publicChatTitle.isNullOrBlank()
@@ -435,6 +442,9 @@ internal fun ChatConversationsList(
onRowPositioned = { offset, size ->
onRowPositioned(ChatListLayout.PUBLIC_CHAT_ROW, offset, size)
},
attachmentDropEnabled = attachmentDropEnabled &&
listMode == ChatsListMode.Normal,
onAttachmentsDropped = onDropAttachmentsOnPublic,
)
}
if (conversations.isNotEmpty()) {
@@ -514,6 +524,11 @@ internal fun ChatConversationsList(
onEnterSelectionMode(lazyIndex, ChatContextMenuTarget.Dm, conversation.otherUserId)
},
onRowPositioned = { offset, size -> onRowPositioned(lazyIndex, offset, size) },
attachmentDropEnabled = attachmentDropEnabled &&
listMode == ChatsListMode.Normal,
onAttachmentsDropped = { uris ->
onDropAttachmentsOnConversation(conversation.otherUserId, uris)
},
)
}
if (index < conversations.lastIndex) {
@@ -827,12 +842,21 @@ internal fun PublicChatRow(
onMouseContextMenu: (menuPosition: Offset, rowOffset: Offset, rowSize: IntSize) -> Unit,
onBodyLongPress: () -> Unit,
onRowPositioned: (offset: Offset, size: IntSize) -> Unit,
attachmentDropEnabled: Boolean = false,
onAttachmentsDropped: (List<String>) -> Unit = {},
) {
var rowRootOffset by remember { mutableStateOf(Offset.Zero) }
var rowSize by remember { mutableStateOf(IntSize.Zero) }
val pressScaleAnim = remember { Animatable(1f) }
val contextMenuScale = chatRowContextMenuScale(rowRevealProgress, contextMenuPressScaleActive)
val rowInteractionSource = remember { MutableInteractionSource() }
val dropBridge = rememberAttachmentDropBridge()
val clipShape = listItemClipShape(listItemPosition, groupItemCount)
DisposableEffect(dropBridge, onAttachmentsDropped) {
dropBridge.consumer = onAttachmentsDropped
onDispose { dropBridge.consumer = null }
}
LaunchedEffect(isPressingForContextMenu, isHiddenForOverlay, rowRevealProgress, contextMenuPressScaleActive) {
when {
@@ -843,46 +867,57 @@ internal fun PublicChatRow(
}
}
ChatRowScaleContainer(
listItemPosition = listItemPosition,
groupItemCount = groupItemCount,
pressScale = pressScaleAnim.value,
AttachmentDropHighlightBox(
active = dropBridge.dropHighlightActive,
shape = clipShape,
modifier = Modifier
.alpha(if (isHiddenForOverlay) 0f else 1f)
.fillMaxWidth()
.onGloballyPositioned { coords ->
rowRootOffset = coords.positionInRoot()
rowSize = coords.size
onRowPositioned(rowRootOffset, rowSize)
},
interactionModifier = Modifier.chatRowOpenAndSelectGestures(
selectionEnabled = listMode == ChatsListMode.Normal,
interactionSource = rowInteractionSource,
onOpen = onOpenPublic,
onEnterSelection = onBodyLongPress,
onMouseContextMenu = { localOffset ->
onMouseContextMenu(rowRootOffset + localOffset, rowRootOffset, rowSize)
},
),
.chatAttachmentDropTarget(
enabled = attachmentDropEnabled,
bridge = dropBridge,
),
) {
PublicChatRowContent(
publicChatTitle = publicChatTitle,
publicChatPreviewState = publicChatPreviewState,
defaultLastMessage = defaultLastMessage,
listMode = listMode,
selectionTransitionProgress = selectionTransitionProgress,
isSelected = isSelected,
ChatRowScaleContainer(
listItemPosition = listItemPosition,
groupItemCount = groupItemCount,
avatarEnabled = listMode == ChatsListMode.Normal,
onOpenPublic = onOpenPublic,
onAvatarPressStart = { onAvatarPressStart(rowRootOffset, rowSize) },
onAvatarPressEnd = onAvatarPressEnd,
onAvatarLongPress = { localOffset ->
onAvatarLongPress(rowRootOffset + localOffset, rowRootOffset, rowSize)
},
onBodyLongPress = onBodyLongPress,
)
pressScale = pressScaleAnim.value,
modifier = Modifier
.alpha(if (isHiddenForOverlay) 0f else 1f)
.fillMaxWidth()
.onGloballyPositioned { coords ->
rowRootOffset = coords.positionInRoot()
rowSize = coords.size
onRowPositioned(rowRootOffset, rowSize)
},
interactionModifier = Modifier.chatRowOpenAndSelectGestures(
selectionEnabled = listMode == ChatsListMode.Normal,
interactionSource = rowInteractionSource,
onOpen = onOpenPublic,
onEnterSelection = onBodyLongPress,
onMouseContextMenu = { localOffset ->
onMouseContextMenu(rowRootOffset + localOffset, rowRootOffset, rowSize)
},
),
) {
PublicChatRowContent(
publicChatTitle = publicChatTitle,
publicChatPreviewState = publicChatPreviewState,
defaultLastMessage = defaultLastMessage,
listMode = listMode,
selectionTransitionProgress = selectionTransitionProgress,
isSelected = isSelected,
listItemPosition = listItemPosition,
groupItemCount = groupItemCount,
avatarEnabled = listMode == ChatsListMode.Normal,
onOpenPublic = onOpenPublic,
onAvatarPressStart = { onAvatarPressStart(rowRootOffset, rowSize) },
onAvatarPressEnd = onAvatarPressEnd,
onAvatarLongPress = { localOffset ->
onAvatarLongPress(rowRootOffset + localOffset, rowRootOffset, rowSize)
},
onBodyLongPress = onBodyLongPress,
)
}
}
}
@@ -974,12 +1009,21 @@ internal fun DmConversationRow(
onBodyLongPress: () -> Unit,
onRowPositioned: (offset: Offset, size: IntSize) -> Unit,
avatarEnabled: Boolean = listMode == ChatsListMode.Normal,
attachmentDropEnabled: Boolean = false,
onAttachmentsDropped: (List<String>) -> Unit = {},
) {
var rowRootOffset by remember { mutableStateOf(Offset.Zero) }
var rowSize by remember { mutableStateOf(IntSize.Zero) }
val pressScaleAnim = remember { Animatable(1f) }
val contextMenuScale = chatRowContextMenuScale(rowRevealProgress, contextMenuPressScaleActive)
val rowInteractionSource = remember { MutableInteractionSource() }
val dropBridge = rememberAttachmentDropBridge()
val clipShape = listItemClipShape(listItemPosition, groupItemCount)
DisposableEffect(dropBridge, onAttachmentsDropped) {
dropBridge.consumer = onAttachmentsDropped
onDispose { dropBridge.consumer = null }
}
LaunchedEffect(isPressingForContextMenu, isHiddenForOverlay, rowRevealProgress, contextMenuPressScaleActive) {
when {
@@ -990,46 +1034,57 @@ internal fun DmConversationRow(
}
}
ChatRowScaleContainer(
listItemPosition = listItemPosition,
groupItemCount = groupItemCount,
pressScale = pressScaleAnim.value,
AttachmentDropHighlightBox(
active = dropBridge.dropHighlightActive,
shape = clipShape,
modifier = Modifier
.alpha(if (isHiddenForOverlay) 0f else 1f)
.fillMaxWidth()
.onGloballyPositioned { coords ->
rowRootOffset = coords.positionInRoot()
rowSize = coords.size
onRowPositioned(rowRootOffset, rowSize)
},
interactionModifier = Modifier.chatRowOpenAndSelectGestures(
selectionEnabled = listMode == ChatsListMode.Normal,
interactionSource = rowInteractionSource,
onOpen = onOpenConversation,
onEnterSelection = onBodyLongPress,
onMouseContextMenu = { localOffset ->
onMouseContextMenu(rowRootOffset + localOffset, rowRootOffset, rowSize)
},
),
.chatAttachmentDropTarget(
enabled = attachmentDropEnabled,
bridge = dropBridge,
),
) {
DmConversationRowContent(
conversation = conversation,
defaultLastMessage = defaultLastMessage,
statusMap = statusMap,
listMode = listMode,
selectionTransitionProgress = selectionTransitionProgress,
isSelected = isSelected,
ChatRowScaleContainer(
listItemPosition = listItemPosition,
groupItemCount = groupItemCount,
avatarEnabled = avatarEnabled,
onOpenConversation = onOpenConversation,
onAvatarPressStart = { onAvatarPressStart(rowRootOffset, rowSize) },
onAvatarPressEnd = onAvatarPressEnd,
onAvatarLongPress = { localOffset ->
onAvatarLongPress(rowRootOffset + localOffset, rowRootOffset, rowSize)
},
onBodyLongPress = onBodyLongPress,
)
pressScale = pressScaleAnim.value,
modifier = Modifier
.alpha(if (isHiddenForOverlay) 0f else 1f)
.fillMaxWidth()
.onGloballyPositioned { coords ->
rowRootOffset = coords.positionInRoot()
rowSize = coords.size
onRowPositioned(rowRootOffset, rowSize)
},
interactionModifier = Modifier.chatRowOpenAndSelectGestures(
selectionEnabled = listMode == ChatsListMode.Normal,
interactionSource = rowInteractionSource,
onOpen = onOpenConversation,
onEnterSelection = onBodyLongPress,
onMouseContextMenu = { localOffset ->
onMouseContextMenu(rowRootOffset + localOffset, rowRootOffset, rowSize)
},
),
) {
DmConversationRowContent(
conversation = conversation,
defaultLastMessage = defaultLastMessage,
statusMap = statusMap,
listMode = listMode,
selectionTransitionProgress = selectionTransitionProgress,
isSelected = isSelected,
listItemPosition = listItemPosition,
groupItemCount = groupItemCount,
avatarEnabled = avatarEnabled,
onOpenConversation = onOpenConversation,
onAvatarPressStart = { onAvatarPressStart(rowRootOffset, rowSize) },
onAvatarPressEnd = onAvatarPressEnd,
onAvatarLongPress = { localOffset ->
onAvatarLongPress(rowRootOffset + localOffset, rowRootOffset, rowSize)
},
onBodyLongPress = onBodyLongPress,
)
}
}
}
@@ -139,10 +139,12 @@ import ru.fromchat.status_connecting
import ru.fromchat.status_updating
import ru.fromchat.account_suspended
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.main.LocalConversationListDetailActive
import ru.fromchat.ui.main.LocalDesktopChatsNavController
import ru.fromchat.ui.main.LocalMainChromeInsets
import ru.fromchat.ui.chat.panels.dm.navigateToDmChat
import ru.fromchat.ui.chat.panels.publicchat.navigateToPublicChat
import ru.fromchat.ui.chat.utils.PendingChatAttachmentDrops
import ru.fromchat.ui.components.BackHandler
import ru.fromchat.ui.components.BrandTitle
import ru.fromchat.ui.components.ConnectingEllipsis
@@ -338,9 +340,14 @@ private fun rememberSearchBarCollapseSnap(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun chatsTopAppBarColors(blurReveal: Float) = TopAppBarDefaults.topAppBarColors(
// Match AppPanel (`surfaceContainerLowest`) when unscrolled so the bar is not a separate stripe.
containerColor = MaterialTheme.colorScheme.surfaceContainerLowest
.copy(alpha = 1f - blurReveal.coerceIn(0f, 1f)),
// Two-pane list sits on AppPanel (`surfaceContainerLowest`); compact uses `surface`.
containerColor = (
if (LocalConversationListDetailActive.current) {
MaterialTheme.colorScheme.surfaceContainerLowest
} else {
MaterialTheme.colorScheme.surface
}
).copy(alpha = 1f - blurReveal.coerceIn(0f, 1f)),
scrolledContainerColor = Color.Transparent,
)
@@ -1001,6 +1008,19 @@ fun ChatsTab(
userId != 0 -> navController.navigateToDmChat(userId)
}
},
attachmentDropEnabled = !selectionMode && !suspensionState.isSuspended,
onDropAttachmentsOnPublic = { uris ->
if (uris.isEmpty() || suspensionState.isSuspended) return@ChatConversationsList
PendingChatAttachmentDrops.offer(PendingChatAttachmentDrops.publicKey(), uris)
navController.navigateToPublicChat()
},
onDropAttachmentsOnConversation = { userId, uris ->
if (userId == 0 || uris.isEmpty() || suspensionState.isSuspended) {
return@ChatConversationsList
}
PendingChatAttachmentDrops.offer(PendingChatAttachmentDrops.dmKey(userId), uris)
navController.navigateToDmChat(userId)
},
onAvatarContextMenuPressStart = { lazyIndex, target, userId, rowOffset, rowSize, position, groupCount ->
if (suspensionState.isSuspended) return@ChatConversationsList
avatarPressMark = TimeSource.Monotonic.markNow()
@@ -56,6 +56,7 @@ import ru.fromchat.settings_hub_profile_sub
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.components.Text
import ru.fromchat.ui.extraStatusBars
import ru.fromchat.ui.main.LocalConversationListDetailActive
import ru.fromchat.ui.main.LocalDesktopSettingsNavController
import ru.fromchat.ui.main.LocalMainChromeInsets
import ru.fromchat.ui.main.mainPagerBottomInset
@@ -89,10 +90,13 @@ fun SettingsTab() {
title = {
Text(stringResource(Res.string.settings), maxLines = 1, overflow = TextOverflow.Ellipsis)
},
// Match AppPanel / Chats unscrolled chrome — default `surface` reads as an
// elevated (scrolled) stripe over `surfaceContainerLowest`.
// Two-pane list sits on AppPanel (`surfaceContainerLowest`); compact uses `surface`.
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerLowest,
containerColor = if (LocalConversationListDetailActive.current) {
MaterialTheme.colorScheme.surfaceContainerLowest
} else {
MaterialTheme.colorScheme.surface
},
),
)
}
@@ -19,10 +19,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardOptions
@@ -112,7 +109,6 @@ import ru.fromchat.server_ip_hint
import ru.fromchat.server_ip_label
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.auth.AuthContentFrame
import ru.fromchat.ui.extraStatusBars
import ru.fromchat.ui.components.CtaShape
import ru.fromchat.ui.components.DisabledBringIntoViewSpec
import ru.fromchat.ui.components.ExpressiveIconFrame
@@ -581,14 +577,8 @@ fun ServerConfigScreen() {
}
if (preAuth) {
Box(
Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing.union(WindowInsets.extraStatusBars)),
) {
AuthContentFrame { _ ->
ServerConfigContent()
}
AuthContentFrame { _ ->
ServerConfigContent()
}
} else {
ServerConfigContent()
@@ -0,0 +1,25 @@
package ru.fromchat.ui.chat
import androidx.compose.runtime.Composable
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
@Composable
internal actual fun MessageContextMenuPopup(
onDismissRequest: () -> Unit,
positionProvider: PopupPositionProvider,
content: @Composable () -> Unit,
) {
Popup(
onDismissRequest = onDismissRequest,
popupPositionProvider = positionProvider,
properties = PopupProperties(
focusable = true,
dismissOnBackPress = true,
dismissOnClickOutside = true,
clippingEnabled = true,
),
content = content,
)
}
@@ -0,0 +1,18 @@
package ru.fromchat.ui.chat.utils
import androidx.compose.runtime.Composable
import androidx.compose.ui.draganddrop.DragAndDropEvent
actual class AttachmentDropPermissionsHost
@Composable
actual fun rememberAttachmentDropPermissionsHost(): AttachmentDropPermissionsHost =
AttachmentDropPermissionsHost()
actual fun acceptsAttachmentDrop(event: DragAndDropEvent): Boolean = false
actual fun handleAttachmentDrop(
host: AttachmentDropPermissionsHost,
event: DragAndDropEvent,
onUris: (List<String>) -> Unit,
): Boolean = false
@@ -0,0 +1,30 @@
package ru.fromchat.ui.chat
import androidx.compose.runtime.Composable
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
/**
* Desktop uses an in-window [Popup] (not a separate OS window) so opening the menu does not
* steal focus from the main frame. [PopupProperties.clippingEnabled] is false so the menu can
* extend past the window edge when placement allows it.
*/
@Composable
internal actual fun MessageContextMenuPopup(
onDismissRequest: () -> Unit,
positionProvider: PopupPositionProvider,
content: @Composable () -> Unit,
) {
Popup(
onDismissRequest = onDismissRequest,
popupPositionProvider = positionProvider,
properties = PopupProperties(
focusable = true,
dismissOnBackPress = true,
dismissOnClickOutside = true,
clippingEnabled = false,
),
content = content,
)
}
@@ -0,0 +1,63 @@
@file:OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
package ru.fromchat.ui.chat.utils
import androidx.compose.runtime.Composable
import androidx.compose.ui.draganddrop.DragAndDropEvent
import androidx.compose.ui.draganddrop.awtTransferable
import java.awt.datatransfer.DataFlavor
import java.io.File
import java.net.URI
actual class AttachmentDropPermissionsHost
@Composable
actual fun rememberAttachmentDropPermissionsHost(): AttachmentDropPermissionsHost =
AttachmentDropPermissionsHost()
actual fun acceptsAttachmentDrop(event: DragAndDropEvent): Boolean {
val transferable = event.awtTransferable
if (transferable.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) return true
return transferable.transferDataFlavors.any { flavor ->
val mime = flavor.mimeType.orEmpty()
mime.startsWith("image/") ||
mime == "application/octet-stream" ||
flavor == DataFlavor.stringFlavor
}
}
actual fun handleAttachmentDrop(
host: AttachmentDropPermissionsHost,
event: DragAndDropEvent,
onUris: (List<String>) -> Unit,
): Boolean {
val uris = extractAttachmentDropUris(event)
if (uris.isEmpty()) return false
onUris(uris)
return true
}
private fun extractAttachmentDropUris(event: DragAndDropEvent): List<String> {
val transferable = event.awtTransferable
if (transferable.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) {
val files = transferable.getTransferData(DataFlavor.javaFileListFlavor) as? List<*>
return files.orEmpty().mapNotNull { entry ->
(entry as? File)?.absolutePath?.takeIf { it.isNotBlank() }
}.distinct()
}
if (transferable.isDataFlavorSupported(DataFlavor.stringFlavor)) {
val text = (transferable.getTransferData(DataFlavor.stringFlavor) as? String)?.trim().orEmpty()
if (text.startsWith("file:")) {
val path = runCatching { URI(text).path }.getOrNull()?.takeIf { it.isNotBlank() }
?: text.removePrefix("file://")
return listOf(path)
}
if (text.startsWith("/") || looksLikeWindowsPath(text)) {
return listOf(text)
}
}
return emptyList()
}
private fun looksLikeWindowsPath(text: String): Boolean =
text.length > 2 && text[0].isLetter() && text[1] == ':'
+13
View File
@@ -0,0 +1,13 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7083b89563e7ce20943037b8cd2b8cc2/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/060bbb778a1f55ea705fdebd2ccfeab9/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/d09679dc60fe5aa05ef7d03efdefac20/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/ed4e3bf2f5e7c5d9aabc4cbd8acd555e/redirect
toolchainVendor=JETBRAINS
toolchainVersion=21