Исправлено поведение аватарок серий в группах и выделение сообщений

This commit is contained in:
2026-03-09 03:53:12 +05:00
parent b0a41b2831
commit 5e908a6d0c
2 changed files with 443 additions and 82 deletions

View File

@@ -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<Int>,
val showOnRunTails: Set<Int>,
val overlays: List<IncomingRunAvatarOverlay>
)
@OptIn(
ExperimentalMaterial3Api::class,
androidx.compose.foundation.ExperimentalFoundationApi::class,
@@ -647,7 +669,250 @@ fun ChatDetailScreen(
// <20>🔥 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<Int, IncomingRunAvatarAccumulator>()
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<Int>()
val showOnRunTails = hashSetOf<Int>()
val overlays = arrayListOf<IncomingRunAvatarOverlay>()
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

View File

@@ -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