Compare commits

...

3 Commits

Author SHA1 Message Date
6a269f93db Улучшение отображения аватаров: добавлена поддержка текста с эмодзи и улучшена логика отображения в AvatarImage. Обновлен SharedMediaFastScrollOverlay для корректного отображения при изменении размера. Исправлено сообщение подсказки в строках. 2026-03-06 19:19:01 +05:00
8bce15cc19 Профиль и группы: фиксированные табы, fast-scroll с датой и Apple Emoji 2026-03-06 18:05:17 +05:00
e9944b3c67 Группы: восстановление ключей по инвайту и Apple Emoji
- Добавлено восстановление локального ключа группы из инвайта при повторном нажатии, даже если на сервере статус уже JOINED.
- В карточке приглашения сначала восстанавливается ключ, затем открывается группа.
- Включено отображение Apple Emoji для названия/описания группы в GroupInfo и в заголовке группы в чате.
- Обновлён превью-заголовок в GroupSetup на Apple Emoji рендер.
2026-03-06 17:23:11 +05:00
11 changed files with 679 additions and 88 deletions

View File

@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// Rosetta versioning — bump here on each release // Rosetta versioning — bump here on each release
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
val rosettaVersionName = "1.1.2" val rosettaVersionName = "1.1.3"
val rosettaVersionCode = 14 // Increment on each release val rosettaVersionCode = 15 // Increment on each release
android { android {
namespace = "com.rosetta.messenger" namespace = "com.rosetta.messenger"

View File

@@ -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( suspend fun synchronizeJoinedGroup(
accountPublicKey: String, accountPublicKey: String,
accountPrivateKey: String, accountPrivateKey: String,

View File

@@ -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.input.*
import com.rosetta.messenger.ui.chats.models.* import com.rosetta.messenger.ui.chats.models.*
import com.rosetta.messenger.ui.chats.utils.* 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.AvatarImage
import com.rosetta.messenger.ui.components.VerifiedBadge import com.rosetta.messenger.ui.components.VerifiedBadge
import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.ui.onboarding.PrimaryBlue
@@ -1133,21 +1134,14 @@ fun ChatDetailScreen(
Alignment Alignment
.CenterVertically .CenterVertically
) { ) {
Text( AppleEmojiText(
text = text = chatTitle,
chatTitle, fontSize = 16.sp,
fontSize = fontWeight = FontWeight.SemiBold,
16.sp, color = Color.White,
fontWeight = maxLines = 1,
FontWeight overflow = android.text.TextUtils.TruncateAt.END,
.SemiBold, enableLinks = false
color =
Color.White,
maxLines =
1,
overflow =
TextOverflow
.Ellipsis
) )
if (!isSavedMessages && if (!isSavedMessages &&
!isGroupChat && !isGroupChat &&

View File

@@ -2919,14 +2919,15 @@ fun ChatItem(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( AppleEmojiText(
text = chat.name, text = chat.name,
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = textColor, color = textColor,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = android.text.TextUtils.TruncateAt.END,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f),
enableLinks = false
) )
if (isMuted) { if (isMuted) {
@@ -3722,13 +3723,14 @@ fun DialogItemContent(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( AppleEmojiText(
text = displayName, text = displayName,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
fontSize = 16.sp, fontSize = 16.sp,
color = textColor, color = textColor,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = android.text.TextUtils.TruncateAt.END,
enableLinks = false
) )
if (isGroupDialog) { if (isGroupDialog) {
Spacer(modifier = Modifier.width(5.dp)) Spacer(modifier = Modifier.width(5.dp))

View File

@@ -14,6 +14,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.statusBarsPadding
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn 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.items
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState 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.Offset
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
@@ -122,7 +126,9 @@ import com.rosetta.messenger.ui.chats.components.ImageAttachment
import com.rosetta.messenger.ui.chats.components.ImageSourceBounds import com.rosetta.messenger.ui.chats.components.ImageSourceBounds
import com.rosetta.messenger.ui.chats.components.ImageViewerScreen import com.rosetta.messenger.ui.chats.components.ImageViewerScreen
import com.rosetta.messenger.ui.chats.components.ViewableImage 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.AvatarImage
import com.rosetta.messenger.ui.components.SharedMediaFastScrollOverlay
import com.rosetta.messenger.ui.components.VerifiedBadge import com.rosetta.messenger.ui.components.VerifiedBadge
import com.rosetta.messenger.ui.icons.TelegramIcons import com.rosetta.messenger.ui.icons.TelegramIcons
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
@@ -132,9 +138,13 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import org.json.JSONArray import org.json.JSONArray
import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale
import java.util.UUID import java.util.UUID
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.ceil
import kotlin.math.roundToInt
private enum class GroupInfoTab(val title: String) { private enum class GroupInfoTab(val title: String) {
MEMBERS("Members"), MEMBERS("Members"),
@@ -369,6 +379,8 @@ fun GroupInfoScreen(
var showImageViewer by rememberSaveable(dialogPublicKey) { mutableStateOf(false) } var showImageViewer by rememberSaveable(dialogPublicKey) { mutableStateOf(false) }
var imageViewerInitialIndex by rememberSaveable(dialogPublicKey) { mutableStateOf(0) } var imageViewerInitialIndex by rememberSaveable(dialogPublicKey) { mutableStateOf(0) }
var imageViewerSourceBounds by remember(dialogPublicKey) { mutableStateOf<ImageSourceBounds?>(null) } var imageViewerSourceBounds by remember(dialogPublicKey) { mutableStateOf<ImageSourceBounds?>(null) }
val groupMediaGridState = rememberLazyGridState()
var groupMediaFastScrollHintDismissed by rememberSaveable(dialogPublicKey) { mutableStateOf(false) }
val groupTitle = remember(groupEntity, groupUser.title) { val groupTitle = remember(groupEntity, groupUser.title) {
groupEntity?.title?.trim().takeUnless { it.isNullOrBlank() } groupEntity?.title?.trim().takeUnless { it.isNullOrBlank() }
@@ -854,13 +866,14 @@ fun GroupInfoScreen(
Spacer(modifier = Modifier.height(14.dp)) Spacer(modifier = Modifier.height(14.dp))
Text( AppleEmojiText(
text = groupTitle, text = groupTitle,
color = Color.White, color = Color.White,
fontSize = 24.sp, fontSize = 24.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = android.text.TextUtils.TruncateAt.END,
enableLinks = false
) )
Text( Text(
@@ -917,12 +930,13 @@ fun GroupInfoScreen(
if (groupDescription.isNotBlank()) { if (groupDescription.isNotBlank()) {
Spacer(modifier = Modifier.height(10.dp)) Spacer(modifier = Modifier.height(10.dp))
Text( AppleEmojiText(
text = groupDescription, text = groupDescription,
color = Color.White.copy(alpha = 0.7f), color = Color.White.copy(alpha = 0.7f),
fontSize = 12.sp, fontSize = 12.sp,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis overflow = android.text.TextUtils.TruncateAt.END,
enableLinks = false
) )
} }
} }
@@ -1131,55 +1145,95 @@ fun GroupInfoScreen(
} else { } else {
val mediaColumns = 3 val mediaColumns = 3
val mediaSpacing = 1.dp val mediaSpacing = 1.dp
val mediaScreenWidth = LocalConfiguration.current.screenWidthDp.dp val mediaRowsCount = remember(groupMediaItems.size) {
val mediaCellSize = ceil(groupMediaItems.size / mediaColumns.toFloat()).toInt().coerceAtLeast(1)
(mediaScreenWidth - mediaSpacing * (mediaColumns - 1)) / mediaColumns }
val mediaIndexedRows = remember(groupMediaItems) { val mediaFastScrollVisible by remember(groupMediaItems.size, groupMediaGridState) {
groupMediaItems.chunked(mediaColumns).mapIndexed { idx, row -> idx to row } 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( Box(modifier = Modifier.fillMaxSize()) {
modifier = Modifier.fillMaxSize(), LazyVerticalGrid(
contentPadding = PaddingValues(bottom = 20.dp), columns = GridCells.Fixed(mediaColumns),
verticalArrangement = Arrangement.spacedBy(mediaSpacing) state = groupMediaGridState,
) { modifier = Modifier.fillMaxSize(),
items(mediaIndexedRows, key = { (idx, _) -> "group_media_row_$idx" }) { (rowIdx, rowMedia) -> contentPadding = PaddingValues(bottom = 20.dp),
Row( horizontalArrangement = Arrangement.spacedBy(mediaSpacing),
modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(mediaSpacing)
horizontalArrangement = Arrangement.spacedBy(mediaSpacing) ) {
) { gridItemsIndexed(groupMediaItems, key = { _, item -> item.key }) { index, mediaItem ->
rowMedia.forEachIndexed { colIdx, mediaItem -> Box(
val globalIndex = rowIdx * mediaColumns + colIdx modifier = Modifier
Box( .fillMaxWidth()
modifier = Modifier .aspectRatio(1f)
.size(mediaCellSize) .clip(RoundedCornerShape(0.dp))
.clip(RoundedCornerShape(0.dp)) .background(if (isDarkTheme) Color(0xFF141416) else Color(0xFFE6E7EB))
.background(if (isDarkTheme) Color(0xFF141416) else Color(0xFFE6E7EB)) ) {
) { ImageAttachment(
ImageAttachment( attachment = mediaItem.attachment,
attachment = mediaItem.attachment, chachaKey = mediaItem.chachaKey,
chachaKey = mediaItem.chachaKey, privateKey = currentUserPrivateKey,
privateKey = currentUserPrivateKey, senderPublicKey = mediaItem.senderPublicKey,
senderPublicKey = mediaItem.senderPublicKey, isOutgoing = mediaItem.senderPublicKey == currentUserPublicKey,
isOutgoing = mediaItem.senderPublicKey == currentUserPublicKey, isDarkTheme = isDarkTheme,
isDarkTheme = isDarkTheme, timestamp = Date(mediaItem.timestamp),
timestamp = Date(mediaItem.timestamp), showTimeOverlay = false,
showTimeOverlay = false, fillMaxSize = true,
fillMaxSize = true, onImageClick = { _, bounds ->
onImageClick = { _, bounds -> imageViewerInitialIndex = index
imageViewerInitialIndex = globalIndex imageViewerSourceBounds = bounds
imageViewerSourceBounds = bounds showImageViewer = true
showImageViewer = true }
} )
)
}
}
repeat(mediaColumns - rowMedia.size) {
Spacer(modifier = Modifier.size(mediaCellSize))
} }
} }
} }
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)
}
}
)
} }
} }
} }
@@ -1865,6 +1919,12 @@ private fun shortPublicKey(publicKey: String): String {
return "${trimmed.take(6)}...${trimmed.takeLast(4)}" 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 { private fun decryptStoredMessageText(encryptedText: String, privateKey: String): String {
if (encryptedText.isBlank()) return "" if (encryptedText.isBlank()) return ""
if (privateKey.isBlank()) return encryptedText if (privateKey.isBlank()) return encryptedText

View File

@@ -81,6 +81,7 @@ import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.components.KeyboardHeightProvider import com.rosetta.messenger.ui.components.KeyboardHeightProvider
import com.rosetta.messenger.ui.components.OptimizedEmojiPicker 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.components.AvatarImage
import com.rosetta.messenger.ui.icons.TelegramIcons import com.rosetta.messenger.ui.icons.TelegramIcons
import com.rosetta.messenger.utils.AvatarFileManager import com.rosetta.messenger.utils.AvatarFileManager
@@ -622,13 +623,14 @@ fun GroupSetupScreen(
Spacer(modifier = Modifier.size(12.dp)) Spacer(modifier = Modifier.size(12.dp))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( AppleEmojiText(
text = title.trim(), text = title.trim(),
color = primaryTextColor, color = primaryTextColor,
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = android.text.TextUtils.TruncateAt.END,
enableLinks = false
) )
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(2.dp))
Text( Text(

View File

@@ -1452,7 +1452,39 @@ private fun GroupInviteInlineCard(
if (parsedInvite == null) return if (parsedInvite == null) return
if (status == GroupStatus.JOINED) { 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 return
} }

View File

@@ -199,6 +199,7 @@ fun AvatarPlaceholder(
} else { } else {
getAvatarText(publicKey) getAvatarText(publicKey)
} }
val resolvedFontSize = fontSize ?: (size.value / 2.5).sp
Box( Box(
modifier = Modifier modifier = Modifier
@@ -207,12 +208,34 @@ fun AvatarPlaceholder(
.background(avatarColors.backgroundColor), .background(avatarColors.backgroundColor),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( if (containsEmojiAvatarText(avatarText)) {
text = avatarText, AppleEmojiText(
color = avatarColors.textColor, text = avatarText,
fontSize = fontSize ?: (size.value / 2.5).sp, color = avatarColors.textColor,
fontWeight = FontWeight.Medium 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()
} }
} }

View File

@@ -0,0 +1,310 @@
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.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
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.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.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
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 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) }
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 - 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()
.onSizeChanged { rootHeightPx = it.height }
) {
AnimatedVisibility(
visible = hintVisible && !isDragging,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = 60.dp)
) {
SharedMediaFastScrollHint(isDarkTheme = isDarkTheme)
}
Box(
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = 2.dp)
.fillMaxHeight(0.86f)
.width(56.dp)
.onSizeChanged { trackHeightPx = it.height }
.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()
}
},
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)
}
}
)
}
) {
TelegramDateHandle(
isDarkTheme = isDarkTheme,
modifier = Modifier
.align(Alignment.TopEnd)
.offset { IntOffset(0, handleOffsetYPx.roundToInt()) }
.size(handleSize)
)
}
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(0xEA2A323D) else Color(0xEA26374E)
val iconBackground = if (isDarkTheme) Color(0x553C4656) else Color(0x55324A67)
Row(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(background)
.padding(horizontal = 10.dp, vertical = 8.dp)
.widthIn(max = 300.dp)
.heightIn(min = 34.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Box(
modifier = Modifier
.size(20.dp)
.clip(RoundedCornerShape(5.dp))
.background(iconBackground),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.UnfoldMore,
contentDescription = null,
tint = Color.White.copy(alpha = 0.92f),
modifier = Modifier.size(12.dp)
)
}
Text(
text = stringResource(R.string.shared_media_fast_scroll_hint),
color = Color.White.copy(alpha = 0.92f),
fontSize = 13.sp,
lineHeight = 16.sp
)
}
}

View File

@@ -29,12 +29,14 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Block import androidx.compose.material.icons.outlined.Block
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -50,6 +52,9 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale 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.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
@@ -65,6 +70,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
@@ -90,6 +96,7 @@ import com.rosetta.messenger.ui.chats.components.ImageViewerScreen
import com.rosetta.messenger.ui.chats.components.ViewableImage import com.rosetta.messenger.ui.chats.components.ViewableImage
import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.components.BlurredAvatarBackground 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.components.VerifiedBadge
import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.utils.AttachmentFileManager import com.rosetta.messenger.utils.AttachmentFileManager
@@ -111,6 +118,7 @@ import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
import kotlin.math.ceil import kotlin.math.ceil
import kotlin.math.roundToInt
// Collapsing header constants // Collapsing header constants
private val EXPANDED_HEADER_HEIGHT_OTHER = 280.dp private val EXPANDED_HEADER_HEIGHT_OTHER = 280.dp
@@ -557,21 +565,96 @@ fun OtherProfileScreen(
val mediaScreenWidth = LocalConfiguration.current.screenWidthDp.dp val mediaScreenWidth = LocalConfiguration.current.screenWidthDp.dp
val mediaCellSize = (mediaScreenWidth - mediaSpacing * (mediaColumns - 1)) / mediaColumns val mediaCellSize = (mediaScreenWidth - mediaSpacing * (mediaColumns - 1)) / mediaColumns
val mediaDecodeSemaphore = remember { Semaphore(4) } 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 // Use stable key for bitmap cache - don't recreate on size change
val mediaBitmapStates = remember { mutableStateMapOf<String, android.graphics.Bitmap?>() } val mediaBitmapStates = remember { mutableStateMapOf<String, android.graphics.Bitmap?>() }
// Pre-compute indexed rows to avoid O(n) indexOf calls // Pre-compute indexed rows to avoid O(n) indexOf calls
val mediaIndexedRows = remember(sharedContent.mediaPhotos) { val mediaIndexedRows = remember(sharedContent.mediaPhotos) {
sharedContent.mediaPhotos.chunked(mediaColumns).mapIndexed { idx, row -> idx to row } 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 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 &&
profileListState.firstVisibleItemIndex >= 1 &&
overlayHeightPx > 0
}
}
Box( Box(
modifier = modifier =
Modifier.fillMaxSize() Modifier.fillMaxSize()
.background(backgroundColor) .background(backgroundColor)
.onSizeChanged { rootHeightPx = it.height }
.nestedScroll(nestedScrollConnection) .nestedScroll(nestedScrollConnection)
) { ) {
// Scrollable content // Scrollable content
LazyColumn( LazyColumn(
state = profileListState,
modifier = modifier =
Modifier.fillMaxSize() Modifier.fillMaxSize()
.padding( .padding(
@@ -724,14 +807,24 @@ fun OtherProfileScreen(
) )
} }
// ═══════════════════════════════════════════════════════════ }
// 📚 SHARED CONTENT (без разделителя — сразу табы)
// ═══════════════════════════════════════════════════════════ stickyHeader(key = "shared_tabs") {
OtherProfileSharedTabs( Box(
selectedTab = selectedTab, modifier = Modifier
onTabSelected = { tab -> selectedTab = tab }, .fillMaxWidth()
isDarkTheme = isDarkTheme .background(backgroundColor)
) .onGloballyPositioned { coords ->
sharedTabsBottomPx =
(coords.positionInRoot().y + coords.size.height).roundToInt()
}
) {
OtherProfileSharedTabs(
selectedTab = selectedTab,
onTabSelected = { tab -> selectedTab = tab },
isDarkTheme = isDarkTheme
)
}
} }
// ══════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════
@@ -959,6 +1052,33 @@ fun OtherProfileScreen(
} }
} }
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 // 🎨 COLLAPSING HEADER with METABALL EFFECT
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
@@ -1481,6 +1601,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 { private fun formatTimestamp(timestamp: Long): String {
return SimpleDateFormat("dd MMM, HH:mm", Locale.getDefault()).format(Date(timestamp)) return SimpleDateFormat("dd MMM, HH:mm", Locale.getDefault()).format(Date(timestamp))
} }

View File

@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">Rosetta</string> <string name="app_name">Rosetta</string>
<string name="shared_media_fast_scroll_hint">You can hold and move this bar for faster scrolling</string>
</resources> </resources>