mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Implement upload indicators
Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
@@ -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<String>) -> Unit): () -> Unit {
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
|
||||
@@ -96,6 +96,10 @@ data class Message(
|
||||
val files: List<DmFile>? = 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. */
|
||||
|
||||
@@ -28,3 +28,6 @@ expect fun rememberFilePicker(onResult: (List<String>) -> 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?
|
||||
|
||||
@@ -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,9 +174,11 @@ fun AttachmentPreview(
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (!isExpanded) {
|
||||
DecryptedImageContent(
|
||||
messageId = messageId ?: -1,
|
||||
fileIndex = fileIndex ?: 0,
|
||||
when {
|
||||
isPendingImage && isImageWithThumb -> UnifiedImageContent(
|
||||
localUri = pendingFileUri,
|
||||
messageId = messageId!!,
|
||||
fileIndex = fileIndex!!,
|
||||
file = file,
|
||||
envelope = dmEnvelope,
|
||||
currentUserId = currentUserId,
|
||||
@@ -172,36 +186,93 @@ fun AttachmentPreview(
|
||||
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
|
||||
}
|
||||
|
||||
@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)
|
||||
) {
|
||||
PendingImageContent(
|
||||
uri = pendingFileUri,
|
||||
isUploading = isUploading,
|
||||
isImage = true
|
||||
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,
|
||||
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(
|
||||
modifier = Modifier
|
||||
.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
|
||||
) {
|
||||
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,6 +416,12 @@ private fun DecryptedImageContent(
|
||||
onFullyLoaded(cachedPath != null)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(RoundedCornerShape(IMAGE_RADIUS))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
when {
|
||||
cachedPath != null -> {
|
||||
@@ -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),
|
||||
.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,7 +531,8 @@ 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),
|
||||
|
||||
@@ -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)
|
||||
) {
|
||||
if (attachment.isImage) {
|
||||
AsyncImage(
|
||||
model = attachment.uri,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(RoundedCornerShape(8.dp)),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = if (attachment.isImage) Icons.Default.Image else Icons.Default.AttachFile,
|
||||
imageVector = Icons.Default.AttachFile,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = attachment.filename,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)) }
|
||||
|
||||
|
||||
@@ -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(
|
||||
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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
if (envelope.replyToId != null) {
|
||||
val replyTo = _state.messages.find { it.id == envelope.replyToId }
|
||||
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 {
|
||||
return { /* Phase 4: UIDocumentPickerViewController */ }
|
||||
}
|
||||
|
||||
actual suspend fun getImageAspectRatio(uri: String): Float? = null
|
||||
|
||||
Reference in New Issue
Block a user