Compare commits
2 Commits
75d0f4726b
...
af4a3a5f27
| Author | SHA1 | Date | |
|---|---|---|---|
| af4a3a5f27 | |||
| 9a411ac473 |
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
Reference in New Issue
Block a user