mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +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.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.core.graphics.scale
|
import androidx.core.graphics.scale
|
||||||
import androidx.exifinterface.media.ExifInterface
|
import androidx.exifinterface.media.ExifInterface
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
|
||||||
actual object PlatformDecodedBitmapCache {
|
actual object PlatformDecodedBitmapCache {
|
||||||
private val cache: LruCache<String, ImageBitmap> = object : LruCache<String, ImageBitmap>(maxCacheBytes()) {
|
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
|
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(
|
return scaleBitmapToFitWithin(
|
||||||
BitmapFactory.decodeByteArray(
|
applyExifOrientation(
|
||||||
bytes,
|
BitmapFactory.decodeByteArray(
|
||||||
0,
|
bytes,
|
||||||
bytes.size,
|
0,
|
||||||
BitmapFactory.Options().apply {
|
bytes.size,
|
||||||
inSampleSize = calculateInSampleSize(
|
BitmapFactory.Options().apply {
|
||||||
bounds.outWidth,
|
inSampleSize = calculateInSampleSize(
|
||||||
bounds.outHeight,
|
orientedW,
|
||||||
reqWidthPx,
|
orientedH,
|
||||||
reqHeightPx
|
reqWidthPx,
|
||||||
)
|
reqHeightPx,
|
||||||
inPreferredConfig = Bitmap.Config.ARGB_8888
|
)
|
||||||
}
|
inPreferredConfig = Bitmap.Config.ARGB_8888
|
||||||
) ?: return null,
|
},
|
||||||
|
) ?: return null,
|
||||||
|
orientation,
|
||||||
|
),
|
||||||
reqWidthPx,
|
reqWidthPx,
|
||||||
reqHeightPx
|
reqHeightPx,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+22
-4
@@ -2,6 +2,7 @@ package ru.fromchat.api.local.download
|
|||||||
|
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import androidx.exifinterface.media.ExifInterface
|
import androidx.exifinterface.media.ExifInterface
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
|
||||||
internal actual fun readLocalImageDimensions(absolutePath: String): Pair<Int, Int>? {
|
internal actual fun readLocalImageDimensions(absolutePath: String): Pair<Int, Int>? {
|
||||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
@@ -13,11 +14,28 @@ internal actual fun readLocalImageDimensions(absolutePath: String): Pair<Int, In
|
|||||||
ExifInterface.ORIENTATION_NORMAL,
|
ExifInterface.ORIENTATION_NORMAL,
|
||||||
)
|
)
|
||||||
}.getOrDefault(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_90,
|
||||||
ExifInterface.ORIENTATION_ROTATE_270,
|
ExifInterface.ORIENTATION_ROTATE_270,
|
||||||
ExifInterface.ORIENTATION_TRANSPOSE,
|
ExifInterface.ORIENTATION_TRANSPOSE,
|
||||||
ExifInterface.ORIENTATION_TRANSVERSE -> bounds.outHeight to bounds.outWidth
|
ExifInterface.ORIENTATION_TRANSVERSE -> height to width
|
||||||
else -> bounds.outWidth to bounds.outHeight
|
else -> width to height
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -58,6 +58,7 @@
|
|||||||
<string name="coming_soon">Скоро…</string>
|
<string name="coming_soon">Скоро…</string>
|
||||||
<string name="contacts_empty_title">Скоро</string>
|
<string name="contacts_empty_title">Скоро</string>
|
||||||
<string name="contacts_empty_body">Список контактов появится здесь, когда функция будет готова.</string>
|
<string name="contacts_empty_body">Список контактов появится здесь, когда функция будет готова.</string>
|
||||||
|
<string name="chat_scroll_to_bottom_cd">Прокрутить к новым сообщениям</string>
|
||||||
<string name="public_chat">Общий чат</string>
|
<string name="public_chat">Общий чат</string>
|
||||||
<string name="chat_last_mesaage">Вы: последнее сообщение</string>
|
<string name="chat_last_mesaage">Вы: последнее сообщение</string>
|
||||||
<string name="chat_preview_attachment">Вложение</string>
|
<string name="chat_preview_attachment">Вложение</string>
|
||||||
|
|||||||
@@ -66,6 +66,7 @@
|
|||||||
<string name="coming_soon">Coming soon…</string>
|
<string name="coming_soon">Coming soon…</string>
|
||||||
<string name="contacts_empty_title">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="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="public_chat">Main chat</string>
|
||||||
<string name="chat_last_mesaage">You: last message</string>
|
<string name="chat_last_mesaage">You: last message</string>
|
||||||
<string name="chat_preview_attachment">Attachment</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.AttachmentMediaLog
|
||||||
import ru.fromchat.api.local.cache.DecryptedImageCache
|
import ru.fromchat.api.local.cache.DecryptedImageCache
|
||||||
import ru.fromchat.api.local.download.readLocalImageDimensions
|
import ru.fromchat.api.local.download.readLocalImageDimensions
|
||||||
|
import ru.fromchat.ui.chat.isImageFilename
|
||||||
|
|
||||||
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
||||||
|
|
||||||
@@ -118,6 +119,7 @@ fun resolveLocalPreviewUri(message: Message): String? {
|
|||||||
message.pendingFileUri?.takeIf { uri ->
|
message.pendingFileUri?.takeIf { uri ->
|
||||||
DecryptedImageCache.isDecryptedImageCacheUri(uri) && localPreviewFileExists(uri)
|
DecryptedImageCache.isDecryptedImageCacheUri(uri) && localPreviewFileExists(uri)
|
||||||
}?.let { return it }
|
}?.let { return it }
|
||||||
|
if (!messageQualifiesForImageCacheHydration(message)) return null
|
||||||
val cid = message.client_message_id?.trim()?.takeIf { it.isNotEmpty() }
|
val cid = message.client_message_id?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
if (cid != null) {
|
if (cid != null) {
|
||||||
DecryptedImageCache.getCached(message.id, fileIndex = 0, cid)
|
DecryptedImageCache.getCached(message.id, fileIndex = 0, cid)
|
||||||
@@ -132,6 +134,35 @@ fun resolveLocalPreviewUri(message: Message): String? {
|
|||||||
return null
|
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). */
|
/** Sync disk lookup for cold-start chat open (no suspend alias copy). */
|
||||||
fun hydrateAttachmentPreviewFromDiskSync(message: Message): Message {
|
fun hydrateAttachmentPreviewFromDiskSync(message: Message): Message {
|
||||||
val previewUri = resolveLocalPreviewUri(message) ?: return hydrateDiskAspectRatioSync(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.encodePersistedDmMessage
|
||||||
import ru.fromchat.api.local.db.encodePersistedPublicMessage
|
import ru.fromchat.api.local.db.encodePersistedPublicMessage
|
||||||
import ru.fromchat.api.local.db.parseDmMessageContent
|
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.hydrateAttachmentPreviewFromDiskSync
|
||||||
import ru.fromchat.api.local.db.resolveLocalPreviewUri
|
|
||||||
import ru.fromchat.api.local.messages.sortMessagesForChatDisplay
|
import ru.fromchat.api.local.messages.sortMessagesForChatDisplay
|
||||||
import ru.fromchat.api.local.send.DmAttachmentOutboxPayload
|
import ru.fromchat.api.local.send.DmAttachmentOutboxPayload
|
||||||
import ru.fromchat.api.local.send.PublicAttachmentOutboxPayload
|
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.DecryptedFileCache
|
||||||
import ru.fromchat.api.local.cache.DecryptedImageCache
|
import ru.fromchat.api.local.cache.DecryptedImageCache
|
||||||
import ru.fromchat.api.local.download.DownloadedFileRegistry
|
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.utils.attachPublicReplyReferences
|
||||||
import ru.fromchat.ui.chat.isImageFilename
|
import ru.fromchat.ui.chat.isImageFilename
|
||||||
import ru.fromchat.ui.chat.utils.dedupeMessagesByClientId
|
import ru.fromchat.ui.chat.utils.dedupeMessagesByClientId
|
||||||
@@ -166,21 +168,20 @@ object MessageCacheStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun replacePublicMessages(messages: List<Message>) {
|
suspend fun replacePublicMessages(messages: List<Message>) {
|
||||||
|
val convId = conversationIdForPublic()
|
||||||
val resolved = messages.map { it.resolvePublicAttachmentLayout() }
|
val resolved = messages.map { it.resolvePublicAttachmentLayout() }
|
||||||
ProfileCache.mergePreviewFromPublicMessages(resolved)
|
ProfileCache.mergePreviewFromPublicMessages(resolved)
|
||||||
conversationIdForPublic().let {
|
val pending = loadPendingMessages(convId)
|
||||||
replaceMessages(
|
val stillPending = filterStillPendingForReplace(convId, pending, resolved)
|
||||||
it,
|
val before = resolved + stillPending
|
||||||
sortMessagesForChatDisplay(
|
val merged = dedupeMessagesByClientId(
|
||||||
dedupeMessagesByClientId(
|
dropSupersededOptimisticMessages(before, ApiClient.user?.id),
|
||||||
resolved + loadPendingMessages(it).filter { p ->
|
).let { sortMessagesForChatDisplay(it) }
|
||||||
val cid = p.client_message_id
|
val iid = instanceId()
|
||||||
cid == null || resolved.none { it.client_message_id == cid }
|
withContext(Dispatchers.Default) {
|
||||||
}
|
purgeSupersededPendingRows(iid, convId, before, merged)
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
replaceMessages(convId, merged)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun clearPublicMessages() {
|
suspend fun clearPublicMessages() {
|
||||||
@@ -305,6 +306,62 @@ object MessageCacheStore {
|
|||||||
var resolved = confirmed.resolvePublicAttachmentLayout()
|
var resolved = confirmed.resolvePublicAttachmentLayout()
|
||||||
resolved = hydrateAttachmentPreviewFromDisk(resolved)
|
resolved = hydrateAttachmentPreviewFromDisk(resolved)
|
||||||
ProfileCache.mergePreviewFromPublicMessage(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)
|
confirmMessage(conversationIdForPublic(), clientMessageId, resolved)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1220,9 +1277,7 @@ object MessageCacheStore {
|
|||||||
isContentCorrupted = parsed.isContentCorrupted,
|
isContentCorrupted = parsed.isContentCorrupted,
|
||||||
)
|
)
|
||||||
val hydrated = base.copy(
|
val hydrated = base.copy(
|
||||||
pendingFileUri = parsed.pendingFileUri
|
pendingFileUri = resolveStoredPendingFileUri(base, parsed),
|
||||||
?: parsed.localPreviewUri
|
|
||||||
?: resolveLocalPreviewUri(base),
|
|
||||||
pendingFilename = parsed.pendingFilename ?: base.pendingFilename,
|
pendingFilename = parsed.pendingFilename ?: base.pendingFilename,
|
||||||
uploadJobId = parsed.uploadJobId ?: base.uploadJobId,
|
uploadJobId = parsed.uploadJobId ?: base.uploadJobId,
|
||||||
fileSizes = parsed.fileSizes ?: base.fileSizes,
|
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). */
|
/** EXIF-oriented width/height from a local file (bounds read only; no full decode). */
|
||||||
internal expect fun readLocalImageDimensions(absolutePath: String): Pair<Int, Int>?
|
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
|
package ru.fromchat.api.local.messages
|
||||||
|
|
||||||
|
import ru.fromchat.api.local.cache.DecryptedImageCache
|
||||||
import ru.fromchat.api.local.db.parseDmMessageContent
|
import ru.fromchat.api.local.db.parseDmMessageContent
|
||||||
import ru.fromchat.api.schema.messages.Message
|
import ru.fromchat.api.schema.messages.Message
|
||||||
import ru.fromchat.api.schema.messages.dm.DmEnvelope
|
import ru.fromchat.api.schema.messages.dm.DmEnvelope
|
||||||
@@ -36,6 +37,7 @@ fun messageHasImageAttachment(message: Message): Boolean {
|
|||||||
if (isImageFilename(file.name)) return true
|
if (isImageFilename(file.name)) return true
|
||||||
}
|
}
|
||||||
if (message.pendingFileUri != null) {
|
if (message.pendingFileUri != null) {
|
||||||
|
if (DecryptedImageCache.isDecryptedImageCacheUri(message.pendingFileUri)) return true
|
||||||
val pendingName = message.pendingFilename?.trim()?.takeIf { it.isNotEmpty() }
|
val pendingName = message.pendingFilename?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
?: message.pendingFileUri.substringAfterLast('/').substringBefore('?')
|
?: message.pendingFileUri.substringAfterLast('/').substringBefore('?')
|
||||||
if (isImageFilename(pendingName)) return true
|
if (isImageFilename(pendingName)) return true
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import kotlinx.coroutines.launch
|
|||||||
|
|
||||||
sealed class OutboundSendProgress {
|
sealed class OutboundSendProgress {
|
||||||
data class Pending(val clientMessageId: String) : 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()
|
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.clearUploadArtifacts
|
||||||
import ru.fromchat.api.local.cache.clearUploadSecretsOnly
|
import ru.fromchat.api.local.cache.clearUploadSecretsOnly
|
||||||
import ru.fromchat.api.local.AttachmentMediaLog
|
import ru.fromchat.api.local.AttachmentMediaLog
|
||||||
|
import ru.fromchat.Logger
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single entry point for enqueueing outbound messages (DB row + outbox + worker).
|
* Single entry point for enqueueing outbound messages (DB row + outbox + worker).
|
||||||
@@ -66,15 +67,21 @@ object OutgoingMessageCoordinator {
|
|||||||
}
|
}
|
||||||
var ok = true
|
var ok = true
|
||||||
sendResult.onSuccess { confirmed ->
|
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) {
|
withContext(Dispatchers.Default) {
|
||||||
MessageCacheStore.confirmPublicMessage(
|
MessageCacheStore.confirmPublicMessage(
|
||||||
row.clientMessageId,
|
row.clientMessageId,
|
||||||
confirmed.copy(client_message_id = row.clientMessageId),
|
resolved,
|
||||||
)
|
)
|
||||||
MessageDatabaseProvider.database.messageDatabaseQueries
|
MessageDatabaseProvider.database.messageDatabaseQueries
|
||||||
.deleteOutboxItem(instanceId, row.clientMessageId)
|
.deleteOutboxItem(instanceId, row.clientMessageId)
|
||||||
}
|
}
|
||||||
|
OutboundSendNotifier.emit(
|
||||||
|
OutboundSendProgress.Success(row.clientMessageId, resolved),
|
||||||
|
)
|
||||||
}.onFailure { error ->
|
}.onFailure { error ->
|
||||||
|
Logger.d("OutgoingMessageCoordinator", "handlePublicOutboxSend: failure clientId=${row.clientMessageId.take(12)} err=${error.message ?: error::class.simpleName}")
|
||||||
when {
|
when {
|
||||||
error.isOutboundPermanentFailure() -> {
|
error.isOutboundPermanentFailure() -> {
|
||||||
val errorKey = outboundFailureErrorKey(error)
|
val errorKey = outboundFailureErrorKey(error)
|
||||||
@@ -152,6 +159,7 @@ object OutgoingMessageCoordinator {
|
|||||||
val conversationId = conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID)
|
val conversationId = conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID)
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
MessageRepository.upsertPublicMessage(optimisticMessage)
|
MessageRepository.upsertPublicMessage(optimisticMessage)
|
||||||
|
Logger.d("OutgoingMessageCoordinator", "enqueuePublicMessage: clientId=${clientMessageId.take(12)} contentLen=${content.length}")
|
||||||
val payload = json.encodeToString(PublicOutboxPayload(content, replyToId))
|
val payload = json.encodeToString(PublicOutboxPayload(content, replyToId))
|
||||||
MessageDatabaseProvider.database.messageDatabaseQueries.upsertOutbox(
|
MessageDatabaseProvider.database.messageDatabaseQueries.upsertOutbox(
|
||||||
instanceId = instanceId,
|
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.coalesceDecodeTarget
|
||||||
import ru.fromchat.ui.chat.utils.decodeSizeChangedMeaningfully
|
import ru.fromchat.ui.chat.utils.decodeSizeChangedMeaningfully
|
||||||
import ru.fromchat.ui.chat.utils.peekDecodedAttachmentBitmap
|
import ru.fromchat.ui.chat.utils.peekDecodedAttachmentBitmap
|
||||||
|
import ru.fromchat.ui.chat.utils.preferDecodedAspectRatio
|
||||||
import ru.fromchat.ui.components.Text
|
import ru.fromchat.ui.components.Text
|
||||||
import com.pr0gramm3r101.utils.scaleOnPress
|
import com.pr0gramm3r101.utils.scaleOnPress
|
||||||
import ru.fromchat.ui.chat.MessageGroupInfo
|
import ru.fromchat.ui.chat.MessageGroupInfo
|
||||||
@@ -202,6 +203,9 @@ fun AttachmentPreview(
|
|||||||
var isFullyLoaded by remember(messageId, fileIndex, file?.path, pendingFileUri) {
|
var isFullyLoaded by remember(messageId, fileIndex, file?.path, pendingFileUri) {
|
||||||
mutableStateOf(false)
|
mutableStateOf(false)
|
||||||
}
|
}
|
||||||
|
var effectiveAspect by remember(messageId, fileIndex, file?.path, pendingFileUri, fileAspectRatio) {
|
||||||
|
mutableStateOf(fileAspectRatio)
|
||||||
|
}
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.then(
|
.then(
|
||||||
@@ -219,7 +223,7 @@ fun AttachmentPreview(
|
|||||||
)
|
)
|
||||||
// Explicit px size from dp max + aspect — do not wrap to thumb intrinsics
|
// Explicit px size from dp max + aspect — do not wrap to thumb intrinsics
|
||||||
// (IntrinsicSize.Max bubbles otherwise shrink to ~80px ≈ 3× too small).
|
// (IntrinsicSize.Max bubbles otherwise shrink to ~80px ≈ 3× too small).
|
||||||
.attachmentTileLayout(aspectRatio = fileAspectRatio)
|
.attachmentTileLayout(aspectRatio = effectiveAspect)
|
||||||
.clip(attachmentImageCornerShape(isAuthor, messageGroup))
|
.clip(attachmentImageCornerShape(isAuthor, messageGroup))
|
||||||
.then(
|
.then(
|
||||||
if (onImageBounds != null && showImageTile) {
|
if (onImageBounds != null && showImageTile) {
|
||||||
@@ -273,6 +277,12 @@ fun AttachmentPreview(
|
|||||||
onCancelUpload = onCancelUpload,
|
onCancelUpload = onCancelUpload,
|
||||||
onFullyLoaded = { if (it) isFullyLoaded = true },
|
onFullyLoaded = { if (it) isFullyLoaded = true },
|
||||||
messageGroup = messageGroup,
|
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,
|
hasSameAuthorAbove = false,
|
||||||
hasSameAuthorBelow = false,
|
hasSameAuthorBelow = false,
|
||||||
),
|
),
|
||||||
|
onResolvedAspectRatio: ((width: Int, height: Int) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val clipShape = attachmentImageCornerShape(isAuthor, messageGroup)
|
val clipShape = attachmentImageCornerShape(isAuthor, messageGroup)
|
||||||
@@ -671,6 +682,11 @@ private fun ChatImageTileContent(
|
|||||||
if (placeholderBitmap != null) decryptFinished = true
|
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) {
|
LaunchedEffect(treatAsOutbound, messageId, fileIndex, cacheClientId) {
|
||||||
if (treatAsOutbound) {
|
if (treatAsOutbound) {
|
||||||
AttachmentDownloadNotifier.clearProgress(messageId, fileIndex, cacheClientId)
|
AttachmentDownloadNotifier.clearProgress(messageId, fileIndex, cacheClientId)
|
||||||
|
|||||||
@@ -93,7 +93,10 @@ abstract class ChatPanel(
|
|||||||
addMessageMutex.withLock {
|
addMessageMutex.withLock {
|
||||||
batchStateUpdates {
|
batchStateUpdates {
|
||||||
updateState { current ->
|
updateState { current ->
|
||||||
val merged = mergeDatabaseMessagesWithPanelState(current.messages, messages)
|
val merged = mergeDatabaseMessagesWithPanelState(
|
||||||
|
panelMessagesForDbMerge(),
|
||||||
|
messages,
|
||||||
|
)
|
||||||
val withReplies = attachPublicReplyReferences(merged)
|
val withReplies = attachPublicReplyReferences(merged)
|
||||||
if (current.messages == withReplies) current
|
if (current.messages == withReplies) current
|
||||||
else current.copy(messages = withReplies)
|
else current.copy(messages = withReplies)
|
||||||
@@ -345,14 +348,39 @@ abstract class ChatPanel(
|
|||||||
updateState { it.copy(messages = emptyList()) }
|
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> {
|
protected fun snapshotPendingOptimisticMessages(): List<Message> {
|
||||||
if (pendingMessages.isEmpty()) return emptyList()
|
if (pendingMessages.isEmpty()) return emptyList()
|
||||||
val pendingClientIds = pendingMessages.keys
|
val pendingClientIds = pendingMessages.keys
|
||||||
return _state.messages.filter { msg ->
|
val fromState = _state.messages.filter { msg ->
|
||||||
val cid = msg.client_message_id?.trim().orEmpty()
|
val cid = msg.client_message_id?.trim().orEmpty()
|
||||||
cid.isNotEmpty() && cid in pendingClientIds
|
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>) {
|
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.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.expandVertically
|
import androidx.compose.animation.expandVertically
|
||||||
|
import androidx.compose.animation.shrinkVertically
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.core.tween
|
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.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SmallFloatingActionButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
@@ -73,6 +78,8 @@ import kotlin.math.roundToInt
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.json.decodeFromJsonElement
|
import kotlinx.serialization.json.decodeFromJsonElement
|
||||||
@@ -119,6 +126,7 @@ import ru.fromchat.back
|
|||||||
import ru.fromchat.action_delete_chat
|
import ru.fromchat.action_delete_chat
|
||||||
import ru.fromchat.ui.profile.peerIsDeleted
|
import ru.fromchat.ui.profile.peerIsDeleted
|
||||||
import ru.fromchat.cd_call
|
import ru.fromchat.cd_call
|
||||||
|
import ru.fromchat.chat_scroll_to_bottom_cd
|
||||||
import ru.fromchat.chat_group_label
|
import ru.fromchat.chat_group_label
|
||||||
import ru.fromchat.status_connecting
|
import ru.fromchat.status_connecting
|
||||||
import ru.fromchat.status_updating
|
import ru.fromchat.status_updating
|
||||||
@@ -175,6 +183,16 @@ fun ChatScreen(
|
|||||||
val listState = rememberSaveable(panelId, saver = LazyListState.Saver) {
|
val listState = rememberSaveable(panelId, saver = LazyListState.Saver) {
|
||||||
LazyListState(0, 0)
|
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 density = LocalDensity.current
|
||||||
val fallbackMessageHeightPx = remember(density) { with(density) { 80.dp.roundToPx() } }
|
val fallbackMessageHeightPx = remember(density) { with(density) { 80.dp.roundToPx() } }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -428,6 +446,7 @@ fun ChatScreen(
|
|||||||
val statusUpdating = stringResource(Res.string.status_updating)
|
val statusUpdating = stringResource(Res.string.status_updating)
|
||||||
val presenceRecently = stringResource(Res.string.presence_recently)
|
val presenceRecently = stringResource(Res.string.presence_recently)
|
||||||
val chatGroupLabel = stringResource(Res.string.chat_group_label)
|
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)
|
val cdCall = stringResource(Res.string.cd_call)
|
||||||
LaunchedEffect(currentTypingUsers) {
|
LaunchedEffect(currentTypingUsers) {
|
||||||
Logger.d("ChatScreen", "currentTypingUsers updated (from panelState): ${currentTypingUsers.map { it.username }}")
|
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)
|
// Scroll to specific message once when requested (e.g., notification / deep link).
|
||||||
LaunchedEffect(scrollToMessageId, panelState.messages) {
|
LaunchedEffect(scrollToMessageId) {
|
||||||
scrollToMessageId?.let(scrollToChatMessage)
|
val messageId = scrollToMessageId ?: return@LaunchedEffect
|
||||||
|
snapshotFlow { lazyIndexForMessageId(listItems, messageId) }
|
||||||
|
.filterNotNull()
|
||||||
|
.first()
|
||||||
|
scrollToChatMessage(messageId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
@@ -693,6 +716,8 @@ fun ChatScreen(
|
|||||||
panel.updateMessageByClientMessageId(progress.clientMessageId) {
|
panel.updateMessageByClientMessageId(progress.clientMessageId) {
|
||||||
it.copy(uploadError = null)
|
it.copy(uploadError = null)
|
||||||
}
|
}
|
||||||
|
is OutboundSendProgress.Success ->
|
||||||
|
panel.handleMessageConfirmed(progress.clientMessageId, progress.message)
|
||||||
is OutboundSendProgress.Failed ->
|
is OutboundSendProgress.Failed ->
|
||||||
panel.updateMessageByClientMessageId(progress.clientMessageId) {
|
panel.updateMessageByClientMessageId(progress.clientMessageId) {
|
||||||
it.copy(uploadError = progress.error)
|
it.copy(uploadError = progress.error)
|
||||||
@@ -1271,6 +1296,34 @@ fun ChatScreen(
|
|||||||
item { Spacer(modifier.height(floatingHeaderClearance)) }
|
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(
|
ChatTopBar(
|
||||||
hazeState = hazeState,
|
hazeState = hazeState,
|
||||||
onBack = { navController.navigateUp() },
|
onBack = { navController.navigateUp() },
|
||||||
|
|||||||
@@ -478,6 +478,8 @@ fun MessageItem(
|
|||||||
horizontalAlignment = if (isAuthor) Alignment.End else Alignment.Start
|
horizontalAlignment = if (isAuthor) Alignment.End else Alignment.Start
|
||||||
) {
|
) {
|
||||||
val pendingIsImage = when {
|
val pendingIsImage = when {
|
||||||
|
message.pendingFileUri != null &&
|
||||||
|
DecryptedImageCache.isDecryptedImageCacheUri(message.pendingFileUri) -> true
|
||||||
message.pendingFilename?.isNotBlank() == true ->
|
message.pendingFilename?.isNotBlank() == true ->
|
||||||
isImageFilename(message.pendingFilename)
|
isImageFilename(message.pendingFilename)
|
||||||
message.pendingFileUri != null -> isImageFilename(
|
message.pendingFileUri != null -> isImageFilename(
|
||||||
@@ -487,7 +489,8 @@ fun MessageItem(
|
|||||||
}
|
}
|
||||||
val pendingHasOutboundFile = message.pendingFileUri != null &&
|
val pendingHasOutboundFile = message.pendingFileUri != null &&
|
||||||
message.files.isNullOrEmpty() &&
|
message.files.isNullOrEmpty() &&
|
||||||
!pendingIsImage
|
!pendingIsImage &&
|
||||||
|
!DecryptedImageCache.isDecryptedImageCacheUri(message.pendingFileUri)
|
||||||
val uploadFailed = !message.uploadError.isNullOrBlank()
|
val uploadFailed = !message.uploadError.isNullOrBlank()
|
||||||
val canCancelUpload = message.isQueuedOutbound() && isAuthor &&
|
val canCancelUpload = message.isQueuedOutbound() && isAuthor &&
|
||||||
!uploadFailed &&
|
!uploadFailed &&
|
||||||
@@ -725,6 +728,13 @@ fun MessageItem(
|
|||||||
(primaryFile != null && !primaryIsImage)
|
(primaryFile != null && !primaryIsImage)
|
||||||
if (showPrimaryImageSlot) {
|
if (showPrimaryImageSlot) {
|
||||||
val imageKey = imageAttachmentKey(message, 0)
|
val imageKey = imageAttachmentKey(message, 0)
|
||||||
|
val primaryThumbBytes = remember(
|
||||||
|
message.id,
|
||||||
|
message.fileThumbnails?.firstOrNull(),
|
||||||
|
) {
|
||||||
|
message.fileThumbnails?.firstOrNull()
|
||||||
|
?.let { decodeAttachmentThumbnailBase64(it) }
|
||||||
|
}
|
||||||
val layoutAspect = imageAspectRatioForMessage(
|
val layoutAspect = imageAspectRatioForMessage(
|
||||||
fileAspectRatios = message.fileAspectRatios,
|
fileAspectRatios = message.fileAspectRatios,
|
||||||
fileDimensions = message.fileDimensions,
|
fileDimensions = message.fileDimensions,
|
||||||
@@ -733,6 +743,7 @@ fun MessageItem(
|
|||||||
fileIndex = 0,
|
fileIndex = 0,
|
||||||
confirmed = message.id > 0,
|
confirmed = message.id > 0,
|
||||||
hasLocalPreview = !message.pendingFileUri.isNullOrBlank(),
|
hasLocalPreview = !message.pendingFileUri.isNullOrBlank(),
|
||||||
|
thumbnailBytes = primaryThumbBytes,
|
||||||
)
|
)
|
||||||
LaunchedEffect(
|
LaunchedEffect(
|
||||||
message.id,
|
message.id,
|
||||||
@@ -896,6 +907,9 @@ fun MessageItem(
|
|||||||
.isDecryptedImageCacheUri(
|
.isDecryptedImageCacheUri(
|
||||||
message.pendingFileUri,
|
message.pendingFileUri,
|
||||||
),
|
),
|
||||||
|
thumbnailBytes = message.fileThumbnails
|
||||||
|
?.getOrNull(index)
|
||||||
|
?.let { decodeAttachmentThumbnailBase64(it) },
|
||||||
),
|
),
|
||||||
fileSizeBytes =
|
fileSizeBytes =
|
||||||
message.fileSizes?.getOrNull(index),
|
message.fileSizes?.getOrNull(index),
|
||||||
@@ -1121,6 +1135,7 @@ private fun MessageReplyQuote(
|
|||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(12.dp))
|
.clip(RoundedCornerShape(12.dp))
|
||||||
.background(quoteBg)
|
.background(quoteBg)
|
||||||
.height(IntrinsicSize.Min),
|
.height(IntrinsicSize.Min),
|
||||||
|
|||||||
+36
-7
@@ -203,7 +203,10 @@ class PublicChatPanel(
|
|||||||
else -> {
|
else -> {
|
||||||
// Never replace the in-memory list with the DB snapshot alone — that
|
// Never replace the in-memory list with the DB snapshot alone — that
|
||||||
// dropped paginated / ahead-of-network rows when reopening (e.g. profile).
|
// dropped paginated / ahead-of-network rows when reopening (e.g. profile).
|
||||||
val merged = mergeDatabaseMessagesWithPanelState(shown, cached)
|
val merged = mergeDatabaseMessagesWithPanelState(
|
||||||
|
panelMessagesForDbMerge(),
|
||||||
|
cached,
|
||||||
|
)
|
||||||
if (merged != shown) {
|
if (merged != shown) {
|
||||||
updateState { it.copy(messages = sortMessagesForChatDisplay(merged)) }
|
updateState { it.copy(messages = sortMessagesForChatDisplay(merged)) }
|
||||||
}
|
}
|
||||||
@@ -272,16 +275,26 @@ class PublicChatPanel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
val ahead = shown.filter { it.id > 0 && it.id !in networkIds }
|
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(
|
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?) {
|
override suspend fun sendMessage(content: String, replyToId: Int?, clientMessageId: String?) {
|
||||||
val cid = clientMessageId?.trim().orEmpty()
|
val cid = clientMessageId?.trim().orEmpty()
|
||||||
if (cid.isEmpty()) return
|
if (cid.isEmpty()) return
|
||||||
val optimistic = _state.messages.find { it.client_message_id == cid } ?: return
|
val optimistic = pendingOptimisticMessage(cid) ?: return
|
||||||
OutgoingMessageCoordinator.enqueuePublicMessage(
|
OutgoingMessageCoordinator.enqueuePublicMessage(
|
||||||
content = content,
|
content = content,
|
||||||
replyToId = replyToId,
|
replyToId = replyToId,
|
||||||
@@ -334,8 +347,15 @@ class PublicChatPanel(
|
|||||||
if (response != null && response.messages.isNotEmpty()) {
|
if (response != null && response.messages.isNotEmpty()) {
|
||||||
val networkMessages = response.messages.map { it.resolvePublicAttachmentLayout() }
|
val networkMessages = response.messages.map { it.resolvePublicAttachmentLayout() }
|
||||||
ProfileCache.mergePreviewFromPublicMessages(networkMessages)
|
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) {
|
withContext(Dispatchers.Main) {
|
||||||
val shown = _state.messages
|
val shown = snapshotUiMessagesForNetworkMerge()
|
||||||
|
Logger.d("PublicChatPanel", "loadMessages: snapshotUiMessagesForNetworkMerge size=${shown.size}")
|
||||||
if (shown.isNotEmpty() && !publicHistoryDiffersForUi(shown, networkMessages)) {
|
if (shown.isNotEmpty() && !publicHistoryDiffersForUi(shown, networkMessages)) {
|
||||||
Logger.d("PublicChatPanel", "Network history matches UI; skip clear/re-add")
|
Logger.d("PublicChatPanel", "Network history matches UI; skip clear/re-add")
|
||||||
val withSenders = mergePublicSenderFieldsFromNetwork(shown, networkMessages)
|
val withSenders = mergePublicSenderFieldsFromNetwork(shown, networkMessages)
|
||||||
@@ -344,6 +364,7 @@ class PublicChatPanel(
|
|||||||
}
|
}
|
||||||
if (_state.hasMoreMessages) setHasMoreMessages(false)
|
if (_state.hasMoreMessages) setHasMoreMessages(false)
|
||||||
if (_state.isLoading) setLoading(false)
|
if (_state.isLoading) setLoading(false)
|
||||||
|
mergedForCache = mergeNetworkHistoryWithShown(shown, networkMessages)
|
||||||
} else {
|
} else {
|
||||||
batchStateUpdates {
|
batchStateUpdates {
|
||||||
val merged = preserveReplyToFromExisting(
|
val merged = preserveReplyToFromExisting(
|
||||||
@@ -354,14 +375,22 @@ class PublicChatPanel(
|
|||||||
addMessages(
|
addMessages(
|
||||||
ProfileCache.enrichPublicMessagesForDisplay(merged),
|
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
|
setHasMoreMessages(false) // TODO: Implement has_more from API
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
mergedForCache = mergeNetworkHistoryWithShown(
|
||||||
|
panelMessagesForDbMerge(),
|
||||||
|
networkMessages,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
val mergedForCache = mergeNetworkHistoryWithShown(_state.messages, networkMessages)
|
val toPersist = mergedForCache
|
||||||
MessageCacheStore.replacePublicMessages(mergedForCache)
|
?: mergeNetworkHistoryWithShown(panelMessagesForDbMerge(), networkMessages)
|
||||||
|
Logger.d("PublicChatPanel", "loadMessages: persisting to cache messages=${toPersist.size}")
|
||||||
|
MessageCacheStore.replacePublicMessages(toPersist)
|
||||||
}
|
}
|
||||||
} else if (responseResult.isFailure) {
|
} else if (responseResult.isFailure) {
|
||||||
val cause = responseResult.exceptionOrNull()
|
val cause = responseResult.exceptionOrNull()
|
||||||
|
|||||||
+35
-6
@@ -135,6 +135,7 @@ internal fun imageAspectRatioForMessage(
|
|||||||
fileIndex: Int = 0,
|
fileIndex: Int = 0,
|
||||||
@Suppress("UNUSED_PARAMETER") confirmed: Boolean = true,
|
@Suppress("UNUSED_PARAMETER") confirmed: Boolean = true,
|
||||||
@Suppress("UNUSED_PARAMETER") hasLocalPreview: Boolean = false,
|
@Suppress("UNUSED_PARAMETER") hasLocalPreview: Boolean = false,
|
||||||
|
thumbnailBytes: ByteArray? = null,
|
||||||
): Float? {
|
): Float? {
|
||||||
val localRatio = pendingFileAspectRatio?.takeIf { fileIndex == 0 && it > 0f }
|
val localRatio = pendingFileAspectRatio?.takeIf { fileIndex == 0 && it > 0f }
|
||||||
val pair = fileAspectRatioPairs?.getOrNull(fileIndex)
|
val pair = fileAspectRatioPairs?.getOrNull(fileIndex)
|
||||||
@@ -148,14 +149,42 @@ internal fun imageAspectRatioForMessage(
|
|||||||
val serverDim = fileDimensions?.getOrNull(fileIndex)
|
val serverDim = fileDimensions?.getOrNull(fileIndex)
|
||||||
val serverRatio = fileAspectRatios?.getOrNull(fileIndex)?.takeIf { it > 0f }
|
val serverRatio = fileAspectRatios?.getOrNull(fileIndex)?.takeIf { it > 0f }
|
||||||
|
|
||||||
pairRatio?.let { return it }
|
val metadataAspect = pairRatio
|
||||||
serverDim?.let { (w, h) ->
|
?: serverDim?.let { (w, h) ->
|
||||||
if (w > 0 && h > 0 && !isPlaceholderAttachmentDimensions(w, h)) {
|
if (w > 0 && h > 0 && !isPlaceholderAttachmentDimensions(w, h)) {
|
||||||
return aspectRatioFromDimensionPair(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 metadataAspect
|
||||||
return localRatio
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
internal fun coalesceDecodeTarget(vararg sizes: ChatPreviewDecodeSize?): ChatPreviewDecodeSize {
|
||||||
|
|||||||
@@ -43,3 +43,4 @@ fun DisplayName(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
@@ -1,3 +1,5 @@
|
|||||||
package ru.fromchat.api.local.download
|
package ru.fromchat.api.local.download
|
||||||
|
|
||||||
internal actual fun readLocalImageDimensions(absolutePath: String): Pair<Int, Int>? = null
|
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