Implement upload indicators

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-03-11 14:47:13 +03:00
Unverified
parent e2e05da0e3
commit a4d8dbef70
10 changed files with 420 additions and 105 deletions
@@ -1,5 +1,6 @@
package ru.fromchat.ui.chat package ru.fromchat.ui.chat
import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import android.provider.OpenableColumns import android.provider.OpenableColumns
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
@@ -19,6 +20,18 @@ actual fun getFilenameFromUri(uri: String): String {
return uri.substringAfterLast('/').takeIf { it.isNotBlank() } ?: "file" 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 @Composable
actual fun rememberImagePicker(onResult: (List<String>) -> Unit): () -> Unit { actual fun rememberImagePicker(onResult: (List<String>) -> Unit): () -> Unit {
val launcher = rememberLauncherForActivityResult( val launcher = rememberLauncherForActivityResult(
@@ -96,6 +96,10 @@ data class Message(
val files: List<DmFile>? = null, val files: List<DmFile>? = null,
/** For optimistic UI: local URI when sending, null when confirmed. */ /** For optimistic UI: local URI when sending, null when confirmed. */
val pendingFileUri: String? = null, 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. */ /** For optimistic UI: jobId to track upload progress. */
val uploadJobId: String? = null, val uploadJobId: String? = null,
/** For optimistic UI: 0-100 upload progress, null when complete. */ /** For optimistic UI: 0-100 upload progress, null when complete. */
@@ -28,3 +28,6 @@ expect fun rememberFilePicker(onResult: (List<String>) -> Unit): () -> Unit
/** Resolve display filename from content URI. Platform-specific. */ /** Resolve display filename from content URI. Platform-specific. */
expect fun getFilenameFromUri(uri: String): String 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?
@@ -2,6 +2,8 @@ package ru.fromchat.ui.chat
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Animatable 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.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
@@ -37,7 +39,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Rect
@@ -55,6 +56,9 @@ import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import coil3.compose.rememberAsyncImagePainter import coil3.compose.rememberAsyncImagePainter
import com.pr0gramm3r101.utils.conditional 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 com.pr0gramm3r101.utils.crypto.Base64
import ru.fromchat.api.DmEnvelope import ru.fromchat.api.DmEnvelope
import ru.fromchat.api.DmFile 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(".png", true) || name.endsWith(".jpg", true) ||
name.endsWith(".jpeg", true) || name.endsWith(".gif", true) || name.endsWith(".webp", true) name.endsWith(".jpeg", true) || name.endsWith(".gif", true) || name.endsWith(".webp", true)
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable @Composable
fun AttachmentPreview( fun AttachmentPreview(
file: DmFile?, file: DmFile?,
dmEnvelope: DmEnvelope?, dmEnvelope: DmEnvelope?,
currentUserId: Int?, currentUserId: Int?,
pendingFileUri: String?, pendingFileUri: String?,
/** Filename for pending (non-image) files; used when pendingFileUri is set. */
pendingFilename: String? = null,
isUploading: Boolean, isUploading: Boolean,
/** 0100 upload progress when isUploading; null = indefinite */
uploadProgress: Int? = null,
fileThumbnail: String? = null, fileThumbnail: String? = null,
fileAspectRatio: Float? = null, fileAspectRatio: Float? = null,
fileSizeBytes: Long? = null, fileSizeBytes: Long? = null,
@@ -88,11 +97,9 @@ fun AttachmentPreview(
val isImage = when { val isImage = when {
file != null -> isImageFilename(file.name) file != null -> isImageFilename(file.name)
pendingFileUri != null -> { pendingFileUri != null -> {
isImageFilename( val nameToCheck = pendingFilename?.takeIf { it.isNotBlank() }
pendingFileUri ?: pendingFileUri.substringAfterLast('/').substringBefore('?')
.substringAfterLast('/') isImageFilename(nameToCheck)
.substringBefore('?')
)
} }
else -> false else -> false
} }
@@ -109,24 +116,28 @@ fun AttachmentPreview(
sizeBytes = fileSizeBytes, sizeBytes = fileSizeBytes,
onClick = onFileClick, onClick = onFileClick,
isAuthor = isAuthor, isAuthor = isAuthor,
isUploading = false,
uploadProgress = null,
modifier = modifier modifier = modifier
) )
} }
isPendingFile -> { isPendingFile -> {
FileIconContent( FileIconContent(
filename = "File", filename = pendingFilename ?: "File",
sizeBytes = null, sizeBytes = null,
onClick = null, onClick = null,
isAuthor = isAuthor, isAuthor = isAuthor,
isUploading = isUploading,
uploadProgress = uploadProgress,
modifier = modifier modifier = modifier
) )
} }
isImageWithThumb -> { isImageWithThumb || isPendingImage -> {
var isFullyLoaded by remember { mutableStateOf(false) } var isFullyLoaded by remember { mutableStateOf(false) }
Box( Box(
modifier = modifier modifier = modifier
.then( .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 else Modifier
) )
.conditional( .conditional(
@@ -140,9 +151,10 @@ fun AttachmentPreview(
Modifier.size(IMAGE_SIZE) Modifier.size(IMAGE_SIZE)
} }
) )
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
.clip(RoundedCornerShape(IMAGE_RADIUS)) .clip(RoundedCornerShape(IMAGE_RADIUS))
.then( .then(
if (onImageBounds != null) { if (onImageBounds != null && (isImageWithThumb || !isPendingImage)) {
Modifier.onGloballyPositioned { coords -> Modifier.onGloballyPositioned { coords ->
val pos = coords.positionInRoot() val pos = coords.positionInRoot()
val size = coords.size val size = coords.size
@@ -162,9 +174,11 @@ fun AttachmentPreview(
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
if (!isExpanded) { if (!isExpanded) {
DecryptedImageContent( when {
messageId = messageId ?: -1, isPendingImage && isImageWithThumb -> UnifiedImageContent(
fileIndex = fileIndex ?: 0, localUri = pendingFileUri,
messageId = messageId!!,
fileIndex = fileIndex!!,
file = file, file = file,
envelope = dmEnvelope, envelope = dmEnvelope,
currentUserId = currentUserId, currentUserId = currentUserId,
@@ -172,36 +186,93 @@ fun AttachmentPreview(
aspectRatio = fileAspectRatio, aspectRatio = fileAspectRatio,
onFullyLoaded = { isFullyLoaded = it } 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 { } else {
Box(modifier = Modifier.fillMaxSize()) 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
@Composable
private fun UnifiedImageContent(
localUri: String,
messageId: Int,
fileIndex: Int,
file: DmFile,
envelope: DmEnvelope,
currentUserId: Int?,
thumbnailBase64: String,
aspectRatio: Float?,
onFullyLoaded: (Boolean) -> Unit = {}
) { ) {
PendingImageContent( var cachedPath by remember(messageId, fileIndex, file.path) {
uri = pendingFileUri, mutableStateOf(DecryptedImageCache.getCached(messageId, fileIndex, file.path))
isUploading = isUploading, }
isImage = true
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( private fun PendingImageContent(
uri: String, uri: String,
isUploading: Boolean, isUploading: Boolean,
uploadProgress: Int?,
isImage: Boolean isImage: Boolean
) { ) {
if (isImage) { if (isImage) {
Box(modifier = Modifier.fillMaxSize()) {
AsyncImage(
model = uri,
contentDescription = null,
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(IMAGE_RADIUS))
.then(if (isUploading) Modifier.blur(8.dp) else Modifier),
contentScale = ContentScale.Crop
)
AnimatedVisibility(
visible = isUploading,
enter = fadeIn(),
exit = fadeOut(animationSpec = tween(300))
) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.5f)), .clip(RoundedCornerShape(IMAGE_RADIUS))
) {
Box(
modifier = Modifier
.matchParentSize()
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
)
if (isUploading) {
Box(
modifier = Modifier
.fillMaxSize()
.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 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 contentAlignment = Alignment.Center
) { ) {
if (isUploading) { if (isUploading) {
InfiniteCircularProgress() if (uploadProgress != null) {
DeterminateCircularProgress(
progress = uploadProgress,
modifier = Modifier.size(40.dp)
)
} else {
IndefiniteCircularProgress(modifier = Modifier.size(40.dp))
}
} else { } else {
Icon( Icon(
imageVector = Icons.Default.AttachFile, imageVector = Icons.Default.AttachFile,
@@ -258,9 +364,29 @@ private fun PendingImageContent(
} }
@Composable @Composable
private fun InfiniteCircularProgress() { private fun IndefiniteCircularProgress(modifier: Modifier = Modifier) {
CircularProgressIndicator( 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 strokeWidth = 3.dp
) )
} }
@@ -290,6 +416,12 @@ private fun DecryptedImageContent(
onFullyLoaded(cachedPath != null) onFullyLoaded(cachedPath != null)
} }
Box(
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(IMAGE_RADIUS))
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
) {
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
when { when {
cachedPath != null -> { cachedPath != null -> {
@@ -303,17 +435,25 @@ private fun DecryptedImageContent(
Image( Image(
painter = fullPainter, painter = fullPainter,
contentDescription = file.name, contentDescription = file.name,
modifier = Modifier modifier = Modifier.fillMaxSize(),
.fillMaxSize()
.clip(RoundedCornerShape(IMAGE_RADIUS)),
contentScale = ContentScale.FillWidth contentScale = ContentScale.FillWidth
) )
} }
is coil3.compose.AsyncImagePainter.State.Loading -> { is coil3.compose.AsyncImagePainter.State.Loading -> {
Box(modifier = Modifier.fillMaxSize()) Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
IndefiniteCircularProgress(modifier = Modifier.size(32.dp))
}
} }
else -> { 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(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
InfiniteCircularProgress() IndefiniteCircularProgress(modifier = Modifier.size(32.dp))
} }
} }
else -> { else -> {
@@ -337,19 +477,23 @@ private fun DecryptedImageContent(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
InfiniteCircularProgress() IndefiniteCircularProgress(modifier = Modifier.size(32.dp))
} }
} }
is coil3.compose.AsyncImagePainter.State.Success -> { is coil3.compose.AsyncImagePainter.State.Success -> {
Image( Box(
painter = thumbPainter,
contentDescription = file.name,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.clip(RoundedCornerShape(IMAGE_RADIUS)) .clip(RoundedCornerShape(IMAGE_RADIUS))
.blur(8.dp), .hazeEffect(style = HazeMaterials.thin())
) {
Image(
painter = thumbPainter,
contentDescription = file.name,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
}
if (cachedPath != null) { if (cachedPath != null) {
val fullPainter = rememberAsyncImagePainter( val fullPainter = rememberAsyncImagePainter(
model = cachedPath!!, model = cachedPath!!,
@@ -367,12 +511,18 @@ private fun DecryptedImageContent(
contentDescription = file.name, contentDescription = file.name,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.clip(RoundedCornerShape(IMAGE_RADIUS))
.alpha(alpha.value), .alpha(alpha.value),
contentScale = ContentScale.FillWidth contentScale = ContentScale.FillWidth
) )
} }
else -> { } else -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
IndefiniteCircularProgress(modifier = Modifier.size(32.dp))
}
}
} }
} }
} }
@@ -381,7 +531,8 @@ private fun DecryptedImageContent(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
InfiniteCircularProgress() IndefiniteCircularProgress(modifier = Modifier.size(32.dp))
}
} }
} }
} }
@@ -405,6 +556,8 @@ private fun FileIconContent(
sizeBytes: Long?, sizeBytes: Long?,
onClick: (() -> Unit)?, onClick: (() -> Unit)?,
isAuthor: Boolean, isAuthor: Boolean,
isUploading: Boolean = false,
uploadProgress: Int? = null,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val contentColor = if (isAuthor) Color.White else MaterialTheme.colorScheme.onSurface val contentColor = if (isAuthor) Color.White else MaterialTheme.colorScheme.onSurface
@@ -418,7 +571,10 @@ private fun FileIconContent(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Box( Box(
modifier = Modifier.size(40.dp).then( modifier = Modifier.size(40.dp),
contentAlignment = Alignment.Center
) {
Box(modifier = Modifier.size(40.dp).then(
if (isAuthor) { if (isAuthor) {
Modifier Modifier
.graphicsLayer { .graphicsLayer {
@@ -450,6 +606,28 @@ private fun FileIconContent(
modifier = Modifier.size(22.dp), modifier = Modifier.size(22.dp),
tint = if (isAuthor) Color.White else iconTint 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( Column(
modifier = Modifier.padding(start = 12.dp), modifier = Modifier.padding(start = 12.dp),
@@ -57,7 +57,9 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
@@ -156,12 +158,23 @@ private fun AttachmentChip(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp) horizontalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
if (attachment.isImage) {
AsyncImage(
model = attachment.uri,
contentDescription = null,
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop
)
} else {
Icon( Icon(
imageVector = if (attachment.isImage) Icons.Default.Image else Icons.Default.AttachFile, imageVector = Icons.Default.AttachFile,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(16.dp), modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant tint = MaterialTheme.colorScheme.onSurfaceVariant
) )
}
Text( Text(
text = attachment.filename, text = attachment.filename,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
@@ -86,6 +86,11 @@ abstract class ChatPanel(
/** /**
* Add message to list. Mutex prevents duplicate adds when same update * Add message to list. Mutex prevents duplicate adds when same update
* is processed concurrently from multiple WebSocket connections. * 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) { suspend fun addMessage(message: Message) {
addMessageMutex.withLock { addMessageMutex.withLock {
@@ -93,7 +98,7 @@ abstract class ChatPanel(
if (!messageExists) { if (!messageExists) {
Logger.d("ChatPanel", "Adding message: id=${message.id}, content=${message.content.take(50)}") Logger.d("ChatPanel", "Adding message: id=${message.id}, content=${message.content.take(50)}")
updateState { currentState -> updateState { currentState ->
val newMessages = (currentState.messages + message).sortedBy { it.timestamp } val newMessages = currentState.messages + message
Logger.d("ChatPanel", "Messages count after add: ${newMessages.size}") Logger.d("ChatPanel", "Messages count after add: ${newMessages.size}")
currentState.copy(messages = newMessages) currentState.copy(messages = newMessages)
} }
@@ -64,6 +64,7 @@ import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials import dev.chrisbanes.haze.materials.HazeMaterials
import dev.chrisbanes.haze.rememberHazeState import dev.chrisbanes.haze.rememberHazeState
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
@@ -83,6 +84,7 @@ import ru.fromchat.core.Logger
import ru.fromchat.ui.HapticFeedbackEvent import ru.fromchat.ui.HapticFeedbackEvent
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.rememberHapticFeedback import ru.fromchat.ui.rememberHapticFeedback
import ru.fromchat.ui.chat.getImageAspectRatio
import ru.fromchat.ui.scaleOnPress import ru.fromchat.ui.scaleOnPress
import ru.fromchat.utils.formatLastSeen import ru.fromchat.utils.formatLastSeen
import kotlin.time.Clock import kotlin.time.Clock
@@ -259,24 +261,31 @@ fun ChatScreen(
// Scroll to bottom when new messages arrive. // Scroll to bottom when new messages arrive.
// - Initial composition: jump (no animation) to avoid jank. // - 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) } 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 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) { if (!didInitialScroll) {
didInitialScroll = true didInitialScroll = true
listState.scrollToItem(lastMessageIndex) listState.scrollToItem(lastIndex)
return@LaunchedEffect return@LaunchedEffect
} }
val lastVisibleIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 val lastVisibleIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
val totalItems = listState.layoutInfo.totalItemsCount
val isNearBottom = lastVisibleIndex >= (totalItems - 3) val isNearBottom = lastVisibleIndex >= (totalItems - 3)
if (isNearBottom) { if (lastIsOurs || isNearBottom) {
listState.animateScrollToItem(lastMessageIndex) 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 -> attachments.forEach { att ->
val jobId = "dm_${Clock.System.now().toEpochMilliseconds()}_${att.id}" val jobId = "dm_${Clock.System.now().toEpochMilliseconds()}_${att.id}"
val tempId = -jobId.hashCode().let { if (it == 0) -1 else it } val tempId = -jobId.hashCode().let { if (it == 0) -1 else it }
val isImage = att.isImage
val optimisticMessage = Message( val optimisticMessage = Message(
id = tempId, id = tempId,
user_id = currentUserId ?: -1, user_id = currentUserId ?: -1,
@@ -486,10 +496,21 @@ fun ChatScreen(
reactions = null, reactions = null,
files = null, files = null,
pendingFileUri = att.uri, pendingFileUri = att.uri,
pendingFilename = att.filename,
uploadJobId = jobId, uploadJobId = jobId,
uploadProgress = 0 uploadProgress = 0
) )
panel.addMessage(optimisticMessage) 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( AttachmentUploadQueue.enqueue(
AttachmentUploadJob( AttachmentUploadJob(
jobId = jobId, jobId = jobId,
@@ -553,7 +574,7 @@ fun ChatScreen(
items( items(
items = panelState.messages, items = panelState.messages,
key = { it.id } key = { it.uploadJobId ?: it.id.toString() }
) { message -> ) { message ->
var tapPositionInRoot by remember { mutableStateOf(IntOffset(0, 0)) } var tapPositionInRoot by remember { mutableStateOf(IntOffset(0, 0)) }
@@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -159,10 +160,16 @@ fun MessageItem(
) { ) {
// Message bubble // Message bubble
val isDark = isSystemInDarkTheme() 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) && val firstContentIsImage = (!showUsername || isAuthor) &&
message.reply_to == null && message.reply_to == null &&
(message.pendingFileUri?.let { isImageFilename(it.substringAfterLast('/').substringBefore('?')) } == true || (pendingIsImage || message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true)
message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true)
Box( Box(
modifier = Modifier modifier = Modifier
.graphicsLayer( .graphicsLayer(
@@ -278,20 +285,61 @@ fun MessageItem(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp) modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)
) )
} else { } 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( AttachmentPreview(
file = null, file = null,
dmEnvelope = null, dmEnvelope = null,
currentUserId = null, currentUserId = null,
pendingFileUri = message.pendingFileUri, pendingFileUri = message.pendingFileUri,
pendingFilename = message.pendingFilename,
isUploading = message.uploadProgress != null, isUploading = message.uploadProgress != null,
uploadProgress = message.uploadProgress,
fileAspectRatio = message.pendingFileAspectRatio,
isAuthor = isAuthor, 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 -> message.files?.forEachIndexed { index, file ->
if (isTransitioning && index == 0) return@forEachIndexed
val isImage = isImageFilename(file.name) val isImage = isImageFilename(file.name)
val imageKey = if (isImage) "img_${message.id}_$index" else null val imageKey = if (isImage) "img_${message.id}_$index" else null
val isFirstImage = index == 0 && isImage
AttachmentPreview( AttachmentPreview(
file = file, file = file,
dmEnvelope = message.dmEnvelope, dmEnvelope = message.dmEnvelope,
@@ -310,10 +358,14 @@ fun MessageItem(
} else null, } else null,
isExpanded = isImage && expandedImageKey != null && expandedImageKey == imageKey && !isImageClosing, isExpanded = isImage && expandedImageKey != null && expandedImageKey == imageKey && !isImageClosing,
isAuthor = isAuthor, isAuthor = isAuthor,
modifier = Modifier.padding( modifier = if (isFirstImage && firstContentIsImage && isImage) {
Modifier.padding(all = 2.dp)
} else {
Modifier.padding(
horizontal = if (isImage) 2.dp else 12.dp, horizontal = if (isImage) 2.dp else 12.dp,
vertical = if (isImage) 2.dp else 4.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( Row(
modifier = Modifier modifier = Modifier
.padding(start = 12.dp, end = 12.dp, top = 4.dp, bottom = 8.dp), .padding(start = 12.dp, end = 12.dp, top = 4.dp, bottom = 8.dp),
horizontalArrangement = Arrangement.End, horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically 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(
text = formatTime(message.timestamp), text = formatTime(message.timestamp),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
@@ -158,9 +158,20 @@ class DmPanel(
if (plaintext != null) { if (plaintext != null) {
if (envelope.senderId == currentUserId) { if (envelope.senderId == currentUserId) {
val oldestOptimistic = _state.messages.filter { it.id < 0 }.minByOrNull { it.timestamp } 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)) addMessage(createMessage(envelope, plaintext))
}
} else {
addMessage(createMessage(envelope, plaintext))
}
if (envelope.replyToId != null) { if (envelope.replyToId != null) {
val replyTo = _state.messages.find { it.id == envelope.replyToId } val replyTo = _state.messages.find { it.id == envelope.replyToId }
updateMessage(envelope.id) { it.copy(reply_to = replyTo) } updateMessage(envelope.id) { it.copy(reply_to = replyTo) }
@@ -15,3 +15,5 @@ actual fun rememberImagePicker(onResult: (List<String>) -> Unit): () -> Unit {
actual fun rememberFilePicker(onResult: (List<String>) -> Unit): () -> Unit { actual fun rememberFilePicker(onResult: (List<String>) -> Unit): () -> Unit {
return { /* Phase 4: UIDocumentPickerViewController */ } return { /* Phase 4: UIDocumentPickerViewController */ }
} }
actual suspend fun getImageAspectRatio(uri: String): Float? = null