mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 11:05:04 +03:00
Fix image and profile bugs
Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
+27
-15
@@ -8,6 +8,7 @@ import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.core.graphics.scale
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import java.io.ByteArrayInputStream
|
||||
|
||||
actual object PlatformDecodedBitmapCache {
|
||||
private val cache: LruCache<String, ImageBitmap> = object : LruCache<String, ImageBitmap>(maxCacheBytes()) {
|
||||
@@ -85,23 +86,34 @@ private fun decodeSampledFromBytes(bytes: ByteArray, reqWidthPx: Int, reqHeightP
|
||||
|
||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||
|
||||
val orientation = runCatching {
|
||||
ExifInterface(ByteArrayInputStream(bytes)).getAttributeInt(
|
||||
ExifInterface.TAG_ORIENTATION,
|
||||
ExifInterface.ORIENTATION_NORMAL,
|
||||
)
|
||||
}.getOrDefault(ExifInterface.ORIENTATION_NORMAL)
|
||||
val (orientedW, orientedH) = orientedDimensions(bounds.outWidth, bounds.outHeight, orientation)
|
||||
|
||||
return scaleBitmapToFitWithin(
|
||||
BitmapFactory.decodeByteArray(
|
||||
bytes,
|
||||
0,
|
||||
bytes.size,
|
||||
BitmapFactory.Options().apply {
|
||||
inSampleSize = calculateInSampleSize(
|
||||
bounds.outWidth,
|
||||
bounds.outHeight,
|
||||
reqWidthPx,
|
||||
reqHeightPx
|
||||
)
|
||||
inPreferredConfig = Bitmap.Config.ARGB_8888
|
||||
}
|
||||
) ?: return null,
|
||||
applyExifOrientation(
|
||||
BitmapFactory.decodeByteArray(
|
||||
bytes,
|
||||
0,
|
||||
bytes.size,
|
||||
BitmapFactory.Options().apply {
|
||||
inSampleSize = calculateInSampleSize(
|
||||
orientedW,
|
||||
orientedH,
|
||||
reqWidthPx,
|
||||
reqHeightPx,
|
||||
)
|
||||
inPreferredConfig = Bitmap.Config.ARGB_8888
|
||||
},
|
||||
) ?: return null,
|
||||
orientation,
|
||||
),
|
||||
reqWidthPx,
|
||||
reqHeightPx
|
||||
reqHeightPx,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+22
-4
@@ -2,6 +2,7 @@ package ru.fromchat.api.local.download
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import java.io.ByteArrayInputStream
|
||||
|
||||
internal actual fun readLocalImageDimensions(absolutePath: String): Pair<Int, Int>? {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
@@ -13,11 +14,28 @@ internal actual fun readLocalImageDimensions(absolutePath: String): Pair<Int, In
|
||||
ExifInterface.ORIENTATION_NORMAL,
|
||||
)
|
||||
}.getOrDefault(ExifInterface.ORIENTATION_NORMAL)
|
||||
return when (orientation) {
|
||||
return orientedPixelDimensions(bounds.outWidth, bounds.outHeight, orientation)
|
||||
}
|
||||
|
||||
internal actual fun readImageDimensionsFromBytes(data: ByteArray): Pair<Int, Int>? {
|
||||
if (data.isEmpty()) return null
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeStream(ByteArrayInputStream(data), null, bounds)
|
||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||
val orientation = runCatching {
|
||||
ExifInterface(ByteArrayInputStream(data)).getAttributeInt(
|
||||
ExifInterface.TAG_ORIENTATION,
|
||||
ExifInterface.ORIENTATION_NORMAL,
|
||||
)
|
||||
}.getOrDefault(ExifInterface.ORIENTATION_NORMAL)
|
||||
return orientedPixelDimensions(bounds.outWidth, bounds.outHeight, orientation)
|
||||
}
|
||||
|
||||
private fun orientedPixelDimensions(width: Int, height: Int, orientation: Int): Pair<Int, Int> =
|
||||
when (orientation) {
|
||||
ExifInterface.ORIENTATION_ROTATE_90,
|
||||
ExifInterface.ORIENTATION_ROTATE_270,
|
||||
ExifInterface.ORIENTATION_TRANSPOSE,
|
||||
ExifInterface.ORIENTATION_TRANSVERSE -> bounds.outHeight to bounds.outWidth
|
||||
else -> bounds.outWidth to bounds.outHeight
|
||||
ExifInterface.ORIENTATION_TRANSVERSE -> height to width
|
||||
else -> width to height
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
<string name="coming_soon">Скоро…</string>
|
||||
<string name="contacts_empty_title">Скоро</string>
|
||||
<string name="contacts_empty_body">Список контактов появится здесь, когда функция будет готова.</string>
|
||||
<string name="chat_scroll_to_bottom_cd">Прокрутить к новым сообщениям</string>
|
||||
<string name="public_chat">Общий чат</string>
|
||||
<string name="chat_last_mesaage">Вы: последнее сообщение</string>
|
||||
<string name="chat_preview_attachment">Вложение</string>
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
<string name="coming_soon">Coming soon…</string>
|
||||
<string name="contacts_empty_title">Coming soon</string>
|
||||
<string name="contacts_empty_body">Your contacts will appear here when this feature is ready.</string>
|
||||
<string name="chat_scroll_to_bottom_cd">Scroll to latest messages</string>
|
||||
<string name="public_chat">Main chat</string>
|
||||
<string name="chat_last_mesaage">You: last message</string>
|
||||
<string name="chat_preview_attachment">Attachment</string>
|
||||
|
||||
@@ -16,6 +16,7 @@ import ru.fromchat.api.schema.messages.dm.DmFile
|
||||
import ru.fromchat.api.local.AttachmentMediaLog
|
||||
import ru.fromchat.api.local.cache.DecryptedImageCache
|
||||
import ru.fromchat.api.local.download.readLocalImageDimensions
|
||||
import ru.fromchat.ui.chat.isImageFilename
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
||||
|
||||
@@ -118,6 +119,7 @@ fun resolveLocalPreviewUri(message: Message): String? {
|
||||
message.pendingFileUri?.takeIf { uri ->
|
||||
DecryptedImageCache.isDecryptedImageCacheUri(uri) && localPreviewFileExists(uri)
|
||||
}?.let { return it }
|
||||
if (!messageQualifiesForImageCacheHydration(message)) return null
|
||||
val cid = message.client_message_id?.trim()?.takeIf { it.isNotEmpty() }
|
||||
if (cid != null) {
|
||||
DecryptedImageCache.getCached(message.id, fileIndex = 0, cid)
|
||||
@@ -132,6 +134,35 @@ fun resolveLocalPreviewUri(message: Message): String? {
|
||||
return null
|
||||
}
|
||||
|
||||
/** True when disk lookup may attach a decrypted image preview to [message]. */
|
||||
internal fun messageQualifiesForImageCacheHydration(message: Message): Boolean {
|
||||
if (message.files.orEmpty().any { isImageFilename(it.name) }) return true
|
||||
if (message.dmEnvelope?.files.orEmpty().any { isImageFilename(it.name) }) return true
|
||||
if (message.pendingFileAspectRatio != null) return true
|
||||
message.pendingFilename?.trim()?.takeIf { it.isNotEmpty() }?.let { name ->
|
||||
if (isImageFilename(name)) return true
|
||||
}
|
||||
message.pendingFileUri?.trim()?.takeIf { it.isNotEmpty() }?.let { uri ->
|
||||
if (DecryptedImageCache.isDecryptedImageCacheUri(uri)) return true
|
||||
val name = uri.substringAfterLast('/').substringBefore('?')
|
||||
if (isImageFilename(name)) return true
|
||||
}
|
||||
val parsed = parseDmMessageContent(message.content)
|
||||
if (!parsed.fileThumbnails.isNullOrEmpty() && !parsed.files.isNullOrEmpty()) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/** Restores pending preview URIs from DB without attaching orphaned image cache files to text rows. */
|
||||
internal fun resolveStoredPendingFileUri(
|
||||
message: Message,
|
||||
parsed: ParsedDmMessageContent,
|
||||
): String? {
|
||||
parsed.pendingFileUri?.trim()?.takeIf { it.isNotEmpty() }?.let { return it }
|
||||
if (!messageQualifiesForImageCacheHydration(message)) return null
|
||||
parsed.localPreviewUri?.takeIf { localPreviewFileExists(it) }?.let { return it }
|
||||
return resolveLocalPreviewUri(message)
|
||||
}
|
||||
|
||||
/** Sync disk lookup for cold-start chat open (no suspend alias copy). */
|
||||
fun hydrateAttachmentPreviewFromDiskSync(message: Message): Message {
|
||||
val previewUri = resolveLocalPreviewUri(message) ?: return hydrateDiskAspectRatioSync(message)
|
||||
|
||||
+71
-16
@@ -26,8 +26,8 @@ import ru.fromchat.api.local.db.encodeOptimisticOutboundMessage
|
||||
import ru.fromchat.api.local.db.encodePersistedDmMessage
|
||||
import ru.fromchat.api.local.db.encodePersistedPublicMessage
|
||||
import ru.fromchat.api.local.db.parseDmMessageContent
|
||||
import ru.fromchat.api.local.db.resolveStoredPendingFileUri
|
||||
import ru.fromchat.api.local.db.hydrateAttachmentPreviewFromDiskSync
|
||||
import ru.fromchat.api.local.db.resolveLocalPreviewUri
|
||||
import ru.fromchat.api.local.messages.sortMessagesForChatDisplay
|
||||
import ru.fromchat.api.local.send.DmAttachmentOutboxPayload
|
||||
import ru.fromchat.api.local.send.PublicAttachmentOutboxPayload
|
||||
@@ -45,6 +45,8 @@ import ru.fromchat.api.crypto.decryptEnvelope
|
||||
import ru.fromchat.api.local.cache.DecryptedFileCache
|
||||
import ru.fromchat.api.local.cache.DecryptedImageCache
|
||||
import ru.fromchat.api.local.download.DownloadedFileRegistry
|
||||
import ru.fromchat.api.local.download.readImageDimensionsFromBytes
|
||||
import com.pr0gramm3r101.utils.crypto.Base64
|
||||
import ru.fromchat.ui.chat.utils.attachPublicReplyReferences
|
||||
import ru.fromchat.ui.chat.isImageFilename
|
||||
import ru.fromchat.ui.chat.utils.dedupeMessagesByClientId
|
||||
@@ -166,21 +168,20 @@ object MessageCacheStore {
|
||||
}
|
||||
|
||||
suspend fun replacePublicMessages(messages: List<Message>) {
|
||||
val convId = conversationIdForPublic()
|
||||
val resolved = messages.map { it.resolvePublicAttachmentLayout() }
|
||||
ProfileCache.mergePreviewFromPublicMessages(resolved)
|
||||
conversationIdForPublic().let {
|
||||
replaceMessages(
|
||||
it,
|
||||
sortMessagesForChatDisplay(
|
||||
dedupeMessagesByClientId(
|
||||
resolved + loadPendingMessages(it).filter { p ->
|
||||
val cid = p.client_message_id
|
||||
cid == null || resolved.none { it.client_message_id == cid }
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
val pending = loadPendingMessages(convId)
|
||||
val stillPending = filterStillPendingForReplace(convId, pending, resolved)
|
||||
val before = resolved + stillPending
|
||||
val merged = dedupeMessagesByClientId(
|
||||
dropSupersededOptimisticMessages(before, ApiClient.user?.id),
|
||||
).let { sortMessagesForChatDisplay(it) }
|
||||
val iid = instanceId()
|
||||
withContext(Dispatchers.Default) {
|
||||
purgeSupersededPendingRows(iid, convId, before, merged)
|
||||
}
|
||||
replaceMessages(convId, merged)
|
||||
}
|
||||
|
||||
suspend fun clearPublicMessages() {
|
||||
@@ -305,6 +306,62 @@ object MessageCacheStore {
|
||||
var resolved = confirmed.resolvePublicAttachmentLayout()
|
||||
resolved = hydrateAttachmentPreviewFromDisk(resolved)
|
||||
ProfileCache.mergePreviewFromPublicMessage(resolved)
|
||||
// If we have a pending optimistic row with a local aspect, prefer it when server
|
||||
// aspect appears to be a rotated reciprocal (common when EXIF/metadata were swapped).
|
||||
try {
|
||||
val convId = conversationIdForPublic()
|
||||
val pending = loadPendingMessages(convId)
|
||||
val local = pending.firstOrNull { it.client_message_id == clientMessageId }
|
||||
if (local != null && !local.files.isNullOrEmpty()) {
|
||||
val localAspect = local.pendingFileAspectRatio
|
||||
?: local.fileDimensions?.firstOrNull()?.let { (w, h) ->
|
||||
if (h > 0) w.toFloat() / h.toFloat() else null
|
||||
}
|
||||
val serverAspect = resolved.fileAspectRatios?.firstOrNull()
|
||||
?: resolved.fileDimensions?.firstOrNull()?.let { (w, h) ->
|
||||
if (h > 0) w.toFloat() / h.toFloat() else null
|
||||
}
|
||||
if (localAspect != null && serverAspect != null) {
|
||||
val product = localAspect * serverAspect
|
||||
if (product in 0.92f..1.08f && kotlin.math.abs(localAspect - serverAspect) > 0.15f) {
|
||||
// Swap to local aspect to preserve correct orientation
|
||||
resolved = resolved.copy(
|
||||
fileAspectRatios = listOf(localAspect),
|
||||
fileDimensions = local.fileDimensions ?: resolved.fileDimensions,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Best-effort only; fall back to server-resolved layout on any failure.
|
||||
}
|
||||
// If no local optimistic row, prefer decoded server thumbnail dims when available
|
||||
try {
|
||||
val convId = conversationIdForPublic()
|
||||
val pending = loadPendingMessages(convId)
|
||||
val local = pending.firstOrNull { it.client_message_id == clientMessageId }
|
||||
if (local == null) {
|
||||
val thumbB64 = resolved.fileThumbnails?.firstOrNull()?.takeIf { it.isNotBlank() }
|
||||
if (!thumbB64.isNullOrBlank()) {
|
||||
val bytes = runCatching { Base64.decode(thumbB64) }.getOrNull()
|
||||
val thumbDims = bytes?.let { readImageDimensionsFromBytes(it) }
|
||||
val decodedAspect = thumbDims?.let { (w, h) -> if (h > 0) w.toFloat() / h.toFloat() else null }
|
||||
val serverAspect = resolved.fileAspectRatios?.firstOrNull()
|
||||
?: resolved.fileDimensions?.firstOrNull()?.let { (w, h) -> if (h > 0) w.toFloat() / h.toFloat() else null }
|
||||
if (decodedAspect != null && serverAspect != null) {
|
||||
val product = decodedAspect * serverAspect
|
||||
if (product in 0.92f..1.08f && kotlin.math.abs(decodedAspect - serverAspect) > 0.15f) {
|
||||
resolved = resolved.copy(
|
||||
fileAspectRatios = listOf(decodedAspect),
|
||||
fileDimensions = thumbDims?.let { listOf(it.first to it.second) } ?: resolved.fileDimensions,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Best-effort only
|
||||
}
|
||||
confirmMessage(conversationIdForPublic(), clientMessageId, resolved)
|
||||
}
|
||||
}
|
||||
@@ -1220,9 +1277,7 @@ object MessageCacheStore {
|
||||
isContentCorrupted = parsed.isContentCorrupted,
|
||||
)
|
||||
val hydrated = base.copy(
|
||||
pendingFileUri = parsed.pendingFileUri
|
||||
?: parsed.localPreviewUri
|
||||
?: resolveLocalPreviewUri(base),
|
||||
pendingFileUri = resolveStoredPendingFileUri(base, parsed),
|
||||
pendingFilename = parsed.pendingFilename ?: base.pendingFilename,
|
||||
uploadJobId = parsed.uploadJobId ?: base.uploadJobId,
|
||||
fileSizes = parsed.fileSizes ?: base.fileSizes,
|
||||
|
||||
+3
@@ -2,3 +2,6 @@ package ru.fromchat.api.local.download
|
||||
|
||||
/** EXIF-oriented width/height from a local file (bounds read only; no full decode). */
|
||||
internal expect fun readLocalImageDimensions(absolutePath: String): Pair<Int, Int>?
|
||||
|
||||
/** Bounds-only width/height from encoded image bytes (JPEG EXIF when supported). */
|
||||
internal expect fun readImageDimensionsFromBytes(data: ByteArray): Pair<Int, Int>?
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package ru.fromchat.api.local.messages
|
||||
|
||||
import ru.fromchat.api.local.cache.DecryptedImageCache
|
||||
import ru.fromchat.api.local.db.parseDmMessageContent
|
||||
import ru.fromchat.api.schema.messages.Message
|
||||
import ru.fromchat.api.schema.messages.dm.DmEnvelope
|
||||
@@ -36,6 +37,7 @@ fun messageHasImageAttachment(message: Message): Boolean {
|
||||
if (isImageFilename(file.name)) return true
|
||||
}
|
||||
if (message.pendingFileUri != null) {
|
||||
if (DecryptedImageCache.isDecryptedImageCacheUri(message.pendingFileUri)) return true
|
||||
val pendingName = message.pendingFilename?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: message.pendingFileUri.substringAfterLast('/').substringBefore('?')
|
||||
if (isImageFilename(pendingName)) return true
|
||||
|
||||
@@ -9,6 +9,8 @@ import kotlinx.coroutines.launch
|
||||
|
||||
sealed class OutboundSendProgress {
|
||||
data class Pending(val clientMessageId: String) : OutboundSendProgress()
|
||||
data class Success(val clientMessageId: String, val message: ru.fromchat.api.schema.messages.Message) :
|
||||
OutboundSendProgress()
|
||||
data class Failed(val clientMessageId: String, val error: String) : OutboundSendProgress()
|
||||
}
|
||||
|
||||
|
||||
+9
-1
@@ -30,6 +30,7 @@ import ru.fromchat.api.local.db.buildDmOutboundPlaintext
|
||||
import ru.fromchat.api.local.cache.clearUploadArtifacts
|
||||
import ru.fromchat.api.local.cache.clearUploadSecretsOnly
|
||||
import ru.fromchat.api.local.AttachmentMediaLog
|
||||
import ru.fromchat.Logger
|
||||
|
||||
/**
|
||||
* Single entry point for enqueueing outbound messages (DB row + outbox + worker).
|
||||
@@ -66,15 +67,21 @@ object OutgoingMessageCoordinator {
|
||||
}
|
||||
var ok = true
|
||||
sendResult.onSuccess { confirmed ->
|
||||
val resolved = confirmed.copy(client_message_id = row.clientMessageId)
|
||||
Logger.d("OutgoingMessageCoordinator", "handlePublicOutboxSend: success clientId=${row.clientMessageId.take(12)} realId=${resolved.id}")
|
||||
withContext(Dispatchers.Default) {
|
||||
MessageCacheStore.confirmPublicMessage(
|
||||
row.clientMessageId,
|
||||
confirmed.copy(client_message_id = row.clientMessageId),
|
||||
resolved,
|
||||
)
|
||||
MessageDatabaseProvider.database.messageDatabaseQueries
|
||||
.deleteOutboxItem(instanceId, row.clientMessageId)
|
||||
}
|
||||
OutboundSendNotifier.emit(
|
||||
OutboundSendProgress.Success(row.clientMessageId, resolved),
|
||||
)
|
||||
}.onFailure { error ->
|
||||
Logger.d("OutgoingMessageCoordinator", "handlePublicOutboxSend: failure clientId=${row.clientMessageId.take(12)} err=${error.message ?: error::class.simpleName}")
|
||||
when {
|
||||
error.isOutboundPermanentFailure() -> {
|
||||
val errorKey = outboundFailureErrorKey(error)
|
||||
@@ -152,6 +159,7 @@ object OutgoingMessageCoordinator {
|
||||
val conversationId = conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID)
|
||||
withContext(Dispatchers.Default) {
|
||||
MessageRepository.upsertPublicMessage(optimisticMessage)
|
||||
Logger.d("OutgoingMessageCoordinator", "enqueuePublicMessage: clientId=${clientMessageId.take(12)} contentLen=${content.length}")
|
||||
val payload = json.encodeToString(PublicOutboxPayload(content, replyToId))
|
||||
MessageDatabaseProvider.database.messageDatabaseQueries.upsertOutbox(
|
||||
instanceId = instanceId,
|
||||
|
||||
@@ -105,6 +105,7 @@ import ru.fromchat.ui.chat.utils.attachmentTileLayout
|
||||
import ru.fromchat.ui.chat.utils.coalesceDecodeTarget
|
||||
import ru.fromchat.ui.chat.utils.decodeSizeChangedMeaningfully
|
||||
import ru.fromchat.ui.chat.utils.peekDecodedAttachmentBitmap
|
||||
import ru.fromchat.ui.chat.utils.preferDecodedAspectRatio
|
||||
import ru.fromchat.ui.components.Text
|
||||
import com.pr0gramm3r101.utils.scaleOnPress
|
||||
import ru.fromchat.ui.chat.MessageGroupInfo
|
||||
@@ -202,6 +203,9 @@ fun AttachmentPreview(
|
||||
var isFullyLoaded by remember(messageId, fileIndex, file?.path, pendingFileUri) {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
var effectiveAspect by remember(messageId, fileIndex, file?.path, pendingFileUri, fileAspectRatio) {
|
||||
mutableStateOf(fileAspectRatio)
|
||||
}
|
||||
Box(
|
||||
modifier = modifier
|
||||
.then(
|
||||
@@ -219,7 +223,7 @@ fun AttachmentPreview(
|
||||
)
|
||||
// Explicit px size from dp max + aspect — do not wrap to thumb intrinsics
|
||||
// (IntrinsicSize.Max bubbles otherwise shrink to ~80px ≈ 3× too small).
|
||||
.attachmentTileLayout(aspectRatio = fileAspectRatio)
|
||||
.attachmentTileLayout(aspectRatio = effectiveAspect)
|
||||
.clip(attachmentImageCornerShape(isAuthor, messageGroup))
|
||||
.then(
|
||||
if (onImageBounds != null && showImageTile) {
|
||||
@@ -273,6 +277,12 @@ fun AttachmentPreview(
|
||||
onCancelUpload = onCancelUpload,
|
||||
onFullyLoaded = { if (it) isFullyLoaded = true },
|
||||
messageGroup = messageGroup,
|
||||
onResolvedAspectRatio = { width, height ->
|
||||
val resolved = preferDecodedAspectRatio(fileAspectRatio, width, height)
|
||||
if (resolved != effectiveAspect) {
|
||||
effectiveAspect = resolved
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -307,6 +317,7 @@ private fun ChatImageTileContent(
|
||||
hasSameAuthorAbove = false,
|
||||
hasSameAuthorBelow = false,
|
||||
),
|
||||
onResolvedAspectRatio: ((width: Int, height: Int) -> Unit)? = null,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val clipShape = attachmentImageCornerShape(isAuthor, messageGroup)
|
||||
@@ -671,6 +682,11 @@ private fun ChatImageTileContent(
|
||||
if (placeholderBitmap != null) decryptFinished = true
|
||||
}
|
||||
|
||||
LaunchedEffect(fullBitmap, placeholderBitmap) {
|
||||
val bitmap = fullBitmap ?: placeholderBitmap ?: return@LaunchedEffect
|
||||
onResolvedAspectRatio?.invoke(bitmap.width, bitmap.height)
|
||||
}
|
||||
|
||||
LaunchedEffect(treatAsOutbound, messageId, fileIndex, cacheClientId) {
|
||||
if (treatAsOutbound) {
|
||||
AttachmentDownloadNotifier.clearProgress(messageId, fileIndex, cacheClientId)
|
||||
|
||||
@@ -93,7 +93,10 @@ abstract class ChatPanel(
|
||||
addMessageMutex.withLock {
|
||||
batchStateUpdates {
|
||||
updateState { current ->
|
||||
val merged = mergeDatabaseMessagesWithPanelState(current.messages, messages)
|
||||
val merged = mergeDatabaseMessagesWithPanelState(
|
||||
panelMessagesForDbMerge(),
|
||||
messages,
|
||||
)
|
||||
val withReplies = attachPublicReplyReferences(merged)
|
||||
if (current.messages == withReplies) current
|
||||
else current.copy(messages = withReplies)
|
||||
@@ -345,14 +348,39 @@ abstract class ChatPanel(
|
||||
updateState { it.copy(messages = emptyList()) }
|
||||
}
|
||||
|
||||
/** In-flight sends only (active [pendingMessages]), not stale cache optimistics. */
|
||||
/** In-flight sends ([pendingMessages]), including rows cleared from [_state] by a DB refresh. */
|
||||
protected fun snapshotPendingOptimisticMessages(): List<Message> {
|
||||
if (pendingMessages.isEmpty()) return emptyList()
|
||||
val pendingClientIds = pendingMessages.keys
|
||||
return _state.messages.filter { msg ->
|
||||
val fromState = _state.messages.filter { msg ->
|
||||
val cid = msg.client_message_id?.trim().orEmpty()
|
||||
cid.isNotEmpty() && cid in pendingClientIds
|
||||
}
|
||||
val coveredClientIds = fromState.mapNotNull { it.client_message_id?.trim()?.takeIf { it.isNotEmpty() } }.toSet()
|
||||
val fromMap = pendingMessages.values.map { it.second }.filter { msg ->
|
||||
val cid = msg.client_message_id?.trim().orEmpty()
|
||||
cid.isEmpty() || cid !in coveredClientIds
|
||||
}
|
||||
if (fromMap.isEmpty()) return fromState
|
||||
return ru.fromchat.ui.chat.utils.dedupeMessagesByClientId(fromState + fromMap)
|
||||
}
|
||||
|
||||
protected fun pendingOptimisticMessage(clientMessageId: String): Message? {
|
||||
val cid = clientMessageId.trim()
|
||||
if (cid.isEmpty()) return null
|
||||
return _state.messages.find { it.client_message_id == cid }
|
||||
?: pendingMessages[cid]?.second
|
||||
}
|
||||
|
||||
/** Returns comma-separated client ids of in-flight pending messages (for debug). */
|
||||
protected fun debugPendingKeys(): String =
|
||||
pendingMessages.keys.joinToString(",")
|
||||
|
||||
/** Panel snapshot for DB observe merges — keeps in-flight sends when SQL omits them. */
|
||||
protected fun panelMessagesForDbMerge(): List<Message> {
|
||||
val pending = snapshotPendingOptimisticMessages()
|
||||
if (pending.isEmpty()) return _state.messages
|
||||
return ru.fromchat.ui.chat.utils.dedupeMessagesByClientId(_state.messages + pending)
|
||||
}
|
||||
|
||||
protected suspend fun restorePendingOptimisticMessages(messages: List<Message>) {
|
||||
|
||||
@@ -23,15 +23,20 @@ import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SmallFloatingActionButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -73,6 +78,8 @@ import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
@@ -119,6 +126,7 @@ import ru.fromchat.back
|
||||
import ru.fromchat.action_delete_chat
|
||||
import ru.fromchat.ui.profile.peerIsDeleted
|
||||
import ru.fromchat.cd_call
|
||||
import ru.fromchat.chat_scroll_to_bottom_cd
|
||||
import ru.fromchat.chat_group_label
|
||||
import ru.fromchat.status_connecting
|
||||
import ru.fromchat.status_updating
|
||||
@@ -175,6 +183,16 @@ fun ChatScreen(
|
||||
val listState = rememberSaveable(panelId, saver = LazyListState.Saver) {
|
||||
LazyListState(0, 0)
|
||||
}
|
||||
var isNearBottom by rememberSaveable(panelId) { mutableStateOf(true) }
|
||||
LaunchedEffect(listState, panelState.messages.size) {
|
||||
snapshotFlow {
|
||||
val minVisibleIndex = listState.layoutInfo.visibleItemsInfo.minOfOrNull { it.index }
|
||||
?: Int.MAX_VALUE
|
||||
minVisibleIndex <= 2
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.collect { nearBottom -> isNearBottom = nearBottom }
|
||||
}
|
||||
val density = LocalDensity.current
|
||||
val fallbackMessageHeightPx = remember(density) { with(density) { 80.dp.roundToPx() } }
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -428,6 +446,7 @@ fun ChatScreen(
|
||||
val statusUpdating = stringResource(Res.string.status_updating)
|
||||
val presenceRecently = stringResource(Res.string.presence_recently)
|
||||
val chatGroupLabel = stringResource(Res.string.chat_group_label)
|
||||
val scrollToBottomCd = stringResource(Res.string.chat_scroll_to_bottom_cd)
|
||||
val cdCall = stringResource(Res.string.cd_call)
|
||||
LaunchedEffect(currentTypingUsers) {
|
||||
Logger.d("ChatScreen", "currentTypingUsers updated (from panelState): ${currentTypingUsers.map { it.username }}")
|
||||
@@ -482,9 +501,13 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll to specific message when requested (e.g., from notification click)
|
||||
LaunchedEffect(scrollToMessageId, panelState.messages) {
|
||||
scrollToMessageId?.let(scrollToChatMessage)
|
||||
// Scroll to specific message once when requested (e.g., notification / deep link).
|
||||
LaunchedEffect(scrollToMessageId) {
|
||||
val messageId = scrollToMessageId ?: return@LaunchedEffect
|
||||
snapshotFlow { lazyIndexForMessageId(listItems, messageId) }
|
||||
.filterNotNull()
|
||||
.first()
|
||||
scrollToChatMessage(messageId)
|
||||
}
|
||||
|
||||
// UI state
|
||||
@@ -693,6 +716,8 @@ fun ChatScreen(
|
||||
panel.updateMessageByClientMessageId(progress.clientMessageId) {
|
||||
it.copy(uploadError = null)
|
||||
}
|
||||
is OutboundSendProgress.Success ->
|
||||
panel.handleMessageConfirmed(progress.clientMessageId, progress.message)
|
||||
is OutboundSendProgress.Failed ->
|
||||
panel.updateMessageByClientMessageId(progress.clientMessageId) {
|
||||
it.copy(uploadError = progress.error)
|
||||
@@ -1271,6 +1296,34 @@ fun ChatScreen(
|
||||
item { Spacer(modifier.height(floatingHeaderClearance)) }
|
||||
}
|
||||
|
||||
val showScrollToBottomFab = panel.usesPublicGroupSubtitle &&
|
||||
!isNearBottom &&
|
||||
panelState.messages.isNotEmpty() &&
|
||||
!contextMenuState.isOpen
|
||||
AnimatedVisibility(
|
||||
visible = showScrollToBottomFab,
|
||||
enter = fadeIn(tween(150)) +
|
||||
expandVertically(expandFrom = Alignment.Bottom),
|
||||
exit = fadeOut(tween(120)) +
|
||||
shrinkVertically(shrinkTowards = Alignment.Bottom),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(end = 16.dp, bottom = 16.dp),
|
||||
) {
|
||||
SmallFloatingActionButton(
|
||||
onClick = {
|
||||
scope.launch { listState.animateScrollToItem(0) }
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.KeyboardArrowDown,
|
||||
contentDescription = scrollToBottomCd,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ChatTopBar(
|
||||
hazeState = hazeState,
|
||||
onBack = { navController.navigateUp() },
|
||||
|
||||
@@ -478,6 +478,8 @@ fun MessageItem(
|
||||
horizontalAlignment = if (isAuthor) Alignment.End else Alignment.Start
|
||||
) {
|
||||
val pendingIsImage = when {
|
||||
message.pendingFileUri != null &&
|
||||
DecryptedImageCache.isDecryptedImageCacheUri(message.pendingFileUri) -> true
|
||||
message.pendingFilename?.isNotBlank() == true ->
|
||||
isImageFilename(message.pendingFilename)
|
||||
message.pendingFileUri != null -> isImageFilename(
|
||||
@@ -487,7 +489,8 @@ fun MessageItem(
|
||||
}
|
||||
val pendingHasOutboundFile = message.pendingFileUri != null &&
|
||||
message.files.isNullOrEmpty() &&
|
||||
!pendingIsImage
|
||||
!pendingIsImage &&
|
||||
!DecryptedImageCache.isDecryptedImageCacheUri(message.pendingFileUri)
|
||||
val uploadFailed = !message.uploadError.isNullOrBlank()
|
||||
val canCancelUpload = message.isQueuedOutbound() && isAuthor &&
|
||||
!uploadFailed &&
|
||||
@@ -725,6 +728,13 @@ fun MessageItem(
|
||||
(primaryFile != null && !primaryIsImage)
|
||||
if (showPrimaryImageSlot) {
|
||||
val imageKey = imageAttachmentKey(message, 0)
|
||||
val primaryThumbBytes = remember(
|
||||
message.id,
|
||||
message.fileThumbnails?.firstOrNull(),
|
||||
) {
|
||||
message.fileThumbnails?.firstOrNull()
|
||||
?.let { decodeAttachmentThumbnailBase64(it) }
|
||||
}
|
||||
val layoutAspect = imageAspectRatioForMessage(
|
||||
fileAspectRatios = message.fileAspectRatios,
|
||||
fileDimensions = message.fileDimensions,
|
||||
@@ -733,6 +743,7 @@ fun MessageItem(
|
||||
fileIndex = 0,
|
||||
confirmed = message.id > 0,
|
||||
hasLocalPreview = !message.pendingFileUri.isNullOrBlank(),
|
||||
thumbnailBytes = primaryThumbBytes,
|
||||
)
|
||||
LaunchedEffect(
|
||||
message.id,
|
||||
@@ -896,6 +907,9 @@ fun MessageItem(
|
||||
.isDecryptedImageCacheUri(
|
||||
message.pendingFileUri,
|
||||
),
|
||||
thumbnailBytes = message.fileThumbnails
|
||||
?.getOrNull(index)
|
||||
?.let { decodeAttachmentThumbnailBase64(it) },
|
||||
),
|
||||
fileSizeBytes =
|
||||
message.fileSizes?.getOrNull(index),
|
||||
@@ -1121,6 +1135,7 @@ private fun MessageReplyQuote(
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(quoteBg)
|
||||
.height(IntrinsicSize.Min),
|
||||
|
||||
+36
-7
@@ -203,7 +203,10 @@ class PublicChatPanel(
|
||||
else -> {
|
||||
// Never replace the in-memory list with the DB snapshot alone — that
|
||||
// dropped paginated / ahead-of-network rows when reopening (e.g. profile).
|
||||
val merged = mergeDatabaseMessagesWithPanelState(shown, cached)
|
||||
val merged = mergeDatabaseMessagesWithPanelState(
|
||||
panelMessagesForDbMerge(),
|
||||
cached,
|
||||
)
|
||||
if (merged != shown) {
|
||||
updateState { it.copy(messages = sortMessagesForChatDisplay(merged)) }
|
||||
}
|
||||
@@ -272,16 +275,26 @@ class PublicChatPanel(
|
||||
}
|
||||
}
|
||||
val ahead = shown.filter { it.id > 0 && it.id !in networkIds }
|
||||
if (ahead.isEmpty()) return merged
|
||||
val inFlight = shown.filter { msg ->
|
||||
msg.id < 0 && (
|
||||
!msg.client_message_id.isNullOrBlank() ||
|
||||
msg.pendingFileUri != null ||
|
||||
!msg.uploadJobId.isNullOrBlank()
|
||||
)
|
||||
}
|
||||
if (ahead.isEmpty() && inFlight.isEmpty()) return merged
|
||||
val combined = merged + ahead + inFlight
|
||||
return ru.fromchat.api.local.messages.sortMessagesForChatDisplay(
|
||||
ru.fromchat.ui.chat.utils.dedupeMessagesByClientId(merged + ahead),
|
||||
ru.fromchat.ui.chat.utils.dedupeMessagesByClientId(combined),
|
||||
)
|
||||
}
|
||||
|
||||
private fun snapshotUiMessagesForNetworkMerge(): List<Message> = panelMessagesForDbMerge()
|
||||
|
||||
override suspend fun sendMessage(content: String, replyToId: Int?, clientMessageId: String?) {
|
||||
val cid = clientMessageId?.trim().orEmpty()
|
||||
if (cid.isEmpty()) return
|
||||
val optimistic = _state.messages.find { it.client_message_id == cid } ?: return
|
||||
val optimistic = pendingOptimisticMessage(cid) ?: return
|
||||
OutgoingMessageCoordinator.enqueuePublicMessage(
|
||||
content = content,
|
||||
replyToId = replyToId,
|
||||
@@ -334,8 +347,15 @@ class PublicChatPanel(
|
||||
if (response != null && response.messages.isNotEmpty()) {
|
||||
val networkMessages = response.messages.map { it.resolvePublicAttachmentLayout() }
|
||||
ProfileCache.mergePreviewFromPublicMessages(networkMessages)
|
||||
val optimisticSnapshot = snapshotPendingOptimisticMessages()
|
||||
val pendingStr = debugPendingKeys().takeIf { it.isNotBlank() } ?: "(none)"
|
||||
val optIds = optimisticSnapshot.mapNotNull { it.client_message_id }.ifEmpty { listOf<String>() }
|
||||
val loadMsg = "loadMessages: pendingKeys=$pendingStr optimisticSnapshot=$optIds stateCount=${_state.messages.size}"
|
||||
Logger.d("PublicChatPanel", loadMsg)
|
||||
var mergedForCache: List<Message>? = null
|
||||
withContext(Dispatchers.Main) {
|
||||
val shown = _state.messages
|
||||
val shown = snapshotUiMessagesForNetworkMerge()
|
||||
Logger.d("PublicChatPanel", "loadMessages: snapshotUiMessagesForNetworkMerge size=${shown.size}")
|
||||
if (shown.isNotEmpty() && !publicHistoryDiffersForUi(shown, networkMessages)) {
|
||||
Logger.d("PublicChatPanel", "Network history matches UI; skip clear/re-add")
|
||||
val withSenders = mergePublicSenderFieldsFromNetwork(shown, networkMessages)
|
||||
@@ -344,6 +364,7 @@ class PublicChatPanel(
|
||||
}
|
||||
if (_state.hasMoreMessages) setHasMoreMessages(false)
|
||||
if (_state.isLoading) setLoading(false)
|
||||
mergedForCache = mergeNetworkHistoryWithShown(shown, networkMessages)
|
||||
} else {
|
||||
batchStateUpdates {
|
||||
val merged = preserveReplyToFromExisting(
|
||||
@@ -354,14 +375,22 @@ class PublicChatPanel(
|
||||
addMessages(
|
||||
ProfileCache.enrichPublicMessagesForDisplay(merged),
|
||||
)
|
||||
Logger.d("PublicChatPanel", "loadMessages: after addMessages mergedSize=${merged.size} restoring optimistic count=${optimisticSnapshot.size}")
|
||||
restorePendingOptimisticMessages(optimisticSnapshot)
|
||||
setHasMoreMessages(false) // TODO: Implement has_more from API
|
||||
setLoading(false)
|
||||
mergedForCache = mergeNetworkHistoryWithShown(
|
||||
panelMessagesForDbMerge(),
|
||||
networkMessages,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.Default) {
|
||||
val mergedForCache = mergeNetworkHistoryWithShown(_state.messages, networkMessages)
|
||||
MessageCacheStore.replacePublicMessages(mergedForCache)
|
||||
val toPersist = mergedForCache
|
||||
?: mergeNetworkHistoryWithShown(panelMessagesForDbMerge(), networkMessages)
|
||||
Logger.d("PublicChatPanel", "loadMessages: persisting to cache messages=${toPersist.size}")
|
||||
MessageCacheStore.replacePublicMessages(toPersist)
|
||||
}
|
||||
} else if (responseResult.isFailure) {
|
||||
val cause = responseResult.exceptionOrNull()
|
||||
|
||||
+35
-6
@@ -135,6 +135,7 @@ internal fun imageAspectRatioForMessage(
|
||||
fileIndex: Int = 0,
|
||||
@Suppress("UNUSED_PARAMETER") confirmed: Boolean = true,
|
||||
@Suppress("UNUSED_PARAMETER") hasLocalPreview: Boolean = false,
|
||||
thumbnailBytes: ByteArray? = null,
|
||||
): Float? {
|
||||
val localRatio = pendingFileAspectRatio?.takeIf { fileIndex == 0 && it > 0f }
|
||||
val pair = fileAspectRatioPairs?.getOrNull(fileIndex)
|
||||
@@ -148,14 +149,42 @@ internal fun imageAspectRatioForMessage(
|
||||
val serverDim = fileDimensions?.getOrNull(fileIndex)
|
||||
val serverRatio = fileAspectRatios?.getOrNull(fileIndex)?.takeIf { it > 0f }
|
||||
|
||||
pairRatio?.let { return it }
|
||||
serverDim?.let { (w, h) ->
|
||||
if (w > 0 && h > 0 && !isPlaceholderAttachmentDimensions(w, h)) {
|
||||
return aspectRatioFromDimensionPair(w, h)
|
||||
val metadataAspect = pairRatio
|
||||
?: serverDim?.let { (w, h) ->
|
||||
if (w > 0 && h > 0 && !isPlaceholderAttachmentDimensions(w, h)) {
|
||||
aspectRatioFromDimensionPair(w, h)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
?: serverRatio?.takeIf { !isPlaceholderAttachmentAspectRatio(it) }
|
||||
?: localRatio
|
||||
|
||||
val thumbDims = thumbnailBytes?.let { ru.fromchat.api.local.download.readImageDimensionsFromBytes(it) }
|
||||
if (thumbDims != null) {
|
||||
val (tw, th) = thumbDims
|
||||
if (tw > 0 && th > 0 && !isPlaceholderAttachmentDimensions(tw, th)) {
|
||||
return preferDecodedAspectRatio(metadataAspect, tw, th)
|
||||
}
|
||||
}
|
||||
serverRatio?.takeIf { !isPlaceholderAttachmentAspectRatio(it) }?.let { return it }
|
||||
return localRatio
|
||||
return metadataAspect
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer decoded / thumbnail pixels when metadata ignored EXIF (common on huge JPEGs):
|
||||
* metadata and decoded aspects are reciprocals (~90° apart).
|
||||
*/
|
||||
internal fun preferDecodedAspectRatio(
|
||||
metadataAspect: Float?,
|
||||
decodedWidth: Int,
|
||||
decodedHeight: Int,
|
||||
): Float {
|
||||
if (decodedWidth <= 0 || decodedHeight <= 0) {
|
||||
return metadataAspect?.takeIf { it.isFinite() && it > 0f } ?: 1f
|
||||
}
|
||||
// Always trust decoded pixels for layout; metadata can disagree on EXIF orientation,
|
||||
// especially for very large images or when dimensions were cached incorrectly.
|
||||
return decodedWidth.toFloat() / decodedHeight.toFloat()
|
||||
}
|
||||
|
||||
internal fun coalesceDecodeTarget(vararg sizes: ChatPreviewDecodeSize?): ChatPreviewDecodeSize {
|
||||
|
||||
@@ -43,3 +43,4 @@ fun DisplayName(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -1,3 +1,5 @@
|
||||
package ru.fromchat.api.local.download
|
||||
|
||||
internal actual fun readLocalImageDimensions(absolutePath: String): Pair<Int, Int>? = null
|
||||
|
||||
internal actual fun readImageDimensionsFromBytes(data: ByteArray): Pair<Int, Int>? = null
|
||||
|
||||
Reference in New Issue
Block a user