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