Compare commits
3 Commits
5de0777063
...
6a269f93db
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a269f93db | |||
| 8bce15cc19 | |||
| e9944b3c67 |
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 &&
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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,28 +1145,56 @@ 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()) {
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Fixed(mediaColumns),
|
||||||
|
state = groupMediaGridState,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(bottom = 20.dp),
|
contentPadding = PaddingValues(bottom = 20.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(mediaSpacing),
|
||||||
verticalArrangement = Arrangement.spacedBy(mediaSpacing)
|
verticalArrangement = Arrangement.spacedBy(mediaSpacing)
|
||||||
) {
|
) {
|
||||||
items(mediaIndexedRows, key = { (idx, _) -> "group_media_row_$idx" }) { (rowIdx, rowMedia) ->
|
gridItemsIndexed(groupMediaItems, key = { _, item -> item.key }) { index, mediaItem ->
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(mediaSpacing)
|
|
||||||
) {
|
|
||||||
rowMedia.forEachIndexed { colIdx, mediaItem ->
|
|
||||||
val globalIndex = rowIdx * mediaColumns + colIdx
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(mediaCellSize)
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(1f)
|
||||||
.clip(RoundedCornerShape(0.dp))
|
.clip(RoundedCornerShape(0.dp))
|
||||||
.background(if (isDarkTheme) Color(0xFF141416) else Color(0xFFE6E7EB))
|
.background(if (isDarkTheme) Color(0xFF141416) else Color(0xFFE6E7EB))
|
||||||
) {
|
) {
|
||||||
@@ -1167,19 +1209,31 @@ fun GroupInfoScreen(
|
|||||||
showTimeOverlay = false,
|
showTimeOverlay = false,
|
||||||
fillMaxSize = true,
|
fillMaxSize = true,
|
||||||
onImageClick = { _, bounds ->
|
onImageClick = { _, bounds ->
|
||||||
imageViewerInitialIndex = globalIndex
|
imageViewerInitialIndex = index
|
||||||
imageViewerSourceBounds = bounds
|
imageViewerSourceBounds = bounds
|
||||||
showImageViewer = true
|
showImageViewer = true
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
repeat(mediaColumns - rowMedia.size) {
|
SharedMediaFastScrollOverlay(
|
||||||
Spacer(modifier = Modifier.size(mediaCellSize))
|
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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -1452,10 +1452,42 @@ private fun GroupInviteInlineCard(
|
|||||||
if (parsedInvite == null) return
|
if (parsedInvite == null) return
|
||||||
|
|
||||||
if (status == GroupStatus.JOINED) {
|
if (status == GroupStatus.JOINED) {
|
||||||
|
if (accountPublicKey.isBlank() || accountPrivateKey.isBlank()) {
|
||||||
openParsedGroup()
|
openParsedGroup()
|
||||||
return
|
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
|
||||||
|
}
|
||||||
|
|
||||||
if (accountPublicKey.isBlank() || accountPrivateKey.isBlank()) {
|
if (accountPublicKey.isBlank() || accountPrivateKey.isBlank()) {
|
||||||
Toast.makeText(context, "Account is not ready", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "Account is not ready", Toast.LENGTH_SHORT).show()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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,13 +208,35 @@ fun AvatarPlaceholder(
|
|||||||
.background(avatarColors.backgroundColor),
|
.background(avatarColors.backgroundColor),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
|
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(
|
||||||
text = avatarText,
|
text = avatarText,
|
||||||
color = avatarColors.textColor,
|
color = avatarColors.textColor,
|
||||||
fontSize = fontSize ?: (size.value / 2.5).sp,
|
fontSize = resolvedFontSize,
|
||||||
fontWeight = FontWeight.Medium
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,15 +807,25 @@ fun OtherProfileScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
}
|
||||||
// 📚 SHARED CONTENT (без разделителя — сразу табы)
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
stickyHeader(key = "shared_tabs") {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(backgroundColor)
|
||||||
|
.onGloballyPositioned { coords ->
|
||||||
|
sharedTabsBottomPx =
|
||||||
|
(coords.positionInRoot().y + coords.size.height).roundToInt()
|
||||||
|
}
|
||||||
|
) {
|
||||||
OtherProfileSharedTabs(
|
OtherProfileSharedTabs(
|
||||||
selectedTab = selectedTab,
|
selectedTab = selectedTab,
|
||||||
onTabSelected = { tab -> selectedTab = tab },
|
onTabSelected = { tab -> selectedTab = tab },
|
||||||
isDarkTheme = isDarkTheme
|
isDarkTheme = isDarkTheme
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════
|
||||||
// TAB CONTENT — HorizontalPager for swipe between tabs
|
// TAB CONTENT — HorizontalPager for swipe between tabs
|
||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user