feat: enhance versioning and avatar handling with dynamic properties and improved UI interactions

This commit is contained in:
2026-02-10 20:41:32 +05:00
parent bbaa04cda5
commit a0ef378909
12 changed files with 401 additions and 99 deletions

View File

@@ -1389,7 +1389,21 @@ fun ChatDetailScreen(
else
user.publicKey,
originalChatPublicKey =
user.publicKey
user.publicKey,
attachments =
msg.attachments
.filter {
it.type !=
AttachmentType
.MESSAGES
}
.map {
attachment ->
attachment.copy(
localUri =
""
)
}
)
}
ForwardManager
@@ -1797,6 +1811,8 @@ fun ChatDetailScreen(
message,
isDarkTheme =
isDarkTheme,
isSelectionMode =
isSelectionMode,
showTail =
showTail,
isGroupStart =

View File

@@ -500,7 +500,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 📨 СНАЧАЛА проверяем ForwardManager - ДО сброса состояния!
// Это важно для правильного отображения forward сообщений сразу
val forwardMessages = ForwardManager.getForwardMessagesForChat(publicKey)
val forwardMessages = ForwardManager.consumeForwardMessagesForChat(publicKey)
val hasForward = forwardMessages.isNotEmpty()
if (hasForward) {}
@@ -543,12 +543,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
text = fm.text,
timestamp = fm.timestamp,
isOutgoing = fm.isOutgoing,
publicKey = fm.senderPublicKey
publicKey = fm.senderPublicKey,
attachments = fm.attachments
)
}
_isForwardMode.value = true
// Очищаем ForwardManager после применения
ForwardManager.clear()
} else {
// Сбрасываем forward state если нет forward сообщений
_replyMessages.value = emptyList()
@@ -1261,6 +1260,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val attType = AttachmentType.fromInt(attJson.optInt("type", 0))
val attPreview = attJson.optString("preview", "")
val attBlob = attJson.optString("blob", "")
val attWidth = attJson.optInt("width", 0)
val attHeight = attJson.optInt("height", 0)
if (attId.isNotEmpty()) {
replyAttachmentsFromJson.add(
@@ -1268,7 +1269,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
id = attId,
type = attType,
preview = attPreview,
blob = attBlob
blob = attBlob,
width = attWidth,
height = attHeight
)
)
}
@@ -1412,7 +1415,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
text = msg.text,
timestamp = msg.timestamp.time,
isOutgoing = msg.isOutgoing,
publicKey = if (msg.isOutgoing) sender else opponent
publicKey = if (msg.isOutgoing) sender else opponent,
attachments =
msg.attachments
.filter { it.type != AttachmentType.MESSAGES }
.map { it.copy(localUri = "") }
)
}
_isForwardMode.value = true
@@ -1572,6 +1579,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
put("id", att.id)
put("type", att.type.value)
put("preview", att.preview)
put("width", att.width)
put("height", att.height)
// Для IMAGE/FILE - blob не включаем (слишком большой)
// Для MESSAGES - включаем blob
put(

View File

@@ -164,6 +164,7 @@ fun ChatsListScreen(
onSettingsClick: () -> Unit,
onInviteFriendsClick: () -> Unit,
onSearchClick: () -> Unit,
onRequestsClick: () -> Unit = {},
onNewChat: () -> Unit,
onUserSelect: (com.rosetta.messenger.network.SearchUser) -> Unit = {},
backgroundBlurColorId: String = "avatar",
@@ -429,7 +430,7 @@ fun ChatsListScreen(
if (showRequestsScreen) return@pointerInput
val velocityTracker = VelocityTracker()
val relaxedTouchSlop = viewConfiguration.touchSlop * 0.45f
val relaxedTouchSlop = viewConfiguration.touchSlop * 0.8f
awaitEachGesture {
val down =
@@ -1062,10 +1063,31 @@ fun ChatsListScreen(
AnimatedContent(
targetState = showRequestsScreen,
transitionSpec = {
fadeIn(
animationSpec = tween(200)
) togetherWith
fadeOut(animationSpec = tween(150))
if (targetState) {
// Opening requests: slide in from right
slideInHorizontally(
animationSpec = tween(280, easing = FastOutSlowInEasing)
) { fullWidth -> fullWidth } + fadeIn(
animationSpec = tween(200)
) togetherWith
slideOutHorizontally(
animationSpec = tween(280, easing = FastOutSlowInEasing)
) { fullWidth -> -fullWidth / 4 } + fadeOut(
animationSpec = tween(150)
)
} else {
// Closing requests: slide out to right
slideInHorizontally(
animationSpec = tween(280, easing = FastOutSlowInEasing)
) { fullWidth -> -fullWidth / 4 } + fadeIn(
animationSpec = tween(200)
) togetherWith
slideOutHorizontally(
animationSpec = tween(280, easing = FastOutSlowInEasing)
) { fullWidth -> fullWidth } + fadeOut(
animationSpec = tween(150)
)
}
},
label = "RequestsTransition"
) { isRequestsScreen ->
@@ -1163,11 +1185,12 @@ fun ChatsListScreen(
RequestsSection(
count =
requestsCount,
requests =
requests,
isDarkTheme =
isDarkTheme,
onClick = {
showRequestsScreen =
true
onRequestsClick()
}
)
Divider(
@@ -2015,8 +2038,14 @@ fun SwipeableDialogItem(
}
?: break
if (change.changedToUpIgnoreConsumed()
)
) {
// Tap detected — finger went up before touchSlop
if (!passedSlop) {
change.consume()
onClick()
}
break
}
val delta = change.positionChange()
totalDragX += delta.x
@@ -2155,7 +2184,7 @@ fun SwipeableDialogItem(
isPinned = isPinned,
isBlocked = isBlocked,
avatarRepository = avatarRepository,
onClick = onClick
onClick = null // Tap handled by parent pointerInput
)
// Сепаратор внутри контента
@@ -2178,7 +2207,7 @@ fun DialogItemContent(
isPinned: Boolean = false,
isBlocked: Boolean = false,
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
onClick: () -> Unit
onClick: (() -> Unit)? = null
) {
// 🔥 ОПТИМИЗАЦИЯ: Кешируем цвета и строки
val textColor = remember(isDarkTheme) { if (isDarkTheme) Color.White else Color.Black }
@@ -2257,7 +2286,10 @@ fun DialogItemContent(
Row(
modifier =
Modifier.fillMaxWidth()
.clickable(onClick = onClick)
.then(
if (onClick != null) Modifier.clickable(onClick = onClick)
else Modifier
)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
@@ -2656,33 +2688,141 @@ fun TypingIndicatorSmall() {
}
}
/** 📬 Секция Requests - кнопка для перехода к списку запросов */
/** 📬 Секция Requests — Telegram-style chat item (как Archived Chats) */
@Composable
fun RequestsSection(count: Int, isDarkTheme: Boolean, onClick: () -> Unit) {
val textColor = if (isDarkTheme) Color(0xFF4DABF7) else Color(0xFF228BE6)
val arrowColor = if (isDarkTheme) Color(0xFFC9C9C9) else Color(0xFF228BE6)
fun RequestsSection(
count: Int,
requests: List<DialogUiModel> = emptyList(),
isDarkTheme: Boolean,
onClick: () -> Unit
) {
val textColor = remember(isDarkTheme) { if (isDarkTheme) Color.White else Color.Black }
val secondaryTextColor =
remember(isDarkTheme) { if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) }
val iconBgColor =
remember(isDarkTheme) { if (isDarkTheme) Color(0xFF3A3A3C) else Color(0xFFC7C7CC) }
// Последний запрос — показываем имя отправителя как subtitle
val lastRequest = remember(requests) { requests.firstOrNull() }
val subtitle = remember(lastRequest) {
when {
lastRequest == null -> ""
lastRequest.opponentTitle.isNotEmpty() &&
lastRequest.opponentTitle != lastRequest.opponentKey &&
lastRequest.opponentTitle != lastRequest.opponentKey.take(7) ->
lastRequest.opponentTitle
lastRequest.opponentUsername.isNotEmpty() ->
"@${lastRequest.opponentUsername}"
else -> lastRequest.opponentKey.take(7)
}
}
Row(
modifier =
Modifier.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.SpaceBetween,
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Requests +$count",
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = textColor
)
// Иконка — круглый аватар как в Telegram Archived Chats
Box(
modifier =
Modifier.size(56.dp)
.clip(CircleShape)
.background(iconBgColor),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = TablerIcons.MailForward,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(26.dp)
)
}
Icon(
imageVector = TablerIcons.ChevronRight,
contentDescription = "Open requests",
tint = arrowColor.copy(alpha = 0.6f),
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(12.dp))
// Текст: название + последний отправитель
Column(modifier = Modifier.weight(1f)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Requests",
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp,
color = textColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
}
if (subtitle.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = subtitle,
fontSize = 14.sp,
color = secondaryTextColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
// Badge с количеством
if (count > 0) {
Spacer(modifier = Modifier.width(8.dp))
Box(
modifier =
Modifier
.defaultMinSize(minWidth = 22.dp, minHeight = 22.dp)
.clip(RoundedCornerShape(11.dp))
.background(iconBgColor),
contentAlignment = Alignment.Center
) {
Text(
text = if (count > 99) "99+" else count.toString(),
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
modifier = Modifier.padding(horizontal = 5.dp, vertical = 1.dp)
)
}
}
}
} else if (count > 0) {
// Если нет subtitle но есть count
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
Box(
modifier =
Modifier
.defaultMinSize(minWidth = 22.dp, minHeight = 22.dp)
.clip(RoundedCornerShape(11.dp))
.background(iconBgColor),
contentAlignment = Alignment.Center
) {
Text(
text = if (count > 99) "99+" else count.toString(),
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
modifier = Modifier.padding(horizontal = 5.dp, vertical = 1.dp)
)
}
}
}
}
}
}

View File

@@ -0,0 +1,86 @@
package com.rosetta.messenger.ui.chats
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import compose.icons.TablerIcons
import compose.icons.tablericons.ArrowLeft
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RequestsListScreen(
isDarkTheme: Boolean,
chatsViewModel: ChatsListViewModel,
onBack: () -> Unit,
onUserSelect: (SearchUser) -> Unit
) {
val chatsState by chatsViewModel.chatsState.collectAsState()
val requests = chatsState.requests
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
val textColor = if (isDarkTheme) Color.White else Color.Black
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = TablerIcons.ArrowLeft,
contentDescription = "Back",
tint = PrimaryBlue
)
}
},
title = {
Text(
text = "Requests",
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
color = textColor
)
},
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = backgroundColor,
scrolledContainerColor = backgroundColor,
navigationIconContentColor = textColor,
titleContentColor = textColor
)
)
},
containerColor = backgroundColor
) { paddingValues ->
Box(
modifier =
Modifier.fillMaxSize()
.background(backgroundColor)
.padding(paddingValues)
) {
RequestsScreen(
requests = requests,
isDarkTheme = isDarkTheme,
onBack = onBack,
onRequestClick = { request ->
onUserSelect(chatsViewModel.dialogToSearchUser(request))
}
)
}
}
}

View File

@@ -240,6 +240,7 @@ fun TypingIndicator(isDarkTheme: Boolean) {
fun MessageBubble(
message: ChatMessage,
isDarkTheme: Boolean,
isSelectionMode: Boolean = false,
showTail: Boolean = true,
isGroupStart: Boolean = false,
isSelected: Boolean = false,
@@ -307,6 +308,8 @@ fun MessageBubble(
if (message.isOutgoing) Color(0xFFB3E5FC) // Светло-голубой на синем фоне
else Color(0xFF2196F3) // Стандартный Material Blue для входящих
}
val linksEnabled = !isSelectionMode
val textClickHandler: (() -> Unit)? = if (isSelectionMode) onClick else null
val timeColor =
remember(message.isOutgoing, isDarkTheme) {
@@ -705,6 +708,10 @@ fun MessageBubble(
fontSize = 16.sp,
linkColor =
linkColor,
enableLinks =
linksEnabled,
onClick =
textClickHandler,
onLongClick =
onLongClick // 🔥 Long press для selection
)
@@ -786,6 +793,8 @@ fun MessageBubble(
color = textColor,
fontSize = 17.sp,
linkColor = linkColor,
enableLinks = linksEnabled,
onClick = textClickHandler,
onLongClick =
onLongClick // 🔥
// Long
@@ -861,6 +870,8 @@ fun MessageBubble(
color = textColor,
fontSize = 17.sp,
linkColor = linkColor,
enableLinks = linksEnabled,
onClick = textClickHandler,
onLongClick =
onLongClick // 🔥
// Long

View File

@@ -399,7 +399,9 @@ fun MessageInputBar(
ReplyImagePreview(
attachment = imageAttachment,
modifier = Modifier.size(36.dp),
senderPublicKey = if (msg.isOutgoing) myPublicKey else opponentPublicKey,
senderPublicKey = msg.publicKey.ifEmpty {
if (msg.isOutgoing) myPublicKey else opponentPublicKey
},
recipientPrivateKey = myPrivateKey
)
Spacer(modifier = Modifier.width(4.dp))