From 5d8984ab912f723a82ef661ba3a9c736c065c45e Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Wed, 18 Mar 2026 20:09:33 +0500 Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=B8=D0=BD=D1=85=D1=80=D0=BE=D0=BD?= =?UTF-8?q?=D0=B8=D0=B7=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D1=8B=20=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=BC=D0=B0=D1=86=D0=B8=D0=B8=20=D1=87=D0=B0=D1=82?= =?UTF-8?q?=D0=B0=20=D0=B8=20Requests=20=D1=81=20Telegram,=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=20shimmer=20=D0=B8=20?= =?UTF-8?q?=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=BE=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BF=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/rosetta/messenger/MainActivity.kt | 2 + .../messenger/ui/chats/ChatDetailScreen.kt | 105 ++++++++++++--- .../messenger/ui/chats/ChatsListScreen.kt | 127 +++++++++++++----- .../ui/components/SwipeBackContainer.kt | 87 ++++++++++-- 4 files changed, 260 insertions(+), 61 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index 8e39a70..32b7ab0 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -49,6 +49,7 @@ import com.rosetta.messenger.ui.chats.SearchScreen import com.rosetta.messenger.ui.components.OptimizedEmojiCache import com.rosetta.messenger.ui.components.SwipeBackBackgroundEffect import com.rosetta.messenger.ui.components.SwipeBackContainer +import com.rosetta.messenger.ui.components.SwipeBackEnterAnimation import com.rosetta.messenger.ui.crashlogs.CrashLogsScreen import com.rosetta.messenger.ui.onboarding.OnboardingScreen import com.rosetta.messenger.ui.settings.BackupScreen @@ -1029,6 +1030,7 @@ fun MainScreen( isDarkTheme = isDarkTheme, layer = 1, swipeEnabled = !isChatSwipeLocked, + enterAnimation = SwipeBackEnterAnimation.SlideFromRight, propagateBackgroundProgress = false ) { selectedUser?.let { currentChatUser -> 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 53cdcf0..d4afcfe 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 @@ -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() + 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>(emptySet()) } - var groupMembersCount by remember(user.publicKey, currentUserPublicKey) { - mutableStateOf(null) + var groupMembersCount by remember(groupMembersCacheKey) { + mutableStateOf(groupMembersCountCache[groupMembersCacheKey]) } var mentionCandidates by remember(user.publicKey, currentUserPublicKey) { mutableStateOf>(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 = @@ -3597,6 +3619,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, diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index fa29f26..d6e8ec8 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -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 + ) } } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt b/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt index cf7c8f7..3c40cfd 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt @@ -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, + enterOffsetAnimatable: Animatable, + 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))) }