Исправлено поведение аватарок серий в группах и выделение сообщений
This commit is contained in:
@@ -62,6 +62,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
|
|||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
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.res.painterResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
@@ -115,6 +117,26 @@ import kotlinx.coroutines.flow.distinctUntilChanged
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
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(
|
@OptIn(
|
||||||
ExperimentalMaterial3Api::class,
|
ExperimentalMaterial3Api::class,
|
||||||
androidx.compose.foundation.ExperimentalFoundationApi::class,
|
androidx.compose.foundation.ExperimentalFoundationApi::class,
|
||||||
@@ -647,7 +669,250 @@ fun ChatDetailScreen(
|
|||||||
// <20>🔥 messagesWithDates — pre-computed in ViewModel on Dispatchers.Default
|
// <20>🔥 messagesWithDates — pre-computed in ViewModel on Dispatchers.Default
|
||||||
// (dedup + sort + date headers off the main thread)
|
// (dedup + sort + date headers off the main thread)
|
||||||
val messagesWithDates by viewModel.messagesWithDates.collectAsState()
|
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 ->
|
val scrollToMessage: (String) -> Unit = { messageId ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -2065,7 +2330,7 @@ fun ChatDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Есть сообщения
|
// Есть сообщения
|
||||||
else ->
|
else -> {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier =
|
modifier =
|
||||||
@@ -2117,36 +2382,6 @@ fun ChatDetailScreen(
|
|||||||
index,
|
index,
|
||||||
(message, showDate)
|
(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 =
|
val prevMessage =
|
||||||
messagesWithDates
|
messagesWithDates
|
||||||
.getOrNull(
|
.getOrNull(
|
||||||
@@ -2154,18 +2389,59 @@ fun ChatDetailScreen(
|
|||||||
1
|
1
|
||||||
)
|
)
|
||||||
?.first
|
?.first
|
||||||
|
val nextMessage =
|
||||||
|
messagesWithDates
|
||||||
|
.getOrNull(
|
||||||
|
index +
|
||||||
|
1
|
||||||
|
)
|
||||||
|
?.first
|
||||||
|
val senderPublicKeyForMessage =
|
||||||
|
resolveSenderPublicKey(
|
||||||
|
message
|
||||||
|
)
|
||||||
|
// Для reverseLayout + DESC списка:
|
||||||
|
// prev = более новое сообщение,
|
||||||
|
// next = более старое.
|
||||||
|
val showTail =
|
||||||
|
isMessageBoundary(message, prevMessage)
|
||||||
val isGroupStart =
|
val isGroupStart =
|
||||||
prevMessage !=
|
isMessageBoundary(message, nextMessage)
|
||||||
null &&
|
val runHeadIndex =
|
||||||
(prevMessage
|
messageRunNewestIndex.getOrNull(
|
||||||
.isOutgoing !=
|
index
|
||||||
message.isOutgoing ||
|
) ?: index
|
||||||
(prevMessage
|
val runTailIndex =
|
||||||
.timestamp
|
messageRunOldestIndexByHead
|
||||||
.time -
|
.getOrNull(
|
||||||
message.timestamp
|
runHeadIndex
|
||||||
.time) >
|
)
|
||||||
60_000)
|
?: 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 {
|
Column {
|
||||||
if (showDate
|
if (showDate
|
||||||
@@ -2182,14 +2458,6 @@ fun ChatDetailScreen(
|
|||||||
}
|
}
|
||||||
val selectionKey =
|
val selectionKey =
|
||||||
message.id
|
message.id
|
||||||
val senderPublicKeyForMessage =
|
|
||||||
if (message.senderPublicKey.isNotBlank()) {
|
|
||||||
message.senderPublicKey
|
|
||||||
} else if (message.isOutgoing) {
|
|
||||||
currentUserPublicKey
|
|
||||||
} else {
|
|
||||||
user.publicKey
|
|
||||||
}
|
|
||||||
MessageBubble(
|
MessageBubble(
|
||||||
message =
|
message =
|
||||||
message,
|
message,
|
||||||
@@ -2201,6 +2469,8 @@ fun ChatDetailScreen(
|
|||||||
isSelectionMode,
|
isSelectionMode,
|
||||||
showTail =
|
showTail =
|
||||||
showTail,
|
showTail,
|
||||||
|
showIncomingGroupAvatar =
|
||||||
|
showIncomingGroupAvatar,
|
||||||
isGroupStart =
|
isGroupStart =
|
||||||
isGroupStart,
|
isGroupStart,
|
||||||
isSelected =
|
isSelected =
|
||||||
@@ -2225,7 +2495,8 @@ fun ChatDetailScreen(
|
|||||||
user.publicKey,
|
user.publicKey,
|
||||||
showGroupSenderLabel =
|
showGroupSenderLabel =
|
||||||
isGroupChat &&
|
isGroupChat &&
|
||||||
!message.isOutgoing,
|
!message.isOutgoing &&
|
||||||
|
isGroupStart,
|
||||||
isGroupSenderAdmin =
|
isGroupSenderAdmin =
|
||||||
isGroupChat &&
|
isGroupChat &&
|
||||||
senderPublicKeyForMessage
|
senderPublicKeyForMessage
|
||||||
@@ -2384,6 +2655,12 @@ fun ChatDetailScreen(
|
|||||||
true
|
true
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
onAvatarClick = {
|
||||||
|
avatarOwnerPublicKey ->
|
||||||
|
openProfileByPublicKey(
|
||||||
|
avatarOwnerPublicKey
|
||||||
|
)
|
||||||
|
},
|
||||||
onForwardedSenderClick = { senderPublicKey ->
|
onForwardedSenderClick = { senderPublicKey ->
|
||||||
// Open profile of the forwarded message sender
|
// Open profile of the forwarded message sender
|
||||||
scope.launch {
|
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
|
} // Конец Column внутри Scaffold content
|
||||||
|
|||||||
@@ -290,6 +290,7 @@ fun MessageBubble(
|
|||||||
isSystemSafeChat: Boolean = false,
|
isSystemSafeChat: Boolean = false,
|
||||||
isSelectionMode: Boolean = false,
|
isSelectionMode: Boolean = false,
|
||||||
showTail: Boolean = true,
|
showTail: Boolean = true,
|
||||||
|
showIncomingGroupAvatar: Boolean? = null,
|
||||||
isGroupStart: Boolean = false,
|
isGroupStart: Boolean = false,
|
||||||
isSelected: Boolean = false,
|
isSelected: Boolean = false,
|
||||||
isHighlighted: Boolean = false,
|
isHighlighted: Boolean = false,
|
||||||
@@ -311,6 +312,7 @@ fun MessageBubble(
|
|||||||
onRetry: () -> Unit = {},
|
onRetry: () -> Unit = {},
|
||||||
onDelete: () -> Unit = {},
|
onDelete: () -> Unit = {},
|
||||||
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
|
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
|
||||||
|
onAvatarClick: (senderPublicKey: String) -> Unit = {},
|
||||||
onForwardedSenderClick: (senderPublicKey: String) -> Unit = {},
|
onForwardedSenderClick: (senderPublicKey: String) -> Unit = {},
|
||||||
onMentionClick: (username: String) -> Unit = {},
|
onMentionClick: (username: String) -> Unit = {},
|
||||||
onGroupInviteOpen: (SearchUser) -> Unit = {},
|
onGroupInviteOpen: (SearchUser) -> Unit = {},
|
||||||
@@ -536,11 +538,12 @@ fun MessageBubble(
|
|||||||
val telegramIncomingAvatarSize = 42.dp
|
val telegramIncomingAvatarSize = 42.dp
|
||||||
val telegramIncomingAvatarLane = 48.dp
|
val telegramIncomingAvatarLane = 48.dp
|
||||||
val telegramIncomingAvatarInset = 6.dp
|
val telegramIncomingAvatarInset = 6.dp
|
||||||
val showIncomingGroupAvatar =
|
val shouldShowIncomingGroupAvatar =
|
||||||
isGroupChat &&
|
showIncomingGroupAvatar
|
||||||
|
?: (isGroupChat &&
|
||||||
!message.isOutgoing &&
|
!message.isOutgoing &&
|
||||||
showTail &&
|
showTail &&
|
||||||
senderPublicKey.isNotBlank()
|
senderPublicKey.isNotBlank())
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -553,7 +556,7 @@ fun MessageBubble(
|
|||||||
) {
|
) {
|
||||||
// Selection checkmark
|
// Selection checkmark
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = isSelected,
|
visible = isSelected && message.isOutgoing,
|
||||||
enter =
|
enter =
|
||||||
fadeIn(tween(150)) +
|
fadeIn(tween(150)) +
|
||||||
scaleIn(
|
scaleIn(
|
||||||
@@ -580,10 +583,14 @@ fun MessageBubble(
|
|||||||
}
|
}
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = !isSelected,
|
visible = !isSelected || !message.isOutgoing,
|
||||||
enter = fadeIn(tween(100)),
|
enter = fadeIn(tween(100)),
|
||||||
exit = fadeOut(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) {
|
if (message.isOutgoing) {
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
@@ -597,7 +604,7 @@ fun MessageBubble(
|
|||||||
.align(Alignment.Bottom),
|
.align(Alignment.Bottom),
|
||||||
contentAlignment = Alignment.BottomStart
|
contentAlignment = Alignment.BottomStart
|
||||||
) {
|
) {
|
||||||
if (showIncomingGroupAvatar) {
|
if (shouldShowIncomingGroupAvatar) {
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.fillMaxSize()
|
Modifier.fillMaxSize()
|
||||||
@@ -607,11 +614,21 @@ fun MessageBubble(
|
|||||||
),
|
),
|
||||||
contentAlignment = Alignment.BottomStart
|
contentAlignment = Alignment.BottomStart
|
||||||
) {
|
) {
|
||||||
|
val avatarClickHandler: (() -> Unit)? =
|
||||||
|
if (!isSelectionMode &&
|
||||||
|
senderPublicKey
|
||||||
|
.isNotBlank()
|
||||||
|
) {
|
||||||
|
{ onAvatarClick(senderPublicKey) }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
AvatarImage(
|
AvatarImage(
|
||||||
publicKey = senderPublicKey,
|
publicKey = senderPublicKey,
|
||||||
avatarRepository = avatarRepository,
|
avatarRepository = avatarRepository,
|
||||||
size = telegramIncomingAvatarSize,
|
size = telegramIncomingAvatarSize,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
|
onClick = avatarClickHandler,
|
||||||
displayName =
|
displayName =
|
||||||
senderName.ifBlank {
|
senderName.ifBlank {
|
||||||
senderPublicKey
|
senderPublicKey
|
||||||
@@ -1217,6 +1234,37 @@ fun MessageBubble(
|
|||||||
// 💬 Context menu anchor (DropdownMenu positions relative to this Box)
|
// 💬 Context menu anchor (DropdownMenu positions relative to this Box)
|
||||||
contextMenuContent()
|
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 {
|
private fun groupSenderLabelColor(publicKey: String, isDarkTheme: Boolean): Color {
|
||||||
val paletteDark =
|
// Match nickname color with avatar initials color.
|
||||||
listOf(
|
return com.rosetta.messenger.ui.chats.getAvatarColor(publicKey, isDarkTheme).textColor
|
||||||
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]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
Reference in New Issue
Block a user