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
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,
/** 0100 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 = {}
) {
PendingImageContent(
uri = pendingFileUri,
isUploading = isUploading,
isImage = true
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,
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