From e9944b3c67c5d0d6486bccc845eb30f971c06848 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Fri, 6 Mar 2026 17:23:11 +0500 Subject: [PATCH 01/19] =?UTF-8?q?=D0=93=D1=80=D1=83=D0=BF=D0=BF=D1=8B:=20?= =?UTF-8?q?=D0=B2=D0=BE=D1=81=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=B9?= =?UTF-8?q?=20=D0=BF=D0=BE=20=D0=B8=D0=BD=D0=B2=D0=B0=D0=B9=D1=82=D1=83=20?= =?UTF-8?q?=D0=B8=20Apple=20Emoji?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавлено восстановление локального ключа группы из инвайта при повторном нажатии, даже если на сервере статус уже JOINED. - В карточке приглашения сначала восстанавливается ключ, затем открывается группа. - Включено отображение Apple Emoji для названия/описания группы в GroupInfo и в заголовке группы в чате. - Обновлён превью-заголовок в GroupSetup на Apple Emoji рендер. --- .../rosetta/messenger/data/GroupRepository.kt | 41 +++++++++++++++++++ .../messenger/ui/chats/ChatDetailScreen.kt | 24 ++++------- .../messenger/ui/chats/GroupInfoScreen.kt | 11 +++-- .../messenger/ui/chats/GroupSetupScreen.kt | 6 ++- .../chats/components/ChatDetailComponents.kt | 34 ++++++++++++++- 5 files changed, 94 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/data/GroupRepository.kt b/app/src/main/java/com/rosetta/messenger/data/GroupRepository.kt index d897b9a..ff1d259 100644 --- a/app/src/main/java/com/rosetta/messenger/data/GroupRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/GroupRepository.kt @@ -284,6 +284,47 @@ class GroupRepository private constructor(context: Context) { ) } + /** + * Desktop parity fix: + * if user is already joined on server, repeated invite click should still restore local group key. + */ + suspend fun ensureLocalGroupFromInvite( + accountPublicKey: String, + accountPrivateKey: String, + inviteString: String + ): GroupJoinResult { + val parsed = parseInviteString(inviteString) + ?: return GroupJoinResult( + success = false, + status = GroupStatus.INVALID, + error = "Invalid invite string" + ) + + val existingGroupKey = getGroupKey(accountPublicKey, accountPrivateKey, parsed.groupId) + if (!existingGroupKey.isNullOrBlank()) { + return GroupJoinResult( + success = true, + status = GroupStatus.JOINED, + dialogPublicKey = toGroupDialogPublicKey(parsed.groupId), + title = parsed.title + ) + } + + persistJoinedGroup( + accountPublicKey = accountPublicKey, + accountPrivateKey = accountPrivateKey, + parsedInvite = parsed, + emitSystemJoinMessage = false + ) + + return GroupJoinResult( + success = true, + status = GroupStatus.JOINED, + dialogPublicKey = toGroupDialogPublicKey(parsed.groupId), + title = parsed.title + ) + } + suspend fun synchronizeJoinedGroup( accountPublicKey: String, accountPrivateKey: String, 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 a54cd72..9f8a99f 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 @@ -90,6 +90,7 @@ import com.rosetta.messenger.ui.chats.components.MultiImageEditorScreen import com.rosetta.messenger.ui.chats.input.* import com.rosetta.messenger.ui.chats.models.* import com.rosetta.messenger.ui.chats.utils.* +import com.rosetta.messenger.ui.components.AppleEmojiText import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.components.VerifiedBadge import com.rosetta.messenger.ui.onboarding.PrimaryBlue @@ -1133,21 +1134,14 @@ fun ChatDetailScreen( Alignment .CenterVertically ) { - Text( - text = - chatTitle, - fontSize = - 16.sp, - fontWeight = - FontWeight - .SemiBold, - color = - Color.White, - maxLines = - 1, - overflow = - TextOverflow - .Ellipsis + AppleEmojiText( + text = chatTitle, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = Color.White, + maxLines = 1, + overflow = android.text.TextUtils.TruncateAt.END, + enableLinks = false ) if (!isSavedMessages && !isGroupChat && diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt index b923518..e717bbf 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt @@ -122,6 +122,7 @@ import com.rosetta.messenger.ui.chats.components.ImageAttachment import com.rosetta.messenger.ui.chats.components.ImageSourceBounds import com.rosetta.messenger.ui.chats.components.ImageViewerScreen import com.rosetta.messenger.ui.chats.components.ViewableImage +import com.rosetta.messenger.ui.components.AppleEmojiText import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.components.VerifiedBadge import com.rosetta.messenger.ui.icons.TelegramIcons @@ -854,13 +855,14 @@ fun GroupInfoScreen( Spacer(modifier = Modifier.height(14.dp)) - Text( + AppleEmojiText( text = groupTitle, color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, - overflow = TextOverflow.Ellipsis + overflow = android.text.TextUtils.TruncateAt.END, + enableLinks = false ) Text( @@ -917,12 +919,13 @@ fun GroupInfoScreen( if (groupDescription.isNotBlank()) { Spacer(modifier = Modifier.height(10.dp)) - Text( + AppleEmojiText( text = groupDescription, color = Color.White.copy(alpha = 0.7f), fontSize = 12.sp, maxLines = 2, - overflow = TextOverflow.Ellipsis + overflow = android.text.TextUtils.TruncateAt.END, + enableLinks = false ) } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt index e1b3ede..e1a4552 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt @@ -81,6 +81,7 @@ import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.ui.components.KeyboardHeightProvider import com.rosetta.messenger.ui.components.OptimizedEmojiPicker +import com.rosetta.messenger.ui.components.AppleEmojiText import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.icons.TelegramIcons import com.rosetta.messenger.utils.AvatarFileManager @@ -622,13 +623,14 @@ fun GroupSetupScreen( Spacer(modifier = Modifier.size(12.dp)) Column(modifier = Modifier.weight(1f)) { - Text( + AppleEmojiText( text = title.trim(), color = primaryTextColor, fontSize = 16.sp, fontWeight = FontWeight.Medium, maxLines = 1, - overflow = TextOverflow.Ellipsis + overflow = android.text.TextUtils.TruncateAt.END, + enableLinks = false ) Spacer(modifier = Modifier.height(2.dp)) Text( diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index bbc9aa1..f4ffb85 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -1452,7 +1452,39 @@ private fun GroupInviteInlineCard( if (parsedInvite == null) return if (status == GroupStatus.JOINED) { - openParsedGroup() + if (accountPublicKey.isBlank() || accountPrivateKey.isBlank()) { + openParsedGroup() + return + } + + scope.launch { + actionLoading = true + val restoreResult = + withContext(Dispatchers.IO) { + groupRepository.ensureLocalGroupFromInvite( + accountPublicKey = accountPublicKey, + accountPrivateKey = accountPrivateKey, + inviteString = normalizedInvite + ) + } + actionLoading = false + + if (restoreResult.success) { + status = GroupStatus.JOINED + groupRepository.cacheInviteInfo( + parsedInvite.groupId, + GroupStatus.JOINED, + membersCount + ) + openParsedGroup() + } else { + Toast.makeText( + context, + restoreResult.error ?: "Failed to restore group access", + Toast.LENGTH_SHORT + ).show() + } + } return } From 8bce15cc19ebd1e0eb965fc2c225791b15476016 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Fri, 6 Mar 2026 18:05:17 +0500 Subject: [PATCH 02/19] =?UTF-8?q?=D0=9F=D1=80=D0=BE=D1=84=D0=B8=D0=BB?= =?UTF-8?q?=D1=8C=20=D0=B8=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF=D1=8B:=20?= =?UTF-8?q?=D1=84=D0=B8=D0=BA=D1=81=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D1=82=D0=B0=D0=B1=D1=8B,=20fast-scroll=20?= =?UTF-8?q?=D1=81=20=D0=B4=D0=B0=D1=82=D0=BE=D0=B9=20=D0=B8=20Apple=20Emoj?= =?UTF-8?q?i?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 4 +- .../messenger/ui/chats/ChatsListScreen.kt | 12 +- .../messenger/ui/chats/GroupInfoScreen.kt | 147 +++++++---- .../messenger/ui/components/AvatarImage.kt | 7 +- .../SharedMediaFastScrollOverlay.kt | 241 ++++++++++++++++++ .../ui/settings/OtherProfileScreen.kt | 109 +++++++- app/src/main/res/values/strings.xml | 1 + 7 files changed, 459 insertions(+), 62 deletions(-) create mode 100644 app/src/main/java/com/rosetta/messenger/ui/components/SharedMediaFastScrollOverlay.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6aab70b..20a3529 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown" // ═══════════════════════════════════════════════════════════ // Rosetta versioning — bump here on each release // ═══════════════════════════════════════════════════════════ -val rosettaVersionName = "1.1.2" -val rosettaVersionCode = 14 // Increment on each release +val rosettaVersionName = "1.1.3" +val rosettaVersionCode = 15 // Increment on each release android { namespace = "com.rosetta.messenger" 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 d7f146f..c0ad443 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 @@ -2919,14 +2919,15 @@ fun ChatItem( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Text( + AppleEmojiText( text = chat.name, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = textColor, maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) + overflow = android.text.TextUtils.TruncateAt.END, + modifier = Modifier.weight(1f), + enableLinks = false ) if (isMuted) { @@ -3722,13 +3723,14 @@ fun DialogItemContent( modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically ) { - Text( + AppleEmojiText( text = displayName, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, color = textColor, maxLines = 1, - overflow = TextOverflow.Ellipsis + overflow = android.text.TextUtils.TruncateAt.END, + enableLinks = false ) if (isGroupDialog) { Spacer(modifier = Modifier.width(5.dp)) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt index e717bbf..9b31a02 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.Canvas import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -30,6 +31,10 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.itemsIndexed as gridItemsIndexed +import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState @@ -82,7 +87,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalUriHandler @@ -124,6 +128,7 @@ import com.rosetta.messenger.ui.chats.components.ImageViewerScreen import com.rosetta.messenger.ui.chats.components.ViewableImage import com.rosetta.messenger.ui.components.AppleEmojiText import com.rosetta.messenger.ui.components.AvatarImage +import com.rosetta.messenger.ui.components.SharedMediaFastScrollOverlay import com.rosetta.messenger.ui.components.VerifiedBadge import com.rosetta.messenger.ui.icons.TelegramIcons import androidx.lifecycle.viewmodel.compose.viewModel @@ -133,9 +138,13 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import androidx.core.view.WindowCompat import org.json.JSONArray +import java.text.SimpleDateFormat import java.util.Date +import java.util.Locale import java.util.UUID import kotlin.math.abs +import kotlin.math.ceil +import kotlin.math.roundToInt private enum class GroupInfoTab(val title: String) { MEMBERS("Members"), @@ -370,6 +379,8 @@ fun GroupInfoScreen( var showImageViewer by rememberSaveable(dialogPublicKey) { mutableStateOf(false) } var imageViewerInitialIndex by rememberSaveable(dialogPublicKey) { mutableStateOf(0) } var imageViewerSourceBounds by remember(dialogPublicKey) { mutableStateOf(null) } + val groupMediaGridState = rememberLazyGridState() + var groupMediaFastScrollHintDismissed by rememberSaveable(dialogPublicKey) { mutableStateOf(false) } val groupTitle = remember(groupEntity, groupUser.title) { groupEntity?.title?.trim().takeUnless { it.isNullOrBlank() } @@ -1134,55 +1145,95 @@ fun GroupInfoScreen( } else { val mediaColumns = 3 val mediaSpacing = 1.dp - val mediaScreenWidth = LocalConfiguration.current.screenWidthDp.dp - val mediaCellSize = - (mediaScreenWidth - mediaSpacing * (mediaColumns - 1)) / mediaColumns - val mediaIndexedRows = remember(groupMediaItems) { - groupMediaItems.chunked(mediaColumns).mapIndexed { idx, row -> idx to row } + val mediaRowsCount = remember(groupMediaItems.size) { + ceil(groupMediaItems.size / mediaColumns.toFloat()).toInt().coerceAtLeast(1) + } + val mediaFastScrollVisible by remember(groupMediaItems.size, groupMediaGridState) { + derivedStateOf { + val visibleItems = groupMediaGridState.layoutInfo.visibleItemsInfo + if (visibleItems.isEmpty()) return@derivedStateOf false + val cellHeight = visibleItems.first().size.height + val viewportHeight = groupMediaGridState.layoutInfo.viewportEndOffset - + groupMediaGridState.layoutInfo.viewportStartOffset + mediaRowsCount * cellHeight > viewportHeight + } + } + val mediaFastScrollProgress by remember(groupMediaItems.size, groupMediaGridState) { + derivedStateOf { + val visibleItems = groupMediaGridState.layoutInfo.visibleItemsInfo + if (visibleItems.isEmpty()) return@derivedStateOf 0f + val cellHeight = visibleItems.first().size.height + if (cellHeight <= 0) return@derivedStateOf 0f + val viewportHeight = groupMediaGridState.layoutInfo.viewportEndOffset - + groupMediaGridState.layoutInfo.viewportStartOffset + val totalHeight = mediaRowsCount * cellHeight + val maxScroll = (totalHeight - viewportHeight).coerceAtLeast(1) + val firstRow = groupMediaGridState.firstVisibleItemIndex / mediaColumns + val scrollY = firstRow * cellHeight + groupMediaGridState.firstVisibleItemScrollOffset + (scrollY.toFloat() / maxScroll.toFloat()).coerceIn(0f, 1f) + } + } + val mediaFastScrollMonthLabel by remember(groupMediaItems, groupMediaGridState.firstVisibleItemIndex) { + derivedStateOf { + if (groupMediaItems.isEmpty()) return@derivedStateOf "" + val index = groupMediaGridState.firstVisibleItemIndex.coerceIn(0, groupMediaItems.lastIndex) + formatMediaMonthLabel(groupMediaItems[index].timestamp) + } } - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(bottom = 20.dp), - verticalArrangement = Arrangement.spacedBy(mediaSpacing) - ) { - items(mediaIndexedRows, key = { (idx, _) -> "group_media_row_$idx" }) { (rowIdx, rowMedia) -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(mediaSpacing) - ) { - rowMedia.forEachIndexed { colIdx, mediaItem -> - val globalIndex = rowIdx * mediaColumns + colIdx - Box( - modifier = Modifier - .size(mediaCellSize) - .clip(RoundedCornerShape(0.dp)) - .background(if (isDarkTheme) Color(0xFF141416) else Color(0xFFE6E7EB)) - ) { - ImageAttachment( - attachment = mediaItem.attachment, - chachaKey = mediaItem.chachaKey, - privateKey = currentUserPrivateKey, - senderPublicKey = mediaItem.senderPublicKey, - isOutgoing = mediaItem.senderPublicKey == currentUserPublicKey, - isDarkTheme = isDarkTheme, - timestamp = Date(mediaItem.timestamp), - showTimeOverlay = false, - fillMaxSize = true, - onImageClick = { _, bounds -> - imageViewerInitialIndex = globalIndex - imageViewerSourceBounds = bounds - showImageViewer = true - } - ) - } - } - - repeat(mediaColumns - rowMedia.size) { - Spacer(modifier = Modifier.size(mediaCellSize)) + Box(modifier = Modifier.fillMaxSize()) { + LazyVerticalGrid( + columns = GridCells.Fixed(mediaColumns), + state = groupMediaGridState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 20.dp), + horizontalArrangement = Arrangement.spacedBy(mediaSpacing), + verticalArrangement = Arrangement.spacedBy(mediaSpacing) + ) { + gridItemsIndexed(groupMediaItems, key = { _, item -> item.key }) { index, mediaItem -> + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(RoundedCornerShape(0.dp)) + .background(if (isDarkTheme) Color(0xFF141416) else Color(0xFFE6E7EB)) + ) { + ImageAttachment( + attachment = mediaItem.attachment, + chachaKey = mediaItem.chachaKey, + privateKey = currentUserPrivateKey, + senderPublicKey = mediaItem.senderPublicKey, + isOutgoing = mediaItem.senderPublicKey == currentUserPublicKey, + isDarkTheme = isDarkTheme, + timestamp = Date(mediaItem.timestamp), + showTimeOverlay = false, + fillMaxSize = true, + onImageClick = { _, bounds -> + imageViewerInitialIndex = index + imageViewerSourceBounds = bounds + showImageViewer = true + } + ) } } } + + SharedMediaFastScrollOverlay( + visible = mediaFastScrollVisible, + progress = mediaFastScrollProgress, + monthLabel = mediaFastScrollMonthLabel, + isDarkTheme = isDarkTheme, + showHint = mediaFastScrollVisible && !groupMediaFastScrollHintDismissed, + onHintDismissed = { groupMediaFastScrollHintDismissed = true }, + onDragProgressChanged = { fraction -> + if (groupMediaItems.isEmpty()) return@SharedMediaFastScrollOverlay + val targetRow = ((mediaRowsCount - 1) * fraction).roundToInt() + val targetIndex = (targetRow * mediaColumns).coerceIn(0, groupMediaItems.lastIndex) + scope.launch { + groupMediaGridState.scrollToItem(targetIndex) + } + } + ) } } } @@ -1868,6 +1919,12 @@ private fun shortPublicKey(publicKey: String): String { return "${trimmed.take(6)}...${trimmed.takeLast(4)}" } +private fun formatMediaMonthLabel(timestamp: Long): String { + return runCatching { + SimpleDateFormat("MMMM yyyy", Locale.getDefault()).format(Date(timestamp)) + }.getOrElse { "" } +} + private fun decryptStoredMessageText(encryptedText: String, privateKey: String): String { if (encryptedText.isBlank()) return "" if (privateKey.isBlank()) return encryptedText diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/AvatarImage.kt b/app/src/main/java/com/rosetta/messenger/ui/components/AvatarImage.kt index 4b8c06a..5c27ae1 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/AvatarImage.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/AvatarImage.kt @@ -207,11 +207,14 @@ fun AvatarPlaceholder( .background(avatarColors.backgroundColor), contentAlignment = Alignment.Center ) { - Text( + AppleEmojiText( text = avatarText, color = avatarColors.textColor, fontSize = fontSize ?: (size.value / 2.5).sp, - fontWeight = FontWeight.Medium + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = android.text.TextUtils.TruncateAt.END, + enableLinks = false ) } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/SharedMediaFastScrollOverlay.kt b/app/src/main/java/com/rosetta/messenger/ui/components/SharedMediaFastScrollOverlay.kt new file mode 100644 index 0000000..7ea06d3 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/components/SharedMediaFastScrollOverlay.kt @@ -0,0 +1,241 @@ +package com.rosetta.messenger.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.UnfoldMore +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.rosetta.messenger.R +import kotlinx.coroutines.delay +import kotlin.math.roundToInt + +@Composable +fun SharedMediaFastScrollOverlay( + visible: Boolean, + progress: Float, + monthLabel: String, + isDarkTheme: Boolean, + showHint: Boolean, + onHintDismissed: () -> Unit, + onDragProgressChanged: (Float) -> Unit, + modifier: Modifier = Modifier +) { + if (!visible) return + + val thumbWidth = 24.dp + val thumbHeight = 44.dp + val thumbHeightPx = with(androidx.compose.ui.platform.LocalDensity.current) { thumbHeight.toPx() } + val monthBubbleOffsetXPx = with(androidx.compose.ui.platform.LocalDensity.current) { (-90).dp.roundToPx() } + + var trackHeightPx by remember { mutableIntStateOf(0) } + var isDragging by remember { mutableStateOf(false) } + var dragProgress by remember { mutableFloatStateOf(progress.coerceIn(0f, 1f)) } + var hintVisible by remember(showHint) { mutableStateOf(showHint) } + + val normalizedProgress = progress.coerceIn(0f, 1f) + + LaunchedEffect(showHint) { + if (showHint) { + hintVisible = true + delay(4000) + hintVisible = false + onHintDismissed() + } else { + hintVisible = false + } + } + + LaunchedEffect(normalizedProgress, isDragging) { + if (!isDragging) { + dragProgress = normalizedProgress + } + } + + val shownProgress = if (isDragging) dragProgress else normalizedProgress + val trackTravelPx = (trackHeightPx - thumbHeightPx).coerceAtLeast(1f) + val thumbOffsetY = (trackTravelPx * shownProgress).coerceIn(0f, trackTravelPx) + + Box( + modifier = modifier.fillMaxSize() + ) { + AnimatedVisibility( + visible = hintVisible && !isDragging, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 44.dp) + ) { + SharedMediaFastScrollHint(isDarkTheme = isDarkTheme) + } + + Box( + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 8.dp) + .fillMaxHeight(0.86f) + .width(40.dp) + .onSizeChanged { trackHeightPx = it.height } + .pointerInput(trackHeightPx, thumbHeightPx) { + if (trackHeightPx <= 0) return@pointerInput + fun updateProgress(y: Float) { + val fraction = ((y - thumbHeightPx / 2f) / trackTravelPx).coerceIn(0f, 1f) + dragProgress = fraction + onDragProgressChanged(fraction) + } + detectDragGestures( + onDragStart = { offset -> + isDragging = true + if (hintVisible) { + hintVisible = false + onHintDismissed() + } + updateProgress(offset.y) + }, + onDragEnd = { isDragging = false }, + onDragCancel = { isDragging = false }, + onDrag = { change, _ -> + updateProgress(change.position.y) + } + ) + } + ) { + val trackColor = if (isDarkTheme) Color(0x5A7C8798) else Color(0x663F4F64) + val thumbColor = if (isDarkTheme) Color(0xFF29364A) else Color(0xFF2B4E73) + val thumbBorderColor = if (isDarkTheme) Color(0x6688A7CC) else Color(0x554B7DB0) + + Box( + modifier = Modifier + .align(Alignment.Center) + .width(3.dp) + .fillMaxHeight() + .clip(RoundedCornerShape(2.dp)) + .background(trackColor) + ) + + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .offset { IntOffset(0, thumbOffsetY.roundToInt()) } + .size(width = thumbWidth, height = thumbHeight) + .clip(RoundedCornerShape(12.dp)) + .background(thumbColor) + .border(1.dp, thumbBorderColor, RoundedCornerShape(12.dp)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.UnfoldMore, + contentDescription = "Fast scroll handle", + tint = Color.White.copy(alpha = 0.92f), + modifier = Modifier.size(18.dp) + ) + } + + AnimatedVisibility( + visible = isDragging && monthLabel.isNotBlank(), + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier + .align(Alignment.TopCenter) + .offset { + IntOffset( + monthBubbleOffsetXPx, + (thumbOffsetY + 6f).roundToInt() + ) + } + ) { + SharedMediaMonthPill( + monthLabel = monthLabel, + isDarkTheme = isDarkTheme + ) + } + } + } +} + +@Composable +private fun SharedMediaFastScrollHint(isDarkTheme: Boolean) { + val background = if (isDarkTheme) Color(0xE6212934) else Color(0xE8263F63) + val iconBackground = if (isDarkTheme) Color(0x553A4A60) else Color(0x553A5A84) + + Row( + modifier = Modifier + .clip(RoundedCornerShape(10.dp)) + .background(background) + .padding(horizontal = 10.dp, vertical = 8.dp) + .widthIn(max = 250.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box( + modifier = Modifier + .size(22.dp) + .clip(RoundedCornerShape(6.dp)) + .background(iconBackground), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.UnfoldMore, + contentDescription = null, + tint = Color.White.copy(alpha = 0.92f), + modifier = Modifier.size(14.dp) + ) + } + Text( + text = stringResource(R.string.shared_media_fast_scroll_hint), + color = Color.White.copy(alpha = 0.92f), + fontSize = 13.sp, + lineHeight = 16.sp + ) + } +} + +@Composable +private fun SharedMediaMonthPill(monthLabel: String, isDarkTheme: Boolean) { + val background = if (isDarkTheme) Color(0xEE2A3445) else Color(0xEE2B4E73) + Text( + text = monthLabel, + color = Color.White.copy(alpha = 0.95f), + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .background(background) + .padding(horizontal = 12.dp, vertical = 6.dp) + ) +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt index c96fb1b..9deb12f 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt @@ -29,12 +29,14 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Block import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier @@ -90,6 +92,7 @@ import com.rosetta.messenger.ui.chats.components.ImageViewerScreen import com.rosetta.messenger.ui.chats.components.ViewableImage import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.components.BlurredAvatarBackground +import com.rosetta.messenger.ui.components.SharedMediaFastScrollOverlay import com.rosetta.messenger.ui.components.VerifiedBadge import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.utils.AttachmentFileManager @@ -111,6 +114,7 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import kotlin.math.ceil +import kotlin.math.roundToInt // Collapsing header constants private val EXPANDED_HEADER_HEIGHT_OTHER = 280.dp @@ -557,12 +561,72 @@ fun OtherProfileScreen( val mediaScreenWidth = LocalConfiguration.current.screenWidthDp.dp val mediaCellSize = (mediaScreenWidth - mediaSpacing * (mediaColumns - 1)) / mediaColumns val mediaDecodeSemaphore = remember { Semaphore(4) } + val profileListState = rememberLazyListState() + var mediaFastScrollHintDismissed by rememberSaveable(user.publicKey) { mutableStateOf(false) } // Use stable key for bitmap cache - don't recreate on size change val mediaBitmapStates = remember { mutableStateMapOf() } // Pre-compute indexed rows to avoid O(n) indexOf calls val mediaIndexedRows = remember(sharedContent.mediaPhotos) { sharedContent.mediaPhotos.chunked(mediaColumns).mapIndexed { idx, row -> idx to row } } + val mediaRowsCount = remember(sharedContent.mediaPhotos.size) { + ceil(sharedContent.mediaPhotos.size / mediaColumns.toFloat()).toInt().coerceAtLeast(1) + } + val mediaRowHeightPx = with(density) { (mediaCellSize + mediaSpacing).toPx() } + val mediaMaxScrollPx by remember(sharedContent.mediaPhotos.size, mediaRowHeightPx, profileListState.layoutInfo) { + derivedStateOf { + val viewportHeight = (profileListState.layoutInfo.viewportEndOffset - profileListState.layoutInfo.viewportStartOffset) + .toFloat() + .coerceAtLeast(1f) + (mediaRowsCount * mediaRowHeightPx - viewportHeight).coerceAtLeast(1f) + } + } + val tabPagerScrollPx by remember(selectedTab, profileListState.layoutInfo, mediaMaxScrollPx) { + derivedStateOf { + if (selectedTab != OtherProfileTab.MEDIA) return@derivedStateOf 0f + val tabPagerInfo = + profileListState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == "tab_pager" } + when { + tabPagerInfo != null -> (-tabPagerInfo.offset).toFloat().coerceIn(0f, mediaMaxScrollPx) + profileListState.layoutInfo.visibleItemsInfo.any { it.key == "bottom_spacer" } -> mediaMaxScrollPx + else -> 0f + } + } + } + val mediaFastScrollProgress by remember( + selectedTab, + tabPagerScrollPx, + mediaMaxScrollPx + ) { + derivedStateOf { + if (selectedTab != OtherProfileTab.MEDIA) { + 0f + } else { + (tabPagerScrollPx / mediaMaxScrollPx).coerceIn(0f, 1f) + } + } + } + val mediaFastScrollMonthLabel by remember( + selectedTab, + sharedContent.mediaPhotos, + tabPagerScrollPx, + mediaRowHeightPx, + mediaRowsCount + ) { + derivedStateOf { + if (selectedTab != OtherProfileTab.MEDIA || sharedContent.mediaPhotos.isEmpty()) return@derivedStateOf "" + val currentOffsetPx = tabPagerScrollPx + val rowIndex = if (mediaRowHeightPx <= 0f) 0 else (currentOffsetPx / mediaRowHeightPx).toInt() + .coerceIn(0, mediaRowsCount - 1) + val itemIndex = (rowIndex * mediaColumns).coerceIn(0, sharedContent.mediaPhotos.lastIndex) + formatMediaMonthLabel(sharedContent.mediaPhotos[itemIndex].timestamp) + } + } + val mediaFastScrollVisible by remember(selectedTab, sharedContent.mediaPhotos.size, mediaMaxScrollPx) { + derivedStateOf { + selectedTab == OtherProfileTab.MEDIA && sharedContent.mediaPhotos.isNotEmpty() && mediaMaxScrollPx > 24f + } + } Box( modifier = @@ -572,6 +636,7 @@ fun OtherProfileScreen( ) { // Scrollable content LazyColumn( + state = profileListState, modifier = Modifier.fillMaxSize() .padding( @@ -724,14 +789,20 @@ fun OtherProfileScreen( ) } - // ═══════════════════════════════════════════════════════════ - // 📚 SHARED CONTENT (без разделителя — сразу табы) - // ═══════════════════════════════════════════════════════════ - OtherProfileSharedTabs( - selectedTab = selectedTab, - onTabSelected = { tab -> selectedTab = tab }, - isDarkTheme = isDarkTheme - ) + } + + stickyHeader(key = "shared_tabs") { + Box( + modifier = Modifier + .fillMaxWidth() + .background(backgroundColor) + ) { + OtherProfileSharedTabs( + selectedTab = selectedTab, + onTabSelected = { tab -> selectedTab = tab }, + isDarkTheme = isDarkTheme + ) + } } // ══════════════════════════════════════════════════════ @@ -959,6 +1030,22 @@ fun OtherProfileScreen( } } + SharedMediaFastScrollOverlay( + visible = mediaFastScrollVisible, + progress = mediaFastScrollProgress, + monthLabel = mediaFastScrollMonthLabel, + isDarkTheme = isDarkTheme, + showHint = mediaFastScrollVisible && !mediaFastScrollHintDismissed, + onHintDismissed = { mediaFastScrollHintDismissed = true }, + onDragProgressChanged = { fraction -> + if (!mediaFastScrollVisible) return@SharedMediaFastScrollOverlay + val targetOffset = (mediaMaxScrollPx * fraction).roundToInt() + coroutineScope.launch { + profileListState.scrollToItem(index = 2, scrollOffset = targetOffset) + } + } + ) + // ═══════════════════════════════════════════════════════════ // 🎨 COLLAPSING HEADER with METABALL EFFECT // ═══════════════════════════════════════════════════════════ @@ -1481,6 +1568,12 @@ private fun formatFileSize(sizeBytes: Long?): String { } } +private fun formatMediaMonthLabel(timestamp: Long): String { + return runCatching { + SimpleDateFormat("MMMM yyyy", Locale.getDefault()).format(Date(timestamp)) + }.getOrElse { "" } +} + private fun formatTimestamp(timestamp: Long): String { return SimpleDateFormat("dd MMM, HH:mm", Locale.getDefault()).format(Date(timestamp)) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1c843e7..8ab8542 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,5 @@ Rosetta + You can hold and move this bar for faster scrolling. From 6a269f93dbdf61c171b591b99336ff8f568a7ee5 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Fri, 6 Mar 2026 19:19:01 +0500 Subject: [PATCH 03/19] =?UTF-8?q?=D0=A3=D0=BB=D1=83=D1=87=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0=D0=B6?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B0=D0=B2=D0=B0=D1=82=D0=B0=D1=80?= =?UTF-8?q?=D0=BE=D0=B2:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BF=D0=BE=D0=B4=D0=B4=D0=B5=D1=80=D0=B6=D0=BA?= =?UTF-8?q?=D0=B0=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=B0=20=D1=81=20=D1=8D?= =?UTF-8?q?=D0=BC=D0=BE=D0=B4=D0=B7=D0=B8=20=D0=B8=20=D1=83=D0=BB=D1=83?= =?UTF-8?q?=D1=87=D1=88=D0=B5=D0=BD=D0=B0=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0=D0=B6=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=B2=20AvatarImage.=20=D0=9E=D0=B1=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=20SharedMediaFastScrollOverlay=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D0=BA=D0=BE=D1=80=D1=80=D0=B5=D0=BA=D1=82?= =?UTF-8?q?=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF=D1=80=D0=B8=20=D0=B8?= =?UTF-8?q?=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D0=B8=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B7=D0=BC=D0=B5=D1=80=D0=B0.=20=D0=98=D1=81=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=BE=20=D1=81=D0=BE=D0=BE=D0=B1?= =?UTF-8?q?=D1=89=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=BE=D0=B4=D1=81=D0=BA?= =?UTF-8?q?=D0=B0=D0=B7=D0=BA=D0=B8=20=D0=B2=20=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=D0=B0=D1=85.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../messenger/ui/components/AvatarImage.kt | 38 ++- .../SharedMediaFastScrollOverlay.kt | 251 +++++++++++------- .../ui/settings/OtherProfileScreen.kt | 67 +++-- app/src/main/res/values/strings.xml | 2 +- 4 files changed, 240 insertions(+), 118 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/AvatarImage.kt b/app/src/main/java/com/rosetta/messenger/ui/components/AvatarImage.kt index 5c27ae1..7617a55 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/AvatarImage.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/AvatarImage.kt @@ -199,6 +199,7 @@ fun AvatarPlaceholder( } else { getAvatarText(publicKey) } + val resolvedFontSize = fontSize ?: (size.value / 2.5).sp Box( modifier = Modifier @@ -207,15 +208,34 @@ fun AvatarPlaceholder( .background(avatarColors.backgroundColor), contentAlignment = Alignment.Center ) { - AppleEmojiText( - text = avatarText, - color = avatarColors.textColor, - fontSize = fontSize ?: (size.value / 2.5).sp, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = android.text.TextUtils.TruncateAt.END, - enableLinks = false - ) + if (containsEmojiAvatarText(avatarText)) { + AppleEmojiText( + text = avatarText, + color = avatarColors.textColor, + fontSize = resolvedFontSize, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = android.text.TextUtils.TruncateAt.END, + enableLinks = false + ) + } else { + Text( + text = avatarText, + color = avatarColors.textColor, + fontSize = resolvedFontSize, + fontWeight = FontWeight.Medium, + maxLines = 1 + ) + } + } +} + +private fun containsEmojiAvatarText(text: String): Boolean { + if (text.contains(":emoji_", ignoreCase = true)) return true + return text.any { ch -> + val type = java.lang.Character.getType(ch.code) + type == java.lang.Character.SURROGATE.toInt() || + type == java.lang.Character.OTHER_SYMBOL.toInt() } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/SharedMediaFastScrollOverlay.kt b/app/src/main/java/com/rosetta/messenger/ui/components/SharedMediaFastScrollOverlay.kt index 7ea06d3..83cd36d 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/SharedMediaFastScrollOverlay.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/SharedMediaFastScrollOverlay.kt @@ -11,11 +11,13 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.UnfoldMore @@ -28,15 +30,21 @@ import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +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 @@ -57,12 +65,14 @@ fun SharedMediaFastScrollOverlay( ) { if (!visible) return - val thumbWidth = 24.dp - val thumbHeight = 44.dp - val thumbHeightPx = with(androidx.compose.ui.platform.LocalDensity.current) { thumbHeight.toPx() } - val monthBubbleOffsetXPx = with(androidx.compose.ui.platform.LocalDensity.current) { (-90).dp.roundToPx() } + val density = androidx.compose.ui.platform.LocalDensity.current + val handleSize = 48.dp + val handleSizePx = with(density) { handleSize.toPx() } + val bubbleOffsetX = with(density) { (-96).dp.roundToPx() } + var rootHeightPx by remember { mutableIntStateOf(0) } var trackHeightPx by remember { mutableIntStateOf(0) } + var monthBubbleHeightPx by remember { mutableIntStateOf(0) } var isDragging by remember { mutableStateOf(false) } var dragProgress by remember { mutableFloatStateOf(progress.coerceIn(0f, 1f)) } var hintVisible by remember(showHint) { mutableStateOf(showHint) } @@ -87,11 +97,18 @@ fun SharedMediaFastScrollOverlay( } val shownProgress = if (isDragging) dragProgress else normalizedProgress - val trackTravelPx = (trackHeightPx - thumbHeightPx).coerceAtLeast(1f) - val thumbOffsetY = (trackTravelPx * shownProgress).coerceIn(0f, trackTravelPx) + val trackTravelPx = (trackHeightPx - handleSizePx).coerceAtLeast(1f) + val handleOffsetYPx = (trackTravelPx * shownProgress).coerceIn(0f, trackTravelPx) + val latestShownProgress by rememberUpdatedState(shownProgress) + val latestHandleOffsetYPx by rememberUpdatedState(handleOffsetYPx) + val handleCenterYPx = handleOffsetYPx + handleSizePx / 2f + val trackTopPx = ((rootHeightPx - trackHeightPx) / 2f).coerceAtLeast(0f) + val bubbleY = (trackTopPx + handleCenterYPx - monthBubbleHeightPx / 2f).roundToInt() Box( - modifier = modifier.fillMaxSize() + modifier = modifier + .fillMaxSize() + .onSizeChanged { rootHeightPx = it.height } ) { AnimatedVisibility( visible = hintVisible && !isDragging, @@ -99,7 +116,7 @@ fun SharedMediaFastScrollOverlay( exit = fadeOut(), modifier = Modifier .align(Alignment.CenterEnd) - .padding(end = 44.dp) + .padding(end = 60.dp) ) { SharedMediaFastScrollHint(isDarkTheme = isDarkTheme) } @@ -107,105 +124,172 @@ fun SharedMediaFastScrollOverlay( Box( modifier = Modifier .align(Alignment.CenterEnd) - .padding(end = 8.dp) + .padding(end = 2.dp) .fillMaxHeight(0.86f) - .width(40.dp) + .width(56.dp) .onSizeChanged { trackHeightPx = it.height } - .pointerInput(trackHeightPx, thumbHeightPx) { - if (trackHeightPx <= 0) return@pointerInput - fun updateProgress(y: Float) { - val fraction = ((y - thumbHeightPx / 2f) / trackTravelPx).coerceIn(0f, 1f) - dragProgress = fraction - onDragProgressChanged(fraction) - } + .pointerInput(trackTravelPx, handleSizePx) { + if (trackTravelPx <= 0f) return@pointerInput + var dragFromHandle = false detectDragGestures( onDragStart = { offset -> + val handleLeft = size.width - handleSizePx + val handleRight = size.width.toFloat() + val handleTop = latestHandleOffsetYPx + val handleBottom = handleTop + handleSizePx + dragFromHandle = + offset.x in handleLeft..handleRight && + offset.y in handleTop..handleBottom + if (!dragFromHandle) return@detectDragGestures + isDragging = true + dragProgress = latestShownProgress if (hintVisible) { hintVisible = false onHintDismissed() } - updateProgress(offset.y) }, - onDragEnd = { isDragging = false }, - onDragCancel = { isDragging = false }, - onDrag = { change, _ -> - updateProgress(change.position.y) + onDragEnd = { + if (dragFromHandle) { + isDragging = false + } + dragFromHandle = false + }, + onDragCancel = { + if (dragFromHandle) { + isDragging = false + } + dragFromHandle = false + }, + onDrag = { change, dragAmount -> + if (!dragFromHandle) return@detectDragGestures + change.consume() + val nextProgress = + (dragProgress + dragAmount.y / trackTravelPx).coerceIn(0f, 1f) + if (nextProgress != dragProgress) { + dragProgress = nextProgress + onDragProgressChanged(nextProgress) + } } ) } ) { - val trackColor = if (isDarkTheme) Color(0x5A7C8798) else Color(0x663F4F64) - val thumbColor = if (isDarkTheme) Color(0xFF29364A) else Color(0xFF2B4E73) - val thumbBorderColor = if (isDarkTheme) Color(0x6688A7CC) else Color(0x554B7DB0) - - Box( + TelegramDateHandle( + isDarkTheme = isDarkTheme, modifier = Modifier - .align(Alignment.Center) - .width(3.dp) - .fillMaxHeight() - .clip(RoundedCornerShape(2.dp)) - .background(trackColor) + .align(Alignment.TopEnd) + .offset { IntOffset(0, handleOffsetYPx.roundToInt()) } + .size(handleSize) ) + } - Box( - modifier = Modifier - .align(Alignment.TopCenter) - .offset { IntOffset(0, thumbOffsetY.roundToInt()) } - .size(width = thumbWidth, height = thumbHeight) - .clip(RoundedCornerShape(12.dp)) - .background(thumbColor) - .border(1.dp, thumbBorderColor, RoundedCornerShape(12.dp)), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Default.UnfoldMore, - contentDescription = "Fast scroll handle", - tint = Color.White.copy(alpha = 0.92f), - modifier = Modifier.size(18.dp) - ) - } - - AnimatedVisibility( - visible = isDragging && monthLabel.isNotBlank(), - enter = fadeIn(), - exit = fadeOut(), - modifier = Modifier - .align(Alignment.TopCenter) - .offset { - IntOffset( - monthBubbleOffsetXPx, - (thumbOffsetY + 6f).roundToInt() - ) - } - ) { - SharedMediaMonthPill( - monthLabel = monthLabel, - isDarkTheme = isDarkTheme - ) - } + AnimatedVisibility( + visible = isDragging && monthLabel.isNotBlank(), + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier + .align(Alignment.TopEnd) + .offset { IntOffset(bubbleOffsetX, bubbleY) } + ) { + SharedMediaMonthBubble( + monthLabel = monthLabel, + isDarkTheme = isDarkTheme, + onMeasured = { monthBubbleHeightPx = it } + ) } } } +@Composable +private fun TelegramDateHandle(isDarkTheme: Boolean, modifier: Modifier = Modifier) { + val handleBackground = if (isDarkTheme) Color(0xFF3D4C63) else Color(0xFFEAF0F8) + val borderColor = if (isDarkTheme) Color(0x668EA0BA) else Color(0x33405673) + val arrowColor = if (isDarkTheme) Color(0xFFF0F4FB) else Color(0xFF4A5A73) + + Box( + modifier = modifier + .shadow(8.dp, CircleShape, clip = false) + .clip(CircleShape) + .background(handleBackground) + .border(1.dp, borderColor, CircleShape) + ) { + androidx.compose.foundation.Canvas(modifier = Modifier.fillMaxSize()) { + drawTelegramArrowPair(color = arrowColor) + } + } +} + +private fun DrawScope.drawTelegramArrowPair(color: Color) { + val centerX = size.width / 2f + val centerY = size.height / 2f + val halfWidth = size.minDimension * 0.12f + val halfHeight = size.minDimension * 0.08f + val gap = size.minDimension * 0.14f + + val up = Path().apply { + moveTo(centerX - halfWidth, centerY - gap + halfHeight) + lineTo(centerX + halfWidth, centerY - gap + halfHeight) + lineTo(centerX, centerY - gap - halfHeight) + close() + } + val down = Path().apply { + moveTo(centerX - halfWidth, centerY + gap - halfHeight) + lineTo(centerX + halfWidth, centerY + gap - halfHeight) + lineTo(centerX, centerY + gap + halfHeight) + close() + } + drawPath(path = up, color = color) + drawPath(path = down, color = color) +} + +@Composable +private fun SharedMediaMonthBubble( + monthLabel: String, + isDarkTheme: Boolean, + onMeasured: (Int) -> Unit +) { + val bubbleBackground = if (isDarkTheme) Color(0xFF3C4655) else Color(0xFFF1F5FB) + val bubbleBorder = if (isDarkTheme) Color(0x55424F62) else Color(0x223D4D66) + val textColor = if (isDarkTheme) Color(0xFFF2F5FB) else Color(0xFF283548) + + Text( + text = monthLabel, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Clip, + style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold + ), + modifier = Modifier + .onSizeChanged { onMeasured(it.height) } + .clip(RoundedCornerShape(18.dp)) + .background(bubbleBackground) + .border(1.dp, bubbleBorder, RoundedCornerShape(18.dp)) + .padding(horizontal = 12.dp, vertical = 6.dp) + .widthIn(min = 94.dp) + ) +} + @Composable private fun SharedMediaFastScrollHint(isDarkTheme: Boolean) { - val background = if (isDarkTheme) Color(0xE6212934) else Color(0xE8263F63) - val iconBackground = if (isDarkTheme) Color(0x553A4A60) else Color(0x553A5A84) + val background = if (isDarkTheme) Color(0xEA2A323D) else Color(0xEA26374E) + val iconBackground = if (isDarkTheme) Color(0x553C4656) else Color(0x55324A67) Row( modifier = Modifier - .clip(RoundedCornerShape(10.dp)) + .clip(RoundedCornerShape(8.dp)) .background(background) .padding(horizontal = 10.dp, vertical = 8.dp) - .widthIn(max = 250.dp), + .widthIn(max = 300.dp) + .heightIn(min = 34.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Box( modifier = Modifier - .size(22.dp) - .clip(RoundedCornerShape(6.dp)) + .size(20.dp) + .clip(RoundedCornerShape(5.dp)) .background(iconBackground), contentAlignment = Alignment.Center ) { @@ -213,7 +297,7 @@ private fun SharedMediaFastScrollHint(isDarkTheme: Boolean) { imageVector = Icons.Default.UnfoldMore, contentDescription = null, tint = Color.White.copy(alpha = 0.92f), - modifier = Modifier.size(14.dp) + modifier = Modifier.size(12.dp) ) } Text( @@ -224,18 +308,3 @@ private fun SharedMediaFastScrollHint(isDarkTheme: Boolean) { ) } } - -@Composable -private fun SharedMediaMonthPill(monthLabel: String, isDarkTheme: Boolean) { - val background = if (isDarkTheme) Color(0xEE2A3445) else Color(0xEE2B4E73) - Text( - text = monthLabel, - color = Color.White.copy(alpha = 0.95f), - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - modifier = Modifier - .clip(RoundedCornerShape(16.dp)) - .background(background) - .padding(horizontal = 12.dp, vertical = 6.dp) - ) -} diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt index 9deb12f..192798b 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt @@ -52,6 +52,9 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity @@ -67,6 +70,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel @@ -561,6 +565,8 @@ fun OtherProfileScreen( val mediaScreenWidth = LocalConfiguration.current.screenWidthDp.dp val mediaCellSize = (mediaScreenWidth - mediaSpacing * (mediaColumns - 1)) / mediaColumns val mediaDecodeSemaphore = remember { Semaphore(4) } + var rootHeightPx by remember { mutableIntStateOf(0) } + var sharedTabsBottomPx by remember { mutableIntStateOf(0) } val profileListState = rememberLazyListState() var mediaFastScrollHintDismissed by rememberSaveable(user.publicKey) { mutableStateOf(false) } // Use stable key for bitmap cache - don't recreate on size change @@ -622,9 +628,20 @@ fun OtherProfileScreen( formatMediaMonthLabel(sharedContent.mediaPhotos[itemIndex].timestamp) } } - val mediaFastScrollVisible by remember(selectedTab, sharedContent.mediaPhotos.size, mediaMaxScrollPx) { + val overlayTopPx = sharedTabsBottomPx.coerceIn(0, rootHeightPx) + val overlayHeightPx = (rootHeightPx - overlayTopPx).coerceAtLeast(0) + val mediaFastScrollVisible by remember( + selectedTab, + sharedContent.mediaPhotos.size, + mediaMaxScrollPx, + overlayHeightPx + ) { derivedStateOf { - selectedTab == OtherProfileTab.MEDIA && sharedContent.mediaPhotos.isNotEmpty() && mediaMaxScrollPx > 24f + selectedTab == OtherProfileTab.MEDIA && + sharedContent.mediaPhotos.isNotEmpty() && + mediaMaxScrollPx > 24f && + profileListState.firstVisibleItemIndex >= 1 && + overlayHeightPx > 0 } } @@ -632,6 +649,7 @@ fun OtherProfileScreen( modifier = Modifier.fillMaxSize() .background(backgroundColor) + .onSizeChanged { rootHeightPx = it.height } .nestedScroll(nestedScrollConnection) ) { // Scrollable content @@ -796,6 +814,10 @@ fun OtherProfileScreen( modifier = Modifier .fillMaxWidth() .background(backgroundColor) + .onGloballyPositioned { coords -> + sharedTabsBottomPx = + (coords.positionInRoot().y + coords.size.height).roundToInt() + } ) { OtherProfileSharedTabs( selectedTab = selectedTab, @@ -1030,21 +1052,32 @@ fun OtherProfileScreen( } } - SharedMediaFastScrollOverlay( - visible = mediaFastScrollVisible, - progress = mediaFastScrollProgress, - monthLabel = mediaFastScrollMonthLabel, - isDarkTheme = isDarkTheme, - showHint = mediaFastScrollVisible && !mediaFastScrollHintDismissed, - onHintDismissed = { mediaFastScrollHintDismissed = true }, - onDragProgressChanged = { fraction -> - if (!mediaFastScrollVisible) return@SharedMediaFastScrollOverlay - val targetOffset = (mediaMaxScrollPx * fraction).roundToInt() - coroutineScope.launch { - profileListState.scrollToItem(index = 2, scrollOffset = targetOffset) - } - } - ) + if (overlayHeightPx > 0) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .offset { IntOffset(0, overlayTopPx) } + .fillMaxWidth() + .height(with(density) { overlayHeightPx.toDp() }) + .clipToBounds() + ) { + SharedMediaFastScrollOverlay( + visible = mediaFastScrollVisible, + progress = mediaFastScrollProgress, + monthLabel = mediaFastScrollMonthLabel, + isDarkTheme = isDarkTheme, + showHint = mediaFastScrollVisible && !mediaFastScrollHintDismissed, + onHintDismissed = { mediaFastScrollHintDismissed = true }, + onDragProgressChanged = { fraction -> + if (!mediaFastScrollVisible) return@SharedMediaFastScrollOverlay + val targetOffset = (mediaMaxScrollPx * fraction).roundToInt() + coroutineScope.launch { + profileListState.scrollToItem(index = 2, scrollOffset = targetOffset) + } + } + ) + } + } // ═══════════════════════════════════════════════════════════ // 🎨 COLLAPSING HEADER with METABALL EFFECT diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8ab8542..e7bbe62 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,5 +1,5 @@ Rosetta - You can hold and move this bar for faster scrolling. + You can hold and move this bar for faster scrolling From 429025537f750b85e70ea4a914e7ca8ff6d107fc Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Fri, 6 Mar 2026 20:03:50 +0500 Subject: [PATCH 04/19] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D0=BB=20=D0=BF=D0=B5=D1=80=D0=B5=D1=85=D0=BE=D0=B4=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D1=81=D0=B2=D0=BE=D0=B5=D0=BC=D1=83=20=D1=82?= =?UTF-8?q?=D1=8D=D0=B3=D1=83=20=D0=B2=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF?= =?UTF-8?q?=D0=B0=D1=85=20=D0=B8=20=D1=83=D0=B1=D1=80=D0=B0=D0=BB=20=D0=BB?= =?UTF-8?q?=D0=B8=D1=88=D0=BD=D1=8E=D1=8E=20=D0=BF=D0=BE=D0=B4=D1=81=D0=B2?= =?UTF-8?q?=D0=B5=D1=82=D0=BA=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Клик по своему упоминанию теперь сразу открывает My Profile без экрана OtherProfile и kebab-меню\n- Нормализовал сравнение аккаунта по publicKey/username (trim + ignoreCase)\n- Убрал жёлтую подсветку сообщений с упоминанием в группах\n- Подровнял положение бейджа верификации рядом с именем --- .../com/rosetta/messenger/MainActivity.kt | 33 ++++++++++++++++--- .../messenger/ui/chats/ChatDetailScreen.kt | 32 +++++++++++++----- .../chats/components/ChatDetailComponents.kt | 29 +--------------- .../messenger/ui/components/VerifiedBadge.kt | 7 ++++ 4 files changed, 61 insertions(+), 40 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index e88ee14..1ce0afc 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -640,9 +640,34 @@ fun MainScreen( if (screen is Screen.Requests && navStack.any { it is Screen.Requests }) return navStack = navStack + screen } + fun isCurrentAccountUser(user: SearchUser): Boolean { + val candidatePublicKey = user.publicKey.trim() + val normalizedAccountPublicKey = accountPublicKey.trim() + if ( + candidatePublicKey.isNotBlank() && + normalizedAccountPublicKey.isNotBlank() && + candidatePublicKey.equals(normalizedAccountPublicKey, ignoreCase = true) + ) { + return true + } + + val candidateUsername = user.username.trim().trimStart('@') + val normalizedAccountUsername = accountUsername.trim().trimStart('@') + return candidatePublicKey.isBlank() && + candidateUsername.isNotBlank() && + normalizedAccountUsername.isNotBlank() && + candidateUsername.equals(normalizedAccountUsername, ignoreCase = true) + } fun popScreen() { navStack = navStack.dropLast(1) } + fun openOwnProfile() { + navStack = + navStack.filterNot { + it is Screen.ChatDetail || it is Screen.OtherProfile || it is Screen.GroupInfo + } + pushScreen(Screen.Profile) + } fun popProfileAndChildren() { navStack = navStack.filterNot { @@ -977,9 +1002,9 @@ fun MainScreen( totalUnreadFromOthers = totalUnreadFromOthers, onBack = { popChatAndChildren() }, onUserProfileClick = { user -> - if (user.publicKey == accountPublicKey) { + if (isCurrentAccountUser(user)) { // Свой профиль — открываем My Profile - pushScreen(Screen.Profile) + openOwnProfile() } else { // Открываем профиль другого пользователя pushScreen(Screen.OtherProfile(user)) @@ -1025,8 +1050,8 @@ fun MainScreen( avatarRepository = avatarRepository, onBack = { navStack = navStack.filterNot { it is Screen.GroupInfo } }, onMemberClick = { member -> - if (member.publicKey == accountPublicKey) { - pushScreen(Screen.Profile) + if (isCurrentAccountUser(member)) { + openOwnProfile() } else { pushScreen(Screen.OtherProfile(member)) } 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 9f8a99f..82c4223 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 @@ -2322,6 +2322,10 @@ fun ChatDetailScreen( username.trim().trimStart('@').lowercase(Locale.ROOT) if (normalizedUsername.isBlank()) return@MessageBubble scope.launch { + val normalizedCurrentUsername = + currentUserUsername.trim().trimStart('@').lowercase(Locale.ROOT) + val normalizedOpponentUsername = + user.username.trim().trimStart('@').lowercase(Locale.ROOT) val targetPublicKey = mentionCandidates .firstOrNull { @@ -2331,22 +2335,34 @@ fun ChatDetailScreen( ) } ?.publicKey + ?.trim() ?.takeIf { it.isNotBlank() } - ?: run { - val normalizedCurrent = - currentUserUsername.trim().trimStart('@').lowercase(Locale.ROOT) - if (normalizedCurrent == normalizedUsername && currentUserPublicKey.isNotBlank()) { - currentUserPublicKey + ?: run { + if (normalizedCurrentUsername == normalizedUsername && currentUserPublicKey.isNotBlank()) { + currentUserPublicKey.trim() } else { - val normalizedOpponent = - user.username.trim().trimStart('@').lowercase(Locale.ROOT) - if (normalizedOpponent == normalizedUsername && user.publicKey.isNotBlank()) user.publicKey + if (normalizedOpponentUsername == normalizedUsername && user.publicKey.isNotBlank()) user.publicKey.trim() else "" } } if (targetPublicKey.isBlank()) return@launch + if (targetPublicKey.equals(currentUserPublicKey.trim(), ignoreCase = true)) { + showContextMenu = false + contextMenuMessage = null + onUserProfileClick( + SearchUser( + title = currentUserName.ifBlank { "You" }, + username = currentUserUsername.trim().trimStart('@'), + publicKey = currentUserPublicKey.trim(), + verified = 0, + online = 0 + ) + ) + return@launch + } + val resolvedUser = viewModel.resolveUserForProfile(targetPublicKey) if (resolvedUser != null) { showContextMenu = false diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index f4ffb85..7dca103 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -90,22 +90,6 @@ import kotlinx.coroutines.withContext * organization */ -private fun containsUserMention(text: String, username: String): Boolean { - val normalizedUsername = username.trim().trimStart('@') - if (normalizedUsername.isBlank()) return false - val mentionRegex = - Regex( - pattern = """(^|\s)@${Regex.escape(normalizedUsername)}(?=\b)""", - options = setOf(RegexOption.IGNORE_CASE) - ) - return mentionRegex.containsMatchIn(text) -} - -private fun containsAllMention(text: String): Boolean { - val mentionRegex = Regex("""(^|\s)@all(?=\b)""", setOf(RegexOption.IGNORE_CASE)) - return mentionRegex.containsMatchIn(text) -} - /** * Telegram-style layout для текста сообщения с временем. Если текст + время помещаются в одну * строку - располагает их рядом. Если текст длинный и переносится - время встаёт в правый нижний @@ -361,21 +345,10 @@ fun MessageBubble( ) // Colors - val isMentionedIncoming = - remember(message.text, message.isOutgoing, isGroupChat, currentUserUsername) { - !message.isOutgoing && - isGroupChat && - message.text.isNotBlank() && - (containsAllMention(message.text) || - containsUserMention(message.text, currentUserUsername)) - } - val bubbleColor = - remember(message.isOutgoing, isDarkTheme, isMentionedIncoming) { + remember(message.isOutgoing, isDarkTheme) { if (message.isOutgoing) { PrimaryBlue - } else if (isMentionedIncoming) { - if (isDarkTheme) Color(0xFF3A3422) else Color(0xFFFFF3CD) } else { if (isDarkTheme) Color(0xFF212121) else Color(0xFFF5F5F5) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/VerifiedBadge.kt b/app/src/main/java/com/rosetta/messenger/ui/components/VerifiedBadge.kt index db955f8..497a8ef 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/VerifiedBadge.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/VerifiedBadge.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -35,6 +36,11 @@ fun VerifiedBadge( if (verified <= 0) return var showDialog by remember { mutableStateOf(false) } + val inlineOffsetY: Dp = when { + size <= 16 -> (-0.5).dp + size <= 20 -> (-1).dp + else -> 0.dp + } // Цвет верификации: в тёмной теме — как индикаторы прочтения (PrimaryBlue), в светлой — #ACD2F9 val badgeColor = @@ -58,6 +64,7 @@ fun VerifiedBadge( contentDescription = "Verified", tint = badgeColor, modifier = modifier + .offset(y = inlineOffsetY) .size(size.dp) .clickable { showDialog = true } ) From b62ff7d7c4cc30ed06210b5c717dccc9a20be024 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sat, 7 Mar 2026 17:15:25 +0500 Subject: [PATCH 05/19] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=BE=20=D0=B0=D0=B2=D1=82=D0=BE=D0=BC=D0=B0=D1=82?= =?UTF-8?q?=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=BE=D0=B5=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=D1=80=D1=83=D1=87=D0=B8=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BA=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=BC=20=D1=81=D0=BE=D0=BE?= =?UTF-8?q?=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D1=8F=D0=BC=20=D0=B8=20=D0=BA?= =?UTF-8?q?=D0=BD=D0=BE=D0=BF=D0=BA=D0=B0=20"=D0=9F=D1=80=D0=BE=D0=BA?= =?UTF-8?q?=D1=80=D1=83=D1=82=D0=B8=D1=82=D1=8C=20=D0=B2=D0=BD=D0=B8=D0=B7?= =?UTF-8?q?"=20=D0=B2=20ChatDetailScreen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../messenger/ui/chats/ChatDetailScreen.kt | 67 +++++++++++++++++-- 1 file changed, 63 insertions(+), 4 deletions(-) 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 82c4223..6904e51 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 @@ -8,6 +8,7 @@ import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts 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.animateDpAsState @@ -721,6 +722,15 @@ fun ChatDetailScreen( .maxWithOrNull(compareBy({ it.timestamp.time }, { it.id })) ?.id var lastNewestMessageId by remember { mutableStateOf(null) } + val isAtBottom by remember(listState) { + derivedStateOf { + listState.firstVisibleItemIndex == 0 && + listState.firstVisibleItemScrollOffset <= 12 + } + } + val showScrollToBottomButton by remember(messagesWithDates, isAtBottom) { + derivedStateOf { messagesWithDates.isNotEmpty() && !isAtBottom } + } // Telegram-style: Прокрутка ТОЛЬКО при новых сообщениях (не при пагинации) // 🔥 Скроллим только если изменился ID самого нового сообщения @@ -730,10 +740,15 @@ fun ChatDetailScreen( lastNewestMessageId != null && newestMessageId != lastNewestMessageId ) { - // Новое сообщение пришло - скроллим вниз - delay(50) // Debounce - ждём стабилизации - listState.animateScrollToItem(0) - wasManualScroll = false + val newestMessage = messages.firstOrNull { it.id == newestMessageId } + val isOwnOutgoingMessage = newestMessage?.isOutgoing == true + val shouldAutoScroll = isAtBottom || isOwnOutgoingMessage + + if (shouldAutoScroll) { + delay(50) // Debounce - ждём стабилизации + listState.animateScrollToItem(0) + wasManualScroll = false + } } lastNewestMessageId = newestMessageId } @@ -2465,6 +2480,50 @@ fun ChatDetailScreen( } } // Конец Column внутри Scaffold content + AnimatedVisibility( + visible = showScrollToBottomButton && !isLoading && !isSelectionMode, + enter = fadeIn(animationSpec = tween(140)) + expandVertically(expandFrom = Alignment.Bottom), + exit = fadeOut(animationSpec = tween(120)) + shrinkVertically(shrinkTowards = Alignment.Bottom), + modifier = + Modifier.align(Alignment.BottomEnd) + .padding( + end = 14.dp, + bottom = if (isSystemAccount) 24.dp else 86.dp + ) + ) { + Box( + modifier = + Modifier.size(38.dp) + .clip(CircleShape) + .background( + if (isDarkTheme) Color(0xFF2D2E31) + else Color.White + ) + .clickable( + indication = null, + interactionSource = + remember { + MutableInteractionSource() + } + ) { + scope.launch { + listState.animateScrollToItem(0) + wasManualScroll = false + } + }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = TablerIcons.ChevronDown, + contentDescription = "Scroll to bottom", + tint = + if (isDarkTheme) Color(0xFFF2F2F3) + else Color(0xFF2D3138), + modifier = Modifier.size(21.dp) + ) + } + } + // 📎 Media Picker — new tab-based ChatAttachAlert (Telegram-style) // Feature flag: set USE_NEW_ATTACH_ALERT to false to use old MediaPickerBottomSheet val USE_NEW_ATTACH_ALERT = true From c5737e51b0d9cc55ccbbbf7a23e4bfdd67c51e10 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sat, 7 Mar 2026 17:34:36 +0500 Subject: [PATCH 06/19] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=BE=20=D0=B0=D0=B2=D1=82=D0=BE=D0=BC=D0=B0=D1=82?= =?UTF-8?q?=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=BE=D0=B5=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=D1=80=D1=83=D1=87=D0=B8=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BA=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=BC=20=D1=81=D0=BE=D0=BE?= =?UTF-8?q?=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D1=8F=D0=BC=20=D0=B8=20=D0=BA?= =?UTF-8?q?=D0=BD=D0=BE=D0=BF=D0=BA=D0=B0=20"=D0=9F=D1=80=D0=BE=D0=BA?= =?UTF-8?q?=D1=80=D1=83=D1=82=D0=B8=D1=82=D1=8C=20=D0=B2=D0=BD=D0=B8=D0=B7?= =?UTF-8?q?"=20=D0=B2=20ChatDetailScreen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt | 3 ++- .../rosetta/messenger/ui/components/AppleEmojiEditText.kt | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) 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 6904e51..b2cbfc4 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 @@ -1156,7 +1156,8 @@ fun ChatDetailScreen( color = Color.White, maxLines = 1, overflow = android.text.TextUtils.TruncateAt.END, - enableLinks = false + enableLinks = false, + minHeightMultiplier = 1.1f ) if (!isSavedMessages && !isGroupChat && diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt b/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt index f9428b2..954da89 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt @@ -358,13 +358,14 @@ fun AppleEmojiText( enableMentions: Boolean = false, onMentionClick: ((String) -> Unit)? = null, onClick: (() -> Unit)? = null, // 🔥 Обычный tap (selection mode в MessageBubble) - onLongClick: (() -> Unit)? = null // 🔥 Callback для long press (selection в MessageBubble) + onLongClick: (() -> Unit)? = null, // 🔥 Callback для long press (selection в MessageBubble) + minHeightMultiplier: Float = 1.5f ) { val fontSizeValue = if (fontSize == androidx.compose.ui.unit.TextUnit.Unspecified) 15f else fontSize.value // Минимальная высота для корректного отображения emoji - val minHeight = (fontSizeValue * 1.5).toInt() + val minHeight = (fontSizeValue * minHeightMultiplier).toInt() // Преобразуем FontWeight в Android typeface style val typefaceStyle = when (fontWeight) { From c674a1ea99c460c9fdb46273a5e231d567458e8f Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sat, 7 Mar 2026 18:08:30 +0500 Subject: [PATCH 07/19] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=BE=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=B0=D0=B2=D0=B0=D1=82=D0=B0=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=20=D0=B4=D0=BB=D1=8F=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF?= =?UTF-8?q?=D0=BE=D0=B2=D1=8B=D1=85=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B9,=20=D1=83=D0=BB=D1=83=D1=87=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D0=B0=20=D0=BE?= =?UTF-8?q?=D0=B1=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BA=D0=B8=20=D0=BA=D0=BB?= =?UTF-8?q?=D1=8E=D1=87=D0=B5=D0=B9=20=D0=B4=D0=B8=D0=B0=D0=BB=D0=BE=D0=B3?= =?UTF-8?q?=D0=B0=20=D0=B2=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=BE=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0=D1=85=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B9.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../messenger/data/MessageRepository.kt | 15 +++++++++------ .../messenger/ui/chats/ChatDetailScreen.kt | 2 ++ .../chats/components/AttachmentComponents.kt | 18 ++++++++++++++---- .../chats/components/ChatDetailComponents.kt | 3 +++ 4 files changed, 28 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt index c085b76..be22180 100644 --- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt @@ -764,10 +764,13 @@ class MessageRepository private constructor(private val context: Context) { groupKey ) - // 📸 Обрабатываем AVATAR attachments - сохраняем аватар отправителя + // 📸 Обрабатываем AVATAR attachments: + // в личке — сохраняем аватар отправителя, в группе — аватар группы (desktop parity) + val avatarOwnerKey = + if (isGroupMessage) packet.toPublicKey else packet.fromPublicKey processAvatarAttachments( packet.attachments, - packet.fromPublicKey, + avatarOwnerKey, packet.chachaKey, privateKey, plainKeyAndNonce, @@ -1510,7 +1513,7 @@ class MessageRepository private constructor(private val context: Context) { */ private suspend fun processAvatarAttachments( attachments: List, - fromPublicKey: String, + avatarOwnerKey: String, encryptedKey: String, privateKey: String, plainKeyAndNonce: ByteArray? = null, @@ -1540,18 +1543,18 @@ class MessageRepository private constructor(private val context: Context) { if (decryptedBlob != null) { // 2. Сохраняем аватар в кэш val filePath = - AvatarFileManager.saveAvatar(context, decryptedBlob, fromPublicKey) + AvatarFileManager.saveAvatar(context, decryptedBlob, avatarOwnerKey) val entity = AvatarCacheEntity( - publicKey = fromPublicKey, + publicKey = avatarOwnerKey, avatar = filePath, timestamp = System.currentTimeMillis() ) avatarDao.insertAvatar(entity) // 3. Очищаем старые аватары (оставляем последние 5) - avatarDao.deleteOldAvatars(fromPublicKey, 5) + avatarDao.deleteOldAvatars(avatarOwnerKey, 5) } else {} } catch (e: Exception) {} } 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 b2cbfc4..71dad8b 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 @@ -2170,6 +2170,8 @@ fun ChatDetailScreen( message.senderName, isGroupChat = isGroupChat, + dialogPublicKey = + user.publicKey, showGroupSenderLabel = isGroupChat && !message.isOutgoing, diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt index 5cb7337..b860866 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt @@ -332,6 +332,8 @@ fun MessageAttachments( isOutgoing: Boolean, isDarkTheme: Boolean, senderPublicKey: String, + dialogPublicKey: String = "", + isGroupChat: Boolean = false, timestamp: java.util.Date, messageStatus: MessageStatus = MessageStatus.READ, avatarRepository: AvatarRepository? = null, @@ -392,6 +394,8 @@ fun MessageAttachments( chachaKey = chachaKey, privateKey = privateKey, senderPublicKey = senderPublicKey, + dialogPublicKey = dialogPublicKey, + isGroupChat = isGroupChat, avatarRepository = avatarRepository, currentUserPublicKey = currentUserPublicKey, isOutgoing = isOutgoing, @@ -1775,6 +1779,8 @@ fun AvatarAttachment( chachaKey: String, privateKey: String, senderPublicKey: String, + dialogPublicKey: String = "", + isGroupChat: Boolean = false, avatarRepository: AvatarRepository?, currentUserPublicKey: String = "", isOutgoing: Boolean, @@ -1924,11 +1930,15 @@ fun AvatarAttachment( // Сохраняем аватар в репозиторий (для UI обновления) // Если это исходящее сообщение с аватаром, сохраняем для текущего // пользователя + val normalizedDialogKey = dialogPublicKey.trim() + val isGroupAvatarAttachment = isGroupChat || isGroupStoredKey(chachaKey) val targetPublicKey = - if (isOutgoing && currentUserPublicKey.isNotEmpty()) { - currentUserPublicKey - } else { - senderPublicKey + when { + isGroupAvatarAttachment && normalizedDialogKey.isNotEmpty() -> + normalizedDialogKey + isOutgoing && currentUserPublicKey.isNotEmpty() -> + currentUserPublicKey + else -> senderPublicKey } // ВАЖНО: ждем завершения сохранения в репозиторий diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index 7dca103..e68ca0c 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -297,6 +297,7 @@ fun MessageBubble( senderPublicKey: String = "", senderName: String = "", isGroupChat: Boolean = false, + dialogPublicKey: String = "", showGroupSenderLabel: Boolean = false, isGroupSenderAdmin: Boolean = false, currentUserPublicKey: String = "", @@ -846,6 +847,8 @@ fun MessageBubble( isOutgoing = message.isOutgoing, isDarkTheme = isDarkTheme, senderPublicKey = senderPublicKey, + dialogPublicKey = dialogPublicKey, + isGroupChat = isGroupChat, timestamp = message.timestamp, messageStatus = attachmentDisplayStatus, avatarRepository = avatarRepository, From 896d54df4deebbc1bd9ed4e66b066791a7514357 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Thu, 5 Mar 2026 20:00:38 +0500 Subject: [PATCH 08/19] =?UTF-8?q?=D0=A0=D0=B5=D0=BB=D0=B8=D0=B7=201.1.3:?= =?UTF-8?q?=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D1=8F=20=D0=B8=20release=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Поднят versionName до 1.1.3 и versionCode до 15. - Обновлены release notes под текущий релиз. - В notes добавлены изменения по reply/mentions в группах, индикаторам чтения и UX-исправлениям. --- .../rosetta/messenger/data/ReleaseNotes.kt | 33 ++++++++----------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt index 762e75c..e247c3f 100644 --- a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt +++ b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt @@ -17,27 +17,22 @@ object ReleaseNotes { val RELEASE_NOTICE = """ Update v$VERSION_PLACEHOLDER - Поиск - - Новый полноценный экран поиска с вкладками: Чаты, Медиа, Загрузки, Файлы - - Медиа-сетка с загрузкой реальных фотографий из истории чатов - - Просмотр фото на весь экран со свайпом между ними из вкладки Медиа - - Вкладка Загрузки — скачанные файлы - - Вкладка Файлы — файловые вложения из всех чатов + Группы и reply + - Исправлено отображение reply в группах: показывается автор сообщения, а не название группы + - Исправлен переход в профиль по клику на @mention в сообщениях и цитатах + - Убраны лишние варианты упоминаний из подсказок (@administrator и @all) - Группы - - Приглашения в группу теперь отображаются внутри пузыря сообщения с кнопкой действия - - Кэширование информации о приглашениях (больше нет загрузки при повторном открытии) - - Кэширование участников группы для быстрого открытия списка - - Экран ключа шифрования переработан в стиле Telegram (12×12 identicon) - - Исправлен онлайн-статус участников — теперь онлайн только те кто реально онлайн - - Emoji-клавиатура в экране создания группы + Упоминания (@) + - Добавлена подсветка @mention в пузырьках как кликабельных ссылок (parity с desktop) + - Добавлено меню участников группы при вводе "@" + - Подсказки показываются с первой совпадающей буквы, с аватаром и именем + - Обновлен стиль меню упоминаний в Telegram-подобном виде с цветами приложения - Чат и интерфейс - - Улучшены индикаторы доставки сообщений - - Менеджер загрузки файлов с отображением прогресса - - Открытие файлов в профиле пользователя (локальные, загруженные, из blob) - - Новые обои для чатов - - Улучшена работа камеры и управление состоянием UI + Индикаторы чтения и UX + - Включена отправка read receipt для групп, синхронизация статусов чтения стала корректнее + - Исправлена логика галочек в списке чатов: двойные синие только после реального прочтения + - Исправлен конфликт горизонтального скролла аватаров с жестом "назад" на экране поиска + - Убрано залипание контекстного меню при открытии чужого профиля """.trimIndent() fun getNotice(version: String): String = From 59499a8f85d07171e0b16f4b035359425f6e47c7 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sat, 7 Mar 2026 02:50:25 +0500 Subject: [PATCH 09/19] =?UTF-8?q?=D0=A0=D0=B5=D0=BB=D0=B8=D0=B7=20v1.1.4:?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D1=84=D0=B8=D0=BB=D1=8C,=20=D0=B3=D1=80?= =?UTF-8?q?=D1=83=D0=BF=D0=BF=D1=8B,=20=D0=B0=D0=B2=D0=B0=D1=82=D0=B0?= =?UTF-8?q?=D1=80=D1=8B=20=D0=B8=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Фиксированные табы в профиле и группах - Fast-scroll с датой в медиа-галерее - Apple Emoji в аватарах и интерфейсе - Восстановление ключей группы по инвайт-ссылке - Улучшено отображение аватаров с эмодзи - Исправлен переход по своему тэгу в группах - Убрана лишняя подсветка, исправлен fast-scroll overlay - Версия 1.1.3 → 1.1.4, versionCode 15 → 16 Co-Authored-By: Claude Opus 4.6 --- app/build.gradle.kts | 4 +-- .../rosetta/messenger/data/ReleaseNotes.kt | 26 +++++++++---------- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 20a3529..479fca6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown" // ═══════════════════════════════════════════════════════════ // Rosetta versioning — bump here on each release // ═══════════════════════════════════════════════════════════ -val rosettaVersionName = "1.1.3" -val rosettaVersionCode = 15 // Increment on each release +val rosettaVersionName = "1.1.4" +val rosettaVersionCode = 16 // Increment on each release android { namespace = "com.rosetta.messenger" diff --git a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt index e247c3f..1b11aeb 100644 --- a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt +++ b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt @@ -17,22 +17,20 @@ object ReleaseNotes { val RELEASE_NOTICE = """ Update v$VERSION_PLACEHOLDER - Группы и reply - - Исправлено отображение reply в группах: показывается автор сообщения, а не название группы - - Исправлен переход в профиль по клику на @mention в сообщениях и цитатах - - Убраны лишние варианты упоминаний из подсказок (@administrator и @all) + Профиль и группы + - Фиксированные табы в профиле и группах + - Fast-scroll с отображением даты в медиа-галерее + - Поддержка Apple Emoji в аватарах и интерфейсе + - Восстановление ключей шифрования группы по инвайт-ссылке - Упоминания (@) - - Добавлена подсветка @mention в пузырьках как кликабельных ссылок (parity с desktop) - - Добавлено меню участников группы при вводе "@" - - Подсказки показываются с первой совпадающей буквы, с аватаром и именем - - Обновлен стиль меню упоминаний в Telegram-подобном виде с цветами приложения + Аватары + - Улучшено отображение аватаров: поддержка текста с эмодзи + - Улучшена логика отображения в компоненте AvatarImage - Индикаторы чтения и UX - - Включена отправка read receipt для групп, синхронизация статусов чтения стала корректнее - - Исправлена логика галочек в списке чатов: двойные синие только после реального прочтения - - Исправлен конфликт горизонтального скролла аватаров с жестом "назад" на экране поиска - - Убрано залипание контекстного меню при открытии чужого профиля + Исправления + - Исправлен переход по своему тэгу в группах + - Убрана лишняя подсветка в чатах + - Корректное отображение fast-scroll при изменении размера экрана """.trimIndent() fun getNotice(version: String): String = From 364b1665812bb9a5dddd386c98f00b19b485b407 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sat, 7 Mar 2026 20:42:21 +0500 Subject: [PATCH 10/19] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=BE=20=D0=BE=D1=82=D1=81=D0=BB=D0=B5=D0=B6=D0=B8?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=BB=D0=B8=D1=87?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=B2=D0=B0=20=D1=83=D1=87=D0=B0=D1=81=D1=82?= =?UTF-8?q?=D0=BD=D0=B8=D0=BA=D0=BE=D0=B2=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF?= =?UTF-8?q?=D1=8B=20=D0=B2=20ChatDetailScreen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../messenger/ui/chats/ChatDetailScreen.kt | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) 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 71dad8b..447c75f 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 @@ -425,6 +425,9 @@ fun ChatDetailScreen( var groupAdminKeys by remember(user.publicKey, currentUserPublicKey) { mutableStateOf>(emptySet()) } + var groupMembersCount by remember(user.publicKey, currentUserPublicKey) { + mutableStateOf(null) + } var mentionCandidates by remember(user.publicKey, currentUserPublicKey) { mutableStateOf>(emptyList()) } @@ -439,6 +442,7 @@ fun ChatDetailScreen( LaunchedEffect(isGroupChat, user.publicKey, currentUserPublicKey) { if (!isGroupChat || user.publicKey.isBlank() || currentUserPublicKey.isBlank()) { groupAdminKeys = emptySet() + groupMembersCount = null mentionCandidates = emptyList() return@LaunchedEffect } @@ -446,15 +450,20 @@ fun ChatDetailScreen( val members = withContext(Dispatchers.IO) { groupRepository.requestGroupMembers(user.publicKey).orEmpty() } - val adminKey = members.firstOrNull().orEmpty() + val normalizedMembers = + members.map { it.trim() } + .filter { it.isNotBlank() } + .distinct() + groupMembersCount = normalizedMembers.size + + val adminKey = normalizedMembers.firstOrNull().orEmpty() groupAdminKeys = if (adminKey.isBlank()) emptySet() else setOf(adminKey) mentionCandidates = withContext(Dispatchers.IO) { - members.map { it.trim() } - .filter { it.isNotBlank() && !it.equals(currentUserPublicKey.trim(), ignoreCase = true) } - .distinct() + normalizedMembers + .filter { !it.equals(currentUserPublicKey.trim(), ignoreCase = true) } .mapNotNull { memberKey -> val resolvedUser = viewModel.resolveUserForProfile(memberKey) ?: return@mapNotNull null val normalizedUsername = resolvedUser.username.trim().trimStart('@') @@ -636,10 +645,13 @@ fun ChatDetailScreen( val isRosettaOfficial = user.title.equals("Rosetta", ignoreCase = true) || user.username.equals("rosetta", ignoreCase = true) || isSystemAccount + val groupMembersSubtitleCount = groupMembersCount ?: 0 + val groupMembersSubtitle = + "$groupMembersSubtitleCount member${if (groupMembersSubtitleCount == 1) "" else "s"}" val chatSubtitle = when { isSavedMessages -> "Notes" - isGroupChat -> "group" + isGroupChat -> groupMembersSubtitle isTyping -> "" // Пустая строка, используем компонент TypingIndicator isOnline -> "online" isSystemAccount -> "official account" From 85bddb798c33c775b26ff06c488597e0658689fd Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sat, 7 Mar 2026 23:43:09 +0500 Subject: [PATCH 11/19] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D1=8B=20=D1=81=D0=B8=D0=BD=D1=85=D1=80=D0=BE?= =?UTF-8?q?=D0=BD=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B3=D1=80=D1=83?= =?UTF-8?q?=D0=BF=D0=BF,=20=D0=B2=D1=8B=D0=B4=D0=B5=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B9=20=D0=B8=20=D1=84=D0=BE=D0=BD=20=D1=87=D0=B0=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../rosetta/messenger/data/GroupRepository.kt | 36 ++++- .../messenger/data/MessageRepository.kt | 7 +- .../messenger/database/AvatarEntities.kt | 6 + .../messenger/repository/AvatarRepository.kt | 56 ++++++- .../messenger/ui/chats/ChatDetailScreen.kt | 145 +++++++++++++++--- .../messenger/ui/chats/GroupInfoScreen.kt | 114 ++++++++++++++ .../chats/components/AttachmentComponents.kt | 18 ++- .../messenger/ui/settings/ThemeScreen.kt | 2 +- 8 files changed, 352 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/data/GroupRepository.kt b/app/src/main/java/com/rosetta/messenger/data/GroupRepository.kt index ff1d259..45b88a3 100644 --- a/app/src/main/java/com/rosetta/messenger/data/GroupRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/GroupRepository.kt @@ -1,6 +1,7 @@ package com.rosetta.messenger.data import android.content.Context +import android.util.Log import com.rosetta.messenger.crypto.CryptoManager import com.rosetta.messenger.database.GroupEntity import com.rosetta.messenger.database.MessageEntity @@ -23,6 +24,7 @@ import kotlin.coroutines.resume class GroupRepository private constructor(context: Context) { + private val appContext = context.applicationContext private val db = RosettaDatabase.getDatabase(context.applicationContext) private val groupDao = db.groupDao() private val messageDao = db.messageDao() @@ -31,9 +33,11 @@ class GroupRepository private constructor(context: Context) { private val inviteInfoCache = ConcurrentHashMap() companion object { + private const val TAG = "GroupRepository" private const val GROUP_PREFIX = "#group:" private const val GROUP_INVITE_PASSWORD = "rosetta_group" private const val GROUP_WAIT_TIMEOUT_MS = 15_000L + private const val GROUP_CREATED_MARKER = "\$a=Group created" @Volatile private var INSTANCE: GroupRepository? = null @@ -232,7 +236,20 @@ class GroupRepository private constructor(context: Context) { return GroupJoinResult(success = false, error = "Failed to construct invite") } - return joinGroup(accountPublicKey, accountPrivateKey, invite) + val joinResult = joinGroup(accountPublicKey, accountPrivateKey, invite) + + if (joinResult.success) { + val dialogPublicKey = joinResult.dialogPublicKey + if (!dialogPublicKey.isNullOrBlank()) { + emitGroupCreatedMarker( + accountPublicKey = accountPublicKey, + accountPrivateKey = accountPrivateKey, + dialogPublicKey = dialogPublicKey + ) + } + } + + return joinResult } suspend fun joinGroup( @@ -455,6 +472,23 @@ class GroupRepository private constructor(context: Context) { ) } + private suspend fun emitGroupCreatedMarker( + accountPublicKey: String, + accountPrivateKey: String, + dialogPublicKey: String + ) { + try { + val messages = MessageRepository.getInstance(appContext) + messages.initialize(accountPublicKey, accountPrivateKey) + messages.sendMessage( + toPublicKey = dialogPublicKey, + text = GROUP_CREATED_MARKER + ) + } catch (e: Exception) { + Log.w(TAG, "Failed to emit group-created marker for sync visibility", e) + } + } + private fun buildStoredGroupKey(groupKey: String, privateKey: String): String { val encrypted = CryptoManager.encryptWithPassword(groupKey, privateKey) return "group:$encrypted" diff --git a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt index be22180..859e8bd 100644 --- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt @@ -767,7 +767,7 @@ class MessageRepository private constructor(private val context: Context) { // 📸 Обрабатываем AVATAR attachments: // в личке — сохраняем аватар отправителя, в группе — аватар группы (desktop parity) val avatarOwnerKey = - if (isGroupMessage) packet.toPublicKey else packet.fromPublicKey + if (isGroupMessage) toGroupDialogPublicKey(packet.toPublicKey) else packet.fromPublicKey processAvatarAttachments( packet.attachments, avatarOwnerKey, @@ -1179,6 +1179,11 @@ class MessageRepository private constructor(private val context: Context) { } } + private fun toGroupDialogPublicKey(value: String): String { + val groupId = normalizeGroupId(value) + return if (groupId.isBlank()) value.trim() else "#group:$groupId" + } + private fun buildStoredGroupKey(groupKey: String, privateKey: String): String { return "group:${CryptoManager.encryptWithPassword(groupKey, privateKey)}" } diff --git a/app/src/main/java/com/rosetta/messenger/database/AvatarEntities.kt b/app/src/main/java/com/rosetta/messenger/database/AvatarEntities.kt index 745e9f9..efda353 100644 --- a/app/src/main/java/com/rosetta/messenger/database/AvatarEntities.kt +++ b/app/src/main/java/com/rosetta/messenger/database/AvatarEntities.kt @@ -42,12 +42,18 @@ interface AvatarDao { */ @Query("SELECT * FROM avatar_cache WHERE public_key = :publicKey ORDER BY timestamp DESC") fun getAvatars(publicKey: String): Flow> + + @Query("SELECT * FROM avatar_cache WHERE public_key IN (:publicKeys) ORDER BY timestamp DESC") + fun getAvatarsByKeys(publicKeys: List): Flow> /** * Получить последний аватар пользователя */ @Query("SELECT * FROM avatar_cache WHERE public_key = :publicKey ORDER BY timestamp DESC LIMIT 1") suspend fun getLatestAvatar(publicKey: String): AvatarCacheEntity? + + @Query("SELECT * FROM avatar_cache WHERE public_key IN (:publicKeys) ORDER BY timestamp DESC LIMIT 1") + suspend fun getLatestAvatarByKeys(publicKeys: List): AvatarCacheEntity? /** * Получить последний аватар пользователя как Flow diff --git a/app/src/main/java/com/rosetta/messenger/repository/AvatarRepository.kt b/app/src/main/java/com/rosetta/messenger/repository/AvatarRepository.kt index 57d4111..5012006 100644 --- a/app/src/main/java/com/rosetta/messenger/repository/AvatarRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/repository/AvatarRepository.kt @@ -53,6 +53,36 @@ class AvatarRepository( return false } } + + private fun normalizeOwnerKey(publicKey: String): String { + val trimmed = publicKey.trim() + if (trimmed.isBlank()) return trimmed + return when { + trimmed.startsWith("#group:") -> { + val groupId = trimmed.removePrefix("#group:").trim() + if (groupId.isBlank()) trimmed else "#group:$groupId" + } + trimmed.startsWith("group:", ignoreCase = true) -> { + val groupId = trimmed.substringAfter(':').trim() + if (groupId.isBlank()) trimmed else "#group:$groupId" + } + else -> trimmed + } + } + + private fun lookupKeys(publicKey: String): List { + val normalized = normalizeOwnerKey(publicKey) + if (normalized.isBlank()) return emptyList() + val keys = linkedSetOf(normalized) + if (normalized.startsWith("#group:")) { + keys.add(normalized.removePrefix("#group:")) + } + val trimmed = publicKey.trim() + if (trimmed.isNotBlank()) { + keys.add(trimmed) + } + return keys.toList() + } /** * Получить аватары пользователя @@ -60,14 +90,20 @@ class AvatarRepository( * @param allDecode true = вся история, false = только последний (для списков) */ fun getAvatars(publicKey: String, allDecode: Boolean = false): StateFlow> { + val normalizedKey = normalizeOwnerKey(publicKey) + val keys = lookupKeys(publicKey) + if (normalizedKey.isBlank() || keys.isEmpty()) { + return MutableStateFlow(emptyList()).asStateFlow() + } + // Проверяем LRU cache (accessOrder=true обновляет позицию при get) - memoryCache[publicKey]?.let { return it.flow.asStateFlow() } + memoryCache[normalizedKey]?.let { return it.flow.asStateFlow() } // Создаем новый flow для этого пользователя val flow = MutableStateFlow>(emptyList()) // Подписываемся на изменения в БД - val job = avatarDao.getAvatars(publicKey) + val job = avatarDao.getAvatarsByKeys(keys) .onEach { entities -> val avatars = if (allDecode) { // Параллельная загрузка всей истории @@ -86,7 +122,7 @@ class AvatarRepository( } .launchIn(repositoryScope) - memoryCache[publicKey] = CacheEntry(flow, job) + memoryCache[normalizedKey] = CacheEntry(flow, job) return flow.asStateFlow() } @@ -94,7 +130,9 @@ class AvatarRepository( * Получить последний аватар пользователя (suspend версия) */ suspend fun getLatestAvatar(publicKey: String): AvatarInfo? { - val entity = avatarDao.getLatestAvatar(publicKey) ?: return null + val keys = lookupKeys(publicKey) + if (keys.isEmpty()) return null + val entity = avatarDao.getLatestAvatarByKeys(keys) ?: return null return loadAndDecryptAvatar(entity) } @@ -108,22 +146,24 @@ class AvatarRepository( suspend fun saveAvatar(fromPublicKey: String, base64Image: String) { withContext(Dispatchers.IO) { try { + val ownerKey = normalizeOwnerKey(fromPublicKey) + if (ownerKey.isBlank()) return@withContext // Сохраняем файл - val filePath = AvatarFileManager.saveAvatar(context, base64Image, fromPublicKey) + val filePath = AvatarFileManager.saveAvatar(context, base64Image, ownerKey) // Сохраняем в БД val entity = AvatarCacheEntity( - publicKey = fromPublicKey, + publicKey = ownerKey, avatar = filePath, timestamp = System.currentTimeMillis() ) avatarDao.insertAvatar(entity) // Очищаем старые аватары (оставляем только последние N) - avatarDao.deleteOldAvatars(fromPublicKey, MAX_AVATAR_HISTORY) + avatarDao.deleteOldAvatars(ownerKey, MAX_AVATAR_HISTORY) // 🔄 Обновляем memory cache если он существует - val cached = memoryCache[fromPublicKey] + val cached = memoryCache[ownerKey] if (cached != null) { val avatarInfo = loadAndDecryptAvatar(entity) if (avatarInfo != null) { 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 447c75f..7e4af98 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 @@ -2,7 +2,13 @@ package com.rosetta.messenger.ui.chats import android.app.Activity import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Shader +import android.graphics.drawable.BitmapDrawable import android.net.Uri +import android.view.Gravity +import android.view.View import android.view.inputmethod.InputMethodManager import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult @@ -66,6 +72,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.viewmodel.compose.viewModel @@ -187,6 +194,10 @@ fun ChatDetailScreen( // 🔥 MESSAGE SELECTION STATE - для Reply/Forward var selectedMessages by remember { mutableStateOf>(emptySet()) } val isSelectionMode = selectedMessages.isNotEmpty() + // После long press AndroidView текста может прислать tap на отпускание. + // В этом окне игнорируем tap по этому же сообщению, чтобы selection не снимался. + var longPressSuppressedMessageId by remember { mutableStateOf(null) } + var longPressSuppressUntilMs by remember { mutableLongStateOf(0L) } // 💬 MESSAGE CONTEXT MENU STATE var contextMenuMessage by remember { mutableStateOf(null) } @@ -211,13 +222,22 @@ fun ChatDetailScreen( } } - // 🔥 Закрытие экрана - мгновенно прячем клавиатуру через InputMethodManager - val hideKeyboardAndBack: () -> Unit = { - // Используем нативный InputMethodManager для МГНОВЕННОГО закрытия + val hideInputOverlays: () -> Unit = { val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager imm.hideSoftInputFromWindow(view.windowToken, 0) - focusManager.clearFocus() + window?.let { win -> + androidx.core.view.WindowCompat.getInsetsController(win, view) + .hide(androidx.core.view.WindowInsetsCompat.Type.ime()) + } + keyboardController?.hide() + focusManager.clearFocus(force = true) + showEmojiPicker = false + } + + // 🔥 Закрытие экрана - мгновенно прячем клавиатуру через InputMethodManager + val hideKeyboardAndBack: () -> Unit = { + hideInputOverlays() onBack() } @@ -229,10 +249,7 @@ fun ChatDetailScreen( else user.title.ifEmpty { user.publicKey.take(10) } val openDialogInfo: () -> Unit = { - val imm = - context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.hideSoftInputFromWindow(view.windowToken, 0) - focusManager.clearFocus() + hideInputOverlays() showContextMenu = false contextMenuMessage = null if (isGroupChat) { @@ -532,6 +549,31 @@ fun ChatDetailScreen( } } + // Long press должен только включать selection для сообщения (идемпотентно), + // иначе при двойном колбэке (text + bubble) сообщение мгновенно "откатывается". + val selectMessageOnLongPress: (messageId: String, canSelect: Boolean) -> Unit = + { messageId, canSelect -> + if (canSelect && !selectedMessages.contains(messageId)) { + selectedMessages = selectedMessages + messageId + } + } + val suppressTapAfterLongPress: (messageId: String) -> Unit = + { messageId -> + longPressSuppressedMessageId = messageId + longPressSuppressUntilMs = System.currentTimeMillis() + 350L + } + val shouldIgnoreTapAfterLongPress: (messageId: String) -> Boolean = + { messageId -> + val now = System.currentTimeMillis() + val isSuppressed = + longPressSuppressedMessageId == messageId && + now <= longPressSuppressUntilMs + if (isSuppressed || now > longPressSuppressUntilMs) { + longPressSuppressedMessageId = null + } + isSuppressed + } + // 🔥 ПАГИНАЦИЯ: Загружаем старые сообщения при прокрутке вверх // NOTE: Не нужен ручной scrollToItem - LazyColumn с reverseLayout=true // автоматически сохраняет позицию благодаря стабильным ключам (key = message.id) @@ -1254,12 +1296,8 @@ fun ChatDetailScreen( Box { IconButton( onClick = { - // Закрываем - // клавиатуру перед открытием меню - keyboardController - ?.hide() - focusManager - .clearFocus() + // Закрываем клавиатуру/emoji перед открытием меню + hideInputOverlays() showMenu = true }, @@ -1305,6 +1343,7 @@ fun ChatDetailScreen( onGroupInfoClick = { showMenu = false + hideInputOverlays() onGroupInfoClick( user ) @@ -1312,6 +1351,7 @@ fun ChatDetailScreen( onSearchMembersClick = { showMenu = false + hideInputOverlays() onGroupInfoClick( user ) @@ -1875,11 +1915,10 @@ fun ChatDetailScreen( // Keep wallpaper on a fixed full-screen layer so it doesn't rescale // when content paddings (bottom bar/IME) change. if (chatWallpaperResId != null) { - Image( - painter = painterResource(id = chatWallpaperResId), - contentDescription = "Chat wallpaper", + TiledChatWallpaper( + wallpaperResId = chatWallpaperResId, modifier = Modifier.matchParentSize(), - contentScale = ContentScale.Crop + tileScale = 0.9f ) } else { Box( @@ -2233,12 +2272,21 @@ fun ChatDetailScreen( .clearFocus() showEmojiPicker = false - toggleMessageSelection( + selectMessageOnLongPress( selectionKey, true ) + suppressTapAfterLongPress( + selectionKey + ) }, onClick = { + if (shouldIgnoreTapAfterLongPress( + selectionKey + ) + ) { + return@MessageBubble + } val hasAvatar = message.attachments .any { @@ -3018,3 +3066,62 @@ fun ChatDetailScreen( } // Закрытие outer Box } + +@Composable +private fun TiledChatWallpaper( + wallpaperResId: Int, + modifier: Modifier = Modifier, + tileScale: Float = 0.9f +) { + val context = LocalContext.current + val wallpaperDrawable = + remember(wallpaperResId, tileScale, context) { + val decoded = BitmapFactory.decodeResource(context.resources, wallpaperResId) + val normalizedScale = tileScale.coerceIn(0.2f, 2f) + + val scaledBitmap = + decoded?.let { original -> + if (normalizedScale == 1f) { + original + } else { + val width = + (original.width * normalizedScale) + .toInt() + .coerceAtLeast(1) + val height = + (original.height * normalizedScale) + .toInt() + .coerceAtLeast(1) + val scaled = + Bitmap.createScaledBitmap( + original, + width, + height, + true + ) + if (scaled != original) { + original.recycle() + } + scaled + } + } + + val safeBitmap = + scaledBitmap + ?: Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) + .apply { + eraseColor(android.graphics.Color.TRANSPARENT) + } + + BitmapDrawable(context.resources, safeBitmap).apply { + setTileModeXY(Shader.TileMode.REPEAT, Shader.TileMode.REPEAT) + gravity = Gravity.TOP or Gravity.START + } + } + + AndroidView( + modifier = modifier, + factory = { ctx -> View(ctx).apply { background = wallpaperDrawable } }, + update = { view -> view.background = wallpaperDrawable } + ) +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt index 9b31a02..a7de082 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt @@ -1,8 +1,12 @@ package com.rosetta.messenger.ui.chats import android.app.Activity +import android.content.Context +import android.view.inputmethod.InputMethodManager import android.widget.Toast import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -88,6 +92,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalView @@ -131,8 +136,14 @@ import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.components.SharedMediaFastScrollOverlay import com.rosetta.messenger.ui.components.VerifiedBadge import com.rosetta.messenger.ui.icons.TelegramIcons +import com.rosetta.messenger.ui.settings.FullScreenAvatarViewer +import com.rosetta.messenger.ui.settings.ProfilePhotoPicker +import com.rosetta.messenger.utils.AvatarFileManager +import com.rosetta.messenger.utils.ImageCropHelper import androidx.lifecycle.viewmodel.compose.viewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -238,6 +249,7 @@ fun GroupInfoScreen( ) { val context = androidx.compose.ui.platform.LocalContext.current val view = LocalView.current + val focusManager = LocalFocusManager.current val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current val hapticFeedback = LocalHapticFeedback.current val scope = rememberCoroutineScope() @@ -254,6 +266,19 @@ fun GroupInfoScreen( val accentColor = if (isDarkTheme) Color(0xFF5AA5FF) else Color(0xFF228BE6) val actionContentColor = if (isDarkTheme) Color.White else Color(0xFF1C1C1E) + LaunchedEffect(Unit) { + val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + repeat(3) { + imm.hideSoftInputFromWindow(view.windowToken, 0) + (context as? Activity)?.window?.let { window -> + WindowCompat.getInsetsController(window, view) + .hide(androidx.core.view.WindowInsetsCompat.Type.ime()) + } + focusManager.clearFocus(force = true) + delay(16) + } + } + // Keep status bar unified with group header color. DisposableEffect(topSurfaceColor, view) { val window = (view.context as? Activity)?.window @@ -301,6 +326,10 @@ fun GroupInfoScreen( var encryptionKeyLoading by remember { mutableStateOf(false) } var membersLoading by remember { mutableStateOf(false) } var isMuted by remember { mutableStateOf(false) } + var showGroupAvatarPicker by rememberSaveable(dialogPublicKey) { mutableStateOf(false) } + var showGroupAvatarViewer by rememberSaveable(dialogPublicKey) { mutableStateOf(false) } + var groupAvatarViewerTimestamp by rememberSaveable(dialogPublicKey) { mutableStateOf(0L) } + var groupAvatarViewerBitmap by remember(dialogPublicKey) { mutableStateOf(null) } var showAddMembersPicker by rememberSaveable(dialogPublicKey) { mutableStateOf(false) } var pendingInviteText by rememberSaveable(dialogPublicKey) { mutableStateOf("") } var isRefreshingMembers by remember(dialogPublicKey) { mutableStateOf(false) } @@ -390,6 +419,49 @@ fun GroupInfoScreen( groupEntity?.description?.trim().orEmpty() } + val cropLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + val croppedUri = ImageCropHelper.getCroppedImageUri(result) + val cropError = ImageCropHelper.getCropError(result) + if (croppedUri != null) { + scope.launch { + val preparedBase64 = + withContext(Dispatchers.IO) { + val imageBytes = + runCatching { + context.contentResolver.openInputStream(croppedUri)?.use { stream -> + stream.readBytes() + } + }.getOrNull() + imageBytes?.let { bytes -> + val prepared = AvatarFileManager.imagePrepareForNetworkTransfer(context, bytes) + if (prepared.isBlank()) null else "data:image/png;base64,$prepared" + } + } + + val repository = avatarRepository + val saved = + preparedBase64 != null && + repository != null && + runCatching { + withContext(Dispatchers.IO) { + repository.saveAvatar(dialogPublicKey, preparedBase64) + } + }.isSuccess + + Toast.makeText( + context, + if (saved) "Group avatar updated" else "Failed to update group avatar", + Toast.LENGTH_SHORT + ).show() + } + } else if (cropError != null) { + Toast.makeText(context, "Failed to crop photo", Toast.LENGTH_SHORT).show() + } + } + LaunchedEffect(currentUserPublicKey, dialogPublicKey) { if (currentUserPublicKey.isNotBlank() && dialogPublicKey.isNotBlank()) { isMuted = preferencesManager.isChatMuted(currentUserPublicKey, dialogPublicKey) @@ -581,6 +653,20 @@ fun GroupInfoScreen( members.firstOrNull()?.trim()?.equals(normalizedCurrentUserKey, ignoreCase = true) == true } } + + fun openGroupAvatarViewer() { + val repository = avatarRepository ?: return + scope.launch { + val latestAvatar = repository.getAvatars(dialogPublicKey, allDecode = false).first().firstOrNull() + ?: return@launch + groupAvatarViewerTimestamp = latestAvatar.timestamp / 1000L + groupAvatarViewerBitmap = + withContext(Dispatchers.IO) { + AvatarFileManager.base64ToBitmap(latestAvatar.base64Data) + } + showGroupAvatarViewer = true + } + } var swipedMemberKey by remember(dialogPublicKey) { mutableStateOf(null) } var memberToKick by remember(dialogPublicKey) { mutableStateOf(null) } var isKickingMember by remember(dialogPublicKey) { mutableStateOf(false) } @@ -861,6 +947,13 @@ fun GroupInfoScreen( avatarRepository = avatarRepository, size = 86.dp, isDarkTheme = isDarkTheme, + onClick = { + if (currentUserIsAdmin) { + showGroupAvatarPicker = true + } else { + openGroupAvatarViewer() + } + }, displayName = groupTitle ) @@ -1408,6 +1501,27 @@ fun GroupInfoScreen( } ) } + + ProfilePhotoPicker( + isVisible = showGroupAvatarPicker, + onDismiss = { showGroupAvatarPicker = false }, + onPhotoSelected = { uri -> + showGroupAvatarPicker = false + val cropIntent = ImageCropHelper.createCropIntent(context, uri, isDarkTheme) + cropLauncher.launch(cropIntent) + }, + isDarkTheme = isDarkTheme + ) + + FullScreenAvatarViewer( + isVisible = showGroupAvatarViewer, + onDismiss = { showGroupAvatarViewer = false }, + displayName = groupTitle.ifBlank { shortPublicKey(dialogPublicKey) }, + avatarTimestamp = groupAvatarViewerTimestamp, + avatarBitmap = groupAvatarViewerBitmap, + publicKey = dialogPublicKey, + isDarkTheme = isDarkTheme + ) } @Composable diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt index b860866..9420f29 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt @@ -80,6 +80,19 @@ private val whitespaceRegex = "\\s+".toRegex() private fun isGroupStoredKey(value: String): Boolean = value.startsWith("group:") +private fun canonicalGroupDialogKey(value: String): String { + val trimmed = value.trim() + if (trimmed.isBlank()) return "" + val groupId = + when { + trimmed.startsWith("#group:") -> trimmed.removePrefix("#group:").trim() + trimmed.startsWith("group:", ignoreCase = true) -> + trimmed.substringAfter(':').trim() + else -> return trimmed + } + return if (groupId.isBlank()) "" else "#group:$groupId" +} + private fun decodeGroupPassword(storedKey: String, privateKey: String): String? { if (!isGroupStoredKey(storedKey)) return null val encoded = storedKey.removePrefix("group:") @@ -1931,11 +1944,12 @@ fun AvatarAttachment( // Если это исходящее сообщение с аватаром, сохраняем для текущего // пользователя val normalizedDialogKey = dialogPublicKey.trim() + val canonicalDialogKey = canonicalGroupDialogKey(normalizedDialogKey) val isGroupAvatarAttachment = isGroupChat || isGroupStoredKey(chachaKey) val targetPublicKey = when { - isGroupAvatarAttachment && normalizedDialogKey.isNotEmpty() -> - normalizedDialogKey + isGroupAvatarAttachment && canonicalDialogKey.isNotEmpty() -> + canonicalDialogKey isOutgoing && currentUserPublicKey.isNotEmpty() -> currentUserPublicKey else -> senderPublicKey diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt index f771de8..b9608d1 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt @@ -575,7 +575,7 @@ private fun ChatPreview(isDarkTheme: Boolean, wallpaperId: String) { painter = painterResource(id = wallpaperResId), contentDescription = "Chat wallpaper preview", modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop + contentScale = ContentScale.FillBounds ) } From c7d5c47dd01b4d2d275017b7dcd1b385272fbeaa Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sun, 8 Mar 2026 02:55:44 +0500 Subject: [PATCH 12/19] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=20=D0=BF=D0=B5=D1=80=D0=B5=D1=85=D0=BE=D0=B4?= =?UTF-8?q?=20=D0=B2=20=D0=BF=D1=80=D0=BE=D1=84=D0=B8=D0=BB=D1=8C=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D1=81=D0=B2=D0=BE=D0=B5=D0=BC=D1=83=20=D1=82=D1=8D?= =?UTF-8?q?=D0=B3=D1=83=20=D0=B8=D0=B7=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF?= =?UTF-8?q?=D0=BE=D0=B2=D0=BE=D0=B3=D0=BE=20=D1=87=D0=B0=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/rosetta/messenger/MainActivity.kt | 53 +++++++++++++++++-- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index 1ce0afc..6716fe4 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -496,6 +496,7 @@ private fun EncryptedAccount.toAccountInfo(): AccountInfo { */ sealed class Screen { data object Profile : Screen() + data object ProfileFromChat : Screen() data object Requests : Screen() data object Search : Screen() data object GroupSetup : Screen() @@ -600,6 +601,9 @@ fun MainScreen( // Derived visibility — only triggers recomposition when THIS screen changes val isProfileVisible by remember { derivedStateOf { navStack.any { it is Screen.Profile } } } + val isProfileFromChatVisible by remember { + derivedStateOf { navStack.any { it is Screen.ProfileFromChat } } + } val isRequestsVisible by remember { derivedStateOf { navStack.any { it is Screen.Requests } } } val isSearchVisible by remember { derivedStateOf { navStack.any { it is Screen.Search } } } val isGroupSetupVisible by remember { derivedStateOf { navStack.any { it is Screen.GroupSetup } } } @@ -662,11 +666,18 @@ fun MainScreen( navStack = navStack.dropLast(1) } fun openOwnProfile() { - navStack = + val filteredStack = navStack.filterNot { it is Screen.ChatDetail || it is Screen.OtherProfile || it is Screen.GroupInfo } - pushScreen(Screen.Profile) + // Single state update avoids intermediate frame (chat list flash/jitter) when opening + // profile from a mention inside chat. + navStack = + if (filteredStack.lastOrNull() == Screen.Profile) { + filteredStack + } else { + filteredStack + Screen.Profile + } } fun popProfileAndChildren() { navStack = @@ -1003,8 +1014,9 @@ fun MainScreen( onBack = { popChatAndChildren() }, onUserProfileClick = { user -> if (isCurrentAccountUser(user)) { - // Свой профиль — открываем My Profile - openOwnProfile() + // Свой профиль из чата открываем поверх текущего чата, + // чтобы возврат оставался в этот чат, а не в chat list. + pushScreen(Screen.ProfileFromChat) } else { // Открываем профиль другого пользователя pushScreen(Screen.OtherProfile(user)) @@ -1028,6 +1040,39 @@ fun MainScreen( } } + SwipeBackContainer( + isVisible = isProfileFromChatVisible, + onBack = { navStack = navStack.filterNot { it is Screen.ProfileFromChat } }, + isDarkTheme = isDarkTheme, + layer = 1, + propagateBackgroundProgress = false + ) { + ProfileScreen( + isDarkTheme = isDarkTheme, + accountName = accountName, + accountUsername = accountUsername, + accountVerified = accountVerified, + accountPublicKey = accountPublicKey, + accountPrivateKeyHash = privateKeyHash, + onBack = { navStack = navStack.filterNot { it is Screen.ProfileFromChat } }, + onSaveProfile = { name, username -> + accountName = name + accountUsername = username + mainScreenScope.launch { onAccountInfoUpdated() } + }, + onLogout = onLogout, + onNavigateToTheme = { pushScreen(Screen.Theme) }, + onNavigateToAppearance = { pushScreen(Screen.Appearance) }, + onNavigateToSafety = { pushScreen(Screen.Safety) }, + onNavigateToLogs = { pushScreen(Screen.Logs) }, + onNavigateToBiometric = { pushScreen(Screen.Biometric) }, + viewModel = profileViewModel, + avatarRepository = avatarRepository, + dialogDao = RosettaDatabase.getDatabase(context).dialogDao(), + backgroundBlurColorId = backgroundBlurColorId + ) + } + var isGroupInfoSwipeEnabled by remember { mutableStateOf(true) } LaunchedEffect(selectedGroup?.publicKey) { isGroupInfoSwipeEnabled = true From b0a41b2831971eba18da18f1e917ca8fa06cfd0a Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sun, 8 Mar 2026 19:07:56 +0500 Subject: [PATCH 13/19] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D1=8B=20typing=20=D0=B8=20=D0=B7=D0=B0=D0=B3?= =?UTF-8?q?=D1=80=D1=83=D0=B7=D0=BA=D0=B8=20=D1=84=D0=B0=D0=B9=D0=BB=D0=BE?= =?UTF-8?q?=D0=B2,=20=D1=83=D0=BB=D1=83=D1=87=D1=88=D0=B5=D0=BD=20UI=20?= =?UTF-8?q?=D1=87=D0=B0=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавлена привязка загрузок файлов к аккаунту и путь сохранения в состоянии загрузки - Реализован экран Downloads с прогрессом и путём файла, плюс анимация открытия/закрытия - Исправлена логика typing-индикаторов в группах и ЛС (без ложных срабатываний) - Доработаны пузырьки групповых сообщений: Telegram-style аватар 42dp и отступы - Исправлено поведение кнопки прокрутки вниз в чате (без мигания при отправке, ближе к инпуту) - Убран Copy на экране Encryption Key группы --- .../messenger/network/FileDownloadManager.kt | 176 +++++++-- .../messenger/network/ProtocolManager.kt | 97 ++++- .../messenger/ui/chats/ChatDetailScreen.kt | 6 +- .../messenger/ui/chats/ChatViewModel.kt | 29 +- .../messenger/ui/chats/ChatsListScreen.kt | 348 ++++++++++++++++-- .../messenger/ui/chats/GroupInfoScreen.kt | 17 +- .../chats/components/AttachmentComponents.kt | 3 + .../chats/components/ChatDetailComponents.kt | 46 ++- 8 files changed, 637 insertions(+), 85 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/network/FileDownloadManager.kt b/app/src/main/java/com/rosetta/messenger/network/FileDownloadManager.kt index 13bb6f7..ca873fd 100644 --- a/app/src/main/java/com/rosetta/messenger/network/FileDownloadManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/FileDownloadManager.kt @@ -12,7 +12,9 @@ data class FileDownloadState( val fileName: String, val status: FileDownloadStatus, /** 0f..1f */ - val progress: Float = 0f + val progress: Float = 0f, + val accountPublicKey: String = "", + val savedPath: String = "" ) enum class FileDownloadStatus { @@ -84,17 +86,27 @@ object FileDownloadManager { downloadTag: String, chachaKey: String, privateKey: String, + accountPublicKey: String, fileName: String, savedFile: File ) { // Уже в процессе? if (jobs[attachmentId]?.isActive == true) return + val normalizedAccount = accountPublicKey.trim() + val savedPath = savedFile.absolutePath - update(attachmentId, fileName, FileDownloadStatus.QUEUED, 0f) + update(attachmentId, fileName, FileDownloadStatus.QUEUED, 0f, normalizedAccount, savedPath) jobs[attachmentId] = scope.launch { try { - update(attachmentId, fileName, FileDownloadStatus.DOWNLOADING, 0f) + update( + attachmentId, + fileName, + FileDownloadStatus.DOWNLOADING, + 0f, + normalizedAccount, + savedPath + ) // Запускаем polling прогресса из TransportManager val progressJob = launch { @@ -103,34 +115,87 @@ object FileDownloadManager { if (entry != null) { // CDN progress: 0-100 → 0f..0.8f (80% круга — скачивание) val p = (entry.progress / 100f) * 0.8f - update(attachmentId, fileName, FileDownloadStatus.DOWNLOADING, p) + update( + attachmentId, + fileName, + FileDownloadStatus.DOWNLOADING, + p, + normalizedAccount, + savedPath + ) } } } val success = withContext(Dispatchers.IO) { if (isGroupStoredKey(chachaKey)) { - downloadGroupFile(attachmentId, downloadTag, chachaKey, privateKey, fileName, savedFile) + downloadGroupFile( + attachmentId = attachmentId, + downloadTag = downloadTag, + chachaKey = chachaKey, + privateKey = privateKey, + fileName = fileName, + savedFile = savedFile, + accountPublicKey = normalizedAccount, + savedPath = savedPath + ) } else { - downloadDirectFile(attachmentId, downloadTag, chachaKey, privateKey, fileName, savedFile) + downloadDirectFile( + attachmentId = attachmentId, + downloadTag = downloadTag, + chachaKey = chachaKey, + privateKey = privateKey, + fileName = fileName, + savedFile = savedFile, + accountPublicKey = normalizedAccount, + savedPath = savedPath + ) } } progressJob.cancel() if (success) { - update(attachmentId, fileName, FileDownloadStatus.DONE, 1f) + update( + attachmentId, + fileName, + FileDownloadStatus.DONE, + 1f, + normalizedAccount, + savedPath + ) } else { - update(attachmentId, fileName, FileDownloadStatus.ERROR, 0f) + update( + attachmentId, + fileName, + FileDownloadStatus.ERROR, + 0f, + normalizedAccount, + savedPath + ) } } catch (e: CancellationException) { throw e } catch (e: Exception) { e.printStackTrace() - update(attachmentId, fileName, FileDownloadStatus.ERROR, 0f) + update( + attachmentId, + fileName, + FileDownloadStatus.ERROR, + 0f, + normalizedAccount, + savedPath + ) } catch (_: OutOfMemoryError) { System.gc() - update(attachmentId, fileName, FileDownloadStatus.ERROR, 0f) + update( + attachmentId, + fileName, + FileDownloadStatus.ERROR, + 0f, + normalizedAccount, + savedPath + ) } finally { jobs.remove(attachmentId) // Автоочистка через 5 секунд после завершения @@ -159,25 +224,55 @@ object FileDownloadManager { chachaKey: String, privateKey: String, fileName: String, - savedFile: File + savedFile: File, + accountPublicKey: String, + savedPath: String ): Boolean { val encryptedContent = TransportManager.downloadFile(attachmentId, downloadTag) - update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.82f) + update( + attachmentId, + fileName, + FileDownloadStatus.DECRYPTING, + 0.82f, + accountPublicKey, + savedPath + ) val groupPassword = decodeGroupPassword(chachaKey, privateKey) if (groupPassword.isNullOrBlank()) return false val decrypted = CryptoManager.decryptWithPassword(encryptedContent, groupPassword) - update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.88f) + update( + attachmentId, + fileName, + FileDownloadStatus.DECRYPTING, + 0.88f, + accountPublicKey, + savedPath + ) val bytes = decrypted?.let { decodeBase64Payload(it) } ?: return false - update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.93f) + update( + attachmentId, + fileName, + FileDownloadStatus.DECRYPTING, + 0.93f, + accountPublicKey, + savedPath + ) withContext(Dispatchers.IO) { savedFile.parentFile?.mkdirs() savedFile.writeBytes(bytes) } - update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.98f) + update( + attachmentId, + fileName, + FileDownloadStatus.DECRYPTING, + 0.98f, + accountPublicKey, + savedPath + ) return true } @@ -187,14 +282,30 @@ object FileDownloadManager { chachaKey: String, privateKey: String, fileName: String, - savedFile: File + savedFile: File, + accountPublicKey: String, + savedPath: String ): Boolean { // Streaming: скачиваем во temp file val tempFile = TransportManager.downloadFileRaw(attachmentId, downloadTag) - update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.83f) + update( + attachmentId, + fileName, + FileDownloadStatus.DECRYPTING, + 0.83f, + accountPublicKey, + savedPath + ) val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(chachaKey, privateKey) - update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.88f) + update( + attachmentId, + fileName, + FileDownloadStatus.DECRYPTING, + 0.88f, + accountPublicKey, + savedPath + ) // Streaming decrypt: tempFile → AES → inflate → base64 → savedFile withContext(Dispatchers.IO) { @@ -208,13 +319,36 @@ object FileDownloadManager { tempFile.delete() } } - update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.98f) + update( + attachmentId, + fileName, + FileDownloadStatus.DECRYPTING, + 0.98f, + accountPublicKey, + savedPath + ) return true } - private fun update(id: String, fileName: String, status: FileDownloadStatus, progress: Float) { + private fun update( + id: String, + fileName: String, + status: FileDownloadStatus, + progress: Float, + accountPublicKey: String, + savedPath: String + ) { _downloads.update { map -> - map + (id to FileDownloadState(id, fileName, status, progress)) + map + ( + id to FileDownloadState( + attachmentId = id, + fileName = fileName, + status = status, + progress = progress, + accountPublicKey = accountPublicKey, + savedPath = savedPath + ) + ) } } } diff --git a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt index 0f5d0db..46a7e62 100644 --- a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt @@ -29,6 +29,7 @@ object ProtocolManager { private const val MANUAL_SYNC_BACKTRACK_MS = 120_000L private const val MAX_DEBUG_LOGS = 600 private const val DEBUG_LOG_FLUSH_DELAY_MS = 60L + private const val TYPING_INDICATOR_TIMEOUT_MS = 3_000L // Server address - same as React Native version private const val SERVER_ADDRESS = "ws://46.28.71.12:3000" @@ -59,6 +60,9 @@ object ProtocolManager { // Typing status private val _typingUsers = MutableStateFlow>(emptySet()) val typingUsers: StateFlow> = _typingUsers.asStateFlow() + private val typingStateLock = Any() + private val typingUsersByDialog = mutableMapOf>() + private val typingTimeoutJobs = ConcurrentHashMap() // Connected devices and pending verification requests private val _devices = MutableStateFlow>(emptyList()) @@ -200,6 +204,7 @@ object ProtocolManager { */ fun initializeAccount(publicKey: String, privateKey: String) { setSyncInProgress(false) + clearTypingState() messageRepository?.initialize(publicKey, privateKey) if (resyncRequiredAfterAccountInit || protocol?.isAuthenticated() == true) { resyncRequiredAfterAccountInit = false @@ -369,13 +374,26 @@ object ProtocolManager { // Обработчик typing (0x0B) waitPacket(0x0B) { packet -> val typingPacket = packet as PacketTyping - - // Добавляем в set и удаляем через 3 секунды - _typingUsers.value = _typingUsers.value + typingPacket.fromPublicKey - scope.launch { - delay(3000) - _typingUsers.value = _typingUsers.value - typingPacket.fromPublicKey + val fromPublicKey = typingPacket.fromPublicKey.trim() + val toPublicKey = typingPacket.toPublicKey.trim() + if (fromPublicKey.isBlank() || toPublicKey.isBlank()) return@waitPacket + + val ownPublicKey = + getProtocol().getPublicKey()?.trim().orEmpty().ifBlank { + messageRepository?.getCurrentAccountKey()?.trim().orEmpty() + } + if (ownPublicKey.isNotBlank() && fromPublicKey.equals(ownPublicKey, ignoreCase = true)) { + return@waitPacket } + + val dialogKey = + resolveTypingDialogKey( + fromPublicKey = fromPublicKey, + toPublicKey = toPublicKey, + ownPublicKey = ownPublicKey + ) ?: return@waitPacket + + rememberTypingEvent(dialogKey, fromPublicKey) } // 📱 Обработчик списка устройств (0x17) @@ -508,6 +526,71 @@ object ProtocolManager { return normalized.startsWith("#group:") || normalized.startsWith("group:") } + private fun normalizeGroupDialogKey(value: String): String { + val trimmed = value.trim() + val normalized = trimmed.lowercase(Locale.ROOT) + return when { + normalized.startsWith("#group:") -> "#group:${trimmed.substringAfter(':').trim()}" + normalized.startsWith("group:") -> "#group:${trimmed.substringAfter(':').trim()}" + else -> trimmed + } + } + + private fun resolveTypingDialogKey( + fromPublicKey: String, + toPublicKey: String, + ownPublicKey: String + ): String? { + return when { + isGroupDialogKey(toPublicKey) -> normalizeGroupDialogKey(toPublicKey) + ownPublicKey.isNotBlank() && toPublicKey.equals(ownPublicKey, ignoreCase = true) -> + fromPublicKey.trim() + else -> null + } + } + + private fun makeTypingTimeoutKey(dialogKey: String, fromPublicKey: String): String { + return "${dialogKey.lowercase(Locale.ROOT)}|${fromPublicKey.lowercase(Locale.ROOT)}" + } + + private fun rememberTypingEvent(dialogKey: String, fromPublicKey: String) { + val normalizedDialogKey = + if (isGroupDialogKey(dialogKey)) normalizeGroupDialogKey(dialogKey) else dialogKey.trim() + val normalizedFrom = fromPublicKey.trim() + if (normalizedDialogKey.isBlank() || normalizedFrom.isBlank()) return + + synchronized(typingStateLock) { + val users = typingUsersByDialog.getOrPut(normalizedDialogKey) { mutableSetOf() } + users.add(normalizedFrom) + _typingUsers.value = typingUsersByDialog.keys.toSet() + } + + val timeoutKey = makeTypingTimeoutKey(normalizedDialogKey, normalizedFrom) + typingTimeoutJobs.remove(timeoutKey)?.cancel() + typingTimeoutJobs[timeoutKey] = + scope.launch { + delay(TYPING_INDICATOR_TIMEOUT_MS) + synchronized(typingStateLock) { + val users = typingUsersByDialog[normalizedDialogKey] + users?.remove(normalizedFrom) + if (users.isNullOrEmpty()) { + typingUsersByDialog.remove(normalizedDialogKey) + } + _typingUsers.value = typingUsersByDialog.keys.toSet() + } + typingTimeoutJobs.remove(timeoutKey) + } + } + + private fun clearTypingState() { + typingTimeoutJobs.values.forEach { it.cancel() } + typingTimeoutJobs.clear() + synchronized(typingStateLock) { + typingUsersByDialog.clear() + _typingUsers.value = emptySet() + } + } + private fun onAuthenticated() { setSyncInProgress(false) TransportManager.requestTransportServer() @@ -1021,6 +1104,7 @@ object ProtocolManager { protocol?.disconnect() protocol?.clearCredentials() messageRepository?.clearInitialization() + clearTypingState() _devices.value = emptyList() _pendingDeviceVerification.value = null syncRequestInFlight = false @@ -1036,6 +1120,7 @@ object ProtocolManager { protocol?.destroy() protocol = null messageRepository?.clearInitialization() + clearTypingState() _devices.value = emptyList() _pendingDeviceVerification.value = null syncRequestInFlight = false 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 7e4af98..96ff3a5 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 @@ -782,8 +782,8 @@ fun ChatDetailScreen( listState.firstVisibleItemScrollOffset <= 12 } } - val showScrollToBottomButton by remember(messagesWithDates, isAtBottom) { - derivedStateOf { messagesWithDates.isNotEmpty() && !isAtBottom } + val showScrollToBottomButton by remember(messagesWithDates, isAtBottom, isSendingMessage) { + derivedStateOf { messagesWithDates.isNotEmpty() && !isAtBottom && !isSendingMessage } } // Telegram-style: Прокрутка ТОЛЬКО при новых сообщениях (не при пагинации) @@ -2551,7 +2551,7 @@ fun ChatDetailScreen( Modifier.align(Alignment.BottomEnd) .padding( end = 14.dp, - bottom = if (isSystemAccount) 24.dp else 86.dp + bottom = if (isSystemAccount) 24.dp else 16.dp ) ) { Box( diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index 4c539d8..69f1b0a 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -242,9 +242,32 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // 🔥 Сохраняем ссылки на обработчики для очистки в onCleared() // ВАЖНО: Должны быть определены ДО init блока! - private val typingPacketHandler: (Packet) -> Unit = { packet -> + private val typingPacketHandler: (Packet) -> Unit = typingPacketHandler@{ packet -> val typingPacket = packet as PacketTyping - if (typingPacket.fromPublicKey == opponentKey) { + val currentDialog = opponentKey?.trim().orEmpty() + val currentAccount = myPublicKey?.trim().orEmpty() + if (currentDialog.isBlank() || currentAccount.isBlank()) { + return@typingPacketHandler + } + + val fromPublicKey = typingPacket.fromPublicKey.trim() + val toPublicKey = typingPacket.toPublicKey.trim() + if (fromPublicKey.isBlank() || toPublicKey.isBlank()) { + return@typingPacketHandler + } + if (fromPublicKey.equals(currentAccount, ignoreCase = true)) { + return@typingPacketHandler + } + + val shouldShowTyping = + if (isGroupDialogKey(currentDialog)) { + normalizeGroupId(toPublicKey).equals(normalizeGroupId(currentDialog), ignoreCase = true) + } else { + fromPublicKey.equals(currentDialog, ignoreCase = true) && + toPublicKey.equals(currentAccount, ignoreCase = true) + } + + if (shouldShowTyping) { showTypingIndicator() } } @@ -4163,7 +4186,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } // 📁 Для Saved Messages - не отправляем typing indicator - if (opponent == sender || isGroupDialogKey(opponent)) { + if (opponent.equals(sender, ignoreCase = true)) { return } 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 c0ad443..3394fcf 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 @@ -192,6 +192,28 @@ private fun isGroupDialogKey(value: String): Boolean { return normalized.startsWith("#group:") || normalized.startsWith("group:") } +private fun normalizeGroupDialogKey(value: String): String { + val trimmed = value.trim() + val normalized = trimmed.lowercase(Locale.ROOT) + return when { + normalized.startsWith("#group:") -> "#group:${trimmed.substringAfter(':').trim()}" + normalized.startsWith("group:") -> "#group:${trimmed.substringAfter(':').trim()}" + else -> trimmed + } +} + +private fun isTypingForDialog(dialogKey: String, typingDialogs: Set): Boolean { + if (typingDialogs.isEmpty()) return false + if (isGroupDialogKey(dialogKey)) { + val normalizedDialogKey = normalizeGroupDialogKey(dialogKey) + return typingDialogs.any { + isGroupDialogKey(it) && + normalizeGroupDialogKey(it).equals(normalizedDialogKey, ignoreCase = true) + } + } + return typingDialogs.any { it.equals(dialogKey.trim(), ignoreCase = true) } +} + private val TELEGRAM_DIALOG_AVATAR_START = 10.dp private val TELEGRAM_DIALOG_TEXT_START = 72.dp private val TELEGRAM_DIALOG_AVATAR_SIZE = 54.dp @@ -442,9 +464,29 @@ fun ChatsListScreen( val syncInProgress by ProtocolManager.syncInProgress.collectAsState() val pendingDeviceVerification by ProtocolManager.pendingDeviceVerification.collectAsState() - // � Active downloads tracking (for header indicator) - val activeDownloads by com.rosetta.messenger.network.TransportManager.downloading.collectAsState() - val hasActiveDownloads = activeDownloads.isNotEmpty() + // 📥 Active FILE downloads tracking (account-scoped, excludes photo downloads) + val currentAccountKey = remember(accountPublicKey) { accountPublicKey.trim() } + val allFileDownloads by + com.rosetta.messenger.network.FileDownloadManager.downloads.collectAsState() + val accountFileDownloads = remember(allFileDownloads, currentAccountKey) { + allFileDownloads.values + .filter { + it.accountPublicKey.equals(currentAccountKey, ignoreCase = true) + } + .sortedByDescending { it.progress } + } + val activeFileDownloads = remember(accountFileDownloads) { + accountFileDownloads.filter { + it.status == com.rosetta.messenger.network.FileDownloadStatus.QUEUED || + it.status == + com.rosetta.messenger.network.FileDownloadStatus + .DOWNLOADING || + it.status == + com.rosetta.messenger.network.FileDownloadStatus + .DECRYPTING + } + } + val hasActiveDownloads = activeFileDownloads.isNotEmpty() // �🔥 Пользователи, которые сейчас печатают val typingUsers by ProtocolManager.typingUsers.collectAsState() @@ -473,6 +515,7 @@ fun ChatsListScreen( // 📬 Requests screen state var showRequestsScreen by remember { mutableStateOf(false) } + var showDownloadsScreen by remember { mutableStateOf(false) } var isInlineRequestsTransitionLocked by remember { mutableStateOf(false) } var isRequestsRouteTapLocked by remember { mutableStateOf(false) } val inlineRequestsTransitionLockMs = 340L @@ -498,6 +541,10 @@ fun ChatsListScreen( } } + LaunchedEffect(currentAccountKey) { + showDownloadsScreen = false + } + // 📂 Accounts section expanded state (arrow toggle) var accountsSectionExpanded by remember { mutableStateOf(false) } @@ -535,8 +582,10 @@ fun ChatsListScreen( // Back: drawer → закрыть, selection → сбросить // Когда ничего не открыто — НЕ перехватываем, система сама закроет приложение корректно - BackHandler(enabled = isSelectionMode || drawerState.isOpen) { - if (isSelectionMode) { + BackHandler(enabled = showDownloadsScreen || isSelectionMode || drawerState.isOpen) { + if (showDownloadsScreen) { + showDownloadsScreen = false + } else if (isSelectionMode) { selectedChatKeys = emptySet() } else if (drawerState.isOpen) { scope.launch { drawerState.close() } @@ -709,7 +758,7 @@ fun ChatsListScreen( ) { ModalNavigationDrawer( drawerState = drawerState, - gesturesEnabled = !showRequestsScreen, + gesturesEnabled = !showRequestsScreen && !showDownloadsScreen, drawerContent = { ModalDrawerSheet( drawerContainerColor = Color.Transparent, @@ -1335,7 +1384,12 @@ fun ChatsListScreen( ) { Scaffold( topBar = { - key(isDarkTheme, showRequestsScreen, isSelectionMode) { + key( + isDarkTheme, + showRequestsScreen, + showDownloadsScreen, + isSelectionMode + ) { Crossfade( targetState = isSelectionMode, animationSpec = tween(200), @@ -1473,12 +1527,16 @@ fun ChatsListScreen( // ═══ NORMAL HEADER ═══ TopAppBar( navigationIcon = { - if (showRequestsScreen) { + if (showRequestsScreen || showDownloadsScreen) { IconButton( onClick = { - setInlineRequestsVisible( - false - ) + if (showDownloadsScreen) { + showDownloadsScreen = false + } else { + setInlineRequestsVisible( + false + ) + } } ) { Icon( @@ -1557,7 +1615,14 @@ fun ChatsListScreen( } }, title = { - if (showRequestsScreen) { + if (showDownloadsScreen) { + Text( + "Downloads", + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + color = Color.White + ) + } else if (showRequestsScreen) { Text( "Requests", fontWeight = @@ -1596,10 +1661,18 @@ fun ChatsListScreen( } }, actions = { - if (!showRequestsScreen) { + if (!showRequestsScreen && !showDownloadsScreen) { // 📥 Animated download indicator (Telegram-style) Box( - modifier = androidx.compose.ui.Modifier.size(48.dp), + modifier = + androidx.compose.ui.Modifier + .size(48.dp) + .clickable( + enabled = hasActiveDownloads, + onClick = { + showDownloadsScreen = true + } + ), contentAlignment = Alignment.Center ) { com.rosetta.messenger.ui.components.AnimatedDownloadIndicator( @@ -1709,39 +1782,105 @@ fun ChatsListScreen( val showSkeleton = isLoading - // 🎬 Animated content transition between main list and - // requests AnimatedContent( - targetState = showRequestsScreen, + targetState = showDownloadsScreen, transitionSpec = { if (targetState) { - // Opening requests: slide in from right + // Opening downloads: slide from right with fade slideInHorizontally( - animationSpec = tween(280, easing = FastOutSlowInEasing) - ) { fullWidth -> fullWidth } + fadeIn( + animationSpec = + tween( + 280, + easing = + FastOutSlowInEasing + ) + ) { fullWidth -> + fullWidth + } + fadeIn( animationSpec = tween(200) ) togetherWith slideOutHorizontally( - animationSpec = tween(280, easing = FastOutSlowInEasing) - ) { fullWidth -> -fullWidth / 4 } + fadeOut( + animationSpec = + tween( + 280, + easing = + FastOutSlowInEasing + ) + ) { fullWidth -> + -fullWidth / 4 + } + fadeOut( animationSpec = tween(150) ) } else { - // Closing requests: slide out to right + // Closing downloads: slide back to right slideInHorizontally( - animationSpec = tween(280, easing = FastOutSlowInEasing) - ) { fullWidth -> -fullWidth / 4 } + fadeIn( + animationSpec = + tween( + 280, + easing = + FastOutSlowInEasing + ) + ) { fullWidth -> + -fullWidth / 4 + } + fadeIn( animationSpec = tween(200) ) togetherWith slideOutHorizontally( - animationSpec = tween(280, easing = FastOutSlowInEasing) - ) { fullWidth -> fullWidth } + fadeOut( + animationSpec = + tween( + 280, + easing = + FastOutSlowInEasing + ) + ) { fullWidth -> + fullWidth + } + fadeOut( animationSpec = tween(150) ) } }, - label = "RequestsTransition" - ) { isRequestsScreen -> + label = "DownloadsTransition" + ) { isDownloadsScreen -> + if (isDownloadsScreen) { + FileDownloadsScreen( + downloads = activeFileDownloads, + isDarkTheme = isDarkTheme, + modifier = Modifier.fillMaxSize() + ) + } else { + // 🎬 Animated content transition between main list and + // requests + AnimatedContent( + targetState = showRequestsScreen, + transitionSpec = { + 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 -> if (isRequestsScreen) { // 📬 Show Requests Screen with swipe-back Box( @@ -2054,13 +2193,14 @@ fun ChatsListScreen( ) val isTyping by remember( - dialog.opponentKey + dialog.opponentKey, + typingUsers ) { derivedStateOf { - typingUsers - .contains( - dialog.opponentKey - ) + isTypingForDialog( + dialog.opponentKey, + typingUsers + ) } } val isSelectedDialog = @@ -2241,7 +2381,9 @@ fun ChatsListScreen( } } } - } // Close AnimatedContent + } // Close AnimatedContent + } // Close downloads/main content switch + } // Close Downloads AnimatedContent // Console button removed } @@ -3746,7 +3888,11 @@ fun DialogItemContent( MessageRepository.isSystemAccount(dialog.opponentKey) if (dialog.verified > 0 || isRosettaOfficial) { Spacer(modifier = Modifier.width(4.dp)) - VerifiedBadge(verified = if (dialog.verified > 0) dialog.verified else 1, size = 16) + VerifiedBadge( + verified = if (dialog.verified > 0) dialog.verified else 1, + size = 16, + modifier = Modifier.offset(y = (-1).dp) + ) } // 🔒 Красная иконка замочка для заблокированных пользователей if (isBlocked) { @@ -4561,6 +4707,136 @@ fun RequestsScreen( } } +@Composable +private fun FileDownloadsScreen( + downloads: List, + isDarkTheme: Boolean, + modifier: Modifier = Modifier +) { + val background = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7) + val card = if (isDarkTheme) Color(0xFF212123) else Color.White + val primaryText = if (isDarkTheme) Color.White else Color(0xFF111111) + val secondaryText = if (isDarkTheme) Color(0xFF9B9B9F) else Color(0xFF6E6E73) + val divider = if (isDarkTheme) Color(0xFF2F2F31) else Color(0xFFE7E7EC) + + if (downloads.isEmpty()) { + Box( + modifier = modifier.background(background), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + imageVector = TablerIcons.Download, + contentDescription = null, + tint = secondaryText, + modifier = Modifier.size(34.dp) + ) + Spacer(modifier = Modifier.height(10.dp)) + Text( + text = "No active file downloads", + color = primaryText, + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "New file downloads will appear here.", + color = secondaryText, + fontSize = 13.sp + ) + } + } + return + } + + LazyColumn( + modifier = modifier.background(background), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(items = downloads, key = { it.attachmentId }) { item -> + Surface( + color = card, + shape = RoundedCornerShape(14.dp), + tonalElevation = 0.dp + ) { + Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(34.dp) + .clip(CircleShape) + .background( + if (isDarkTheme) Color(0xFF2B4E6E) + else Color(0xFFDCEEFF) + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = TablerIcons.Download, + contentDescription = null, + tint = if (isDarkTheme) Color(0xFF8FC6FF) else Color(0xFF228BE6), + modifier = Modifier.size(18.dp) + ) + } + Spacer(modifier = Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = item.fileName.ifBlank { "Unknown file" }, + color = primaryText, + fontSize = 15.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = formatDownloadStatusText(item), + color = secondaryText, + fontSize = 12.sp + ) + } + } + Spacer(modifier = Modifier.height(10.dp)) + LinearProgressIndicator( + progress = item.progress.coerceIn(0f, 1f), + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .clip(RoundedCornerShape(50)), + color = if (isDarkTheme) Color(0xFF4DA6FF) else Color(0xFF228BE6), + trackColor = if (isDarkTheme) Color(0xFF3A3A3D) else Color(0xFFD8D8DE) + ) + Spacer(modifier = Modifier.height(8.dp)) + Divider(color = divider, thickness = 0.5.dp) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = item.savedPath.ifBlank { "Storage path unavailable" }, + color = secondaryText, + fontSize = 11.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } +} + +private fun formatDownloadStatusText( + item: com.rosetta.messenger.network.FileDownloadState +): String { + val percent = (item.progress.coerceIn(0f, 1f) * 100).toInt().coerceIn(0, 100) + return when (item.status) { + com.rosetta.messenger.network.FileDownloadStatus.QUEUED -> "Queued" + com.rosetta.messenger.network.FileDownloadStatus.DOWNLOADING -> "Downloading $percent%" + com.rosetta.messenger.network.FileDownloadStatus.DECRYPTING -> "Decrypting" + com.rosetta.messenger.network.FileDownloadStatus.DONE -> "Completed" + com.rosetta.messenger.network.FileDownloadStatus.ERROR -> "Download failed" + } +} + /** 🎨 Enhanced Drawer Menu Item - пункт меню в стиле Telegram */ @Composable fun DrawerMenuItemEnhanced( diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt index a7de082..620da55 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt @@ -1428,11 +1428,7 @@ fun GroupInfoScreen( isDarkTheme = isDarkTheme, topSurfaceColor = topSurfaceColor, backgroundColor = backgroundColor, - onBack = { showEncryptionPage = false }, - onCopy = { - clipboardManager.setText(AnnotatedString(encryptionKey)) - Toast.makeText(context, "Encryption key copied", Toast.LENGTH_SHORT).show() - } + onBack = { showEncryptionPage = false } ) } @@ -1570,8 +1566,7 @@ private fun GroupEncryptionKeyPage( isDarkTheme: Boolean, topSurfaceColor: Color, backgroundColor: Color, - onBack: () -> Unit, - onCopy: () -> Unit + onBack: () -> Unit ) { val uriHandler = LocalUriHandler.current val safePeerTitle = peerTitle.ifBlank { "this group" } @@ -1614,14 +1609,6 @@ private fun GroupEncryptionKeyPage( fontSize = 20.sp, fontWeight = FontWeight.SemiBold ) - Spacer(modifier = Modifier.weight(1f)) - TextButton(onClick = onCopy) { - Text( - text = "Copy", - color = Color.White.copy(alpha = 0.9f), - fontWeight = FontWeight.Medium - ) - } } // Two-half layout like Telegram diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt index 9420f29..8303514 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt @@ -395,6 +395,7 @@ fun MessageAttachments( attachment = attachment, chachaKey = chachaKey, privateKey = privateKey, + currentUserPublicKey = currentUserPublicKey, isOutgoing = isOutgoing, isDarkTheme = isDarkTheme, timestamp = timestamp, @@ -1444,6 +1445,7 @@ fun FileAttachment( attachment: MessageAttachment, chachaKey: String, privateKey: String, + currentUserPublicKey: String = "", isOutgoing: Boolean, isDarkTheme: Boolean, timestamp: java.util.Date, @@ -1557,6 +1559,7 @@ fun FileAttachment( downloadTag = downloadTag, chachaKey = chachaKey, privateKey = privateKey, + accountPublicKey = currentUserPublicKey, fileName = fileName, savedFile = savedFile ) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index e68ca0c..04b19f0 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -71,6 +71,7 @@ import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.ui.chats.models.* import com.rosetta.messenger.ui.chats.utils.* import com.rosetta.messenger.ui.components.AppleEmojiText +import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.utils.AttachmentFileManager import com.vanniktech.blurhash.BlurHash @@ -532,6 +533,15 @@ fun MessageBubble( val combinedBackgroundColor = if (isSelected) selectionBackgroundColor else highlightBackgroundColor + val telegramIncomingAvatarSize = 42.dp + val telegramIncomingAvatarLane = 48.dp + val telegramIncomingAvatarInset = 6.dp + val showIncomingGroupAvatar = + isGroupChat && + !message.isOutgoing && + showTail && + senderPublicKey.isNotBlank() + Row( modifier = Modifier.fillMaxWidth() @@ -579,6 +589,39 @@ fun MessageBubble( Spacer(modifier = Modifier.weight(1f)) } + if (!message.isOutgoing && isGroupChat) { + Box( + modifier = + Modifier.width(telegramIncomingAvatarLane) + .height(telegramIncomingAvatarSize) + .align(Alignment.Bottom), + contentAlignment = Alignment.BottomStart + ) { + if (showIncomingGroupAvatar) { + Box( + modifier = + Modifier.fillMaxSize() + .padding( + start = + telegramIncomingAvatarInset + ), + contentAlignment = Alignment.BottomStart + ) { + AvatarImage( + publicKey = senderPublicKey, + avatarRepository = avatarRepository, + size = telegramIncomingAvatarSize, + isDarkTheme = isDarkTheme, + displayName = + senderName.ifBlank { + senderPublicKey + } + ) + } + } + } + } + // Проверяем - есть ли только фотки без текста val hasOnlyMedia = message.attachments.isNotEmpty() && @@ -708,7 +751,8 @@ fun MessageBubble( Box( modifier = - Modifier.padding(end = 12.dp) + Modifier.align(Alignment.Bottom) + .padding(end = 12.dp) .then(bubbleWidthModifier) .graphicsLayer { this.alpha = selectionAlpha From 5e908a6d0c784cb95461da87c86e9c744e8717a0 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Mon, 9 Mar 2026 03:53:12 +0500 Subject: [PATCH 14/19] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=BE=20=D0=BF=D0=BE=D0=B2=D0=B5=D0=B4=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=B0=D0=B2=D0=B0=D1=82=D0=B0=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=20=D1=81=D0=B5=D1=80=D0=B8=D0=B9=20=D0=B2=20=D0=B3=D1=80?= =?UTF-8?q?=D1=83=D0=BF=D0=BF=D0=B0=D1=85=20=D0=B8=20=D0=B2=D1=8B=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=81=D0=BE=D0=BE=D0=B1?= =?UTF-8?q?=D1=89=D0=B5=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../messenger/ui/chats/ChatDetailScreen.kt | 434 ++++++++++++++++-- .../chats/components/ChatDetailComponents.kt | 91 ++-- 2 files changed, 443 insertions(+), 82 deletions(-) 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 96ff3a5..3a47c19 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 @@ -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, + val showOnRunTails: Set, + val overlays: List +) + @OptIn( ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class, @@ -647,7 +669,250 @@ fun ChatDetailScreen( // �🔥 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() + + 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() + val showOnRunTails = hashSetOf() + val overlays = arrayListOf() + + 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 diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index 04b19f0..afb8d36 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -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 From ce376d340f97973b5aa276934220798c6b8fbdc6 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Mon, 9 Mar 2026 16:06:49 +0500 Subject: [PATCH 15/19] =?UTF-8?q?=D0=94=D0=BE=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D0=B0=D0=BD=D1=8B=20GroupSetup=20=D0=B8=20Search:=20?= =?UTF-8?q?=D0=BA=D0=BB=D0=B0=D0=B2=D0=B8=D0=B0=D1=82=D1=83=D1=80=D0=B0,?= =?UTF-8?q?=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B0=20=D0=BA=D0=B0=D0=BC?= =?UTF-8?q?=D0=B5=D1=80=D1=8B=20=D0=B8=20=D0=BF=D0=BE=D0=BF=D0=B0=D0=BF=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B4=D1=81=D0=BA=D0=B0=D0=B7=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../messenger/ui/chats/GroupSetupScreen.kt | 26 +++- .../messenger/ui/chats/SearchScreen.kt | 111 ++++++++++++++++-- 2 files changed, 128 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt index 0a02a5e..0e7973e 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt @@ -68,6 +68,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat import coil.compose.AsyncImage import coil.request.ImageRequest import com.rosetta.messenger.R @@ -149,6 +150,10 @@ fun GroupSetupScreen( val primaryTextColor = if (isDarkTheme) Color.White else Color.Black val secondaryTextColor = Color(0xFF8E8E93) val accentColor = if (isDarkTheme) Color(0xFF5AA5FF) else Color(0xFF228BE6) + val groupAvatarCameraButtonColor = + if (isDarkTheme) sectionColor else Color(0xFF8CC9F6) + val groupAvatarCameraIconColor = + if (isDarkTheme) accentColor else Color.White androidx.compose.runtime.DisposableEffect(topSurfaceColor, view) { val window = (view.context as? Activity)?.window @@ -208,12 +213,28 @@ fun GroupSetupScreen( } } + fun dismissInputUi() { + if (showEmojiKeyboard || coordinator.isEmojiVisible || coordinator.isEmojiBoxVisible) { + coordinator.closeEmoji(hideEmoji = { showEmojiKeyboard = false }) + } else { + showEmojiKeyboard = false + } + focusManager.clearFocus(force = true) + keyboardController?.hide() + val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.hideSoftInputFromWindow(view.windowToken, 0) + (context as? Activity)?.window?.let { window -> + WindowCompat.getInsetsController(window, view).hide(WindowInsetsCompat.Type.ime()) + } + } + fun handleBack() { if (isLoading) return errorText = null if (step == GroupSetupStep.DESCRIPTION) { step = GroupSetupStep.DETAILS } else { + dismissInputUi() onBack() } } @@ -402,7 +423,7 @@ fun GroupSetupScreen( Modifier .size(64.dp) .clip(CircleShape) - .background(sectionColor) + .background(groupAvatarCameraButtonColor) .clickable(enabled = !isLoading) { showPhotoPicker = true }, @@ -423,7 +444,7 @@ fun GroupSetupScreen( Icon( painter = TelegramIcons.Camera, contentDescription = "Set group avatar", - tint = accentColor, + tint = groupAvatarCameraIconColor, modifier = Modifier.size(24.dp) ) } @@ -713,6 +734,7 @@ fun GroupSetupScreen( avatarUriString = selectedAvatarUri ) } + dismissInputUi() openGroup( dialogPublicKey = result.dialogPublicKey, groupTitle = result.title.ifBlank { title.trim() } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt index 2fc23b4..2be8c01 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt @@ -32,9 +32,11 @@ import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import com.rosetta.messenger.repository.AvatarRepository @@ -446,9 +448,22 @@ private fun ChatsTabContent( val dividerColor = remember(isDarkTheme) { if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8) } + val suggestionsPrefs = remember(currentUserPublicKey) { + context.getSharedPreferences("search_suggestions", Context.MODE_PRIVATE) + } + val hiddenSuggestionsKey = remember(currentUserPublicKey) { + "hidden_frequent_${currentUserPublicKey}" + } // ─── Загрузка частых контактов из БД (top dialogs) ─── var frequentContacts by remember { mutableStateOf>(emptyList()) } + var hiddenSuggestionKeys by remember(currentUserPublicKey) { mutableStateOf>(emptySet()) } + var frequentSuggestionToRemove by remember { mutableStateOf(null) } + + LaunchedEffect(currentUserPublicKey) { + hiddenSuggestionKeys = + suggestionsPrefs.getStringSet(hiddenSuggestionsKey, emptySet())?.toSet() ?: emptySet() + } LaunchedEffect(currentUserPublicKey) { if (currentUserPublicKey.isBlank()) return@LaunchedEffect @@ -478,6 +493,14 @@ private fun ChatsTabContent( } catch (_: Exception) { } } } + val visibleFrequentContacts = remember(frequentContacts, hiddenSuggestionKeys) { + frequentContacts.filterNot { hiddenSuggestionKeys.contains(it.publicKey) } + } + fun removeFrequentSuggestion(publicKey: String) { + val updated = (hiddenSuggestionKeys + publicKey).toSet() + hiddenSuggestionKeys = updated + suggestionsPrefs.edit().putStringSet(hiddenSuggestionsKey, updated).apply() + } Box(modifier = Modifier.fillMaxSize()) { if (searchQuery.isEmpty()) { @@ -486,10 +509,10 @@ private fun ChatsTabContent( modifier = Modifier.fillMaxSize() ) { // ─── Горизонтальный ряд частых контактов (как в Telegram) ─── - if (frequentContacts.isNotEmpty()) { + if (visibleFrequentContacts.isNotEmpty()) { item { FrequentContactsRow( - contacts = frequentContacts, + contacts = visibleFrequentContacts, avatarRepository = avatarRepository, isDarkTheme = isDarkTheme, textColor = textColor, @@ -504,6 +527,9 @@ private fun ChatsTabContent( ) RecentSearchesManager.addUser(user) onUserSelect(user) + }, + onLongPress = { contact -> + frequentSuggestionToRemove = contact } ) } @@ -653,6 +679,69 @@ private fun ChatsTabContent( } ) } + frequentSuggestionToRemove?.let { contact -> + val scrimColor = if (isDarkTheme) Color.Black.copy(alpha = 0.42f) else Color.Black.copy(alpha = 0.24f) + val popupColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White + val popupSecondaryText = if (isDarkTheme) Color(0xFFAEAEB2) else Color(0xFF6D6D72) + + Box( + modifier = + Modifier + .matchParentSize() + .background(scrimColor) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { frequentSuggestionToRemove = null } + ) + + Surface( + modifier = + Modifier + .align(Alignment.BottomCenter) + .imePadding() + .padding(start = 20.dp, end = 20.dp, bottom = 14.dp), + color = popupColor, + shape = RoundedCornerShape(18.dp), + tonalElevation = 0.dp, + shadowElevation = 0.dp + ) { + Column( + modifier = Modifier.padding(horizontal = 18.dp, vertical = 16.dp) + ) { + Text( + text = "Remove suggestion", + color = textColor, + fontWeight = FontWeight.Bold, + fontSize = 17.sp + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Are you sure you want to remove ${contact.name} from suggestions?", + color = popupSecondaryText, + fontSize = 16.sp, + lineHeight = 22.sp + ) + Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = { frequentSuggestionToRemove = null }) { + Text("Cancel", color = AppPrimaryBlue, fontSize = 17.sp) + } + TextButton( + onClick = { + removeFrequentSuggestion(contact.publicKey) + frequentSuggestionToRemove = null + } + ) { + Text("Remove", color = Color(0xFFFF5A5F), fontSize = 17.sp) + } + } + } + } + } } } @@ -669,13 +758,16 @@ private data class FrequentContact( ) @Composable +@OptIn(ExperimentalFoundationApi::class) private fun FrequentContactsRow( contacts: List, avatarRepository: AvatarRepository?, isDarkTheme: Boolean, textColor: Color, - onClick: (FrequentContact) -> Unit + onClick: (FrequentContact) -> Unit, + onLongPress: (FrequentContact) -> Unit ) { + val haptic = LocalHapticFeedback.current LazyRow( modifier = Modifier .fillMaxWidth() @@ -687,10 +779,15 @@ private fun FrequentContactsRow( Column( modifier = Modifier .width(72.dp) - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { onClick(contact) } + .combinedClickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = { onClick(contact) }, + onLongClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onLongPress(contact) + } + ) .padding(vertical = 4.dp), horizontalAlignment = Alignment.CenterHorizontally ) { From f35596f18d29fd0d52365b77c82f449d91e623fb Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Mon, 9 Mar 2026 16:52:46 +0500 Subject: [PATCH 16/19] =?UTF-8?q?=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB?= =?UTF-8?q?=D1=91=D0=BD=20=D1=81=D1=82=D0=B8=D0=BB=D1=8C=20=D0=BA=D0=BD?= =?UTF-8?q?=D0=BE=D0=BF=D0=BE=D0=BA=20=D0=BD=D0=B0=20=D1=8D=D0=BA=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B5=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../messenger/ui/chats/GroupInfoScreen.kt | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt index 620da55..93859d1 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt @@ -265,6 +265,7 @@ fun GroupInfoScreen( val secondaryText = Color(0xFF8E8E93) val accentColor = if (isDarkTheme) Color(0xFF5AA5FF) else Color(0xFF228BE6) val actionContentColor = if (isDarkTheme) Color.White else Color(0xFF1C1C1E) + val groupActionButtonBlue = if (isDarkTheme) Color(0xFF285683) else Color(0xFF2478C2) LaunchedEffect(Unit) { val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager @@ -863,12 +864,12 @@ fun GroupInfoScreen( modifier = Modifier .fillMaxSize() .background(backgroundColor) - .statusBarsPadding() ) { Box( modifier = Modifier .fillMaxWidth() .background(topSurfaceColor) + .statusBarsPadding() .padding(horizontal = 14.dp, vertical = 10.dp) ) { IconButton(onClick = onBack, modifier = Modifier.align(Alignment.TopStart)) { @@ -989,16 +990,18 @@ fun GroupInfoScreen( modifier = Modifier.weight(1f), icon = Icons.Default.Message, label = "Message", - backgroundColor = cardColor, - contentColor = actionContentColor, + backgroundColor = groupActionButtonBlue, + contentColor = Color.White, + iconColor = Color.White, onClick = onBack ) GroupActionButton( modifier = Modifier.weight(1f), icon = if (isMuted) Icons.Default.Notifications else Icons.Default.NotificationsOff, label = if (isMuted) "Unmute" else "Mute", - backgroundColor = cardColor, - contentColor = actionContentColor, + backgroundColor = groupActionButtonBlue, + contentColor = Color.White, + iconColor = Color.White, onClick = { scope.launch { val newMutedState = !isMuted @@ -1015,8 +1018,9 @@ fun GroupInfoScreen( modifier = Modifier.weight(1f), icon = Icons.Default.ExitToApp, label = "Leave", - backgroundColor = cardColor, - contentColor = Color(0xFFFF7A7A), + backgroundColor = groupActionButtonBlue, + contentColor = Color.White, + iconColor = Color.White, onClick = { showLeaveConfirm = true } ) } @@ -1586,13 +1590,13 @@ private fun GroupEncryptionKeyPage( Column( modifier = Modifier .fillMaxSize() - .statusBarsPadding() ) { // Top bar Row( modifier = Modifier .fillMaxWidth() .background(topSurfaceColor) + .statusBarsPadding() .padding(horizontal = 4.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically ) { @@ -1696,6 +1700,7 @@ private fun GroupActionButton( label: String, backgroundColor: Color, contentColor: Color, + iconColor: Color = contentColor, onClick: () -> Unit ) { Surface( @@ -1714,7 +1719,7 @@ private fun GroupActionButton( Icon( imageVector = icon, contentDescription = null, - tint = contentColor, + tint = iconColor, modifier = Modifier.size(20.dp) ) Spacer(modifier = Modifier.height(4.dp)) From 1b3c4c8ceaec33dabaa04610911e649df9123d9f Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Mon, 9 Mar 2026 19:09:20 +0500 Subject: [PATCH 17/19] =?UTF-8?q?=D0=98=D0=B7=D0=BC=D0=B5=D0=BD=D1=91?= =?UTF-8?q?=D0=BD=20=D1=86=D0=B2=D0=B5=D1=82=20=D1=82=D0=B5=D0=BA=D1=81?= =?UTF-8?q?=D1=82=D0=B0=20=D0=B7=D0=B0=D0=B3=D0=BE=D0=BB=D0=BE=D0=B2=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=B4=D0=B0=D1=82=D1=8B=20=D0=B2=20=D0=B7=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=81=D0=B8=D0=BC=D0=BE=D1=81=D1=82=D0=B8=20=D0=BE=D1=82?= =?UTF-8?q?=20=D1=82=D0=B5=D0=BC=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 3a47c19..5aeee90 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 @@ -183,6 +183,7 @@ fun ChatDetailScreen( val chatWallpaperResId = remember(chatWallpaperId) { ThemeWallpapers.drawableResOrNull(chatWallpaperId) } val textColor = if (isDarkTheme) Color.White else Color.Black val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93) + val dateHeaderTextColor = if (isDarkTheme) Color.White else secondaryTextColor val headerIconColor = Color.White // 🔥 Keyboard & Emoji Coordinator @@ -2453,7 +2454,7 @@ fun ChatDetailScreen( .time ), secondaryTextColor = - secondaryTextColor + dateHeaderTextColor ) } val selectionKey = From 41faa981304ecfcce3a94d72cce8c23cad1f5c09 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Mon, 9 Mar 2026 19:23:28 +0500 Subject: [PATCH 18/19] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=20=D0=BA=D1=8D=D1=88=D0=B8=D1=80=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D1=83=D1=87=D0=B0=D1=81=D1=82=D0=BD=D0=B8?= =?UTF-8?q?=D0=BA=D0=BE=D0=B2=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF=20=D0=B2=20?= =?UTF-8?q?=D0=BF=D0=B0=D0=BC=D1=8F=D1=82=D0=B8=20=D0=B8=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=B4=D0=B8=D1=81=D0=BA=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../messenger/ui/chats/GroupInfoScreen.kt | 136 ++++++++++++++++-- 1 file changed, 127 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt index 93859d1..7a354d3 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt @@ -149,6 +149,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import androidx.core.view.WindowCompat import org.json.JSONArray +import org.json.JSONObject import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -214,6 +215,75 @@ private object GroupMembersMemoryCache { } } +private data class GroupMembersDiskCacheEntry( + val members: List, + val updatedAtMs: Long +) + +private object GroupMembersDiskCache { + private const val PREFS_NAME = "group_members_cache" + private const val KEY_PREFIX = "entry_" + private const val TTL_MS = 12 * 60 * 60 * 1000L + + fun getAny(context: Context, key: String): GroupMembersDiskCacheEntry? = read(context, key) + + fun getFresh(context: Context, key: String): GroupMembersDiskCacheEntry? { + val entry = read(context, key) ?: return null + return if (System.currentTimeMillis() - entry.updatedAtMs <= TTL_MS) entry else null + } + + fun put(context: Context, key: String, members: List) { + if (key.isBlank()) return + val normalizedMembers = members.map { it.trim() }.filter { it.isNotBlank() }.distinct() + if (normalizedMembers.isEmpty()) return + val payload = + JSONObject().apply { + put("updatedAtMs", System.currentTimeMillis()) + put("members", JSONArray().apply { normalizedMembers.forEach { put(it) } }) + }.toString() + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putString(KEY_PREFIX + key, payload) + .apply() + } + + fun remove(context: Context, key: String) { + if (key.isBlank()) return + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .remove(KEY_PREFIX + key) + .apply() + } + + private fun read(context: Context, key: String): GroupMembersDiskCacheEntry? { + if (key.isBlank()) return null + val raw = + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .getString(KEY_PREFIX + key, null) ?: return null + return runCatching { + val json = JSONObject(raw) + val membersArray = json.optJSONArray("members") ?: JSONArray() + val membersList = + buildList { + repeat(membersArray.length()) { index -> + val value = membersArray.optString(index).trim() + if (value.isNotBlank()) add(value) + } + } + .distinct() + if (membersList.isEmpty()) { + null + } else { + GroupMembersDiskCacheEntry( + members = membersList, + updatedAtMs = json.optLong("updatedAtMs", 0L) + ) + } + } + .getOrNull() + } +} + private data class GroupMediaItem( val key: String, val attachment: MessageAttachment, @@ -335,13 +405,27 @@ fun GroupInfoScreen( var pendingInviteText by rememberSaveable(dialogPublicKey) { mutableStateOf("") } var isRefreshingMembers by remember(dialogPublicKey) { mutableStateOf(false) } - var members by remember(dialogPublicKey) { mutableStateOf>(emptyList()) } - val memberInfoByKey = remember(dialogPublicKey) { mutableStateMapOf() } - // Real online status from PacketOnlineState (0x05), NOT from SearchUser.online - val memberOnlineStatus = remember(dialogPublicKey) { mutableStateMapOf() } val membersCacheKey = remember(currentUserPublicKey, normalizedGroupId) { "${currentUserPublicKey.trim().lowercase()}|${normalizedGroupId.trim().lowercase()}" } + val initialMemoryMembersCache = remember(membersCacheKey) { + GroupMembersMemoryCache.getAny(membersCacheKey) + } + val initialDiskMembersCache = remember(membersCacheKey) { + GroupMembersDiskCache.getAny(context, membersCacheKey) + } + val initialMembers = remember(initialMemoryMembersCache, initialDiskMembersCache) { + initialMemoryMembersCache?.members ?: initialDiskMembersCache?.members.orEmpty() + } + var members by remember(dialogPublicKey) { mutableStateOf(initialMembers) } + val memberInfoByKey = + remember(dialogPublicKey) { + mutableStateMapOf().apply { + initialMemoryMembersCache?.memberInfoByKey?.let { putAll(it) } + } + } + // Real online status from PacketOnlineState (0x05), NOT from SearchUser.online + val memberOnlineStatus = remember(dialogPublicKey) { mutableStateMapOf() } val groupEntity by produceState( initialValue = null, @@ -490,7 +574,9 @@ fun GroupInfoScreen( } } - val hasAnyCache = GroupMembersMemoryCache.getAny(membersCacheKey) != null + val hasAnyCache = + GroupMembersMemoryCache.getAny(membersCacheKey) != null || + GroupMembersDiskCache.getAny(context, membersCacheKey) != null val shouldShowLoader = showLoader && members.isEmpty() && !hasAnyCache if (shouldShowLoader) membersLoading = true isRefreshingMembers = true @@ -528,6 +614,7 @@ fun GroupInfoScreen( members = members, memberInfoByKey = memberInfoByKey.toMap() ) + GroupMembersDiskCache.put(context, membersCacheKey, members) } finally { if (shouldShowLoader) membersLoading = false isRefreshingMembers = false @@ -536,15 +623,44 @@ fun GroupInfoScreen( } LaunchedEffect(membersCacheKey) { - val cachedEntry = GroupMembersMemoryCache.getAny(membersCacheKey) - cachedEntry?.let { cached -> + val memoryCachedEntry = GroupMembersMemoryCache.getAny(membersCacheKey) + val diskCachedEntry = GroupMembersDiskCache.getAny(context, membersCacheKey) + + memoryCachedEntry?.let { cached -> members = cached.members memberInfoByKey.clear() memberInfoByKey.putAll(cached.memberInfoByKey) + } ?: diskCachedEntry?.let { cached -> + members = cached.members + if (memberInfoByKey.isEmpty()) { + val resolvedUsers = withContext(Dispatchers.IO) { + val resolvedMap = LinkedHashMap() + cached.members.forEach { memberKey -> + ProtocolManager.getCachedUserInfo(memberKey)?.let { resolved -> + resolvedMap[memberKey] = resolved + } + } + resolvedMap + } + if (resolvedUsers.isNotEmpty()) { + memberInfoByKey.putAll(resolvedUsers) + } + } + GroupMembersMemoryCache.put( + key = membersCacheKey, + members = members, + memberInfoByKey = memberInfoByKey.toMap() + ) } - if (GroupMembersMemoryCache.getFresh(membersCacheKey) == null) { - refreshMembers(force = true, showLoader = cachedEntry == null) + val hasFreshCache = + GroupMembersMemoryCache.getFresh(membersCacheKey) != null || + GroupMembersDiskCache.getFresh(context, membersCacheKey) != null + if (!hasFreshCache) { + refreshMembers( + force = true, + showLoader = memoryCachedEntry == null && diskCachedEntry == null + ) } } @@ -787,6 +903,7 @@ fun GroupInfoScreen( isLeaving = false if (left) { GroupMembersMemoryCache.remove(membersCacheKey) + GroupMembersDiskCache.remove(context, membersCacheKey) onGroupLeft() } else { Toast.makeText(context, "Failed to leave group", Toast.LENGTH_SHORT).show() @@ -831,6 +948,7 @@ fun GroupInfoScreen( members = members, memberInfoByKey = memberInfoByKey.toMap() ) + GroupMembersDiskCache.put(context, membersCacheKey, members) refreshMembers(force = true, showLoader = false) Toast.makeText(context, "Member removed", Toast.LENGTH_SHORT).show() } else { From c2e27cf5438ad0bbf3ac593bb7850ec496426ed4 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Mon, 9 Mar 2026 20:20:27 +0500 Subject: [PATCH 19/19] =?UTF-8?q?=D0=A3=D1=81=D0=BA=D0=BE=D1=80=D0=B5?= =?UTF-8?q?=D0=BD=D0=BE=20=D0=BF=D0=BE=D0=B4=D0=BA=D0=BB=D1=8E=D1=87=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5,=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=20=D0=BA=D1=8D=D1=88=20=D1=83=D1=87=D0=B0?= =?UTF-8?q?=D1=81=D1=82=D0=BD=D0=B8=D0=BA=D0=BE=D0=B2=20=D0=B3=D1=80=D1=83?= =?UTF-8?q?=D0=BF=D0=BF=20=D0=B8=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=B4=D0=BE=201.1.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 4 ++-- .../rosetta/messenger/data/ReleaseNotes.kt | 21 ++++++++----------- .../messenger/network/ProtocolManager.kt | 4 ++-- .../rosetta/messenger/providers/AuthState.kt | 10 ++------- .../messenger/ui/auth/AuthProtocolSync.kt | 15 ++++++++----- .../messenger/ui/auth/SetPasswordScreen.kt | 15 ++++--------- .../rosetta/messenger/ui/auth/UnlockScreen.kt | 7 +------ .../messenger/ui/chats/ChatsListScreen.kt | 2 +- .../messenger/ui/chats/GroupInfoScreen.kt | 13 +++++++----- 9 files changed, 39 insertions(+), 52 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 479fca6..42f765e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown" // ═══════════════════════════════════════════════════════════ // Rosetta versioning — bump here on each release // ═══════════════════════════════════════════════════════════ -val rosettaVersionName = "1.1.4" -val rosettaVersionCode = 16 // Increment on each release +val rosettaVersionName = "1.1.5" +val rosettaVersionCode = 17 // Increment on each release android { namespace = "com.rosetta.messenger" diff --git a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt index 1b11aeb..567a7d7 100644 --- a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt +++ b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt @@ -17,20 +17,17 @@ object ReleaseNotes { val RELEASE_NOTICE = """ Update v$VERSION_PLACEHOLDER - Профиль и группы - - Фиксированные табы в профиле и группах - - Fast-scroll с отображением даты в медиа-галерее - - Поддержка Apple Emoji в аватарах и интерфейсе - - Восстановление ключей шифрования группы по инвайт-ссылке + Подключение + - Ускорен старт соединения и handshake при входе в аккаунт + - Логика reconnect синхронизирована с desktop-поведением + - Обновлён серверный endpoint на основной production (wss) - Аватары - - Улучшено отображение аватаров: поддержка текста с эмодзи - - Улучшена логика отображения в компоненте AvatarImage + Группы + - Добавлено предзагруженное кэширование участников группы + - Убран скачок "0 members" при повторном открытии группы - Исправления - - Исправлен переход по своему тэгу в группах - - Убрана лишняя подсветка в чатах - - Корректное отображение fast-scroll при изменении размера экрана + Интерфейс + - Исправлено вертикальное выравнивание verified-галочки в списке чатов """.trimIndent() fun getNotice(version: String): String = diff --git a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt index 46a7e62..554469b 100644 --- a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt @@ -31,8 +31,8 @@ object ProtocolManager { private const val DEBUG_LOG_FLUSH_DELAY_MS = 60L private const val TYPING_INDICATOR_TIMEOUT_MS = 3_000L - // Server address - same as React Native version - private const val SERVER_ADDRESS = "ws://46.28.71.12:3000" + // Desktop parity: use the same primary WebSocket endpoint as desktop client. + private const val SERVER_ADDRESS = "wss://wss.rosetta.im" private const val DEVICE_PREFS = "rosetta_protocol" private const val DEVICE_ID_KEY = "device_id" private const val DEVICE_ID_LENGTH = 128 diff --git a/app/src/main/java/com/rosetta/messenger/providers/AuthState.kt b/app/src/main/java/com/rosetta/messenger/providers/AuthState.kt index 5632e3a..61449cd 100644 --- a/app/src/main/java/com/rosetta/messenger/providers/AuthState.kt +++ b/app/src/main/java/com/rosetta/messenger/providers/AuthState.kt @@ -163,11 +163,8 @@ class AuthStateManager( // Step 8: Connect and authenticate with protocol ProtocolManager.connect() - - // Give WebSocket time to connect before authenticating - kotlinx.coroutines.delay(500) - ProtocolManager.authenticate(keyPair.publicKey, privateKeyHash) + ProtocolManager.reconnectNowIfNeeded("auth_state_create") Result.success(decryptedAccount) } catch (e: Exception) { @@ -210,11 +207,8 @@ class AuthStateManager( // Connect and authenticate with protocol ProtocolManager.connect() - - // Give WebSocket time to connect before authenticating - kotlinx.coroutines.delay(500) - ProtocolManager.authenticate(decryptedAccount.publicKey, decryptedAccount.privateKeyHash) + ProtocolManager.reconnectNowIfNeeded("auth_state_unlock") Result.success(decryptedAccount) } catch (e: Exception) { diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/AuthProtocolSync.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/AuthProtocolSync.kt index da4d999..805a591 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/AuthProtocolSync.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/AuthProtocolSync.kt @@ -2,20 +2,24 @@ package com.rosetta.messenger.ui.auth import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.ProtocolState -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.withTimeoutOrNull +internal fun startAuthHandshakeFast(publicKey: String, privateKeyHash: String) { + // Desktop parity: start connection+handshake immediately, without artificial waits. + ProtocolManager.connect() + ProtocolManager.authenticate(publicKey, privateKeyHash) + ProtocolManager.reconnectNowIfNeeded("auth_fast_start") +} + internal suspend fun awaitAuthHandshakeState( publicKey: String, privateKeyHash: String, attempts: Int = 2, timeoutMs: Long = 25_000L ): ProtocolState? { - repeat(attempts) { - ProtocolManager.disconnect() - delay(200) - ProtocolManager.authenticate(publicKey, privateKeyHash) + repeat(attempts) { attempt -> + startAuthHandshakeFast(publicKey, privateKeyHash) val state = withTimeoutOrNull(timeoutMs) { ProtocolManager.state.first { @@ -26,6 +30,7 @@ internal suspend fun awaitAuthHandshakeState( if (state != null) { return state } + ProtocolManager.reconnectNowIfNeeded("auth_handshake_retry_${attempt + 1}") } return null } diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt index f27dfca..c314f8b 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt @@ -527,17 +527,10 @@ fun SetPasswordScreen( keyPair.privateKey ) - val handshakeState = - awaitAuthHandshakeState( - keyPair.publicKey, - privateKeyHash - ) - if (handshakeState == null) { - error = - "Failed to connect to server. Please try again." - isCreating = false - return@launch - } + startAuthHandshakeFast( + keyPair.publicKey, + privateKeyHash + ) accountManager.setCurrentAccount(keyPair.publicKey) diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt index 234a297..fb5ef8d 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt @@ -116,12 +116,7 @@ val decryptedPrivateKey = CryptoManager.decryptWithPassword( name = selectedAccount.name ) - val handshakeState = awaitAuthHandshakeState(account.publicKey, privateKeyHash) - if (handshakeState == null) { - onError("Failed to connect to server") - onUnlocking(false) - return - } + startAuthHandshakeFast(account.publicKey, privateKeyHash) accountManager.setCurrentAccount(account.publicKey) onSuccess(decryptedAccount) 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 3394fcf..90141d7 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 @@ -3891,7 +3891,7 @@ fun DialogItemContent( VerifiedBadge( verified = if (dialog.verified > 0) dialog.verified else 1, size = 16, - modifier = Modifier.offset(y = (-1).dp) + modifier = Modifier.offset(y = (-2).dp) ) } // 🔒 Красная иконка замочка для заблокированных пользователей diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt index 7a354d3..00652f2 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt @@ -395,7 +395,7 @@ fun GroupInfoScreen( var showEncryptionPage by rememberSaveable(dialogPublicKey) { mutableStateOf(false) } var encryptionKey by rememberSaveable(dialogPublicKey) { mutableStateOf("") } var encryptionKeyLoading by remember { mutableStateOf(false) } - var membersLoading by remember { mutableStateOf(false) } + var membersLoading by remember(dialogPublicKey) { mutableStateOf(false) } var isMuted by remember { mutableStateOf(false) } var showGroupAvatarPicker by rememberSaveable(dialogPublicKey) { mutableStateOf(false) } var showGroupAvatarViewer by rememberSaveable(dialogPublicKey) { mutableStateOf(false) } @@ -414,18 +414,21 @@ fun GroupInfoScreen( val initialDiskMembersCache = remember(membersCacheKey) { GroupMembersDiskCache.getAny(context, membersCacheKey) } + val hasInitialMembersCache = remember(initialMemoryMembersCache, initialDiskMembersCache) { + initialMemoryMembersCache != null || initialDiskMembersCache != null + } val initialMembers = remember(initialMemoryMembersCache, initialDiskMembersCache) { initialMemoryMembersCache?.members ?: initialDiskMembersCache?.members.orEmpty() } - var members by remember(dialogPublicKey) { mutableStateOf(initialMembers) } + var members by remember(dialogPublicKey, membersCacheKey) { mutableStateOf(initialMembers) } val memberInfoByKey = - remember(dialogPublicKey) { + remember(dialogPublicKey, membersCacheKey) { mutableStateMapOf().apply { initialMemoryMembersCache?.memberInfoByKey?.let { putAll(it) } } } // Real online status from PacketOnlineState (0x05), NOT from SearchUser.online - val memberOnlineStatus = remember(dialogPublicKey) { mutableStateMapOf() } + val memberOnlineStatus = remember(dialogPublicKey, membersCacheKey) { mutableStateMapOf() } val groupEntity by produceState( initialValue = null, @@ -1089,7 +1092,7 @@ fun GroupInfoScreen( ) Text( - text = if (membersLoading) { + text = if (membersLoading || (members.isEmpty() && !hasInitialMembersCache)) { "Loading members..." } else { "${members.size} members, $onlineCount online"