mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
More desktop and mobile fixes
Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
Generated
+3
-1
@@ -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>
|
||||||
Generated
+3
-1
@@ -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>
|
||||||
Generated
-1
@@ -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$" />
|
||||||
|
|||||||
Generated
+5
@@ -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" />
|
||||||
|
|||||||
+12
-9
@@ -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,22 +70,25 @@ 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).
|
||||||
releaseStorePassword=$RELEASE_STORE_PASS
|
# Outer "…" / '…' around values are optional; Gradle strips them.
|
||||||
releaseKeyPassword=$RELEASE_KEY_PASS
|
cat > app/android/keys/keystore.properties << EOF
|
||||||
debugStorePassword=$DEBUG_STORE_PASS
|
releaseStorePassword=$RELEASE_STORE_PASS
|
||||||
debugKeyPassword=$DEBUG_KEY_PASS
|
releaseKeyPassword=$RELEASE_KEY_PASS
|
||||||
EOF
|
debugStorePassword=$DEBUG_STORE_PASS
|
||||||
|
debugKeyPassword=$DEBUG_KEY_PASS
|
||||||
|
EOF
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Open in Android Studio:** `File → Open` → repo root. Gradle syncs dependencies automatically.
|
3. **Open in Android Studio:** `File → Open` → repo root. Gradle syncs dependencies automatically.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+25
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
+132
@@ -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 parent’s window origin again (double offset
|
// Alignment+offset would add the popup parent’s window origin again (double offset
|
||||||
// in list–detail panes). Place with an absolute provider instead.
|
// in list–detail 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 list–detail).
|
||||||
*/
|
*/
|
||||||
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
|
||||||
|
|||||||
+5
@@ -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 list–detail).
|
||||||
*/
|
*/
|
||||||
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,
|
||||||
@@ -884,6 +918,7 @@ internal fun PublicChatRow(
|
|||||||
onBodyLongPress = onBodyLongPress,
|
onBodyLongPress = onBodyLongPress,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -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,
|
||||||
@@ -1031,6 +1085,7 @@ internal fun DmConversationRow(
|
|||||||
onBodyLongPress = onBodyLongPress,
|
onBodyLongPress = onBodyLongPress,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -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
|
||||||
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
-10
@@ -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] == ':'
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user