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
|
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,
|
||||||
|
/** 0–100 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 = {}
|
||||||
|
) {
|
||||||
|
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(
|
AsyncImage(
|
||||||
uri = pendingFileUri,
|
model = localUri,
|
||||||
isUploading = isUploading,
|
contentDescription = null,
|
||||||
isImage = true
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user