mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Implement image loading
Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
@@ -331,6 +331,31 @@ object ApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch encrypted file bytes. Path is e.g. "/uploads/files/encrypted/xxx.jpg" or "/api/uploads/files/encrypted/xxx.jpg".
|
||||||
|
* Backend may return path with /api prefix; apiBaseUrl already includes /api, so we avoid double /api.
|
||||||
|
* @param thumb if true, appends ?thumb=1 for thumbnail (JPEG, ~5 KB)
|
||||||
|
*/
|
||||||
|
suspend fun fetchEncryptedFile(path: String, thumb: Boolean = false): ByteArray {
|
||||||
|
val baseUrl = when {
|
||||||
|
path.startsWith("http") -> path
|
||||||
|
path.startsWith("/api") -> {
|
||||||
|
val serverBase = Config.apiBaseUrl.removeSuffix("/api")
|
||||||
|
"$serverBase$path"
|
||||||
|
}
|
||||||
|
else -> "${Config.apiBaseUrl}$path"
|
||||||
|
}
|
||||||
|
val url = if (thumb) "$baseUrl?thumb=1" else baseUrl
|
||||||
|
return http.get(url).body()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch thumbnail for an image file. Returns null if thumbnail not available (404).
|
||||||
|
*/
|
||||||
|
suspend fun fetchThumbnail(path: String): ByteArray? = runCatching {
|
||||||
|
fetchEncryptedFile(path, thumb = true)
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Edit an existing direct message using the same transport encryption scheme as /dm/send.
|
* Edit an existing direct message using the same transport encryption scheme as /dm/send.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -92,7 +92,16 @@ data class Message(
|
|||||||
val verified: Boolean? = null,
|
val verified: Boolean? = null,
|
||||||
val reply_to: Message? = null,
|
val reply_to: Message? = null,
|
||||||
val client_message_id: String? = null,
|
val client_message_id: String? = null,
|
||||||
val reactions: List<ReactionData>? = null
|
val reactions: List<ReactionData>? = null,
|
||||||
|
val files: List<DmFile>? = null,
|
||||||
|
/** For optimistic UI: local URI when sending, null when confirmed. */
|
||||||
|
val pendingFileUri: String? = null,
|
||||||
|
/** For optimistic UI: jobId to track upload progress. */
|
||||||
|
val uploadJobId: String? = null,
|
||||||
|
/** For optimistic UI: 0-100 upload progress, null when complete. */
|
||||||
|
val uploadProgress: Int? = null,
|
||||||
|
/** For DM file decryption; not serialized over network. */
|
||||||
|
@kotlinx.serialization.Transient val dmEnvelope: DmEnvelope? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
@@ -46,3 +46,25 @@ suspend fun decryptEnvelope(envelope: DmEnvelope, currentUserId: Int?): String {
|
|||||||
val plaintext = DmCrypto.decryptEnvelope(envelope.ivB64, envelope.ciphertextB64, mek)
|
val plaintext = DmCrypto.decryptEnvelope(envelope.ivB64, envelope.ciphertextB64, mek)
|
||||||
return plaintext.decodeToString()
|
return plaintext.decodeToString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt a DM file attachment. Fetches encrypted bytes, unwraps file MEK, decrypts.
|
||||||
|
*/
|
||||||
|
suspend fun decryptFile(
|
||||||
|
file: ru.fromchat.api.DmFile,
|
||||||
|
envelope: DmEnvelope,
|
||||||
|
currentUserId: Int?
|
||||||
|
): ByteArray {
|
||||||
|
val wrappedMekB64 = file.wrappedMekB64
|
||||||
|
?: envelope.files?.find { it.path == file.path }?.wrappedMekB64
|
||||||
|
?: envelope.wrappedMekB64
|
||||||
|
?: throw IllegalArgumentException("No MEK available for file decryption: ${file.path}")
|
||||||
|
val nonceB64 = file.nonceB64
|
||||||
|
?: envelope.files?.find { it.path == file.path }?.nonceB64
|
||||||
|
?: throw IllegalArgumentException("No nonce available for file decryption: ${file.path}")
|
||||||
|
|
||||||
|
val mek = unwrapMek(wrappedMekB64, envelope, currentUserId)
|
||||||
|
val encryptedBytes = ru.fromchat.api.ApiClient.fetchEncryptedFile(file.path)
|
||||||
|
val ciphertextB64 = com.pr0gramm3r101.utils.crypto.Base64.encode(encryptedBytes)
|
||||||
|
return DmCrypto.decryptEnvelope(nonceB64, ciphertextB64, mek)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
package ru.fromchat.ui.chat
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.AttachFile
|
||||||
|
import androidx.compose.material.icons.filled.Image
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.blur
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.api.DmEnvelope
|
||||||
|
import ru.fromchat.api.DmFile
|
||||||
|
import ru.fromchat.crypto.decryptFile
|
||||||
|
|
||||||
|
private val IMAGE_SIZE = 160.dp
|
||||||
|
private val IMAGE_RADIUS = 8.dp
|
||||||
|
|
||||||
|
private 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)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AttachmentPreview(
|
||||||
|
file: DmFile?,
|
||||||
|
dmEnvelope: DmEnvelope?,
|
||||||
|
currentUserId: Int?,
|
||||||
|
pendingFileUri: String?,
|
||||||
|
isUploading: Boolean,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val isImage = file?.let { isImageFilename(it.name) } ?: pendingFileUri?.let {
|
||||||
|
it.contains("image", ignoreCase = true) || it.endsWith(".jpg", true) ||
|
||||||
|
it.endsWith(".png", true) || it.endsWith(".jpeg", true) ||
|
||||||
|
it.endsWith(".gif", true) || it.endsWith(".webp", true)
|
||||||
|
} ?: false
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.size(IMAGE_SIZE)
|
||||||
|
.clip(RoundedCornerShape(IMAGE_RADIUS))
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
pendingFileUri != null -> {
|
||||||
|
PendingImageContent(
|
||||||
|
uri = pendingFileUri,
|
||||||
|
isUploading = isUploading,
|
||||||
|
isImage = isImage
|
||||||
|
)
|
||||||
|
}
|
||||||
|
file != null && isImage && dmEnvelope != null -> {
|
||||||
|
DecryptedImageContent(
|
||||||
|
file = file,
|
||||||
|
envelope = dmEnvelope,
|
||||||
|
currentUserId = currentUserId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
file != null && !isImage -> {
|
||||||
|
FileIconContent(filename = file.name)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Image,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PendingImageContent(
|
||||||
|
uri: String,
|
||||||
|
isUploading: Boolean,
|
||||||
|
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)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
InfiniteCircularProgress()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
if (isUploading) {
|
||||||
|
InfiniteCircularProgress()
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.AttachFile,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun InfiniteCircularProgress() {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
strokeWidth = 3.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DecryptedImageContent(
|
||||||
|
file: DmFile,
|
||||||
|
envelope: DmEnvelope,
|
||||||
|
currentUserId: Int?
|
||||||
|
) {
|
||||||
|
var thumbnailBytes by remember(file.path) { mutableStateOf<ByteArray?>(null) }
|
||||||
|
var fullBytes by remember(file.path) { mutableStateOf<ByteArray?>(null) }
|
||||||
|
var imageReadyToUnblur by remember(file.path) { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(file.path) {
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
coroutineScope {
|
||||||
|
val thumbDeferred = async {
|
||||||
|
ApiClient.fetchThumbnail(file.path)
|
||||||
|
}
|
||||||
|
val fullDeferred = async {
|
||||||
|
runCatching { decryptFile(file, envelope, currentUserId) }.getOrNull()
|
||||||
|
}
|
||||||
|
thumbnailBytes = thumbDeferred.await()
|
||||||
|
fullBytes = fullDeferred.await()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val hasThumb = thumbnailBytes != null
|
||||||
|
val hasFull = fullBytes != null
|
||||||
|
val showContent = hasThumb || hasFull
|
||||||
|
|
||||||
|
if (!showContent) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(hasFull) {
|
||||||
|
if (hasFull) {
|
||||||
|
delay(80)
|
||||||
|
imageReadyToUnblur = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val blurProgress by animateFloatAsState(
|
||||||
|
targetValue = if (imageReadyToUnblur) 0f else 1f,
|
||||||
|
animationSpec = tween(300),
|
||||||
|
label = "blur"
|
||||||
|
)
|
||||||
|
val blurRadius = with(LocalDensity.current) { (blurProgress * 8.dp.toPx()).toDp() }
|
||||||
|
val displayBytes = fullBytes ?: thumbnailBytes!!
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
AsyncImage(
|
||||||
|
model = displayBytes,
|
||||||
|
contentDescription = file.name,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.clip(RoundedCornerShape(IMAGE_RADIUS))
|
||||||
|
.then(if (blurProgress > 0.01f) Modifier.blur(blurRadius) else Modifier),
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FileIconContent(filename: String) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.AttachFile,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(40.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = filename.take(20) + if (filename.length > 20) "…" else "",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 2
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,7 +87,7 @@ 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.
|
||||||
*/
|
*/
|
||||||
protected suspend fun addMessage(message: Message) {
|
suspend fun addMessage(message: Message) {
|
||||||
addMessageMutex.withLock {
|
addMessageMutex.withLock {
|
||||||
val messageExists = _state.messages.any { it.id == message.id }
|
val messageExists = _state.messages.any { it.id == message.id }
|
||||||
if (!messageExists) {
|
if (!messageExists) {
|
||||||
@@ -104,9 +104,9 @@ abstract class ChatPanel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update existing message
|
* Update existing message (public for ChatScreen optimistic UI)
|
||||||
*/
|
*/
|
||||||
protected fun updateMessage(messageId: Int, updates: (Message) -> Message) {
|
fun updateMessage(messageId: Int, updates: (Message) -> Message) {
|
||||||
updateState { currentState ->
|
updateState { currentState ->
|
||||||
currentState.copy(
|
currentState.copy(
|
||||||
messages = currentState.messages.map { msg ->
|
messages = currentState.messages.map { msg ->
|
||||||
|
|||||||
@@ -273,6 +273,24 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(panel) {
|
||||||
|
if (panel.getRecipientId() != null) {
|
||||||
|
AttachmentUploadQueue.progressFlow.collect { progress ->
|
||||||
|
when (progress) {
|
||||||
|
is ru.fromchat.api.AttachmentUploadProgress.InProgress ->
|
||||||
|
panel.updateMessage(-progress.jobId.hashCode().let { if (it == 0) -1 else it }) {
|
||||||
|
if (it.uploadJobId == progress.jobId) it.copy(uploadProgress = progress.percent) else it
|
||||||
|
}
|
||||||
|
is ru.fromchat.api.AttachmentUploadProgress.Success ->
|
||||||
|
panel.updateMessage(-progress.jobId.hashCode().let { if (it == 0) -1 else it }) {
|
||||||
|
if (it.uploadJobId == progress.jobId) it.copy(uploadProgress = null) else it
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -442,9 +460,30 @@ fun ChatScreen(
|
|||||||
if (attachments.isNotEmpty() && recipientId != null) {
|
if (attachments.isNotEmpty() && recipientId != null) {
|
||||||
val plaintext = text.ifBlank { "" }
|
val plaintext = text.ifBlank { "" }
|
||||||
attachments.forEach { att ->
|
attachments.forEach { att ->
|
||||||
|
val jobId = "dm_${Clock.System.now().toEpochMilliseconds()}_${att.id}"
|
||||||
|
val tempId = -jobId.hashCode().let { if (it == 0) -1 else it }
|
||||||
|
val optimisticMessage = Message(
|
||||||
|
id = tempId,
|
||||||
|
user_id = currentUserId ?: -1,
|
||||||
|
content = plaintext.ifBlank { att.filename },
|
||||||
|
timestamp = Clock.System.now().toString(),
|
||||||
|
is_read = false,
|
||||||
|
is_edited = false,
|
||||||
|
username = "You",
|
||||||
|
profile_picture = null,
|
||||||
|
verified = null,
|
||||||
|
reply_to = replyTo,
|
||||||
|
client_message_id = null,
|
||||||
|
reactions = null,
|
||||||
|
files = null,
|
||||||
|
pendingFileUri = att.uri,
|
||||||
|
uploadJobId = jobId,
|
||||||
|
uploadProgress = 0
|
||||||
|
)
|
||||||
|
panel.addMessage(optimisticMessage)
|
||||||
AttachmentUploadQueue.enqueue(
|
AttachmentUploadQueue.enqueue(
|
||||||
AttachmentUploadJob(
|
AttachmentUploadJob(
|
||||||
jobId = "dm_${Clock.System.now().toEpochMilliseconds()}_${att.id}",
|
jobId = jobId,
|
||||||
fileUri = att.uri,
|
fileUri = att.uri,
|
||||||
filename = att.filename,
|
filename = att.filename,
|
||||||
recipientId = recipientId,
|
recipientId = recipientId,
|
||||||
@@ -523,6 +562,7 @@ fun ChatScreen(
|
|||||||
MessageItem(
|
MessageItem(
|
||||||
message = message,
|
message = message,
|
||||||
isAuthor = message.user_id == currentUserId,
|
isAuthor = message.user_id == currentUserId,
|
||||||
|
currentUserId = currentUserId,
|
||||||
onLongPress = {
|
onLongPress = {
|
||||||
contextMenuState = ContextMenuState(
|
contextMenuState = ContextMenuState(
|
||||||
isOpen = true,
|
isOpen = true,
|
||||||
|
|||||||
@@ -52,7 +52,8 @@ fun MessageItem(
|
|||||||
onLongPress: () -> Unit,
|
onLongPress: () -> Unit,
|
||||||
onTapPosition: (Offset) -> Unit = {},
|
onTapPosition: (Offset) -> Unit = {},
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
showUsername: Boolean = true
|
showUsername: Boolean = true,
|
||||||
|
currentUserId: Int? = null
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = true,
|
visible = true,
|
||||||
@@ -199,17 +200,39 @@ fun MessageItem(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Message content
|
// Attachments (images/files)
|
||||||
Text(
|
if (message.pendingFileUri != null) {
|
||||||
text = message.content,
|
AttachmentPreview(
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
file = null,
|
||||||
color = if (isAuthor) {
|
dmEnvelope = null,
|
||||||
Color.White
|
currentUserId = null,
|
||||||
} else {
|
pendingFileUri = message.pendingFileUri,
|
||||||
MaterialTheme.colorScheme.onSurface
|
isUploading = message.uploadProgress != null,
|
||||||
},
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||||
modifier = Modifier.padding(horizontal = 12.dp)
|
)
|
||||||
)
|
}
|
||||||
|
message.files?.forEach { file ->
|
||||||
|
AttachmentPreview(
|
||||||
|
file = file,
|
||||||
|
dmEnvelope = message.dmEnvelope,
|
||||||
|
currentUserId = currentUserId,
|
||||||
|
pendingFileUri = null,
|
||||||
|
isUploading = false,
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (message.content.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
text = message.content,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = if (isAuthor) {
|
||||||
|
Color.White
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
},
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Timestamp and edited indicator
|
// Timestamp and edited indicator
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
@@ -146,6 +146,10 @@ class DmPanel(
|
|||||||
scope.launch(Dispatchers.Default) {
|
scope.launch(Dispatchers.Default) {
|
||||||
val plaintext = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull()
|
val plaintext = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull()
|
||||||
if (plaintext != null) {
|
if (plaintext != null) {
|
||||||
|
if (envelope.senderId == currentUserId) {
|
||||||
|
val oldestOptimistic = _state.messages.filter { it.id < 0 }.minByOrNull { it.timestamp }
|
||||||
|
oldestOptimistic?.let { removeMessage(it.id) }
|
||||||
|
}
|
||||||
addMessage(createMessage(envelope, plaintext))
|
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 }
|
||||||
@@ -188,7 +192,9 @@ class DmPanel(
|
|||||||
verified = null,
|
verified = null,
|
||||||
reply_to = null,
|
reply_to = null,
|
||||||
client_message_id = null,
|
client_message_id = null,
|
||||||
reactions = null
|
reactions = null,
|
||||||
|
files = envelope.files,
|
||||||
|
dmEnvelope = envelope
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user