Слит dev в master: изменения после 1.2.1 и обновлены release notes
All checks were successful
Android Kernel Build / build (push) Successful in 1h10m41s

This commit is contained in:
2026-03-18 23:55:19 +05:00
9 changed files with 593 additions and 266 deletions

View File

@@ -17,8 +17,13 @@ import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.SizeTransform
@@ -53,6 +58,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
@@ -119,6 +125,8 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
private val groupMembersCountCache = java.util.concurrent.ConcurrentHashMap<String, Int>()
private data class IncomingRunAvatarAccumulator(
val senderPublicKey: String,
val senderDisplayName: String,
@@ -515,11 +523,15 @@ fun ChatDetailScreen(
val chatsListViewModel: ChatsListViewModel = viewModel()
val dialogsList by chatsListViewModel.dialogs.collectAsState()
val groupRepository = remember { GroupRepository.getInstance(context) }
val groupMembersCacheKey =
remember(user.publicKey, currentUserPublicKey) {
"${currentUserPublicKey.trim()}::${user.publicKey.trim()}"
}
var groupAdminKeys by remember(user.publicKey, currentUserPublicKey) {
mutableStateOf<Set<String>>(emptySet())
}
var groupMembersCount by remember(user.publicKey, currentUserPublicKey) {
mutableStateOf<Int?>(null)
var groupMembersCount by remember(groupMembersCacheKey) {
mutableStateOf<Int?>(groupMembersCountCache[groupMembersCacheKey])
}
var mentionCandidates by remember(user.publicKey, currentUserPublicKey) {
mutableStateOf<List<MentionCandidate>>(emptyList())
@@ -540,14 +552,25 @@ fun ChatDetailScreen(
return@LaunchedEffect
}
val cachedMembersCount = groupMembersCountCache[groupMembersCacheKey]
groupMembersCount = cachedMembersCount
val members = withContext(Dispatchers.IO) {
groupRepository.requestGroupMembers(user.publicKey).orEmpty()
groupRepository.requestGroupMembers(user.publicKey)
}
if (members == null) {
groupAdminKeys = emptySet()
mentionCandidates = emptyList()
return@LaunchedEffect
}
val normalizedMembers =
members.map { it.trim() }
.filter { it.isNotBlank() }
.distinct()
groupMembersCount = normalizedMembers.size
if (normalizedMembers.isNotEmpty()) {
groupMembersCountCache[groupMembersCacheKey] = normalizedMembers.size
}
val adminKey = normalizedMembers.firstOrNull().orEmpty()
groupAdminKeys =
@@ -1006,9 +1029,13 @@ fun ChatDetailScreen(
val isRosettaOfficial = user.title.equals("Rosetta", ignoreCase = true) ||
user.username.equals("rosetta", ignoreCase = true) ||
isSystemAccount
val groupMembersSubtitleCount = groupMembersCount ?: 0
val groupMembersSubtitleCount = groupMembersCount
val groupMembersSubtitle =
"$groupMembersSubtitleCount member${if (groupMembersSubtitleCount == 1) "" else "s"}"
if (groupMembersSubtitleCount == null) {
""
} else {
"$groupMembersSubtitleCount member${if (groupMembersSubtitleCount == 1) "" else "s"}"
}
val chatSubtitle =
when {
isSavedMessages -> "Notes"
@@ -1249,19 +1276,10 @@ fun ChatDetailScreen(
extractCopyableMessageText(
msg
)
if (messageText.isBlank()) {
null
} else {
val time =
SimpleDateFormat(
"HH:mm",
Locale.getDefault()
)
.format(
msg.timestamp
)
"[${if (msg.isOutgoing) "You" else chatTitle}] $time\n$messageText"
}
messageText
.takeIf {
it.isNotBlank()
}
}
.joinToString(
"\n\n"
@@ -1582,6 +1600,10 @@ fun ChatDetailScreen(
isDarkTheme =
isDarkTheme
)
} else if (isGroupChat &&
groupMembersCount == null
) {
GroupMembersSubtitleSkeleton()
} else {
Text(
text =
@@ -3601,6 +3623,53 @@ fun ChatDetailScreen(
} // Закрытие outer Box
}
@Composable
private fun GroupMembersSubtitleSkeleton() {
val transition = rememberInfiniteTransition(label = "groupMembersSkeleton")
val shimmerProgress by
transition.animateFloat(
initialValue = -1f,
targetValue = 2f,
animationSpec =
infiniteRepeatable(
animation = tween(1200, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "groupMembersSkeletonShimmer"
)
BoxWithConstraints(
modifier =
Modifier.padding(top = 2.dp)
.width(76.dp)
.height(12.dp)
) {
val density = LocalDensity.current
val widthPx = with(density) { maxWidth.toPx() }.coerceAtLeast(1f)
val gradientWidthPx = with(density) { 44.dp.toPx() }
val shimmerX = shimmerProgress * widthPx
val shimmerBrush =
Brush.linearGradient(
colorStops =
arrayOf(
0f to Color.White.copy(alpha = 0.22f),
0.5f to Color.White.copy(alpha = 0.56f),
1f to Color.White.copy(alpha = 0.22f)
),
start = Offset(shimmerX - gradientWidthPx, 0f),
end = Offset(shimmerX, 0f)
)
Box(
modifier =
Modifier.fillMaxSize()
.clip(RoundedCornerShape(6.dp))
.background(shimmerBrush)
)
}
}
@Composable
private fun TiledChatWallpaper(
wallpaperResId: Int,

View File

@@ -2171,39 +2171,102 @@ fun ChatsListScreen(
}
}
if (requestsCount > 0) {
item(
key =
"requests_section"
item(key = "requests_section") {
val isRequestsSectionVisible =
requestsCount > 0 &&
isRequestsVisible
AnimatedVisibility(
visible =
isRequestsSectionVisible,
enter =
slideInVertically(
initialOffsetY = {
fullHeight ->
-fullHeight /
3
},
animationSpec =
tween(
durationMillis =
260,
easing =
FastOutSlowInEasing
)
) +
expandVertically(
expandFrom =
Alignment
.Top,
animationSpec =
tween(
durationMillis =
260,
easing =
FastOutSlowInEasing
)
) +
fadeIn(
animationSpec =
tween(
durationMillis =
180
),
initialAlpha =
0.7f
),
exit =
slideOutVertically(
targetOffsetY = {
fullHeight ->
-fullHeight /
3
},
animationSpec =
tween(
durationMillis =
220,
easing =
FastOutSlowInEasing
)
) +
shrinkVertically(
shrinkTowards =
Alignment
.Top,
animationSpec =
tween(
durationMillis =
220,
easing =
FastOutSlowInEasing
)
) +
fadeOut(
animationSpec =
tween(
durationMillis =
140
)
)
) {
AnimatedVisibility(
visible = isRequestsVisible,
enter = expandVertically(
animationSpec = tween(250, easing = FastOutSlowInEasing)
) + fadeIn(animationSpec = tween(200)),
exit = shrinkVertically(
animationSpec = tween(250, easing = FastOutSlowInEasing)
) + fadeOut(animationSpec = tween(200))
) {
Column {
RequestsSection(
count =
requestsCount,
requests =
requests,
isDarkTheme =
isDarkTheme,
onClick = {
openRequestsRouteSafely()
}
)
Divider(
color =
dividerColor,
thickness =
0.5.dp
)
}
Column {
RequestsSection(
count =
requestsCount,
requests =
requests,
isDarkTheme =
isDarkTheme,
onClick = {
openRequestsRouteSafely()
}
)
Divider(
color =
dividerColor,
thickness =
0.5.dp
)
}
}
}
@@ -2886,37 +2949,62 @@ private fun DeviceResolveDialog(
@Composable
private fun ChatsListSkeleton(isDarkTheme: Boolean) {
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
val shimmerBase = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE0E0E0)
val shimmerHighlight = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFF0F0F0)
val shimmerBase = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFE5E5EA)
val shimmerHighlight = if (isDarkTheme) Color(0xFF48484A) else Color(0xFFFFFFFF)
val transition = rememberInfiniteTransition(label = "shimmer")
val shimmerProgress by
transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec =
infiniteRepeatable(
animation =
tween(durationMillis = 1200, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "shimmerAlpha"
BoxWithConstraints(modifier = Modifier.fillMaxSize().background(backgroundColor)) {
val density = LocalDensity.current
val maxWidthPx = with(density) { maxWidth.toPx() }.coerceAtLeast(1f)
val gradientWidthPx = with(density) { 280.dp.toPx() }
val transition = rememberInfiniteTransition(label = "shimmer")
val shimmerX by transition.animateFloat(
initialValue = -gradientWidthPx,
targetValue = maxWidthPx + gradientWidthPx,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1400, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "shimmerX"
)
val shimmerColor = lerp(shimmerBase, shimmerHighlight, shimmerProgress)
LazyColumn(
modifier = Modifier.fillMaxSize().background(backgroundColor),
userScrollEnabled = false
) {
items(10) {
SkeletonDialogItem(shimmerColor = shimmerColor, isDarkTheme = isDarkTheme)
val shimmerBrush = Brush.linearGradient(
colorStops = arrayOf(
0f to shimmerBase,
0.4f to shimmerHighlight,
0.6f to shimmerHighlight,
1f to shimmerBase
),
start = Offset(shimmerX - gradientWidthPx / 2f, 0f),
end = Offset(shimmerX + gradientWidthPx / 2f, 0f)
)
val nameWidths = remember {
floatArrayOf(0.38f, 0.52f, 0.44f, 0.61f, 0.35f, 0.48f, 0.56f, 0.41f, 0.63f, 0.39f)
}
val msgWidths = remember {
floatArrayOf(0.72f, 0.55f, 0.82f, 0.64f, 0.78f, 0.51f, 0.69f, 0.87f, 0.60f, 0.74f)
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
userScrollEnabled = false
) {
items(10) { index ->
SkeletonDialogItem(
shimmerBrush = shimmerBrush,
nameWidth = nameWidths[index],
messageWidth = msgWidths[index],
isDarkTheme = isDarkTheme
)
}
}
}
}
@Composable
private fun SkeletonDialogItem(shimmerColor: Color, isDarkTheme: Boolean) {
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8)
private fun SkeletonDialogItem(shimmerBrush: Brush, nameWidth: Float, messageWidth: Float, isDarkTheme: Boolean) {
val dividerColor = if (isDarkTheme) Color(0xFF38383A) else Color(0xFFE5E5EA)
Row(
modifier =
@@ -2932,41 +3020,42 @@ private fun SkeletonDialogItem(shimmerColor: Color, isDarkTheme: Boolean) {
modifier =
Modifier.size(TELEGRAM_DIALOG_AVATAR_SIZE)
.clip(CircleShape)
.background(shimmerColor)
.background(shimmerBrush)
)
Spacer(modifier = Modifier.width(TELEGRAM_DIALOG_AVATAR_GAP))
Column(modifier = Modifier.weight(1f)) {
// Name placeholder
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(7.dp)) {
// Name + time row
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier =
Modifier.fillMaxWidth(nameWidth)
.height(15.dp)
.clip(RoundedCornerShape(6.dp))
.background(shimmerBrush)
)
Box(
modifier =
Modifier.width(34.dp)
.height(11.dp)
.clip(RoundedCornerShape(4.dp))
.background(shimmerBrush)
)
}
// Message preview placeholder
Box(
modifier =
Modifier.fillMaxWidth(0.45f)
.height(16.dp)
.clip(RoundedCornerShape(4.dp))
.background(shimmerColor)
)
Spacer(modifier = Modifier.height(8.dp))
// Message placeholder
Box(
modifier =
Modifier.fillMaxWidth(0.7f)
.height(14.dp)
.clip(RoundedCornerShape(4.dp))
.background(shimmerColor)
Modifier.fillMaxWidth(messageWidth)
.height(13.dp)
.clip(RoundedCornerShape(5.dp))
.background(shimmerBrush)
)
}
Spacer(modifier = Modifier.width(8.dp))
// Time placeholder
Box(
modifier =
Modifier.width(36.dp)
.height(12.dp)
.clip(RoundedCornerShape(4.dp))
.background(shimmerColor)
)
}
Divider(
color = dividerColor,

View File

@@ -44,6 +44,7 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
@@ -58,10 +59,10 @@ import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Divider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
@@ -90,6 +91,7 @@ import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
@@ -107,6 +109,7 @@ 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.window.PopupProperties
import com.airbnb.lottie.compose.LottieAnimation
import com.airbnb.lottie.compose.LottieCompositionSpec
import com.airbnb.lottie.compose.LottieConstants
@@ -1007,52 +1010,71 @@ fun GroupInfoScreen(
tint = Color.White
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
val menuBgColor = if (isDarkTheme) Color(0xFF272829) else Color.White
val menuTextColor = if (isDarkTheme) Color.White else Color(0xFF222222)
val menuIconColor = if (isDarkTheme) Color.White.copy(alpha = 0.47f) else Color(0xFF676B70)
val menuDividerColor = if (isDarkTheme) Color(0xFF1C1D1F) else Color(0xFFF5F5F5)
val menuDangerColor = Color(0xFFFF3B30)
MaterialTheme(
colorScheme = MaterialTheme.colorScheme.copy(
surface = menuBgColor,
onSurface = menuTextColor
)
) {
DropdownMenuItem(
text = { Text("Search members") },
leadingIcon = {
Icon(Icons.Default.Search, contentDescription = null)
},
onClick = {
showMenu = false
selectedTab = GroupInfoTab.MEMBERS
showSearch = true
}
)
DropdownMenuItem(
text = { Text("Copy invite") },
leadingIcon = {
Icon(Icons.Default.PersonAdd, contentDescription = null)
},
onClick = {
showMenu = false
copyInvite()
}
)
DropdownMenuItem(
text = { Text("Encryption key") },
leadingIcon = {
Icon(Icons.Default.Lock, contentDescription = null)
},
onClick = {
showMenu = false
openEncryptionKey()
}
)
Divider()
DropdownMenuItem(
text = { Text("Leave group", color = Color(0xFFFF3B30)) },
leadingIcon = {
Icon(Icons.Default.ExitToApp, contentDescription = null, tint = Color(0xFFFF3B30))
},
onClick = {
showMenu = false
showLeaveConfirm = true
}
)
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
modifier = Modifier.widthIn(min = 196.dp).background(menuBgColor),
properties = PopupProperties(
focusable = true,
dismissOnBackPress = true,
dismissOnClickOutside = true
)
) {
GroupInfoMenuItem(
icon = Icons.Default.Search,
text = "Search members",
tintColor = menuIconColor,
textColor = menuTextColor,
onClick = {
showMenu = false
selectedTab = GroupInfoTab.MEMBERS
showSearch = true
}
)
GroupInfoMenuItem(
icon = Icons.Default.PersonAdd,
text = "Copy invite",
tintColor = menuIconColor,
textColor = menuTextColor,
onClick = {
showMenu = false
copyInvite()
}
)
GroupInfoMenuItem(
icon = Icons.Default.Lock,
text = "Encryption key",
tintColor = menuIconColor,
textColor = menuTextColor,
onClick = {
showMenu = false
openEncryptionKey()
}
)
Divider(color = menuDividerColor)
GroupInfoMenuItem(
icon = Icons.Default.ExitToApp,
text = "Leave group",
tintColor = menuDangerColor,
textColor = menuDangerColor,
onClick = {
showMenu = false
showLeaveConfirm = true
}
)
}
}
}
@@ -1158,75 +1180,81 @@ fun GroupInfoScreen(
}
}
Spacer(modifier = Modifier.height(8.dp))
Surface(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
color = sectionColor,
shape = RoundedCornerShape(0.dp)
shape = RoundedCornerShape(12.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { openInviteSharePicker() }
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.PersonAdd,
contentDescription = null,
tint = accentColor
)
Spacer(modifier = Modifier.size(12.dp))
Text(
text = "Add Members",
color = accentColor,
fontSize = 17.sp,
fontWeight = FontWeight.Medium
)
}
}
Surface(
modifier = Modifier.fillMaxWidth(),
color = sectionColor,
shape = RoundedCornerShape(0.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clickable { openEncryptionKey() }
.padding(horizontal = 16.dp, vertical = 11.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column {
// Add Members
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { openInviteSharePicker() }
.padding(horizontal = 16.dp, vertical = 13.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Add Members",
color = primaryText,
fontSize = 16.sp,
modifier = Modifier.weight(1f)
)
Icon(
imageVector = Icons.Default.Lock,
imageVector = Icons.Default.PersonAdd,
contentDescription = null,
tint = accentColor,
modifier = Modifier.size(20.dp)
modifier = Modifier.size(22.dp)
)
Spacer(modifier = Modifier.width(12.dp))
}
Divider(
color = borderColor,
thickness = 0.5.dp,
modifier = Modifier.padding(start = 16.dp)
)
// Encryption Key
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { openEncryptionKey() }
.padding(horizontal = 16.dp, vertical = 13.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Encryption Key",
color = primaryText,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f)
)
if (encryptionKeyLoading) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
color = accentColor
)
} else {
val identiconKey = encryptionKey.ifBlank { dialogPublicKey }
Box(
modifier = Modifier
.size(34.dp)
.clip(RoundedCornerShape(6.dp))
) {
TelegramStyleIdenticon(
keyRender = identiconKey,
size = 34.dp,
isDarkTheme = isDarkTheme
)
}
}
}
Spacer(modifier = Modifier.height(3.dp))
Text(
text = "Tap to view key used for secure group communication",
color = secondaryText,
fontSize = 12.sp
)
}
}
Spacer(modifier = Modifier.height(8.dp))
TabRow(
selectedTabIndex = GroupInfoTab.entries.indexOf(selectedTab),
@@ -1812,6 +1840,39 @@ private fun GroupEncryptionKeyPage(
}
}
@Composable
private fun GroupInfoMenuItem(
icon: ImageVector,
text: String,
tintColor: Color,
textColor: Color,
onClick: () -> Unit
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.clickable(onClick = onClick)
.padding(horizontal = 18.dp),
contentAlignment = Alignment.CenterStart
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = icon,
contentDescription = null,
tint = tintColor,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(19.dp))
Text(
text = text,
color = textColor,
fontSize = 16.sp
)
}
}
}
@Composable
private fun GroupActionButton(
modifier: Modifier = Modifier,

View File

@@ -2596,19 +2596,40 @@ fun MessageSkeletonList(
label = "telegramSkeletonProgress"
)
// Telegram-style deterministic pseudo-randomness.
val widthRandom = remember { floatArrayOf(0.18f, 0.74f, 0.32f, 0.61f, 0.27f, 0.84f, 0.49f, 0.12f) }
val heightRandom = remember { floatArrayOf(0.25f, 0.68f, 0.14f, 0.52f, 0.37f, 0.79f, 0.29f, 0.57f) }
// Deterministic pseudo-random widths (fraction of bubbleMaxWidth)
val widthRandom = remember {
floatArrayOf(0.55f, 0.72f, 0.38f, 0.65f, 0.48f, 0.80f, 0.42f, 0.68f,
0.35f, 0.75f, 0.58f, 0.44f, 0.70f, 0.52f, 0.82f, 0.40f,
0.63f, 0.77f, 0.46f, 0.60f, 0.34f, 0.71f, 0.50f, 0.66f)
}
// Realistic message bubble heights in dp (single-line to ~3-line)
val heightRandom = remember {
intArrayOf(44, 52, 40, 60, 36, 56, 44, 48, 64, 40, 52, 44,
36, 68, 52, 44, 60, 36, 48, 56, 40, 64, 44, 52)
}
// false = incoming (left), true = outgoing (right)
val sidePattern = remember {
booleanArrayOf(false, false, true, true, true, false, true,
false, false, true, true, false, true, true, false,
false, false, true, true, true, false, false, true, true)
}
val bubbleShape =
remember {
RoundedCornerShape(
topStart = TelegramBubbleSpec.bubbleRadius,
topEnd = TelegramBubbleSpec.bubbleRadius,
bottomStart = TelegramBubbleSpec.nearRadius,
bottomEnd = TelegramBubbleSpec.bubbleRadius
)
}
val incomingShape = remember {
RoundedCornerShape(
topStart = TelegramBubbleSpec.bubbleRadius,
topEnd = TelegramBubbleSpec.bubbleRadius,
bottomStart = TelegramBubbleSpec.nearRadius,
bottomEnd = TelegramBubbleSpec.bubbleRadius
)
}
val outgoingShape = remember {
RoundedCornerShape(
topStart = TelegramBubbleSpec.bubbleRadius,
topEnd = TelegramBubbleSpec.bubbleRadius,
bottomStart = TelegramBubbleSpec.bubbleRadius,
bottomEnd = TelegramBubbleSpec.nearRadius
)
}
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
val density = LocalDensity.current
@@ -2616,37 +2637,33 @@ fun MessageSkeletonList(
val gradientWidthPx = with(density) { 200.dp.toPx() }
val shimmerX = shimmerProgress * maxWidthPx
val color0 =
if (isDarkTheme) Color(0x36FFFFFF) else Color(0x1F000000)
val color1 =
if (isDarkTheme) Color(0x1CFFFFFF) else Color(0x12000000)
val outlineColor =
if (isDarkTheme) Color(0x40FFFFFF) else Color(0x24FFFFFF)
val color0 = if (isDarkTheme) Color(0x36FFFFFF) else Color(0x1F000000)
val color1 = if (isDarkTheme) Color(0x1CFFFFFF) else Color(0x12000000)
val outlineColor = if (isDarkTheme) Color(0x40FFFFFF) else Color(0x24FFFFFF)
val shimmerBrush =
Brush.linearGradient(
colorStops =
arrayOf(
0f to color1,
0.4f to color0,
0.6f to color0,
1f to color1
),
colorStops = arrayOf(
0f to color1,
0.4f to color0,
0.6f to color0,
1f to color1
),
start = Offset(shimmerX - gradientWidthPx, 0f),
end = Offset(shimmerX, 0f)
)
val bottomInset = 58.dp
val containerWidth = this@BoxWithConstraints.maxWidth
val bubbleMaxWidth = (containerWidth * 0.8f) - if (isGroupChat) 42.dp else 0.dp
val avatarSize = 36.dp
val avatarGap = 6.dp
val containerWidth = this@BoxWithConstraints.maxWidth
val bubbleMaxWidth = containerWidth * 0.78f - if (isGroupChat) (avatarSize + avatarGap) else 0.dp
val availableHeight = (maxHeight - bottomInset).coerceAtLeast(0.dp)
var usedHeight = 0.dp
var rowCount = 0
while (rowCount < 24 && usedHeight < availableHeight) {
val randomHeight = heightRandom[rowCount % heightRandom.size]
usedHeight += (64.dp + (randomHeight * 64f).dp + 3.dp)
while (rowCount < heightRandom.size && usedHeight < availableHeight) {
usedHeight += (heightRandom[rowCount].dp + 4.dp)
rowCount++
}
if (rowCount < 6) rowCount = 6
@@ -2655,49 +2672,37 @@ fun MessageSkeletonList(
modifier =
Modifier.align(Alignment.BottomStart)
.fillMaxWidth()
.padding(start = 3.dp, end = 8.dp, bottom = bottomInset),
verticalArrangement = Arrangement.spacedBy(3.dp)
.padding(start = 8.dp, end = 8.dp, bottom = bottomInset),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
repeat(rowCount) { index ->
val lineWidth =
minOf(
bubbleMaxWidth,
42.dp +
containerWidth *
(0.4f +
widthRandom[index % widthRandom.size] *
0.35f)
)
val lineHeight =
64.dp +
(heightRandom[index % heightRandom.size] * 64f).dp
val isOutgoing = sidePattern[index % sidePattern.size]
val bubbleWidth = bubbleMaxWidth * widthRandom[index % widthRandom.size]
val bubbleHeight = heightRandom[index % heightRandom.size].dp
val shape = if (isOutgoing) outgoingShape else incomingShape
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (isOutgoing) Arrangement.End else Arrangement.Start,
verticalAlignment = Alignment.Bottom
) {
if (isGroupChat) {
if (!isOutgoing && isGroupChat) {
Box(
modifier =
Modifier.size(42.dp)
Modifier.size(avatarSize)
.clip(CircleShape)
.background(shimmerBrush)
.border(1.dp, outlineColor, CircleShape)
)
Spacer(modifier = Modifier.width(avatarGap))
}
Box(
modifier =
Modifier.width(lineWidth)
.height(lineHeight)
.clip(bubbleShape)
Modifier.width(bubbleWidth)
.height(bubbleHeight)
.clip(shape)
.background(shimmerBrush)
.border(
width = 1.dp,
color = outlineColor,
shape = bubbleShape
)
.border(1.dp, outlineColor, shape)
)
}
}

View File

@@ -1,6 +1,7 @@
package com.rosetta.messenger.ui.components
import android.content.Context
import android.view.animation.DecelerateInterpolator
import android.view.inputmethod.InputMethodManager
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
@@ -19,15 +20,21 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.isActive
import kotlin.coroutines.coroutineContext
// Telegram's CubicBezierInterpolator(0.25, 0.1, 0.25, 1.0)
private val TelegramEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1.0f)
private val TelegramOpenDecelerateEasing =
Easing { input -> DecelerateInterpolator(1.5f).getInterpolation(input) }
// Swipe-back thresholds tuned for ultra-light navigation.
private const val COMPLETION_THRESHOLD = 0.05f // 5% of screen width
private const val FLING_VELOCITY_THRESHOLD = 35f // px/s
private const val ANIMATION_DURATION_ENTER = 300
private const val ANIMATION_DURATION_ENTER_TELEGRAM_CHAT = 150
private const val ANIMATION_DURATION_EXIT = 200
private const val EDGE_ZONE_DP = 320
private const val EDGE_ZONE_WIDTH_FRACTION = 0.85f
@@ -36,6 +43,42 @@ private const val HORIZONTAL_DOMINANCE_RATIO = 1.05f
private const val BACKGROUND_MIN_SCALE = 0.97f
private const val BACKGROUND_PARALLAX_DP = 4
enum class SwipeBackEnterAnimation {
Fade,
SlideFromRight
}
private suspend fun runTelegramChatEnterAnimation(
alphaAnimatable: Animatable<Float, AnimationVector1D>,
enterOffsetAnimatable: Animatable<Float, AnimationVector1D>,
startOffsetPx: Float
) {
var progress = 0f
var lastFrameTimeMs = withFrameNanos { it } / 1_000_000L
while (coroutineContext.isActive && progress < 1f) {
val nowMs = withFrameNanos { it } / 1_000_000L
var dtMs = nowMs - lastFrameTimeMs
if (dtMs > 40L && progress == 0f) {
dtMs = 0L
} else if (dtMs > 18L) {
dtMs = 18L
}
lastFrameTimeMs = nowMs
progress =
(progress + (dtMs / ANIMATION_DURATION_ENTER_TELEGRAM_CHAT.toFloat()))
.coerceIn(0f, 1f)
val interpolated = TelegramOpenDecelerateEasing.transform(progress).coerceIn(0f, 1f)
alphaAnimatable.snapTo(interpolated)
enterOffsetAnimatable.snapTo(startOffsetPx * (1f - interpolated))
}
alphaAnimatable.snapTo(1f)
enterOffsetAnimatable.snapTo(0f)
}
/**
* Telegram-style swipe back container (optimized)
*
@@ -89,6 +132,7 @@ fun SwipeBackContainer(
swipeEnabled: Boolean = true,
propagateBackgroundProgress: Boolean = true,
deferToChildren: Boolean = false,
enterAnimation: SwipeBackEnterAnimation = SwipeBackEnterAnimation.Fade,
content: @Composable () -> Unit
) {
// 🚀 Lazy composition: skip ALL setup until the screen is opened for the first time.
@@ -103,10 +147,12 @@ fun SwipeBackContainer(
val edgeZonePx = with(density) { EDGE_ZONE_DP.dp.toPx() }
val effectiveEdgeZonePx = maxOf(edgeZonePx, screenWidthPx * EDGE_ZONE_WIDTH_FRACTION)
val backgroundParallaxPx = with(density) { BACKGROUND_PARALLAX_DP.dp.toPx() }
val chatEnterOffsetPx = with(density) { 48.dp.toPx() }
val containerId = remember { System.identityHashCode(Any()).toLong() }
// Animation state for swipe (used only for swipe animations, not during drag)
val offsetAnimatable = remember { Animatable(0f) }
val enterOffsetAnimatable = remember { Animatable(0f) }
// Alpha animation for fade-in entry
val alphaAnimatable = remember { Animatable(0f) }
@@ -129,8 +175,15 @@ fun SwipeBackContainer(
val view = LocalView.current
val focusManager = LocalFocusManager.current
// Current offset: use drag offset during drag, animatable otherwise
val currentOffset = if (isDragging) dragOffset else offsetAnimatable.value
// Current offset: use drag offset during drag, animatable otherwise + optional enter slide
val baseOffset = if (isDragging) dragOffset else offsetAnimatable.value
val enterOffset =
if (isAnimatingIn && enterAnimation == SwipeBackEnterAnimation.SlideFromRight) {
enterOffsetAnimatable.value
} else {
0f
}
val currentOffset = baseOffset + enterOffset
// Current alpha: use animatable during fade animations, otherwise 1
val currentAlpha = if (isAnimatingIn || isAnimatingOut) alphaAnimatable.value else 1f
@@ -186,17 +239,29 @@ fun SwipeBackContainer(
isAnimatingIn = true
try {
offsetAnimatable.snapTo(0f) // No slide for entry
dragOffset = 0f
alphaAnimatable.snapTo(0f)
alphaAnimatable.animateTo(
targetValue = 1f,
animationSpec =
tween(
durationMillis = ANIMATION_DURATION_ENTER,
easing = FastOutSlowInEasing
)
)
if (enterAnimation == SwipeBackEnterAnimation.SlideFromRight) {
enterOffsetAnimatable.snapTo(chatEnterOffsetPx)
runTelegramChatEnterAnimation(
alphaAnimatable = alphaAnimatable,
enterOffsetAnimatable = enterOffsetAnimatable,
startOffsetPx = chatEnterOffsetPx
)
} else {
enterOffsetAnimatable.snapTo(0f)
alphaAnimatable.animateTo(
targetValue = 1f,
animationSpec =
tween(
durationMillis = ANIMATION_DURATION_ENTER,
easing = FastOutSlowInEasing
)
)
}
} finally {
isAnimatingIn = false
enterOffsetAnimatable.snapTo(0f)
}
} else if (!isVisible && shouldShow) {
// Animate out: fade-out (when triggered by button, not swipe)
@@ -231,7 +296,7 @@ fun SwipeBackContainer(
}
) {
// Scrim (dimming layer behind the screen) - only when swiping
if (currentOffset > 0f) {
if (!isAnimatingIn && currentOffset > 0f) {
Box(modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = scrimAlpha)))
}