From 1fb891df53d8beb5a190a4a219e1f824c4af9405 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Thu, 5 Mar 2026 20:00:38 +0500 Subject: [PATCH 1/8] =?UTF-8?q?=D0=A0=D0=B5=D0=BB=D0=B8=D0=B7=201.1.3:=20?= =?UTF-8?q?=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=B2?= =?UTF-8?q?=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-исправлениям. --- app/build.gradle.kts | 4 +-- .../rosetta/messenger/data/ReleaseNotes.kt | 33 ++++++++----------- 2 files changed, 16 insertions(+), 21 deletions(-) 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/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 59d71c97170304de1ef886b865d252ad43236fa1 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Fri, 6 Mar 2026 17:23:11 +0500 Subject: [PATCH 2/8] =?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 6429a61ad07c975c6955e2c1c779ed1b6170e872 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Fri, 6 Mar 2026 18:05:17 +0500 Subject: [PATCH 3/8] =?UTF-8?q?=D0=9F=D1=80=D0=BE=D1=84=D0=B8=D0=BB=D1=8C?= =?UTF-8?q?=20=D0=B8=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF=D1=8B:=20=D1=84?= =?UTF-8?q?=D0=B8=D0=BA=D1=81=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20=D1=82=D0=B0=D0=B1=D1=8B,=20fast-scroll=20=D1=81?= =?UTF-8?q?=20=D0=B4=D0=B0=D1=82=D0=BE=D0=B9=20=D0=B8=20Apple=20Emoji?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../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 + 6 files changed, 457 insertions(+), 60 deletions(-) create mode 100644 app/src/main/java/com/rosetta/messenger/ui/components/SharedMediaFastScrollOverlay.kt 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 06dc9a2b5db36fae22c0d512ca892a10d9b05538 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Fri, 6 Mar 2026 19:19:01 +0500 Subject: [PATCH 4/8] =?UTF-8?q?=D0=A3=D0=BB=D1=83=D1=87=D1=88=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0=D0=B6=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=B0=D0=B2=D0=B0=D1=82=D0=B0=D1=80=D0=BE?= =?UTF-8?q?=D0=B2:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=BE=D0=B4=D0=B4=D0=B5=D1=80=D0=B6=D0=BA=D0=B0?= =?UTF-8?q?=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=B0=20=D1=81=20=D1=8D=D0=BC?= =?UTF-8?q?=D0=BE=D0=B4=D0=B7=D0=B8=20=D0=B8=20=D1=83=D0=BB=D1=83=D1=87?= =?UTF-8?q?=D1=88=D0=B5=D0=BD=D0=B0=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0=D0=B6=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=B2=20AvatarImage.=20=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=20SharedMediaFastScrollOverlay=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=BA=D0=BE=D1=80=D1=80=D0=B5=D0=BA=D1=82=D0=BD?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=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=BF=D1=80=D0=B8=20=D0=B8=D0=B7?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D0=B8=20=D1=80=D0=B0=D0=B7?= =?UTF-8?q?=D0=BC=D0=B5=D1=80=D0=B0.=20=D0=98=D1=81=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=BE=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=BE=D0=B4=D1=81=D0=BA=D0=B0?= =?UTF-8?q?=D0=B7=D0=BA=D0=B8=20=D0=B2=20=D1=81=D1=82=D1=80=D0=BE=D0=BA?= =?UTF-8?q?=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 db605cb392f9bdf346492594debce69a48ea29e5 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Fri, 6 Mar 2026 20:03:50 +0500 Subject: [PATCH 5/8] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=BF=D0=B5=D1=80=D0=B5=D1=85=D0=BE=D0=B4=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=B2=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF=D0=B0?= =?UTF-8?q?=D1=85=20=D0=B8=20=D1=83=D0=B1=D1=80=D0=B0=D0=BB=20=D0=BB=D0=B8?= =?UTF-8?q?=D1=88=D0=BD=D1=8E=D1=8E=20=D0=BF=D0=BE=D0=B4=D1=81=D0=B2=D0=B5?= =?UTF-8?q?=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 c2761ecedfa6814efe281db245ea459ba08588d6 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sat, 7 Mar 2026 02:50:25 +0500 Subject: [PATCH 6/8] =?UTF-8?q?=D0=A0=D0=B5=D0=BB=D0=B8=D0=B7=20v1.1.4:=20?= =?UTF-8?q?=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 5d8dc32672a5c96e80184b2162cafe909d9b1afe Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sat, 7 Mar 2026 18:43:58 +0500 Subject: [PATCH 7/8] =?UTF-8?q?=D0=97=D0=B0=D0=BC=D0=B5=D0=BD=D0=B0=20Basi?= =?UTF-8?q?cTextField=20=D0=BD=D0=B0=20AppleEmojiTextField=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D1=83=D0=BB=D1=83=D1=87=D1=88=D0=B5=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=20=D0=B2=D0=B2=D0=BE=D0=B4=D0=B0=20=D0=BD=D0=B0=D0=B7=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF=D1=8B?= =?UTF-8?q?=20=D1=81=20=D0=BF=D0=BE=D0=B4=D0=B4=D0=B5=D1=80=D0=B6=D0=BA?= =?UTF-8?q?=D0=BE=D0=B9=20=D1=8D=D0=BC=D0=BE=D0=B4=D0=B7=D0=B8=20=D0=B8=20?= =?UTF-8?q?=D1=83=D0=BB=D1=83=D1=87=D1=88=D0=B5=D0=BD=D0=BD=D0=BE=D0=B9=20?= =?UTF-8?q?=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D0=BE=D0=B9=20=D0=BE=D1=82=D0=BE?= =?UTF-8?q?=D0=B1=D1=80=D0=B0=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../messenger/ui/chats/GroupSetupScreen.kt | 50 ++++++------------- 1 file changed, 16 insertions(+), 34 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 e1a4552..89798ac 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 @@ -26,7 +26,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicTextField import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton @@ -54,9 +53,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext @@ -66,7 +63,6 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -79,6 +75,7 @@ import com.rosetta.messenger.data.GroupRepository import com.rosetta.messenger.network.GroupStatus import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.repository.AvatarRepository +import com.rosetta.messenger.ui.components.AppleEmojiTextField import com.rosetta.messenger.ui.components.KeyboardHeightProvider import com.rosetta.messenger.ui.components.OptimizedEmojiPicker import com.rosetta.messenger.ui.components.AppleEmojiText @@ -441,44 +438,29 @@ fun GroupSetupScreen( verticalAlignment = Alignment.CenterVertically ) { Box(modifier = Modifier.weight(1f)) { - BasicTextField( + AppleEmojiTextField( value = title, onValueChange = { newValue -> title = newValue.take(80) }, - singleLine = true, - textStyle = TextStyle( - color = primaryTextColor, - fontSize = 18.sp, - fontWeight = FontWeight.Medium - ), - cursorBrush = SolidColor(accentColor), - enabled = !isLoading, + textColor = primaryTextColor, + textSize = 18f, + hint = "Group name", + hintColor = secondaryTextColor.copy(alpha = 0.88f), modifier = Modifier .fillMaxWidth() .focusRequester(nameFocusRequester) - .onFocusChanged { focusState -> - if (focusState.isFocused && - showEmojiKeyboard && - !coordinator.isTransitioning - ) { - coordinator.closeEmoji(hideEmoji = { showEmojiKeyboard = false }) - } - } .padding(vertical = 2.dp), - decorationBox = { innerTextField -> - Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.CenterStart + onViewCreated = { editText -> + editText.isSingleLine = true + editText.maxLines = 1 + editText.setHorizontallyScrolling(true) + }, + onFocusChanged = { hasFocus -> + if (hasFocus && + showEmojiKeyboard && + !coordinator.isTransitioning ) { - if (title.isBlank()) { - Text( - text = "Group name", - color = secondaryTextColor.copy(alpha = 0.88f), - fontSize = 18.sp, - fontWeight = FontWeight.Normal - ) - } - innerTextField() + coordinator.closeEmoji(hideEmoji = { showEmojiKeyboard = false }) } } ) From 9625763b0cb872f369034e251066ce5605e9f7b4 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sat, 7 Mar 2026 19:00:39 +0500 Subject: [PATCH 8/8] =?UTF-8?q?=D0=A3=D0=B1=D1=80=D0=B0=D0=BB=20=D1=81?= =?UTF-8?q?=D0=B2=D0=B5=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=83=20=D0=BA?= =?UTF-8?q?=D0=BD=D0=BE=D0=BF=D0=BA=D0=B8=20=D0=BD=D0=B0=20=D1=8D=D0=BA?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=D0=B5=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt | 1 + 1 file changed, 1 insertion(+) 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 89798ac..0a02a5e 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 @@ -687,6 +687,7 @@ fun GroupSetupScreen( } FloatingActionButton( + elevation = androidx.compose.material3.FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp), onClick = { if (step == GroupSetupStep.DETAILS) { if (canGoNext) {