From 5e908a6d0c784cb95461da87c86e9c744e8717a0 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Mon, 9 Mar 2026 03:53:12 +0500 Subject: [PATCH] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=BE=20=D0=BF=D0=BE=D0=B2=D0=B5=D0=B4=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=B0=D0=B2=D0=B0=D1=82=D0=B0=D1=80=D0=BE=D0=BA?= =?UTF-8?q?=20=D1=81=D0=B5=D1=80=D0=B8=D0=B9=20=D0=B2=20=D0=B3=D1=80=D1=83?= =?UTF-8?q?=D0=BF=D0=BF=D0=B0=D1=85=20=D0=B8=20=D0=B2=D1=8B=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../messenger/ui/chats/ChatDetailScreen.kt | 434 ++++++++++++++++-- .../chats/components/ChatDetailComponents.kt | 91 ++-- 2 files changed, 443 insertions(+), 82 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 96ff3a5..3a47c19 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -62,6 +62,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalLifecycleOwner @@ -70,6 +71,7 @@ import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView @@ -115,6 +117,26 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +private data class IncomingRunAvatarAccumulator( + val senderPublicKey: String, + val senderDisplayName: String, + var minTopPx: Float, + var maxBottomPx: Float +) + +private data class IncomingRunAvatarOverlay( + val runHeadIndex: Int, + val senderPublicKey: String, + val senderDisplayName: String, + val topPx: Float +) + +private data class IncomingRunAvatarUiState( + val showOnRunHeads: Set, + val showOnRunTails: Set, + val overlays: List +) + @OptIn( ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class, @@ -647,7 +669,250 @@ fun ChatDetailScreen( // �🔥 messagesWithDates — pre-computed in ViewModel on Dispatchers.Default // (dedup + sort + date headers off the main thread) val messagesWithDates by viewModel.messagesWithDates.collectAsState() + val resolveSenderPublicKey: (ChatMessage?) -> String = + remember(isGroupChat, currentUserPublicKey, user.publicKey) { + { msg -> + when { + msg == null -> "" + msg.senderPublicKey.isNotBlank() -> msg.senderPublicKey.trim() + msg.isOutgoing -> currentUserPublicKey.trim() + isGroupChat -> "" + else -> user.publicKey.trim() + } + } + } + val resolveSenderIdentity: (ChatMessage?) -> String = + remember(isGroupChat, currentUserPublicKey, user.publicKey) { + { msg -> + when { + msg == null -> "" + msg.isOutgoing -> + "out:${currentUserPublicKey.trim().lowercase(Locale.ROOT)}" + msg.senderPublicKey.isNotBlank() -> + "in:${msg.senderPublicKey.trim().lowercase(Locale.ROOT)}" + isGroupChat && msg.senderName.isNotBlank() -> + "name:${msg.senderName.trim().lowercase(Locale.ROOT)}" + !isGroupChat -> + "in:${user.publicKey.trim().lowercase(Locale.ROOT)}" + else -> "" + } + } + } + val isMessageBoundary: (ChatMessage, ChatMessage?) -> Boolean = + remember(isGroupChat, currentUserPublicKey, user.publicKey) { + { 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 + ) { + true + } else if ( + isGroupChat && + !currentMessage.isOutgoing && + !adjacentMessage.isOutgoing + ) { + val currentSenderIdentity = + resolveSenderIdentity(currentMessage) + val adjacentSenderIdentity = + resolveSenderIdentity(adjacentMessage) + if ( + currentSenderIdentity.isBlank() || + adjacentSenderIdentity.isBlank() + ) { + true + } else { + !currentSenderIdentity.equals( + adjacentSenderIdentity, + ignoreCase = true + ) + } + } else { + false + } + } + } + val messageRunNewestIndex = + remember(messagesWithDates, isGroupChat, currentUserPublicKey, user.publicKey) { + IntArray(messagesWithDates.size).also { runHeadByIndex -> + messagesWithDates.indices.forEach { messageIndex -> + if (messageIndex == 0) { + runHeadByIndex[messageIndex] = messageIndex + } else { + val currentMessage = messagesWithDates[messageIndex].first + val newerMessage = messagesWithDates[messageIndex - 1].first + runHeadByIndex[messageIndex] = + if (isMessageBoundary(currentMessage, newerMessage)) { + messageIndex + } else { + runHeadByIndex[messageIndex - 1] + } + } + } + } + } + val messageRunOldestIndexByHead = + remember(messageRunNewestIndex) { + IntArray(messageRunNewestIndex.size) { it }.also { runTailByHead -> + messageRunNewestIndex.indices.forEach { messageIndex -> + val runHeadIndex = messageRunNewestIndex[messageIndex] + runTailByHead[runHeadIndex] = messageIndex + } + } + } + val density = LocalDensity.current + val incomingRunAvatarSize = 42.dp + val incomingRunAvatarInsetStart = 6.dp + val incomingRunAvatarTopGuard = 4.dp + val incomingRunAvatarUiState by remember( + isGroupChat, + messageRunNewestIndex, + messageRunOldestIndexByHead, + messagesWithDates, + listState, + density + ) { + derivedStateOf { + if (!isGroupChat || messagesWithDates.isEmpty()) { + return@derivedStateOf IncomingRunAvatarUiState( + showOnRunHeads = emptySet(), + showOnRunTails = emptySet(), + overlays = emptyList() + ) + } + val layoutInfo = listState.layoutInfo + val visibleItems = layoutInfo.visibleItemsInfo + if (visibleItems.isEmpty()) { + return@derivedStateOf IncomingRunAvatarUiState( + showOnRunHeads = emptySet(), + showOnRunTails = emptySet(), + overlays = emptyList() + ) + } + val avatarSizePx = with(density) { incomingRunAvatarSize.toPx() } + val topGuardPx = with(density) { incomingRunAvatarTopGuard.toPx() } + val viewportStart = layoutInfo.viewportStartOffset.toFloat() + val viewportEnd = layoutInfo.viewportEndOffset.toFloat() + val maxAvatarTop = viewportEnd - avatarSizePx + val visibleIndexSet = visibleItems.map { it.index }.toHashSet() + val visibleRuns = linkedMapOf() + + visibleItems.forEach { itemInfo -> + val visibleIndex = itemInfo.index + if (visibleIndex !in messagesWithDates.indices) { + return@forEach + } + val message = messagesWithDates[visibleIndex].first + if (message.isOutgoing) { + return@forEach + } + val senderPublicKey = resolveSenderPublicKey(message).trim() + if (senderPublicKey.isBlank()) { + return@forEach + } + val runHeadIndex = + messageRunNewestIndex.getOrNull(visibleIndex) + ?: visibleIndex + val itemTopPx = itemInfo.offset.toFloat() + val itemBottomPx = (itemInfo.offset + itemInfo.size).toFloat() + val currentRun = visibleRuns[runHeadIndex] + if (currentRun == null) { + visibleRuns[runHeadIndex] = + IncomingRunAvatarAccumulator( + senderPublicKey = senderPublicKey, + senderDisplayName = + message.senderName.ifBlank { + senderPublicKey + }, + minTopPx = itemTopPx, + maxBottomPx = itemBottomPx + ) + } else { + if (itemTopPx < currentRun.minTopPx) { + currentRun.minTopPx = itemTopPx + } + if (itemBottomPx > currentRun.maxBottomPx) { + currentRun.maxBottomPx = itemBottomPx + } + } + } + + val showOnRunHeads = hashSetOf() + val showOnRunTails = hashSetOf() + val overlays = arrayListOf() + + visibleRuns.forEach { (runHeadIndex, runData) -> + val runTailIndex = + messageRunOldestIndexByHead.getOrNull(runHeadIndex) + ?: runHeadIndex + val isRunHeadVisible = visibleIndexSet.contains(runHeadIndex) + val isRunTailVisible = visibleIndexSet.contains(runTailIndex) + + when { + isRunHeadVisible -> { + // Start/default phase: keep avatar on the lower (newest) bubble. + showOnRunHeads.add(runHeadIndex) + } + isRunTailVisible -> { + // End phase: keep avatar attached to the last bubble in run. + showOnRunTails.add(runHeadIndex) + } + else -> { + // Middle phase: floating avatar while scrolling through run. + var avatarTopPx = + kotlin.math.min( + runData.maxBottomPx - avatarSizePx, + maxAvatarTop + ) + val topClampPx = runData.minTopPx + topGuardPx + if (avatarTopPx < topClampPx) { + avatarTopPx = topClampPx + } + if ( + avatarTopPx + avatarSizePx > viewportStart && + avatarTopPx < viewportEnd + ) { + overlays += + IncomingRunAvatarOverlay( + runHeadIndex = runHeadIndex, + senderPublicKey = + runData.senderPublicKey, + senderDisplayName = + runData.senderDisplayName, + topPx = avatarTopPx + ) + } + } + } + } + + IncomingRunAvatarUiState( + showOnRunHeads = showOnRunHeads, + showOnRunTails = showOnRunTails, + overlays = overlays.sortedBy { it.topPx } + ) + } + } + val openProfileByPublicKey: (String) -> Unit = { rawPublicKey -> + val normalizedPublicKey = rawPublicKey.trim() + if (normalizedPublicKey.isNotBlank()) { + scope.launch { + val resolvedUser = + viewModel.resolveUserForProfile(normalizedPublicKey) + if (resolvedUser != null) { + showContextMenu = false + contextMenuMessage = null + onUserProfileClick(resolvedUser) + } + } + } + } // 🔥 Функция для скролла к сообщению с подсветкой val scrollToMessage: (String) -> Unit = { messageId -> scope.launch { @@ -2065,7 +2330,7 @@ fun ChatDetailScreen( } } // Есть сообщения - else -> + else -> { LazyColumn( state = listState, modifier = @@ -2117,36 +2382,6 @@ fun ChatDetailScreen( index, (message, showDate) -> - // Определяем, - // показывать ли - // хвостик - // (последнее - // сообщение в - // группе) - val nextMessage = - messagesWithDates - .getOrNull( - index + - 1 - ) - ?.first - val showTail = - nextMessage == - null || - nextMessage - .isOutgoing != - message.isOutgoing || - (message.timestamp - .time - - nextMessage - .timestamp - .time) > - 60_000 - - // Определяем начало - // новой - // группы (для - // отступов) val prevMessage = messagesWithDates .getOrNull( @@ -2154,18 +2389,59 @@ fun ChatDetailScreen( 1 ) ?.first + val nextMessage = + messagesWithDates + .getOrNull( + index + + 1 + ) + ?.first + val senderPublicKeyForMessage = + resolveSenderPublicKey( + message + ) + // Для reverseLayout + DESC списка: + // prev = более новое сообщение, + // next = более старое. + val showTail = + isMessageBoundary(message, prevMessage) val isGroupStart = - prevMessage != - null && - (prevMessage - .isOutgoing != - message.isOutgoing || - (prevMessage - .timestamp - .time - - message.timestamp - .time) > - 60_000) + 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)) Column { if (showDate @@ -2182,14 +2458,6 @@ fun ChatDetailScreen( } val selectionKey = message.id - val senderPublicKeyForMessage = - if (message.senderPublicKey.isNotBlank()) { - message.senderPublicKey - } else if (message.isOutgoing) { - currentUserPublicKey - } else { - user.publicKey - } MessageBubble( message = message, @@ -2201,6 +2469,8 @@ fun ChatDetailScreen( isSelectionMode, showTail = showTail, + showIncomingGroupAvatar = + showIncomingGroupAvatar, isGroupStart = isGroupStart, isSelected = @@ -2225,7 +2495,8 @@ fun ChatDetailScreen( user.publicKey, showGroupSenderLabel = isGroupChat && - !message.isOutgoing, + !message.isOutgoing && + isGroupStart, isGroupSenderAdmin = isGroupChat && senderPublicKeyForMessage @@ -2384,6 +2655,12 @@ fun ChatDetailScreen( true ) }, + onAvatarClick = { + avatarOwnerPublicKey -> + openProfileByPublicKey( + avatarOwnerPublicKey + ) + }, onForwardedSenderClick = { senderPublicKey -> // Open profile of the forwarded message sender scope.launch { @@ -2539,6 +2816,61 @@ fun ChatDetailScreen( } } } + if (incomingRunAvatarUiState.overlays.isNotEmpty()) { + val avatarInsetPx = + with(density) { + incomingRunAvatarInsetStart + .roundToPx() + } + Box( + modifier = + Modifier.matchParentSize() + .graphicsLayer { + clip = true + } + ) { + incomingRunAvatarUiState.overlays.forEach { overlay -> + key( + overlay.runHeadIndex, + overlay.senderPublicKey + ) { + Box( + modifier = + Modifier.offset { + IntOffset( + avatarInsetPx, + overlay.topPx + .toInt() + ) + } + ) { + AvatarImage( + publicKey = + overlay.senderPublicKey, + avatarRepository = + avatarRepository, + size = + incomingRunAvatarSize, + isDarkTheme = + isDarkTheme, + onClick = + if (isSelectionMode) null + else { + { + openProfileByPublicKey( + overlay.senderPublicKey + ) + } + }, + displayName = + overlay.senderDisplayName + ) + } + } + } + } + } + } } } } // Конец Column внутри Scaffold content diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index 04b19f0..afb8d36 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -290,6 +290,7 @@ fun MessageBubble( isSystemSafeChat: Boolean = false, isSelectionMode: Boolean = false, showTail: Boolean = true, + showIncomingGroupAvatar: Boolean? = null, isGroupStart: Boolean = false, isSelected: Boolean = false, isHighlighted: Boolean = false, @@ -311,6 +312,7 @@ fun MessageBubble( onRetry: () -> Unit = {}, onDelete: () -> Unit = {}, onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> }, + onAvatarClick: (senderPublicKey: String) -> Unit = {}, onForwardedSenderClick: (senderPublicKey: String) -> Unit = {}, onMentionClick: (username: String) -> Unit = {}, onGroupInviteOpen: (SearchUser) -> Unit = {}, @@ -536,11 +538,12 @@ fun MessageBubble( val telegramIncomingAvatarSize = 42.dp val telegramIncomingAvatarLane = 48.dp val telegramIncomingAvatarInset = 6.dp - val showIncomingGroupAvatar = - isGroupChat && - !message.isOutgoing && - showTail && - senderPublicKey.isNotBlank() + val shouldShowIncomingGroupAvatar = + showIncomingGroupAvatar + ?: (isGroupChat && + !message.isOutgoing && + showTail && + senderPublicKey.isNotBlank()) Row( modifier = @@ -553,7 +556,7 @@ fun MessageBubble( ) { // Selection checkmark AnimatedVisibility( - visible = isSelected, + visible = isSelected && message.isOutgoing, enter = fadeIn(tween(150)) + scaleIn( @@ -579,11 +582,15 @@ fun MessageBubble( } } - AnimatedVisibility( - visible = !isSelected, + AnimatedVisibility( + visible = !isSelected || !message.isOutgoing, enter = fadeIn(tween(100)), exit = fadeOut(tween(100)) - ) { Spacer(modifier = Modifier.width(12.dp)) } + ) { + val leadingSpacerWidth = + if (!message.isOutgoing && isGroupChat) 0.dp else 12.dp + Spacer(modifier = Modifier.width(leadingSpacerWidth)) + } if (message.isOutgoing) { Spacer(modifier = Modifier.weight(1f)) @@ -597,7 +604,7 @@ fun MessageBubble( .align(Alignment.Bottom), contentAlignment = Alignment.BottomStart ) { - if (showIncomingGroupAvatar) { + if (shouldShowIncomingGroupAvatar) { Box( modifier = Modifier.fillMaxSize() @@ -607,11 +614,21 @@ fun MessageBubble( ), contentAlignment = Alignment.BottomStart ) { + val avatarClickHandler: (() -> Unit)? = + if (!isSelectionMode && + senderPublicKey + .isNotBlank() + ) { + { onAvatarClick(senderPublicKey) } + } else { + null + } AvatarImage( publicKey = senderPublicKey, avatarRepository = avatarRepository, size = telegramIncomingAvatarSize, isDarkTheme = isDarkTheme, + onClick = avatarClickHandler, displayName = senderName.ifBlank { senderPublicKey @@ -1217,6 +1234,37 @@ fun MessageBubble( // 💬 Context menu anchor (DropdownMenu positions relative to this Box) contextMenuContent() } + + if (!message.isOutgoing) { + Spacer(modifier = Modifier.weight(1f)) + AnimatedVisibility( + visible = isSelected, + enter = + fadeIn(tween(150)) + + scaleIn( + initialScale = 0.3f, + animationSpec = + spring(dampingRatio = 0.6f) + ), + exit = fadeOut(tween(100)) + scaleOut(targetScale = 0.3f) + ) { + Box( + modifier = + Modifier.padding(start = 4.dp, end = 12.dp) + .size(24.dp) + .clip(CircleShape) + .background(PrimaryBlue), + contentAlignment = Alignment.Center + ) { + Icon( + painter = TelegramIcons.Done, + contentDescription = "Selected", + tint = Color.White, + modifier = Modifier.size(16.dp) + ) + } + } + } } } } @@ -1259,27 +1307,8 @@ private fun isGroupInviteCode(text: String): Boolean { } private fun groupSenderLabelColor(publicKey: String, isDarkTheme: Boolean): Color { - val paletteDark = - listOf( - Color(0xFF7ED957), - Color(0xFF6EC1FF), - Color(0xFFFF9F68), - Color(0xFFC38AFF), - Color(0xFFFF7AA2), - Color(0xFF4DD7C8) - ) - val paletteLight = - listOf( - Color(0xFF2E7D32), - Color(0xFF1565C0), - Color(0xFFEF6C00), - Color(0xFF6A1B9A), - Color(0xFFC2185B), - Color(0xFF00695C) - ) - val palette = if (isDarkTheme) paletteDark else paletteLight - val index = kotlin.math.abs(publicKey.hashCode()) % palette.size - return palette[index] + // Match nickname color with avatar initials color. + return com.rosetta.messenger.ui.chats.getAvatarColor(publicKey, isDarkTheme).textColor } @Composable