Compare commits

..

2 Commits

4 changed files with 142 additions and 79 deletions

View File

@@ -126,6 +126,25 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
private val groupMembersCountCache = java.util.concurrent.ConcurrentHashMap<String, Int>()
private const val GROUP_MESSAGE_STACK_TIME_DIFF_MS = 5 * 60_000L
private const val DIRECT_MESSAGE_STACK_TIME_DIFF_MS = 60_000L
private fun isSameLocalDay(firstTimestampMs: Long, secondTimestampMs: Long): Boolean {
val firstCalendar =
java.util.Calendar.getInstance().apply {
timeInMillis = firstTimestampMs
}
val secondCalendar =
java.util.Calendar.getInstance().apply {
timeInMillis = secondTimestampMs
}
return firstCalendar.get(java.util.Calendar.ERA) ==
secondCalendar.get(java.util.Calendar.ERA) &&
firstCalendar.get(java.util.Calendar.YEAR) ==
secondCalendar.get(java.util.Calendar.YEAR) &&
firstCalendar.get(java.util.Calendar.DAY_OF_YEAR) ==
secondCalendar.get(java.util.Calendar.DAY_OF_YEAR)
}
private data class IncomingRunAvatarAccumulator(
val senderPublicKey: String,
@@ -777,16 +796,26 @@ fun ChatDetailScreen(
}
val isMessageBoundary: (ChatMessage, ChatMessage?) -> Boolean =
remember(isGroupChat, currentUserPublicKey, user.publicKey) {
val maxStackTimeDiffMs =
if (isGroupChat) {
GROUP_MESSAGE_STACK_TIME_DIFF_MS
} else {
DIRECT_MESSAGE_STACK_TIME_DIFF_MS
}
{ currentMessage, adjacentMessage ->
if (adjacentMessage == null) {
true
} else if (adjacentMessage.isOutgoing != currentMessage.isOutgoing) {
true
} else if (
kotlin.math.abs(
currentMessage.timestamp.time -
adjacentMessage.timestamp.time
) > 60_000L
!isSameLocalDay(
currentMessage.timestamp.time,
adjacentMessage.timestamp.time
) ||
kotlin.math.abs(
currentMessage.timestamp.time -
adjacentMessage.timestamp.time
) > maxStackTimeDiffMs
) {
true
} else if (
@@ -2531,41 +2560,12 @@ fun ChatDetailScreen(
isMessageBoundary(message, prevMessage)
val isGroupStart =
isMessageBoundary(message, nextMessage)
val runHeadIndex =
messageRunNewestIndex.getOrNull(
index
) ?: index
val runTailIndex =
messageRunOldestIndexByHead
.getOrNull(
runHeadIndex
)
?: runHeadIndex
val isHeadPhase =
incomingRunAvatarUiState
.showOnRunHeads
.contains(
runHeadIndex
)
val isTailPhase =
incomingRunAvatarUiState
.showOnRunTails
.contains(
runHeadIndex
)
val showIncomingGroupAvatar =
isGroupChat &&
!message.isOutgoing &&
senderPublicKeyForMessage
.isNotBlank() &&
((index ==
runHeadIndex &&
isHeadPhase &&
showTail) ||
(index ==
runTailIndex &&
isTailPhase &&
isGroupStart))
isGroupStart
Column {
if (showDate

View File

@@ -902,37 +902,60 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 🔥 Берём ТЕКУЩЕЕ состояние UI (может уже содержать новые сообщения)
val currentMessages = _messages.value
val existingIds = currentMessages.map { it.id }.toSet()
val entitiesById = entities.associateBy { it.messageId }
// 🔄 Синхронизируем статусы/время уже существующих сообщений из БД.
// Это критично для случая WAITING -> ERROR: без этого в открытом диалоге остаются "часики".
val reconciledMessages =
currentMessages.map { message ->
val entity = entitiesById[message.id] ?: return@map message
val dbStatus =
when (entity.delivered) {
0 -> MessageStatus.SENDING
1 -> if (entity.read == 1) MessageStatus.READ else MessageStatus.DELIVERED
2 -> MessageStatus.ERROR
3 -> MessageStatus.READ
else -> MessageStatus.SENT
}
var updatedMessage = message
if (updatedMessage.status != dbStatus) {
updatedMessage = updatedMessage.copy(status = dbStatus)
}
if (updatedMessage.timestamp.time != entity.timestamp) {
updatedMessage = updatedMessage.copy(timestamp = Date(entity.timestamp))
}
updatedMessage
}
val hasExistingUpdates = reconciledMessages != currentMessages
// 🔥 Находим только НОВЫЕ сообщения (которых нет в текущем UI)
val newEntities = entities.filter { it.messageId !in existingIds }
if (newEntities.isNotEmpty()) {
val semaphore = Semaphore(DECRYPT_PARALLELISM)
val newMessages = coroutineScope {
newEntities
.map { entity ->
async { semaphore.withPermit { entityToChatMessage(entity) } }
}
.awaitAll()
}
val newMessages =
if (newEntities.isNotEmpty()) {
val semaphore = Semaphore(DECRYPT_PARALLELISM)
coroutineScope {
newEntities
.map { entity ->
async { semaphore.withPermit { entityToChatMessage(entity) } }
}
.awaitAll()
}
} else {
emptyList()
}
// 🔥 ДОБАВЛЯЕМ новые к текущим, а не заменяем!
// Сортируем по timestamp чтобы новые были в конце
if (hasExistingUpdates || newMessages.isNotEmpty()) {
// 🔥 ДОБАВЛЯЕМ новые + применяем статусные апдейты к существующим
val updatedMessages =
sortMessagesAscending((currentMessages + newMessages).distinctBy { it.id })
sortMessagesAscending((reconciledMessages + newMessages).distinctBy { it.id })
// 🔥 ИСПРАВЛЕНИЕ: Обновляем кэш сохраняя ВСЕ сообщения, не только отображаемые!
// Объединяем существующий кэш с новыми сообщениями
// 🔥 Обновляем кэш: сохраняем старые страницы + применяем новые статусы/время
val existingCache = dialogMessagesCache[cacheKey(account, dialogKey)] ?: emptyList()
val allCachedIds = existingCache.map { it.id }.toSet()
val trulyNewMessages = newMessages.filter { it.id !in allCachedIds }
if (trulyNewMessages.isNotEmpty()) {
updateCacheWithLimit(
account,
dialogKey,
sortMessagesAscending(existingCache + trulyNewMessages)
)
}
val mergedCache = sortMessagesAscending((existingCache + updatedMessages).distinctBy { it.id })
updateCacheWithLimit(account, dialogKey, mergedCache)
withContext(Dispatchers.Main.immediate) { _messages.value = updatedMessages }
}

View File

@@ -834,10 +834,6 @@ fun ChatsListScreen(
modifier =
Modifier.size(72.dp)
.clip(CircleShape)
.background(
Color.White
.copy(alpha = 0.2f)
)
.combinedClickable(
onClick = {
scope.launch {
@@ -858,8 +854,7 @@ fun ChatsListScreen(
accountPublicKey
}
}
)
.padding(3.dp),
),
contentAlignment =
Alignment.Center
) {
@@ -868,7 +863,7 @@ fun ChatsListScreen(
accountPublicKey,
avatarRepository =
avatarRepository,
size = 66.dp,
size = 72.dp,
isDarkTheme =
isDarkTheme,
displayName =

View File

@@ -759,14 +759,9 @@ fun MessageBubble(
} else if (isStandaloneGroupInvite) {
Modifier.widthIn(min = 180.dp, max = 260.dp)
} else if (hasImageWithCaption || hasOnlyMedia) {
if (hasGroupSenderName) {
Modifier.widthIn(min = photoWidth)
} else {
Modifier.width(
photoWidth
) // 🔥 Фиксированная ширина = размер фото (убирает лишний
// отступ)
}
Modifier.width(
photoWidth
) // Для медиа держим фиксированную ширину как в desktop/telegram
} else {
Modifier.widthIn(min = if (hasGroupSenderName) 120.dp else 60.dp, max = 280.dp)
}
@@ -849,8 +844,19 @@ fun MessageBubble(
!message.isOutgoing &&
senderName.isNotBlank()
) {
val isMediaGroupBubble = hasImageWithCaption || hasOnlyMedia
val senderLabelTopPadding =
if (isMediaGroupBubble) 8.dp else 0.dp
val senderLabelHorizontalPadding =
if (isMediaGroupBubble) 10.dp else 0.dp
Row(
modifier = Modifier.padding(bottom = 4.dp),
modifier =
Modifier.padding(
start = senderLabelHorizontalPadding,
end = senderLabelHorizontalPadding,
top = senderLabelTopPadding,
bottom = 4.dp
),
verticalAlignment = Alignment.CenterVertically
) {
Text(
@@ -2433,6 +2439,7 @@ private fun ForwardedImagePreview(
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit
) {
val context = androidx.compose.ui.platform.LocalContext.current
val configuration = androidx.compose.ui.platform.LocalConfiguration.current
val cacheKey = "img_${attachment.id}"
var imageBitmap by remember(attachment.id) {
@@ -2527,17 +2534,55 @@ private fun ForwardedImagePreview(
}
}
val imgWidth = attachment.width.takeIf { it > 0 } ?: 300
val imgHeight = attachment.height.takeIf { it > 0 } ?: 200
val aspectRatio = (imgWidth.toFloat() / imgHeight.toFloat()).coerceIn(0.5f, 2.5f)
val maxPhotoWidthDp = TelegramBubbleSpec.maxPhotoWidth(configuration.screenWidthDp).dp
val minPhotoWidthDp = TelegramBubbleSpec.minPhotoWidth.dp
val minPhotoHeightDp = TelegramBubbleSpec.minPhotoHeight.dp
val maxPhotoHeightDp = TelegramBubbleSpec.maxPhotoHeight.dp
val (previewWidth, previewHeight) =
remember(
attachment.width,
attachment.height,
configuration.screenWidthDp
) {
val actualWidth = attachment.width.takeIf { it > 0 } ?: 0
val actualHeight = attachment.height.takeIf { it > 0 } ?: 0
if (actualWidth > 0 && actualHeight > 0) {
val ar = actualWidth.toFloat() / actualHeight.toFloat()
var w = if (ar >= 1f) {
maxPhotoWidthDp.value
} else {
maxPhotoWidthDp.value * 0.75f
}
var h = w / ar
if (h > maxPhotoHeightDp.value) {
h = maxPhotoHeightDp.value
w = h * ar
}
if (h < minPhotoHeightDp.value) {
h = minPhotoHeightDp.value
w = h * ar
}
w = w.coerceIn(minPhotoWidthDp.value, maxPhotoWidthDp.value)
h = h.coerceIn(minPhotoHeightDp.value, maxPhotoHeightDp.value)
w.dp to h.dp
} else {
val fallbackWidth = maxPhotoWidthDp
val fallbackHeight = (maxPhotoWidthDp.value * 0.75f)
.coerceIn(minPhotoHeightDp.value, maxPhotoHeightDp.value)
.dp
fallbackWidth to fallbackHeight
}
}
var photoBoxBounds by remember { mutableStateOf<ImageSourceBounds?>(null) }
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(aspectRatio)
.heightIn(max = 200.dp)
.width(previewWidth)
.height(previewHeight)
.clip(RoundedCornerShape(6.dp))
.background(Color.Gray.copy(alpha = 0.2f))
.onGloballyPositioned { coords ->