diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/AttachmentPickers.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/AttachmentPickers.android.kt index 5f7ac54..7d990ad 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/AttachmentPickers.android.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/ui/chat/AttachmentPickers.android.kt @@ -1,5 +1,6 @@ package ru.fromchat.ui.chat +import android.graphics.BitmapFactory import android.net.Uri import android.provider.OpenableColumns import androidx.activity.compose.rememberLauncherForActivityResult @@ -19,6 +20,18 @@ actual fun getFilenameFromUri(uri: String): String { return uri.substringAfterLast('/').takeIf { it.isNotBlank() } ?: "file" } +actual suspend fun getImageAspectRatio(uri: String): Float? { + val context = com.pr0gramm3r101.utils.UtilsLibrary.context + context.contentResolver.openInputStream(Uri.parse(uri))?.use { stream -> + val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeStream(stream, null, options) + val w = options.outWidth + val h = options.outHeight + if (w > 0 && h > 0) return w.toFloat() / h + } + return null +} + @Composable actual fun rememberImagePicker(onResult: (List) -> Unit): () -> Unit { val launcher = rememberLauncherForActivityResult( diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt index 2c3fbef..c941c4a 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt @@ -96,6 +96,10 @@ data class Message( val files: List? = null, /** For optimistic UI: local URI when sending, null when confirmed. */ val pendingFileUri: String? = null, + /** For optimistic UI: filename when sending file (non-image), null when confirmed. */ + val pendingFilename: String? = null, + /** For optimistic UI: aspect ratio (width/height) when sending image, null when confirmed. */ + val pendingFileAspectRatio: Float? = null, /** For optimistic UI: jobId to track upload progress. */ val uploadJobId: String? = null, /** For optimistic UI: 0-100 upload progress, null when complete. */ diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPickers.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPickers.kt index 82758ea..48cf4f9 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPickers.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPickers.kt @@ -28,3 +28,6 @@ expect fun rememberFilePicker(onResult: (List) -> Unit): () -> Unit /** Resolve display filename from content URI. Platform-specific. */ expect fun getFilenameFromUri(uri: String): String + +/** Get image aspect ratio (width/height) from URI without loading full image. Returns null if unavailable. */ +expect suspend fun getImageAspectRatio(uri: String): Float? diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt index 4d30a41..eaa022e 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/AttachmentPreview.kt @@ -2,6 +2,8 @@ package ru.fromchat.ui.chat import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -37,7 +39,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Rect @@ -55,6 +56,9 @@ import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage import coil3.compose.rememberAsyncImagePainter import com.pr0gramm3r101.utils.conditional +import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import dev.chrisbanes.haze.materials.HazeMaterials import com.pr0gramm3r101.utils.crypto.Base64 import ru.fromchat.api.DmEnvelope import ru.fromchat.api.DmFile @@ -66,13 +70,18 @@ internal fun isImageFilename(name: String): Boolean = name.endsWith(".png", true) || name.endsWith(".jpg", true) || name.endsWith(".jpeg", true) || name.endsWith(".gif", true) || name.endsWith(".webp", true) +@OptIn(ExperimentalHazeMaterialsApi::class) @Composable fun AttachmentPreview( file: DmFile?, dmEnvelope: DmEnvelope?, currentUserId: Int?, pendingFileUri: String?, + /** Filename for pending (non-image) files; used when pendingFileUri is set. */ + pendingFilename: String? = null, isUploading: Boolean, + /** 0–100 upload progress when isUploading; null = indefinite */ + uploadProgress: Int? = null, fileThumbnail: String? = null, fileAspectRatio: Float? = null, fileSizeBytes: Long? = null, @@ -88,11 +97,9 @@ fun AttachmentPreview( val isImage = when { file != null -> isImageFilename(file.name) pendingFileUri != null -> { - isImageFilename( - pendingFileUri - .substringAfterLast('/') - .substringBefore('?') - ) + val nameToCheck = pendingFilename?.takeIf { it.isNotBlank() } + ?: pendingFileUri.substringAfterLast('/').substringBefore('?') + isImageFilename(nameToCheck) } else -> false } @@ -109,24 +116,28 @@ fun AttachmentPreview( sizeBytes = fileSizeBytes, onClick = onFileClick, isAuthor = isAuthor, + isUploading = false, + uploadProgress = null, modifier = modifier ) } isPendingFile -> { FileIconContent( - filename = "File", + filename = pendingFilename ?: "File", sizeBytes = null, onClick = null, isAuthor = isAuthor, + isUploading = isUploading, + uploadProgress = uploadProgress, modifier = modifier ) } - isImageWithThumb -> { + isImageWithThumb || isPendingImage -> { var isFullyLoaded by remember { mutableStateOf(false) } Box( modifier = modifier .then( - if (onImageClick != null && isFullyLoaded && !isExpanded) Modifier.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = onImageClick) + if (onImageClick != null && isFullyLoaded && !isExpanded && (isImageWithThumb || !isPendingImage)) Modifier.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = onImageClick) else Modifier ) .conditional( @@ -140,9 +151,10 @@ fun AttachmentPreview( Modifier.size(IMAGE_SIZE) } ) + .background(MaterialTheme.colorScheme.surfaceContainerHighest) .clip(RoundedCornerShape(IMAGE_RADIUS)) .then( - if (onImageBounds != null) { + if (onImageBounds != null && (isImageWithThumb || !isPendingImage)) { Modifier.onGloballyPositioned { coords -> val pos = coords.positionInRoot() val size = coords.size @@ -162,46 +174,105 @@ fun AttachmentPreview( contentAlignment = Alignment.Center ) { if (!isExpanded) { - DecryptedImageContent( - messageId = messageId ?: -1, - fileIndex = fileIndex ?: 0, - file = file, - envelope = dmEnvelope, - currentUserId = currentUserId, - thumbnailBase64 = fileThumbnail, - aspectRatio = fileAspectRatio, - onFullyLoaded = { isFullyLoaded = it } - ) + when { + isPendingImage && isImageWithThumb -> UnifiedImageContent( + localUri = pendingFileUri, + messageId = messageId!!, + fileIndex = fileIndex!!, + file = file, + envelope = dmEnvelope, + currentUserId = currentUserId, + thumbnailBase64 = fileThumbnail, + aspectRatio = fileAspectRatio, + onFullyLoaded = { isFullyLoaded = it } + ) + isPendingImage -> PendingImageContent( + uri = pendingFileUri!!, + isUploading = isUploading, + uploadProgress = uploadProgress, + isImage = true + ) + else -> DecryptedImageContent( + messageId = messageId ?: -1, + fileIndex = fileIndex ?: 0, + file = file!!, + envelope = dmEnvelope!!, + currentUserId = currentUserId, + thumbnailBase64 = fileThumbnail!!, + aspectRatio = fileAspectRatio, + onFullyLoaded = { isFullyLoaded = it } + ) + } } else { Box(modifier = Modifier.fillMaxSize()) } } } - isPendingImage -> { - Box( - modifier = modifier - .conditional( - fileAspectRatio != null && fileAspectRatio > 0f, - `if` = { - Modifier - .aspectRatio(fileAspectRatio!!) - .sizeIn(maxWidth = IMAGE_SIZE, maxHeight = IMAGE_SIZE) - .clip(RoundedCornerShape(IMAGE_RADIUS)) - }, - `else` = { - Modifier - .size(IMAGE_SIZE) - .clip(RoundedCornerShape(IMAGE_RADIUS)) - } - ), - contentAlignment = Alignment.Center - ) { - PendingImageContent( - uri = pendingFileUri, - isUploading = isUploading, - isImage = true + } +} + +@Composable +private fun UnifiedImageContent( + localUri: String, + messageId: Int, + fileIndex: Int, + file: DmFile, + envelope: DmEnvelope, + currentUserId: Int?, + thumbnailBase64: String, + aspectRatio: Float?, + onFullyLoaded: (Boolean) -> Unit = {} +) { + var cachedPath by remember(messageId, fileIndex, file.path) { + mutableStateOf(DecryptedImageCache.getCached(messageId, fileIndex, file.path)) + } + + LaunchedEffect(messageId, fileIndex, file.path, envelope) { + cachedPath = DecryptedImageCache.getOrDecrypt(messageId, fileIndex, file, envelope, currentUserId) + } + LaunchedEffect(Unit) { onFullyLoaded(true) } + + Box( + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(IMAGE_RADIUS)) + .background(MaterialTheme.colorScheme.surfaceContainerHighest) + ) { + AsyncImage( + model = localUri, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + when { + cachedPath != null -> { + val fullPainter = rememberAsyncImagePainter( + model = cachedPath!!, + contentScale = ContentScale.FillWidth ) + val fullState by fullPainter.state.collectAsState() + when (fullState) { + is coil3.compose.AsyncImagePainter.State.Success -> { + LaunchedEffect(Unit) { onFullyLoaded(true) } + val alpha = remember { Animatable(0f) } + LaunchedEffect(Unit) { + alpha.animateTo(1f, animationSpec = tween(300)) + } + Image( + painter = fullPainter, + contentDescription = file.name, + modifier = Modifier + .fillMaxSize() + .alpha(alpha.value), + contentScale = ContentScale.FillWidth + ) + } + else -> { + LaunchedEffect(Unit) { onFullyLoaded(false) } + } + } } + else -> LaunchedEffect(Unit) { onFullyLoaded(false) } } } } @@ -210,31 +281,59 @@ fun AttachmentPreview( private fun PendingImageContent( uri: String, isUploading: Boolean, + uploadProgress: Int?, isImage: Boolean ) { if (isImage) { - Box(modifier = Modifier.fillMaxSize()) { - AsyncImage( - model = uri, - contentDescription = null, + Box( + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(IMAGE_RADIUS)) + ) { + Box( modifier = Modifier - .fillMaxSize() - .clip(RoundedCornerShape(IMAGE_RADIUS)) - .then(if (isUploading) Modifier.blur(8.dp) else Modifier), - contentScale = ContentScale.Crop + .matchParentSize() + .background(MaterialTheme.colorScheme.surfaceContainerHighest) ) - AnimatedVisibility( - visible = isUploading, - enter = fadeIn(), - exit = fadeOut(animationSpec = tween(300)) - ) { + if (isUploading) { Box( modifier = Modifier .fillMaxSize() - .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.5f)), + .clip(RoundedCornerShape(IMAGE_RADIUS)) + .hazeEffect(style = HazeMaterials.thin()) + ) { + AsyncImage( + model = uri, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } + } else { + AsyncImage( + model = uri, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } + AnimatedVisibility( + visible = isUploading, + enter = fadeIn(animationSpec = tween(150)), + exit = fadeOut(animationSpec = tween(300)) + ) { + Box( + modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { - InfiniteCircularProgress() + if (uploadProgress != null) { + DeterminateCircularProgress( + progress = uploadProgress, + modifier = Modifier.size(32.dp) + ) + } else { + IndefiniteCircularProgress(modifier = Modifier.size(32.dp)) + } } } } @@ -244,7 +343,14 @@ private fun PendingImageContent( contentAlignment = Alignment.Center ) { if (isUploading) { - InfiniteCircularProgress() + if (uploadProgress != null) { + DeterminateCircularProgress( + progress = uploadProgress, + modifier = Modifier.size(40.dp) + ) + } else { + IndefiniteCircularProgress(modifier = Modifier.size(40.dp)) + } } else { Icon( imageVector = Icons.Default.AttachFile, @@ -258,9 +364,29 @@ private fun PendingImageContent( } @Composable -private fun InfiniteCircularProgress() { +private fun IndefiniteCircularProgress(modifier: Modifier = Modifier) { CircularProgressIndicator( - modifier = Modifier.size(32.dp), + modifier = modifier, + strokeWidth = 3.dp + ) +} + +@Composable +private fun DeterminateCircularProgress( + progress: Int, + modifier: Modifier = Modifier +) { + val animatedProgress by animateFloatAsState( + targetValue = (progress.coerceIn(0, 100) / 100f), + animationSpec = tween( + durationMillis = 250, + easing = FastOutSlowInEasing + ), + label = "uploadProgress" + ) + CircularProgressIndicator( + progress = { animatedProgress }, + modifier = modifier, strokeWidth = 3.dp ) } @@ -290,7 +416,13 @@ private fun DecryptedImageContent( onFullyLoaded(cachedPath != null) } - Box(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(IMAGE_RADIUS)) + .background(MaterialTheme.colorScheme.surfaceContainerHighest) + ) { + Box(modifier = Modifier.fillMaxSize()) { when { cachedPath != null -> { val fullPainter = rememberAsyncImagePainter( @@ -303,17 +435,25 @@ private fun DecryptedImageContent( Image( painter = fullPainter, contentDescription = file.name, - modifier = Modifier - .fillMaxSize() - .clip(RoundedCornerShape(IMAGE_RADIUS)), + modifier = Modifier.fillMaxSize(), contentScale = ContentScale.FillWidth ) } is coil3.compose.AsyncImagePainter.State.Loading -> { - Box(modifier = Modifier.fillMaxSize()) + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + IndefiniteCircularProgress(modifier = Modifier.size(32.dp)) + } } else -> { - Box(modifier = Modifier.fillMaxSize()) + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + IndefiniteCircularProgress(modifier = Modifier.size(32.dp)) + } } } } @@ -322,7 +462,7 @@ private fun DecryptedImageContent( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { - InfiniteCircularProgress() + IndefiniteCircularProgress(modifier = Modifier.size(32.dp)) } } else -> { @@ -337,19 +477,23 @@ private fun DecryptedImageContent( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { - InfiniteCircularProgress() + IndefiniteCircularProgress(modifier = Modifier.size(32.dp)) } } is coil3.compose.AsyncImagePainter.State.Success -> { - Image( - painter = thumbPainter, - contentDescription = file.name, + Box( modifier = Modifier .fillMaxSize() .clip(RoundedCornerShape(IMAGE_RADIUS)) - .blur(8.dp), - contentScale = ContentScale.Crop - ) + .hazeEffect(style = HazeMaterials.thin()) + ) { + Image( + painter = thumbPainter, + contentDescription = file.name, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } if (cachedPath != null) { val fullPainter = rememberAsyncImagePainter( model = cachedPath!!, @@ -367,12 +511,18 @@ private fun DecryptedImageContent( contentDescription = file.name, modifier = Modifier .fillMaxSize() - .clip(RoundedCornerShape(IMAGE_RADIUS)) .alpha(alpha.value), contentScale = ContentScale.FillWidth ) } - else -> { } + else -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + IndefiniteCircularProgress(modifier = Modifier.size(32.dp)) + } + } } } } @@ -381,12 +531,13 @@ private fun DecryptedImageContent( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { - InfiniteCircularProgress() + IndefiniteCircularProgress(modifier = Modifier.size(32.dp)) } } } } } + } } } @@ -405,6 +556,8 @@ private fun FileIconContent( sizeBytes: Long?, onClick: (() -> Unit)?, isAuthor: Boolean, + isUploading: Boolean = false, + uploadProgress: Int? = null, modifier: Modifier = Modifier ) { val contentColor = if (isAuthor) Color.White else MaterialTheme.colorScheme.onSurface @@ -418,7 +571,10 @@ private fun FileIconContent( verticalAlignment = Alignment.CenterVertically ) { Box( - modifier = Modifier.size(40.dp).then( + modifier = Modifier.size(40.dp), + contentAlignment = Alignment.Center + ) { + Box(modifier = Modifier.size(40.dp).then( if (isAuthor) { Modifier .graphicsLayer { @@ -450,6 +606,28 @@ private fun FileIconContent( modifier = Modifier.size(22.dp), tint = if (isAuthor) Color.White else iconTint ) + } + if (isUploading) { + Box( + modifier = Modifier + .size(40.dp) + .align(Alignment.Center) + .background( + MaterialTheme.colorScheme.surface.copy(alpha = 0.6f), + RoundedCornerShape(20.dp) + ), + contentAlignment = Alignment.Center + ) { + if (uploadProgress != null) { + DeterminateCircularProgress( + progress = uploadProgress, + modifier = Modifier.size(28.dp) + ) + } else { + IndefiniteCircularProgress(modifier = Modifier.size(28.dp)) + } + } + } } Column( modifier = Modifier.padding(start = 12.dp), diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt index a785c48..081c06e 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatInput.kt @@ -57,7 +57,9 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi @@ -156,12 +158,23 @@ private fun AttachmentChip( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp) ) { - Icon( - imageVector = if (attachment.isImage) Icons.Default.Image else Icons.Default.AttachFile, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) + if (attachment.isImage) { + AsyncImage( + model = attachment.uri, + contentDescription = null, + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Crop + ) + } else { + Icon( + imageVector = Icons.Default.AttachFile, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } Text( text = attachment.filename, style = MaterialTheme.typography.labelSmall, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt index b49b785..64f62e5 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt @@ -86,6 +86,11 @@ abstract class ChatPanel( /** * Add message to list. Mutex prevents duplicate adds when same update * is processed concurrently from multiple WebSocket connections. + * + * Messages are appended in arrival order instead of being re-sorted. + * This guarantees that new optimistic messages and live updates always + * appear at the bottom, even if timestamps are slightly out of sync + * between client and server. */ suspend fun addMessage(message: Message) { addMessageMutex.withLock { @@ -93,7 +98,7 @@ abstract class ChatPanel( if (!messageExists) { Logger.d("ChatPanel", "Adding message: id=${message.id}, content=${message.content.take(50)}") updateState { currentState -> - val newMessages = (currentState.messages + message).sortedBy { it.timestamp } + val newMessages = currentState.messages + message Logger.d("ChatPanel", "Messages count after add: ${newMessages.size}") currentState.copy(messages = newMessages) } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt index 43cb5ce..a4c5913 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt @@ -64,6 +64,7 @@ import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.HazeMaterials import dev.chrisbanes.haze.rememberHazeState +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.jsonObject @@ -83,6 +84,7 @@ import ru.fromchat.core.Logger import ru.fromchat.ui.HapticFeedbackEvent import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.rememberHapticFeedback +import ru.fromchat.ui.chat.getImageAspectRatio import ru.fromchat.ui.scaleOnPress import ru.fromchat.utils.formatLastSeen import kotlin.time.Clock @@ -259,24 +261,31 @@ fun ChatScreen( // Scroll to bottom when new messages arrive. // - Initial composition: jump (no animation) to avoid jank. - // - Subsequent messages: only auto-scroll if user is already near bottom. + // - Subsequent messages: always scroll when we sent (last message is ours); otherwise only if near bottom. + // - Scroll to last item (totalItemsCount - 1) so new message appears at bottom; delay to allow layout. var didInitialScroll by remember(panel) { mutableStateOf(false) } - LaunchedEffect(panelState.messages.size) { + LaunchedEffect(panelState.messages.size, panelState.messages.lastOrNull()?.id, panelState.messages.lastOrNull()?.pendingFileAspectRatio) { if (panelState.messages.isEmpty()) return@LaunchedEffect - val lastMessageIndex = panelState.messages.size // account for top spacer item at index 0 + val lastMessage = panelState.messages.lastOrNull() + val lastIsOurs = lastMessage?.user_id == currentUserId + val totalItems = 2 + panelState.messages.size // top spacer + messages + bottom spacer + val lastIndex = totalItems - 1 if (!didInitialScroll) { didInitialScroll = true - listState.scrollToItem(lastMessageIndex) + listState.scrollToItem(lastIndex) return@LaunchedEffect } val lastVisibleIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 - val totalItems = listState.layoutInfo.totalItemsCount val isNearBottom = lastVisibleIndex >= (totalItems - 3) - if (isNearBottom) { - listState.animateScrollToItem(lastMessageIndex) + if (lastIsOurs || isNearBottom) { + delay(100) // Allow new item to be composed and laid out + listState.animateScrollToItem(lastIndex) + // Re-scroll after layout may change (e.g. aspect ratio update) + delay(150) + listState.animateScrollToItem(lastIndex) } } @@ -471,6 +480,7 @@ fun ChatScreen( attachments.forEach { att -> val jobId = "dm_${Clock.System.now().toEpochMilliseconds()}_${att.id}" val tempId = -jobId.hashCode().let { if (it == 0) -1 else it } + val isImage = att.isImage val optimisticMessage = Message( id = tempId, user_id = currentUserId ?: -1, @@ -486,10 +496,21 @@ fun ChatScreen( reactions = null, files = null, pendingFileUri = att.uri, + pendingFilename = att.filename, uploadJobId = jobId, uploadProgress = 0 ) panel.addMessage(optimisticMessage) + if (isImage) { + scope.launch { + val aspectRatio = getImageAspectRatio(att.uri) + if (aspectRatio != null && aspectRatio > 0f) { + panel.updateMessage(tempId) { + if (it.uploadJobId == jobId) it.copy(pendingFileAspectRatio = aspectRatio) else it + } + } + } + } AttachmentUploadQueue.enqueue( AttachmentUploadJob( jobId = jobId, @@ -553,7 +574,7 @@ fun ChatScreen( items( items = panelState.messages, - key = { it.id } + key = { it.uploadJobId ?: it.id.toString() } ) { message -> var tapPositionInRoot by remember { mutableStateOf(IntOffset(0, 0)) } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt index 766157f..3cf2370 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt @@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -159,10 +160,16 @@ fun MessageItem( ) { // Message bubble val isDark = isSystemInDarkTheme() + val pendingIsImage = when { + message.pendingFilename?.isNotBlank() == true -> isImageFilename(message.pendingFilename) + message.pendingFileUri != null -> isImageFilename( + message.pendingFileUri.substringAfterLast('/').substringBefore('?') + ) + else -> false + } val firstContentIsImage = (!showUsername || isAuthor) && message.reply_to == null && - (message.pendingFileUri?.let { isImageFilename(it.substringAfterLast('/').substringBefore('?')) } == true || - message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true) + (pendingIsImage || message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true) Box( modifier = Modifier .graphicsLayer( @@ -278,20 +285,61 @@ fun MessageItem( modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp) ) } else { - if (message.pendingFileUri != null) { + val firstFile = message.files?.firstOrNull() + val firstFileIsImage = firstFile?.let { isImageFilename(it.name) } ?: false + val isTransitioning = message.pendingFileUri != null && firstFileIsImage && + message.dmEnvelope != null && !(message.fileThumbnails?.firstOrNull().isNullOrBlank()) + if (isTransitioning) { + val file = firstFile + val imageKey = "img_${message.id}_0" + AttachmentPreview( + file = file, + dmEnvelope = message.dmEnvelope, + currentUserId = currentUserId, + pendingFileUri = message.pendingFileUri, + pendingFilename = message.pendingFilename, + isUploading = false, + uploadProgress = null, + fileThumbnail = message.fileThumbnails!!.first().takeIf { it.isNotBlank() }, + fileAspectRatio = message.fileAspectRatios?.firstOrNull()?.takeIf { it > 0f } + ?: message.pendingFileAspectRatio, + fileSizeBytes = message.fileSizes?.firstOrNull(), + messageId = message.id, + fileIndex = 0, + onFileClick = null, + onImageClick = { onImageClick?.invoke(message, 0) }, + onImageBounds = if (onImageBounds != null) { rect -> onImageBounds.invoke(imageKey, rect) } else null, + isExpanded = expandedImageKey != null && expandedImageKey == imageKey && !isImageClosing, + isAuthor = isAuthor, + modifier = Modifier.padding(all = 2.dp) + ) + } else if (message.pendingFileUri != null) { + val isPendingImage = message.pendingFilename?.let { isImageFilename(it) } ?: false AttachmentPreview( file = null, dmEnvelope = null, currentUserId = null, pendingFileUri = message.pendingFileUri, + pendingFilename = message.pendingFilename, isUploading = message.uploadProgress != null, + uploadProgress = message.uploadProgress, + fileAspectRatio = message.pendingFileAspectRatio, isAuthor = isAuthor, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp) + modifier = if (isPendingImage && firstContentIsImage) { + Modifier.padding(all = 2.dp) + } else { + Modifier.padding( + horizontal = if (isPendingImage) 2.dp else 12.dp, + vertical = if (isPendingImage) 2.dp else 4.dp + ) + } ) } message.files?.forEachIndexed { index, file -> + if (isTransitioning && index == 0) return@forEachIndexed val isImage = isImageFilename(file.name) val imageKey = if (isImage) "img_${message.id}_$index" else null + val isFirstImage = index == 0 && isImage AttachmentPreview( file = file, dmEnvelope = message.dmEnvelope, @@ -310,10 +358,14 @@ fun MessageItem( } else null, isExpanded = isImage && expandedImageKey != null && expandedImageKey == imageKey && !isImageClosing, isAuthor = isAuthor, - modifier = Modifier.padding( - horizontal = if (isImage) 2.dp else 12.dp, - vertical = if (isImage) 2.dp else 4.dp - ) + modifier = if (isFirstImage && firstContentIsImage && isImage) { + Modifier.padding(all = 2.dp) + } else { + Modifier.padding( + horizontal = if (isImage) 2.dp else 12.dp, + vertical = if (isImage) 2.dp else 4.dp + ) + } ) } } @@ -330,13 +382,26 @@ fun MessageItem( ) } - // Timestamp and edited indicator + // Timestamp, sending indicator, and edited indicator + val isSendingText = message.id < 0 && message.uploadJobId == null Row( modifier = Modifier .padding(start = 12.dp, end = 12.dp, top = 4.dp, bottom = 8.dp), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically ) { + if (isSendingText) { + CircularProgressIndicator( + modifier = Modifier.size(12.dp), + strokeWidth = 1.5.dp, + color = if (isAuthor) { + Color.White.copy(alpha = 0.7f) + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + } + ) + Spacer(modifier = Modifier.width(6.dp)) + } Text( text = formatTime(message.timestamp), style = MaterialTheme.typography.labelSmall, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt index 06d9af7..7a8f910 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt @@ -158,9 +158,20 @@ class DmPanel( if (plaintext != null) { if (envelope.senderId == currentUserId) { val oldestOptimistic = _state.messages.filter { it.id < 0 }.minByOrNull { it.timestamp } - oldestOptimistic?.let { removeMessage(it.id) } + if (oldestOptimistic != null) { + val real = createMessage(envelope, plaintext).copy( + uploadJobId = oldestOptimistic.uploadJobId, + pendingFileUri = oldestOptimistic.pendingFileUri, + pendingFilename = oldestOptimistic.pendingFilename, + pendingFileAspectRatio = oldestOptimistic.pendingFileAspectRatio + ) + updateMessage(oldestOptimistic.id) { real } + } else { + addMessage(createMessage(envelope, plaintext)) + } + } else { + addMessage(createMessage(envelope, plaintext)) } - addMessage(createMessage(envelope, plaintext)) if (envelope.replyToId != null) { val replyTo = _state.messages.find { it.id == envelope.replyToId } updateMessage(envelope.id) { it.copy(reply_to = replyTo) } diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/AttachmentPickers.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/AttachmentPickers.ios.kt index 465b0ab..6ced6ce 100644 --- a/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/AttachmentPickers.ios.kt +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/ui/chat/AttachmentPickers.ios.kt @@ -15,3 +15,5 @@ actual fun rememberImagePicker(onResult: (List) -> Unit): () -> Unit { actual fun rememberFilePicker(onResult: (List) -> Unit): () -> Unit { return { /* Phase 4: UIDocumentPickerViewController */ } } + +actual suspend fun getImageAspectRatio(uri: String): Float? = null