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"> <component name="ArtifactManager">
<artifact type="jar" name="shared"> <artifact type="jar" name="shared">
<output-path>$PROJECT_DIR$/utils/shared/build/libs</output-path> <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> </artifact>
</component> </component>
+3 -1
View File
@@ -1,6 +1,8 @@
<component name="ArtifactManager"> <component name="ArtifactManager">
<artifact type="jar" name="shared-jvm"> <artifact type="jar" name="shared-jvm">
<output-path>$PROJECT_DIR$/utils/shared/build/libs</output-path> <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> </artifact>
</component> </component>
-1
View File
@@ -6,7 +6,6 @@
<GradleProjectSettings> <GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" /> <option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" /> <option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="jbr-21" />
<option name="modules"> <option name="modules">
<set> <set>
<option value="$PROJECT_DIR$" /> <option value="$PROJECT_DIR$" />
+5
View File
@@ -1,5 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="CidrRootsConfiguration">
<excludeRoots>
<file path="$PROJECT_DIR$/app/android/keys" />
</excludeRoots>
</component>
<component name="ExternalStorageConfigurationManager" enabled="true" /> <component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" project-jdk-name="jbr-21" project-jdk-type="JavaSDK"> <component name="ProjectRootManager" version="2" languageLevel="JDK_17" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" /> <output url="file://$PROJECT_DIR$/build/classes" />
+7 -4
View File
@@ -19,7 +19,7 @@ FromChat is a 100% free and open-source messenger. This repository is the cross-
## 📊 Client Comparison ## 📊 Client Comparison
| Feature | Android | Desktop | Web | iOS | | Feature | Android | Desktop | Web | iOS |
| --- | --- | --- | --- | --- | |-----------------------------|---------|--------------------------|-----|-------------------|
| **Messaging and profiles** | ✅ | ✅ | ✅ | ✅ | | **Messaging and profiles** | ✅ | ✅ | ✅ | ✅ |
| **Voice/video calls** | ✅ | ✅ | ✅ | ✅ | | **Voice/video calls** | ✅ | ✅ | ✅ | ✅ |
| **Screen sharing** | ✅ | ❌ | ✅ | ❌ | | **Screen sharing** | ✅ | ❌ | ✅ | ❌ |
@@ -70,17 +70,20 @@ Desktop replaces the former Electron client in the Web repo. Run with `./gradlew
mkdir -p app/android/keys mkdir -p app/android/keys
# Quote passwords: characters like & * ? ! break unquoted shell args.
keytool -genkey -v -keystore app/android/keys/debug.jks \ keytool -genkey -v -keystore app/android/keys/debug.jks \
-keyalg RSA -keysize 2048 -validity 10000 \ -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" -dname "CN=Debug, O=FromChat, C=RU"
keytool -genkey -v -keystore app/android/keys/release.jks \ keytool -genkey -v -keystore app/android/keys/release.jks \
-keyalg RSA -keysize 2048 -validity 10000 \ -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" -dname "CN=Release, O=FromChat, C=RU"
cat > app/android/keystore.properties << 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 releaseStorePassword=$RELEASE_STORE_PASS
releaseKeyPassword=$RELEASE_KEY_PASS releaseKeyPassword=$RELEASE_KEY_PASS
debugStorePassword=$DEBUG_STORE_PASS debugStorePassword=$DEBUG_STORE_PASS
+8 -8
View File
@@ -19,7 +19,7 @@ FromChat — 100% бесплатный и открытый мессенджер.
## 📊 Сравнение клиентов ## 📊 Сравнение клиентов
| Возможность | Android | Desktop | Web | iOS | | Возможность | Android | Desktop | Web | iOS |
| --- | --- | --- | --- | --- | |------------------------------------|---------|--------------------------|-----|---------------------|
| **Обмен сообщениями и профили** | ✅ | ✅ | ✅ | ✅ | | **Обмен сообщениями и профили** | ✅ | ✅ | ✅ | ✅ |
| **Голосовые/видеозвонки** | ✅ | ✅ | ✅ | ✅ | | **Голосовые/видеозвонки** | ✅ | ✅ | ✅ | ✅ |
| **Демонстрация экрана** | ✅ | ❌ | ✅ | ❌ | | **Демонстрация экрана** | ✅ | ❌ | ✅ | ❌ |
@@ -63,24 +63,24 @@ Desktop заменяет бывший Electron-клиент в репозито
2. **Сгенерируйте ключи (Debug & Release):** 2. **Сгенерируйте ключи (Debug & Release):**
```bash ```bash
DEBUG_STORE_PASS=CHANGEME DEBUG_STORE_PASS="CHANGEME"
DEBUG_KEY_PASS=CHANGEME DEBUG_KEY_PASS="CHANGEME"
RELEASE_STORE_PASS=CHANGEME RELEASE_STORE_PASS="CHANGEME"
RELEASE_KEY_PASS=CHANGEME RELEASE_KEY_PASS="CHANGEME"
mkdir -p app/android/keys mkdir -p app/android/keys
keytool -genkey -v -keystore app/android/keys/debug.jks \ keytool -genkey -v -keystore app/android/keys/debug.jks \
-keyalg RSA -keysize 2048 -validity 10000 \ -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" -dname "CN=Debug, O=FromChat, C=RU"
keytool -genkey -v -keystore app/android/keys/release.jks \ keytool -genkey -v -keystore app/android/keys/release.jks \
-keyalg RSA -keysize 2048 -validity 10000 \ -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" -dname "CN=Release, O=FromChat, C=RU"
cat > app/android/keystore.properties << EOF cat > app/android/keys/keystore.properties << EOF
releaseStorePassword=$RELEASE_STORE_PASS releaseStorePassword=$RELEASE_STORE_PASS
releaseKeyPassword=$RELEASE_KEY_PASS releaseKeyPassword=$RELEASE_KEY_PASS
debugStorePassword=$DEBUG_STORE_PASS debugStorePassword=$DEBUG_STORE_PASS
+11 -5
View File
@@ -75,19 +75,25 @@ extensions.configure<ApplicationExtension> {
load(file("keys/keystore.properties").inputStream()) 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") { create("release") {
storeFile = file("keys/release.jks") storeFile = file("keys/release.jks")
keyAlias = "key0" keyAlias = "key0"
storePassword = keystoreProperties["releaseStorePassword"].toString() storePassword = keystoreProperties.password("releaseStorePassword")
keyPassword = keystoreProperties["releaseKeyPassword"].toString() keyPassword = keystoreProperties.password("releaseKeyPassword")
enableV3Signing = true enableV3Signing = true
} }
getByName("debug") { getByName("debug") {
storeFile = file("keys/debug.jks") storeFile = file("keys/debug.jks")
keyAlias = "debug" keyAlias = "key0"
storePassword = keystoreProperties["debugStorePassword"].toString() storePassword = keystoreProperties.password("debugStorePassword")
keyPassword = keystoreProperties["debugKeyPassword"].toString() keyPassword = keystoreProperties.password("debugKeyPassword")
enableV3Signing = true 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">Ничего не найдено</string>
<string name="search_not_found_message">Ничего не найдено. Попробуйте переформулировать запрос.</string> <string name="search_not_found_message">Ничего не найдено. Попробуйте переформулировать запрос.</string>
<string name="message_placeholder">Напишите сообщение…</string> <string name="message_placeholder">Напишите сообщение…</string>
<string name="chat_drop_attachment_hint">Отпустите файл здесь</string>
<string name="status_connecting">Соединение</string> <string name="status_connecting">Соединение</string>
<string name="status_connected">Подключено</string> <string name="status_connected">Подключено</string>
<string name="status_disconnected">Отключено</string> <string name="status_disconnected">Отключено</string>
@@ -120,6 +120,7 @@
<string name="search_not_found">No results</string> <string name="search_not_found">No results</string>
<string name="search_not_found_message">Nothing found here. Try rephrasing your query.</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="message_placeholder">Write a message…</string>
<string name="chat_drop_attachment_hint">Drop file here</string>
<!-- Connection / chat chrome --> <!-- Connection / chat chrome -->
<string name="status_connecting">Connecting</string> <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.GetUpdatesRequest
import ru.fromchat.api.schema.websocket.requests.GetUpdatesResponse import ru.fromchat.api.schema.websocket.requests.GetUpdatesResponse
import kotlin.concurrent.Volatile import kotlin.concurrent.Volatile
import kotlin.coroutines.cancellation.CancellationException
/** /**
* Per-device update cursor: advance + ack only after batches are applied successfully. * Per-device update cursor: advance + ack only after batches are applied successfully.
@@ -51,9 +52,10 @@ object UpdateSyncManager {
val userId = currentUserId ?: return val userId = currentUserId ?: return
val key = KEY_UPDATES_LAST_SEQ_PREFIX + userId val key = KEY_UPDATES_LAST_SEQ_PREFIX + userId
val stored = runCatching { settings.getInt(key, 0) }.getOrDefault(0) val stored = runCatching { settings.getInt(key, 0) }.getOrDefault(0)
Logger.d("UpdateSyncManager", "Loaded lastSeq=$stored for userId=$userId") val next = maxOf(_lastSeq.value, stored)
_lastSeq.value = stored Logger.d("UpdateSyncManager", "Loaded lastSeq=$stored for userId=$userId (inMemory=${_lastSeq.value}$next)")
ConnectionStateStore.updateSeqAndMissed(lastSeq = stored, missedCount = null) _lastSeq.value = next
ConnectionStateStore.updateSeqAndMissed(lastSeq = next, missedCount = _lastMissedCount.value)
} }
/** /**
@@ -122,6 +124,7 @@ object UpdateSyncManager {
ConnectionStateStore.onUpdating(start = true) ConnectionStateStore.onUpdating(start = true)
try { try {
initializeFromStorage(ApiClient.user?.id)
var rounds = 0 var rounds = 0
var consecutiveFailures = 0 var consecutiveFailures = 0
while (rounds < 100) { while (rounds < 100) {
@@ -160,6 +163,16 @@ object UpdateSyncManager {
when (response.status) { when (response.status) {
"tooLong" -> { "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() val ok = rebuildStateFromHistory()
if (ok) { if (ok) {
persistLastSeq(response.lastSeq) persistLastSeq(response.lastSeq)
@@ -207,6 +220,8 @@ object UpdateSyncManager {
} }
} }
} }
} catch (t: CancellationException) {
throw t
} catch (t: Throwable) { } catch (t: Throwable) {
Logger.w("UpdateSyncManager", "Gap detection failed: ${t.message}", t) Logger.w("UpdateSyncManager", "Gap detection failed: ${t.message}", t)
} finally { } 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.dm.navigateToDmChat
import ru.fromchat.ui.chat.panels.publicchat.PublicChatNav import ru.fromchat.ui.chat.panels.publicchat.PublicChatNav
import ru.fromchat.ui.chat.panels.publicchat.navigateToPublicChat 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.ConversationListDetailShell
import ru.fromchat.ui.main.DesktopChatsDetailNavHost import ru.fromchat.ui.main.DesktopChatsDetailNavHost
import ru.fromchat.ui.main.DesktopContactsDetailNavHost import ru.fromchat.ui.main.DesktopContactsDetailNavHost
@@ -242,6 +243,7 @@ fun App(
runCatching { ensureFromChatCacheGeneration() } runCatching { ensureFromChatCacheGeneration() }
runCatching { NetworkConnectivity.ensureStarted() } runCatching { NetworkConnectivity.ensureStarted() }
runCatching { ApiClient.loadPersistedData() } runCatching { ApiClient.loadPersistedData() }
runCatching { UpdateSyncManager.initializeFromStorage(ApiClient.user?.id) }
Logger.i("App", "FromChat started") Logger.i("App", "FromChat started")
} }
@@ -265,10 +267,6 @@ fun App(
} }
onContentReady() onContentReady()
runCatching {
UpdateSyncManager.initializeFromStorage(ApiClient.user?.id)
}
DeferredStartupNetwork.scheduleAfterUiVisible() DeferredStartupNetwork.scheduleAfterUiVisible()
if (!hasToken) return@LaunchedEffect if (!hasToken) return@LaunchedEffect
@@ -525,7 +523,11 @@ fun App(
} }
ScreenSurface { ScreenSurface {
Box(Modifier.fillMaxSize()) { Box(
Modifier
.fillMaxSize()
.appRootAttachmentDropTarget(),
) {
@Composable @Composable
fun AppNavHost(modifier: Modifier = Modifier) { fun AppNavHost(modifier: Modifier = Modifier) {
NavHost( NavHost(
@@ -3,10 +3,14 @@ package ru.fromchat.ui.auth
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding 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.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
@@ -30,6 +34,7 @@ import ru.fromchat.logs_title
import ru.fromchat.more import ru.fromchat.more
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import ru.fromchat.ui.extraStatusBars
import ru.fromchat.ui.main.settings.SettingsRoutes import ru.fromchat.ui.main.settings.SettingsRoutes
/** Margin between the window edge and the wide [AuthContentFrame] panel. */ /** 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). * 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 @Composable
fun AuthContentFrame( fun AuthContentFrame(
@@ -47,6 +53,11 @@ fun AuthContentFrame(
BoxWithConstraints(modifier.fillMaxSize()) { BoxWithConstraints(modifier.fillMaxSize()) {
val widePanel = maxWidth > 600.dp val widePanel = maxWidth > 600.dp
if (widePanel) { if (widePanel) {
Box(
Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing.union(WindowInsets.extraStatusBars)),
) {
Box( Box(
Modifier Modifier
.align(Alignment.Center) .align(Alignment.Center)
@@ -56,6 +67,7 @@ fun AuthContentFrame(
) { ) {
content(true) content(true)
} }
}
} else { } else {
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
content(false) content(false)
@@ -1,12 +1,7 @@
package ru.fromchat.ui.auth package ru.fromchat.ui.auth
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding 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.Icons
import androidx.compose.material.icons.filled.Storage import androidx.compose.material.icons.filled.Storage
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -442,13 +437,7 @@ fun AuthScreen(
} }
val yandexStep = yandexParams val yandexStep = yandexParams
// Clear title bar / system bars outside the rounded panel (same as WelcomeScreen). AuthContentFrame { widePanel ->
Box(
Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing.union(WindowInsets.extraStatusBars)),
) {
AuthContentFrame { _ ->
ExpressiveStepFlowScaffold( ExpressiveStepFlowScaffold(
flowState = flowState, flowState = flowState,
pages = listOf( pages = listOf(
@@ -561,8 +550,12 @@ fun AuthScreen(
onBackAtFirstPage = wrappedBackToWelcome, onBackAtFirstPage = wrappedBackToWelcome,
trailingTopBarActions = { PreAuthOverflowMenu() }, trailingTopBarActions = { PreAuthOverflowMenu() },
contentBackground = MaterialTheme.colorScheme.background, contentBackground = MaterialTheme.colorScheme.background,
topBarWindowInsets = WindowInsets(0, 0, 0, 0), // 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.AnimatedVisibility
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.animateContentSize 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.animateFloatAsState
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
@@ -53,17 +56,25 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.BlurredEdgeTreatment
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor 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.graphics.vector.ImageVector
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType 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.onPreviewKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.layout
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
@@ -89,6 +101,7 @@ import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.api.schema.messages.Message import ru.fromchat.api.schema.messages.Message
import ru.fromchat.chat_drop_attachment_hint
import ru.fromchat.cd_close import ru.fromchat.cd_close
import ru.fromchat.cd_emoji import ru.fromchat.cd_emoji
import ru.fromchat.cd_pick_file import ru.fromchat.cd_pick_file
@@ -104,10 +117,16 @@ import ru.fromchat.message_placeholder
import ru.fromchat.message_replying_to import ru.fromchat.message_replying_to
import ru.fromchat.suspend_chat_banner_message import ru.fromchat.suspend_chat_banner_message
import ru.fromchat.ui.chat.utils.SelectedAttachment 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.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.getFilenameFromUri
import ru.fromchat.ui.chat.utils.rememberFilePicker import ru.fromchat.ui.chat.utils.rememberFilePicker
import ru.fromchat.ui.chat.utils.rememberImagePicker import ru.fromchat.ui.chat.utils.rememberImagePicker
import ru.fromchat.ui.chat.utils.urisToSelectedAttachments
import kotlin.math.roundToInt
import kotlin.time.Clock import kotlin.time.Clock
private val ChatInputChromeHeight = 54.dp private val ChatInputChromeHeight = 54.dp
@@ -118,6 +137,23 @@ private val ChatInputIconSlotSize = 36.dp
private val ChatInputIconSlotVerticalInset = private val ChatInputIconSlotVerticalInset =
(ChatInputChromeHeight - ChatInputIconSlotSize) / 2f (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 @Composable
private fun <T> AnimatedPreviewBar( private fun <T> AnimatedPreviewBar(
state: T?, state: T?,
@@ -194,12 +230,29 @@ private fun PreviewBar(
@Composable @Composable
private fun AttachmentChip( private fun AttachmentChip(
attachment: SelectedAttachment, attachment: SelectedAttachment,
onRemoveStart: () -> Unit,
onRemove: () -> Unit, onRemove: () -> Unit,
removeContentDescription: String, removeContentDescription: String,
modifier: Modifier = Modifier, 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( Row(
modifier = modifier modifier = Modifier
.scaleFadeLayout(scale.value)
.then(modifier)
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant) .background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 8.dp, vertical = 6.dp), .padding(horizontal = 8.dp, vertical = 6.dp),
@@ -231,7 +284,15 @@ private fun AttachmentChip(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.widthIn(max = 120.dp), 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( Icon(
imageVector = Icons.Rounded.Close, imageVector = Icons.Rounded.Close,
contentDescription = removeContentDescription, contentDescription = removeContentDescription,
@@ -259,11 +320,36 @@ fun ChatInput(
onReadOnlyMessageClick: () -> Unit = {}, onReadOnlyMessageClick: () -> Unit = {},
snackbarHostState: SnackbarHostState? = null, snackbarHostState: SnackbarHostState? = null,
hazeBlurEnabled: Boolean = true, hazeBlurEnabled: Boolean = true,
attachmentDropBridge: AttachmentDropBridge,
pendingDropUris: List<String> = emptyList(),
) { ) {
val composerHazeStyle = rememberChatSurfaceContainerHazeStyle() 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() val scope = rememberCoroutineScope()
var typingJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) } var typingJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
var attachments by remember { mutableStateOf<List<SelectedAttachment>>(emptyList()) } 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 -> val launchImagePicker = rememberImagePicker { uris ->
attachments = attachments + uris.map { uri -> 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) { LaunchedEffect(text) {
if (text.isNotBlank()) { if (text.isNotBlank()) {
typingJob?.cancel() 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 enterToSend = Settings.enterToSend
val cdClose = stringResource(Res.string.cd_close) val cdClose = stringResource(Res.string.cd_close)
val cdRemove = stringResource(Res.string.cd_remove) val cdRemove = stringResource(Res.string.cd_remove)
@@ -313,14 +422,16 @@ fun ChatInput(
val corruptedShort = stringResource(Res.string.message_corrupted_short) val corruptedShort = stringResource(Res.string.message_corrupted_short)
val editingTitle = stringResource(Res.string.message_editing_title) val editingTitle = stringResource(Res.string.message_editing_title)
val blockedMessage = stringResource(Res.string.suspend_chat_banner_message) val blockedMessage = stringResource(Res.string.suspend_chat_banner_message)
val dropAttachmentHint = stringResource(Res.string.chat_drop_attachment_hint)
val cdVoiceUnavailable = "Функция пока не готова. Следите за обновлениями!" val cdVoiceUnavailable = "Функция пока не готова. Следите за обновлениями!"
fun sendMessage() { fun sendMessage() {
if (!canSend) return if (!canSend) return
val plaintext = text.trim().ifBlank { "" } val plaintext = text.trim().ifBlank { "" }
onSend(plaintext, attachments) onSend(plaintext, attachments.filter { it.id !in exitingAttachmentIds })
onTextChange("") onTextChange("")
attachments = emptyList() attachments = emptyList()
exitingAttachmentIds = emptySet()
typingHandler.stopTyping() typingHandler.stopTyping()
} }
@@ -360,7 +471,12 @@ fun ChatInput(
} }
} else { } else {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.chatAttachmentDropTarget(
enabled = attachmentDropEnabled,
bridge = attachmentDropBridge,
),
verticalAlignment = Alignment.Bottom, verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
@@ -370,17 +486,28 @@ fun ChatInput(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
verticalAlignment = Alignment.Bottom, verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.graphicsLayer {
scaleX = dropScale
scaleY = dropScale
}
.clip(pillShape),
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainer, pillShape) .background(MaterialTheme.colorScheme.surfaceContainer, pillShape)
.clip(pillShape)
.hazeEffect(state = hazeState) { .hazeEffect(state = hazeState) {
blurEffect { blurEffect {
blurEnabled = hazeBlurEnabled blurEnabled = hazeBlurEnabled
style = composerHazeStyle style = composerHazeStyle
} }
}
.conditional(dropBlurRadius > 0.dp) {
blur(dropBlurRadius, BlurredEdgeTreatment.Unbounded)
}, },
) { ) {
AnimatedPreviewBar(replyTo) { reply -> AnimatedPreviewBar(replyTo) { reply ->
@@ -414,27 +541,36 @@ fun ChatInput(
) )
} }
AnimatedVisibility( if (attachments.isNotEmpty()) {
visible = attachments.isNotEmpty(),
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.horizontalScroll(rememberScrollState()) .horizontalScroll(rememberScrollState())
.padding(horizontal = 10.dp, vertical = 4.dp), .padding(start = 10.dp, end = 2.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically,
) { ) {
attachments.forEach { attachment -> attachments.forEach { attachment ->
key(attachment.id) {
AttachmentChip( AttachmentChip(
attachment = attachment, attachment = attachment,
onRemove = { attachments = attachments.filter { it.id != attachment.id } }, onRemoveStart = {
exitingAttachmentIds = exitingAttachmentIds + attachment.id
},
onRemove = {
attachments = attachments.filter { it.id != attachment.id }
exitingAttachmentIds = exitingAttachmentIds - attachment.id
},
removeContentDescription = cdRemove, removeContentDescription = cdRemove,
modifier = Modifier.padding(
end = 8.dp,
top = 4.dp,
bottom = 4.dp,
),
) )
} }
} }
} }
}
Row( Row(
modifier = Modifier modifier = Modifier
@@ -563,6 +699,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),
)
}
}
} }
// Fixed slot next to the pill; width must stay non-zero (see Row + fillMaxWidth pitfall above). // Fixed slot next to the pill; width must stay non-zero (see Row + fillMaxWidth pitfall above).
@@ -147,9 +147,12 @@ import ru.fromchat.status_connecting
import ru.fromchat.status_updating import ru.fromchat.status_updating
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.chat.utils.AttachmentDownloadVisibility 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.getImageAspectRatio
import ru.fromchat.ui.chat.utils.getImageDimensions import ru.fromchat.ui.chat.utils.getImageDimensions
import ru.fromchat.ui.chat.utils.imageAttachmentKey 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.chat.utils.visibleMessageIdsInChatList
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import ru.fromchat.ui.components.SuspendedAccountSupportSheet import ru.fromchat.ui.components.SuspendedAccountSupportSheet
@@ -443,6 +446,13 @@ fun ChatScreen(
val online by NetworkConnectivity.isOnline.collectAsState(initial = true) val online by NetworkConnectivity.isOnline.collectAsState(initial = true)
val suspensionState by ApiClient.suspensionState.collectAsState() val suspensionState by ApiClient.suspensionState.collectAsState()
val isReadOnly = suspensionState.isSuspended 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() val dmRecipientId = panel.getRecipientId()
var peerDeleted by remember(dmRecipientId) { mutableStateOf(false) } var peerDeleted by remember(dmRecipientId) { mutableStateOf(false) }
val deleteChatLabel = stringResource(Res.string.action_delete_chat) val deleteChatLabel = stringResource(Res.string.action_delete_chat)
@@ -875,6 +885,8 @@ fun ChatScreen(
text = inputText, text = inputText,
onTextChange = { inputText = it }, onTextChange = { inputText = it },
currentUserId = currentUserId, currentUserId = currentUserId,
attachmentDropBridge = attachmentDropBridge,
pendingDropUris = pendingDropUris,
onSend = { text, attachments -> onSend = { text, attachments ->
if (editingMessage != null) { if (editingMessage != null) {
scope.launch { scope.launch {
@@ -1086,6 +1098,10 @@ fun ChatScreen(
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.chatAttachmentDropTarget(
enabled = attachmentDropEnabled,
bridge = attachmentDropBridge,
)
.then( .then(
if (contextMenuState.isOpen) { if (contextMenuState.isOpen) {
Modifier.pointerInput(Unit) { Modifier.pointerInput(Unit) {
@@ -411,10 +411,6 @@ fun ChatTopBar(
blurEffect { blurEffect {
blurEnabled = hazeBlurEnabled blurEnabled = hazeBlurEnabled
style = hazeStyle style = hazeStyle
progressive = HazeProgressive.verticalGradient(
startIntensity = 1f,
endIntensity = 0f,
)
} }
}, },
) )
@@ -453,10 +449,10 @@ private fun ChatTopBarPill(
val pillShape = RoundedCornerShape(28.dp) val pillShape = RoundedCornerShape(28.dp)
Box(modifier = modifier.fillMaxWidth()) { Box(modifier = modifier.fillMaxWidth()) {
// Progressive blur only — not part of the pill chrome.
Box( Box(
modifier = Modifier modifier = Modifier
.matchParentSize() .matchParentSize()
.background(chromeColor)
.hazeEffect(state = hazeState) { .hazeEffect(state = hazeState) {
blurEffect { blurEffect {
blurEnabled = hazeBlurEnabled blurEnabled = hazeBlurEnabled
@@ -53,9 +53,7 @@ import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.action_cancel_send import ru.fromchat.action_cancel_send
@@ -72,6 +70,14 @@ import ru.fromchat.api.schema.messages.Message
import ru.fromchat.supportsMouseMessageInteraction import ru.fromchat.supportsMouseMessageInteraction
import ru.fromchat.ui.components.Text 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( data class ContextMenuState(
val isOpen: Boolean = false, val isOpen: Boolean = false,
val message: Message? = null, val message: Message? = null,
@@ -102,8 +108,8 @@ internal fun messageContextMenuFingerprint(
} }
private val contextMenuEnterSpec = spring<Float>( private val contextMenuEnterSpec = spring<Float>(
dampingRatio = Spring.DampingRatioNoBouncy, dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessMedium, stiffness = Spring.StiffnessMediumLow,
) )
private fun clampContextMenuOffset( private fun clampContextMenuOffset(
@@ -226,20 +232,17 @@ fun MessageContextMenu(
} }
} }
val scale = 0.8f + 0.2f * animationProgress.floatValue // Match list-item menus (0.5→1) so spring overshoot past 1 is visible.
val alpha = animationProgress.floatValue val scale = 0.5f + 0.5f * animationProgress.floatValue
val alpha = animationProgress.floatValue.coerceIn(0f, 1f)
// [state.position] is window coordinates (see MessageItem localToWindow). // [state.position] is window coordinates (see MessageItem localToWindow).
// Alignment+offset would add the popup parents window origin again (double offset // Alignment+offset would add the popup parents window origin again (double offset
// in listdetail panes). Place with an absolute provider instead. // 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, onDismissRequest = onDismiss,
popupPositionProvider = positionProvider, positionProvider = positionProvider,
properties = PopupProperties(
dismissOnBackPress = true,
dismissOnClickOutside = true,
clippingEnabled = false,
),
) { ) {
ContextMenuContent( ContextMenuContent(
message = state.message, message = state.message,
@@ -72,6 +72,7 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.util.fastAll import androidx.compose.ui.util.fastAll
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Logger import ru.fromchat.Logger
@@ -246,24 +247,6 @@ fun MessageItem(
val isCorrupted = remember(message.files, message.fileThumbnails, message.dmEnvelope) { val isCorrupted = remember(message.files, message.fileThumbnails, message.dmEnvelope) {
isMessageCorrupted(message) 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) { val formattedTime = remember(message.timestamp) {
formatMessageTimeLocal(message.timestamp) formatMessageTimeLocal(message.timestamp)
} }
@@ -289,13 +272,16 @@ fun MessageItem(
var rowLayoutCoords by remember(message.id) { mutableStateOf<LayoutCoordinates?>(null) } var rowLayoutCoords by remember(message.id) { mutableStateOf<LayoutCoordinates?>(null) }
var bubbleContentCoords by remember(message.id) { mutableStateOf<LayoutCoordinates?>(null) } var bubbleContentCoords by remember(message.id) { mutableStateOf<LayoutCoordinates?>(null) }
LaunchedEffect(isContextMenuForThisMessage) { LaunchedEffect(isContextMenuForThisMessage) {
if (!isContextMenuForThisMessage) isPressed = false 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 = when {
isContextMenuForThisMessage -> 0.96f
isPressed && !isContextMenuOpen -> 0.96f
else -> 1f
} }
val scaleTarget = if (isPressed) 0.96f else 1f
val avatarScaleTarget = if (avatarPressed && !isContextMenuOpen) 0.96f else 1f val avatarScaleTarget = if (avatarPressed && !isContextMenuOpen) 0.96f else 1f
val replyScaleTarget = if (replyPressed && !isContextMenuOpen) 0.96f else 1f val replyScaleTarget = if (replyPressed && !isContextMenuOpen) 0.96f else 1f
val scale by animateFloatAsState( val scale by animateFloatAsState(
@@ -371,22 +357,17 @@ fun MessageItem(
"enterFinished=$enterFinished scale=${enterScale.value}", "enterFinished=$enterFinished scale=${enterScale.value}",
) )
} }
LaunchedEffect(enterIdentity, isNewEnterRole, primaryIsImageMessage) { LaunchedEffect(enterIdentity, isNewEnterRole) {
if (isNewEnterRole && primaryIsImageMessage) { if (isNewEnterRole && !enterStarted) {
enterStarted = true
enterFinished = true
enterScale.snapTo(1f)
} else if (isNewEnterRole && !enterStarted) {
enterStarted = true enterStarted = true
enterFinished = false enterFinished = false
enterScale.snapTo(0f) enterScale.snapTo(0f)
} }
} }
val runEnterAnimation = val runEnterAnimation = enterStarted && !enterFinished && !isExiting
enterStarted && !enterFinished && !isExiting && !primaryIsImageMessage
// Single effect: start the spring as soon as this bubble is marked for enter. // Single effect: start the spring as soon as this bubble is marked for enter.
LaunchedEffect(enterIdentity, runEnterAnimation, primaryIsImageMessage) { LaunchedEffect(enterIdentity, runEnterAnimation) {
if (primaryIsImageMessage || !runEnterAnimation) return@LaunchedEffect if (!runEnterAnimation) return@LaunchedEffect
Logger.d( Logger.d(
"EnterAnim", "EnterAnim",
"spring_start identity=${enterIdentity.take(12)} " + "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 * Opens a DM chat, replacing any prior conversation/profile above the main `chat` root
* so large-screen switches do not stack previous chats. * 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( fun NavController.navigateToDmChat(
otherUserId: Int, otherUserId: Int,
sourceMessageId: Int? = null, sourceMessageId: Int? = null,
builder: NavOptionsBuilder.() -> Unit = {}, builder: NavOptionsBuilder.() -> Unit = {},
) { ) {
if (isCurrentDmChat(otherUserId)) return
navigate(DmNav.chatRoute(otherUserId, sourceMessageId)) { navigate(DmNav.chatRoute(otherUserId, sourceMessageId)) {
popUpTo(graph.startDestinationId) { saveState = true } popUpTo(graph.startDestinationId) { saveState = true }
launchSingleTop = 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-" private const val DM_AVATAR_KEY_PREFIX = "dm-avatar-"
@Composable @Composable
@@ -26,10 +26,15 @@ object PublicChatNav {
/** /**
* Opens the public chat, replacing any prior conversation/profile above the main `chat` root. * 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( fun NavController.navigateToPublicChat(
builder: NavOptionsBuilder.() -> Unit = {}, builder: NavOptionsBuilder.() -> Unit = {},
) { ) {
if (currentBackStackEntry?.destination?.route == PublicChatNav.CHAT_ROUTE) return
navigate(PublicChatNav.CHAT_ROUTE) { navigate(PublicChatNav.CHAT_ROUTE) {
popUpTo(graph.startDestinationId) { saveState = true } popUpTo(graph.startDestinationId) { saveState = true }
launchSingleTop = 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.Stable
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.composed import androidx.compose.ui.composed
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
@@ -141,6 +141,27 @@ private fun effectiveImeBottomPx(
return (reportedImeBottomPx * (1f - progress)).roundToInt() 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. */ /** Scrolls the list when the IME would cover the focused field; skips if already fully visible. */
@Composable @Composable
fun LazyListImeScrollEffect( 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( LaunchedEffect(
imeMotion.currentBottomPx, imeMotion.currentBottomPx,
imeMotion.sourceBottomPx, imeMotion.sourceBottomPx,
imeMotion.targetBottomPx, imeMotion.targetBottomPx,
predictiveBackProgress(), predictiveBackProgress(),
scrollState.focusedItemIndex, scrollState.focusedItemIndex,
scrollState.focusedBoundsInWindow,
viewportBoundsInWindow, viewportBoundsInWindow,
contentPaddingTop, contentPaddingTop,
contentPaddingBottom, contentPaddingBottom,
@@ -208,12 +230,18 @@ fun LazyListImeScrollEffect(
val reportedImeBottomPx = imeMotion.currentBottomPx val reportedImeBottomPx = imeMotion.currentBottomPx
val previousReported = previousReportedImeBottom.intValue val previousReported = previousReportedImeBottom.intValue
val progress = currentPredictiveProgress.value().coerceIn(0f, 1f)
val phase = imeMotion.keyboardPhase( val phase = imeMotion.keyboardPhase(
settledImeBottomPx = settledImeBottom.intValue, settledImeBottomPx = settledImeBottom.intValue,
previousReportedImeBottomPx = previousReported, 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 settledImeBottom.intValue = reportedImeBottomPx
} }
@@ -225,7 +253,6 @@ fun LazyListImeScrollEffect(
else -> Unit else -> Unit
} }
val progress = currentPredictiveProgress.value().coerceIn(0f, 1f)
val followImeBottom = if (progress > 0f) { val followImeBottom = if (progress > 0f) {
effectiveImeBottomPx(reportedImeBottomPx, progress) effectiveImeBottomPx(reportedImeBottomPx, progress)
} else { } else {
@@ -260,9 +287,27 @@ fun LazyListImeScrollEffect(
wasFollowingKeyboardDismiss.value = true wasFollowingKeyboardDismiss.value = true
previousFollowImeBottom.intValue = followImeBottom previousFollowImeBottom.intValue = followImeBottom
previousReportedImeBottom.intValue = reportedImeBottomPx 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 wasFollowingKeyboardDismiss.value = false
dismissFollowKeyboardTopGap.value = null dismissFollowKeyboardTopGap.value = null
if (phase == ImeKeyboardPhase.Open) {
skipStableAnchorAfterReopen.value = true
}
if (!imeMotion.isAnimating) {
settledImeBottom.intValue = reportedImeBottomPx
}
} }
return@LaunchedEffect return@LaunchedEffect
} }
@@ -45,6 +45,7 @@ import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.ripple import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue 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.unit.dp
import androidx.compose.ui.util.fastAll import androidx.compose.ui.util.fastAll
import kotlinx.coroutines.withTimeout 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.SearchBar
import ru.fromchat.ui.components.SearchBarSharedElement import ru.fromchat.ui.components.SearchBarSharedElement
import com.pr0gramm3r101.components.Category import com.pr0gramm3r101.components.Category
@@ -313,6 +317,9 @@ internal fun ChatConversationsList(
) -> Unit, ) -> Unit,
onEnterSelectionMode: (lazyIndex: Int, target: ChatContextMenuTarget, userId: Int?) -> Unit, onEnterSelectionMode: (lazyIndex: Int, target: ChatContextMenuTarget, userId: Int?) -> Unit,
onRowPositioned: (lazyIndex: Int, offset: Offset, size: IntSize) -> 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 scrollBlocked = contextMenuState.isOverlayActive
val showPublicChat = listFilter == ChatListFilter.Active && !publicChatTitle.isNullOrBlank() val showPublicChat = listFilter == ChatListFilter.Active && !publicChatTitle.isNullOrBlank()
@@ -435,6 +442,9 @@ internal fun ChatConversationsList(
onRowPositioned = { offset, size -> onRowPositioned = { offset, size ->
onRowPositioned(ChatListLayout.PUBLIC_CHAT_ROW, offset, size) onRowPositioned(ChatListLayout.PUBLIC_CHAT_ROW, offset, size)
}, },
attachmentDropEnabled = attachmentDropEnabled &&
listMode == ChatsListMode.Normal,
onAttachmentsDropped = onDropAttachmentsOnPublic,
) )
} }
if (conversations.isNotEmpty()) { if (conversations.isNotEmpty()) {
@@ -514,6 +524,11 @@ internal fun ChatConversationsList(
onEnterSelectionMode(lazyIndex, ChatContextMenuTarget.Dm, conversation.otherUserId) onEnterSelectionMode(lazyIndex, ChatContextMenuTarget.Dm, conversation.otherUserId)
}, },
onRowPositioned = { offset, size -> onRowPositioned(lazyIndex, offset, size) }, onRowPositioned = { offset, size -> onRowPositioned(lazyIndex, offset, size) },
attachmentDropEnabled = attachmentDropEnabled &&
listMode == ChatsListMode.Normal,
onAttachmentsDropped = { uris ->
onDropAttachmentsOnConversation(conversation.otherUserId, uris)
},
) )
} }
if (index < conversations.lastIndex) { if (index < conversations.lastIndex) {
@@ -827,12 +842,21 @@ internal fun PublicChatRow(
onMouseContextMenu: (menuPosition: Offset, rowOffset: Offset, rowSize: IntSize) -> Unit, onMouseContextMenu: (menuPosition: Offset, rowOffset: Offset, rowSize: IntSize) -> Unit,
onBodyLongPress: () -> Unit, onBodyLongPress: () -> Unit,
onRowPositioned: (offset: Offset, size: IntSize) -> Unit, onRowPositioned: (offset: Offset, size: IntSize) -> Unit,
attachmentDropEnabled: Boolean = false,
onAttachmentsDropped: (List<String>) -> Unit = {},
) { ) {
var rowRootOffset by remember { mutableStateOf(Offset.Zero) } var rowRootOffset by remember { mutableStateOf(Offset.Zero) }
var rowSize by remember { mutableStateOf(IntSize.Zero) } var rowSize by remember { mutableStateOf(IntSize.Zero) }
val pressScaleAnim = remember { Animatable(1f) } val pressScaleAnim = remember { Animatable(1f) }
val contextMenuScale = chatRowContextMenuScale(rowRevealProgress, contextMenuPressScaleActive) val contextMenuScale = chatRowContextMenuScale(rowRevealProgress, contextMenuPressScaleActive)
val rowInteractionSource = remember { MutableInteractionSource() } 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) { LaunchedEffect(isPressingForContextMenu, isHiddenForOverlay, rowRevealProgress, contextMenuPressScaleActive) {
when { when {
@@ -843,6 +867,16 @@ internal fun PublicChatRow(
} }
} }
AttachmentDropHighlightBox(
active = dropBridge.dropHighlightActive,
shape = clipShape,
modifier = Modifier
.fillMaxWidth()
.chatAttachmentDropTarget(
enabled = attachmentDropEnabled,
bridge = dropBridge,
),
) {
ChatRowScaleContainer( ChatRowScaleContainer(
listItemPosition = listItemPosition, listItemPosition = listItemPosition,
groupItemCount = groupItemCount, groupItemCount = groupItemCount,
@@ -885,6 +919,7 @@ internal fun PublicChatRow(
) )
} }
} }
}
@Composable @Composable
internal fun PublicChatRowContent( internal fun PublicChatRowContent(
@@ -974,12 +1009,21 @@ internal fun DmConversationRow(
onBodyLongPress: () -> Unit, onBodyLongPress: () -> Unit,
onRowPositioned: (offset: Offset, size: IntSize) -> Unit, onRowPositioned: (offset: Offset, size: IntSize) -> Unit,
avatarEnabled: Boolean = listMode == ChatsListMode.Normal, avatarEnabled: Boolean = listMode == ChatsListMode.Normal,
attachmentDropEnabled: Boolean = false,
onAttachmentsDropped: (List<String>) -> Unit = {},
) { ) {
var rowRootOffset by remember { mutableStateOf(Offset.Zero) } var rowRootOffset by remember { mutableStateOf(Offset.Zero) }
var rowSize by remember { mutableStateOf(IntSize.Zero) } var rowSize by remember { mutableStateOf(IntSize.Zero) }
val pressScaleAnim = remember { Animatable(1f) } val pressScaleAnim = remember { Animatable(1f) }
val contextMenuScale = chatRowContextMenuScale(rowRevealProgress, contextMenuPressScaleActive) val contextMenuScale = chatRowContextMenuScale(rowRevealProgress, contextMenuPressScaleActive)
val rowInteractionSource = remember { MutableInteractionSource() } 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) { LaunchedEffect(isPressingForContextMenu, isHiddenForOverlay, rowRevealProgress, contextMenuPressScaleActive) {
when { when {
@@ -990,6 +1034,16 @@ internal fun DmConversationRow(
} }
} }
AttachmentDropHighlightBox(
active = dropBridge.dropHighlightActive,
shape = clipShape,
modifier = Modifier
.fillMaxWidth()
.chatAttachmentDropTarget(
enabled = attachmentDropEnabled,
bridge = dropBridge,
),
) {
ChatRowScaleContainer( ChatRowScaleContainer(
listItemPosition = listItemPosition, listItemPosition = listItemPosition,
groupItemCount = groupItemCount, groupItemCount = groupItemCount,
@@ -1032,6 +1086,7 @@ internal fun DmConversationRow(
) )
} }
} }
}
@Composable @Composable
internal fun DmConversationRowContent( internal fun DmConversationRowContent(
@@ -139,10 +139,12 @@ import ru.fromchat.status_connecting
import ru.fromchat.status_updating import ru.fromchat.status_updating
import ru.fromchat.account_suspended import ru.fromchat.account_suspended
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.main.LocalConversationListDetailActive
import ru.fromchat.ui.main.LocalDesktopChatsNavController import ru.fromchat.ui.main.LocalDesktopChatsNavController
import ru.fromchat.ui.main.LocalMainChromeInsets import ru.fromchat.ui.main.LocalMainChromeInsets
import ru.fromchat.ui.chat.panels.dm.navigateToDmChat import ru.fromchat.ui.chat.panels.dm.navigateToDmChat
import ru.fromchat.ui.chat.panels.publicchat.navigateToPublicChat 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.BackHandler
import ru.fromchat.ui.components.BrandTitle import ru.fromchat.ui.components.BrandTitle
import ru.fromchat.ui.components.ConnectingEllipsis import ru.fromchat.ui.components.ConnectingEllipsis
@@ -338,9 +340,14 @@ private fun rememberSearchBarCollapseSnap(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun chatsTopAppBarColors(blurReveal: Float) = TopAppBarDefaults.topAppBarColors( private fun chatsTopAppBarColors(blurReveal: Float) = TopAppBarDefaults.topAppBarColors(
// Match AppPanel (`surfaceContainerLowest`) when unscrolled so the bar is not a separate stripe. // Two-pane list sits on AppPanel (`surfaceContainerLowest`); compact uses `surface`.
containerColor = MaterialTheme.colorScheme.surfaceContainerLowest containerColor = (
.copy(alpha = 1f - blurReveal.coerceIn(0f, 1f)), if (LocalConversationListDetailActive.current) {
MaterialTheme.colorScheme.surfaceContainerLowest
} else {
MaterialTheme.colorScheme.surface
}
).copy(alpha = 1f - blurReveal.coerceIn(0f, 1f)),
scrolledContainerColor = Color.Transparent, scrolledContainerColor = Color.Transparent,
) )
@@ -1001,6 +1008,19 @@ fun ChatsTab(
userId != 0 -> navController.navigateToDmChat(userId) 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 -> onAvatarContextMenuPressStart = { lazyIndex, target, userId, rowOffset, rowSize, position, groupCount ->
if (suspensionState.isSuspended) return@ChatConversationsList if (suspensionState.isSuspended) return@ChatConversationsList
avatarPressMark = TimeSource.Monotonic.markNow() avatarPressMark = TimeSource.Monotonic.markNow()
@@ -56,6 +56,7 @@ import ru.fromchat.settings_hub_profile_sub
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import ru.fromchat.ui.extraStatusBars import ru.fromchat.ui.extraStatusBars
import ru.fromchat.ui.main.LocalConversationListDetailActive
import ru.fromchat.ui.main.LocalDesktopSettingsNavController import ru.fromchat.ui.main.LocalDesktopSettingsNavController
import ru.fromchat.ui.main.LocalMainChromeInsets import ru.fromchat.ui.main.LocalMainChromeInsets
import ru.fromchat.ui.main.mainPagerBottomInset import ru.fromchat.ui.main.mainPagerBottomInset
@@ -89,10 +90,13 @@ fun SettingsTab() {
title = { title = {
Text(stringResource(Res.string.settings), maxLines = 1, overflow = TextOverflow.Ellipsis) Text(stringResource(Res.string.settings), maxLines = 1, overflow = TextOverflow.Ellipsis)
}, },
// Match AppPanel / Chats unscrolled chrome — default `surface` reads as an // Two-pane list sits on AppPanel (`surfaceContainerLowest`); compact uses `surface`.
// elevated (scrolled) stripe over `surfaceContainerLowest`.
colors = TopAppBarDefaults.topAppBarColors( 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.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.statusBars 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.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardOptions 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.server_ip_label
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.auth.AuthContentFrame import ru.fromchat.ui.auth.AuthContentFrame
import ru.fromchat.ui.extraStatusBars
import ru.fromchat.ui.components.CtaShape import ru.fromchat.ui.components.CtaShape
import ru.fromchat.ui.components.DisabledBringIntoViewSpec import ru.fromchat.ui.components.DisabledBringIntoViewSpec
import ru.fromchat.ui.components.ExpressiveIconFrame import ru.fromchat.ui.components.ExpressiveIconFrame
@@ -581,15 +577,9 @@ fun ServerConfigScreen() {
} }
if (preAuth) { if (preAuth) {
Box(
Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing.union(WindowInsets.extraStatusBars)),
) {
AuthContentFrame { _ -> AuthContentFrame { _ ->
ServerConfigContent() ServerConfigContent()
} }
}
} else { } else {
ServerConfigContent() 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