feat: Enhance search functionality and user experience
- Added local account metadata handling in SearchScreen for improved "Saved Messages" search fallback. - Updated search logic to include username and account name checks when searching for the user. - Introduced search logging in SearchUsersViewModel for better debugging and tracking of search queries. - Refactored image download process in AttachmentComponents to include detailed logging for debugging. - Created AttachmentDownloadDebugLogger to manage and display download logs. - Improved DeviceVerificationBanner UI for better user engagement during device verification. - Adjusted OtherProfileScreen layout to enhance information visibility and user interaction. - Updated network security configuration to include new Let's Encrypt certificate for CDN.
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
package com.rosetta.messenger.ui.auth
|
||||
|
||||
import com.rosetta.messenger.network.ProtocolManager
|
||||
import com.rosetta.messenger.network.ProtocolState
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
internal suspend fun awaitAuthHandshakeState(
|
||||
publicKey: String,
|
||||
privateKeyHash: String,
|
||||
attempts: Int = 2,
|
||||
timeoutMs: Long = 25_000L
|
||||
): ProtocolState? {
|
||||
repeat(attempts) {
|
||||
ProtocolManager.disconnect()
|
||||
delay(200)
|
||||
ProtocolManager.authenticate(publicKey, privateKeyHash)
|
||||
|
||||
val state = withTimeoutOrNull(timeoutMs) {
|
||||
ProtocolManager.state.first {
|
||||
it == ProtocolState.AUTHENTICATED ||
|
||||
it == ProtocolState.DEVICE_VERIFICATION_REQUIRED
|
||||
}
|
||||
}
|
||||
if (state != null) {
|
||||
return state
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
package com.rosetta.messenger.ui.auth
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -15,6 +20,7 @@ import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
@@ -29,6 +35,8 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@@ -54,11 +62,16 @@ fun DeviceConfirmScreen(
|
||||
isDarkTheme: Boolean,
|
||||
onExit: () -> Unit
|
||||
) {
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
|
||||
val cardColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White
|
||||
val cardBorderColor = if (isDarkTheme) Color(0xFF3A3A3C) else Color(0xFFE8E8ED)
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||
val backgroundTop = if (isDarkTheme) Color(0xFF17181D) else Color(0xFFF4F7FC)
|
||||
val backgroundBottom = if (isDarkTheme) Color(0xFF121316) else Color(0xFFE9EEF7)
|
||||
val cardColor = if (isDarkTheme) Color(0xFF23252B) else Color.White
|
||||
val cardBorderColor = if (isDarkTheme) Color(0xFF343844) else Color(0xFFDCE4F0)
|
||||
val textColor = if (isDarkTheme) Color(0xFFF2F3F5) else Color(0xFF1B1C1F)
|
||||
val secondaryTextColor = if (isDarkTheme) Color(0xFFB2B5BD) else Color(0xFF6F7480)
|
||||
val accentColor = if (isDarkTheme) Color(0xFF4A9FFF) else PrimaryBlue
|
||||
val deviceCardColor = if (isDarkTheme) Color(0xFF1A1C22) else Color(0xFFF5F8FD)
|
||||
val exitButtonColor = if (isDarkTheme) Color(0xFF3D2227) else Color(0xFFFFEAED)
|
||||
val exitButtonTextColor = Color(0xFFFF5E61)
|
||||
val onExitState by rememberUpdatedState(onExit)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
@@ -92,7 +105,12 @@ fun DeviceConfirmScreen(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(backgroundColor)
|
||||
.background(
|
||||
brush =
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(backgroundTop, backgroundBottom)
|
||||
)
|
||||
)
|
||||
.navigationBarsPadding()
|
||||
.padding(horizontal = 22.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
@@ -102,98 +120,160 @@ fun DeviceConfirmScreen(
|
||||
.fillMaxWidth()
|
||||
.widthIn(max = 400.dp),
|
||||
color = cardColor,
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
border = BorderStroke(1.dp, cardBorderColor)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 20.dp),
|
||||
modifier = Modifier.padding(horizontal = 22.dp, vertical = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
LottieAnimation(
|
||||
composition = composition,
|
||||
progress = { progress },
|
||||
modifier = Modifier.size(128.dp)
|
||||
)
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(118.dp)
|
||||
.clip(CircleShape)
|
||||
.background(accentColor.copy(alpha = if (isDarkTheme) 0.16f else 0.1f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
LottieAnimation(
|
||||
composition = composition,
|
||||
progress = { progress },
|
||||
modifier = Modifier.size(96.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = TablerIcons.DeviceMobile,
|
||||
contentDescription = null,
|
||||
tint = PrimaryBlue
|
||||
tint = accentColor
|
||||
)
|
||||
Spacer(modifier = Modifier.size(6.dp))
|
||||
Text(
|
||||
text = "NEW DEVICE REQUEST",
|
||||
color = PrimaryBlue,
|
||||
color = accentColor,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
Text(
|
||||
text = "Waiting for approval",
|
||||
color = textColor,
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
Text(
|
||||
text = "Open Rosetta on your first device and approve this login request.",
|
||||
color = secondaryTextColor,
|
||||
fontSize = 14.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
lineHeight = 20.sp
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
|
||||
Text(
|
||||
text = "\"$localDeviceName\" is waiting for approval",
|
||||
color = textColor.copy(alpha = 0.9f),
|
||||
fontSize = 13.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
lineHeight = 20.sp
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
|
||||
Text(
|
||||
text = "If you didn't request this login, tap Exit.",
|
||||
color = secondaryTextColor,
|
||||
fontSize = 12.sp,
|
||||
text = "Waiting for approval",
|
||||
color = textColor,
|
||||
fontSize = 34.sp,
|
||||
lineHeight = 38.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Button(
|
||||
onClick = onExitState,
|
||||
modifier = Modifier.height(42.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFFFF3B30),
|
||||
contentColor = Color.White
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
Text(
|
||||
text = "Open Rosetta on your first device and approve this login request.",
|
||||
color = secondaryTextColor,
|
||||
fontSize = 15.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
lineHeight = 22.sp
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = deviceCardColor,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
border = BorderStroke(1.dp, cardBorderColor.copy(alpha = if (isDarkTheme) 0.7f else 1f))
|
||||
) {
|
||||
Text("Exit")
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Text(
|
||||
text = "Device waiting for approval",
|
||||
color = secondaryTextColor,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = localDeviceName,
|
||||
color = textColor,
|
||||
fontSize = 17.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "Waiting for confirmation...",
|
||||
color = secondaryTextColor,
|
||||
fontSize = 11.sp
|
||||
)
|
||||
Button(
|
||||
onClick = onExitState,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(46.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = exitButtonColor,
|
||||
contentColor = exitButtonTextColor
|
||||
),
|
||||
border = BorderStroke(1.dp, exitButtonTextColor.copy(alpha = 0.35f)),
|
||||
shape = RoundedCornerShape(14.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Exit",
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Waiting for confirmation",
|
||||
color = secondaryTextColor,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
WaitingDots(color = secondaryTextColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WaitingDots(color: Color) {
|
||||
val transition = rememberInfiniteTransition(label = "waiting-dots")
|
||||
val progress by transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1f,
|
||||
animationSpec =
|
||||
infiniteRepeatable(
|
||||
animation = tween(durationMillis = 1000, easing = FastOutSlowInEasing)
|
||||
),
|
||||
label = "waiting-dots-progress"
|
||||
)
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
repeat(3) { index ->
|
||||
val shifted = (progress + index * 0.22f) % 1f
|
||||
val alpha = 0.3f + (1f - shifted) * 0.7f
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(5.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color.copy(alpha = alpha))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ import com.rosetta.messenger.crypto.CryptoManager
|
||||
import com.rosetta.messenger.data.AccountManager
|
||||
import com.rosetta.messenger.data.DecryptedAccount
|
||||
import com.rosetta.messenger.data.EncryptedAccount
|
||||
import com.rosetta.messenger.network.ProtocolManager
|
||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -520,20 +519,26 @@ fun SetPasswordScreen(
|
||||
)
|
||||
|
||||
accountManager.saveAccount(account)
|
||||
accountManager.setCurrentAccount(keyPair.publicKey)
|
||||
|
||||
// 🔌 Connect to server and authenticate
|
||||
val privateKeyHash =
|
||||
CryptoManager.generatePrivateKeyHash(
|
||||
keyPair.privateKey
|
||||
)
|
||||
ProtocolManager.connect()
|
||||
// Give WebSocket time to connect before authenticating
|
||||
kotlinx.coroutines.delay(500)
|
||||
ProtocolManager.authenticate(
|
||||
keyPair.publicKey,
|
||||
privateKeyHash
|
||||
)
|
||||
|
||||
val handshakeState =
|
||||
awaitAuthHandshakeState(
|
||||
keyPair.publicKey,
|
||||
privateKeyHash
|
||||
)
|
||||
if (handshakeState == null) {
|
||||
error =
|
||||
"Failed to connect to server. Please try again."
|
||||
isCreating = false
|
||||
return@launch
|
||||
}
|
||||
|
||||
accountManager.setCurrentAccount(keyPair.publicKey)
|
||||
|
||||
// Create DecryptedAccount to pass to callback
|
||||
val decryptedAccount =
|
||||
|
||||
@@ -43,19 +43,16 @@ import com.rosetta.messenger.crypto.CryptoManager
|
||||
import com.rosetta.messenger.data.AccountManager
|
||||
import com.rosetta.messenger.data.DecryptedAccount
|
||||
import com.rosetta.messenger.data.EncryptedAccount
|
||||
import com.rosetta.messenger.data.resolveAccountDisplayName
|
||||
import com.rosetta.messenger.database.RosettaDatabase
|
||||
import com.rosetta.messenger.network.ProtocolManager
|
||||
import com.rosetta.messenger.network.ProtocolState
|
||||
import com.rosetta.messenger.repository.AvatarRepository
|
||||
import com.rosetta.messenger.ui.components.AvatarImage
|
||||
import com.rosetta.messenger.ui.chats.getAvatarColor
|
||||
import com.rosetta.messenger.ui.chats.getAvatarText
|
||||
import com.rosetta.messenger.ui.chats.utils.getInitials
|
||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// Account model for dropdown
|
||||
data class AccountItem(
|
||||
@@ -116,33 +113,17 @@ val decryptedPrivateKey = CryptoManager.decryptWithPassword(
|
||||
privateKey = decryptedPrivateKey,
|
||||
seedPhrase = decryptedSeedPhrase,
|
||||
privateKeyHash = privateKeyHash,
|
||||
name = account.name
|
||||
name = selectedAccount.name
|
||||
)
|
||||
|
||||
// Connect to server
|
||||
val connectStart = System.currentTimeMillis()
|
||||
ProtocolManager.connect()
|
||||
|
||||
// Wait for websocket connection
|
||||
val connected = withTimeoutOrNull(5000) {
|
||||
ProtocolManager.state.first { it != ProtocolState.DISCONNECTED }
|
||||
}
|
||||
val connectTime = System.currentTimeMillis() - connectStart
|
||||
if (connected == null) {
|
||||
val handshakeState = awaitAuthHandshakeState(account.publicKey, privateKeyHash)
|
||||
if (handshakeState == null) {
|
||||
onError("Failed to connect to server")
|
||||
onUnlocking(false)
|
||||
return
|
||||
}
|
||||
|
||||
kotlinx.coroutines.delay(300)
|
||||
|
||||
// Authenticate
|
||||
val authStart = System.currentTimeMillis()
|
||||
ProtocolManager.authenticate(account.publicKey, privateKeyHash)
|
||||
val authTime = System.currentTimeMillis() - authStart
|
||||
|
||||
accountManager.setCurrentAccount(account.publicKey)
|
||||
|
||||
val totalTime = System.currentTimeMillis() - totalStart
|
||||
onSuccess(decryptedAccount)
|
||||
} catch (e: Exception) {
|
||||
onError("Failed to unlock: ${e.message}")
|
||||
@@ -216,7 +197,11 @@ fun UnlockScreen(
|
||||
val allAccounts = accountManager.getAllAccounts()
|
||||
accounts =
|
||||
allAccounts.map { acc ->
|
||||
AccountItem(publicKey = acc.publicKey, name = acc.name, encryptedAccount = acc)
|
||||
AccountItem(
|
||||
publicKey = acc.publicKey,
|
||||
name = resolveAccountDisplayName(acc.publicKey, acc.name, acc.username),
|
||||
encryptedAccount = acc
|
||||
)
|
||||
}
|
||||
|
||||
// Find the target account - приоритет: selectedAccountId > lastLoggedKey > первый
|
||||
@@ -359,6 +344,7 @@ fun UnlockScreen(
|
||||
avatarRepository = avatarRepository,
|
||||
size = 120.dp,
|
||||
isDarkTheme = isDarkTheme,
|
||||
displayName = selectedAccount!!.name,
|
||||
shape = RoundedCornerShape(28.dp)
|
||||
)
|
||||
} else {
|
||||
@@ -479,7 +465,8 @@ fun UnlockScreen(
|
||||
publicKey = account.publicKey,
|
||||
avatarRepository = avatarRepository,
|
||||
size = 40.dp,
|
||||
isDarkTheme = isDarkTheme
|
||||
isDarkTheme = isDarkTheme,
|
||||
displayName = account.name
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
@@ -69,7 +69,6 @@ import com.rosetta.messenger.data.ForwardManager
|
||||
import com.rosetta.messenger.data.MessageRepository
|
||||
import com.rosetta.messenger.database.RosettaDatabase
|
||||
import com.rosetta.messenger.network.AttachmentType
|
||||
import com.rosetta.messenger.network.ProtocolManager
|
||||
import com.rosetta.messenger.network.SearchUser
|
||||
import com.rosetta.messenger.repository.AvatarRepository
|
||||
import com.rosetta.messenger.ui.chats.components.*
|
||||
@@ -339,14 +338,6 @@ fun ChatDetailScreen(
|
||||
var showDeleteConfirm by remember { mutableStateOf(false) }
|
||||
var showBlockConfirm by remember { mutableStateOf(false) }
|
||||
var showUnblockConfirm by remember { mutableStateOf(false) }
|
||||
var showDebugLogs by remember { mutableStateOf(false) }
|
||||
|
||||
// Debug logs из ProtocolManager
|
||||
val debugLogs by ProtocolManager.debugLogs.collectAsState()
|
||||
|
||||
// Включаем UI логи только когда открыт bottom sheet
|
||||
LaunchedEffect(showDebugLogs) { ProtocolManager.enableUILogs(showDebugLogs) }
|
||||
|
||||
// Наблюдаем за статусом блокировки в реальном времени через Flow
|
||||
val isBlocked by
|
||||
database.blacklistDao()
|
||||
@@ -462,6 +453,7 @@ fun ChatDetailScreen(
|
||||
Lifecycle.Event.ON_RESUME -> {
|
||||
isScreenActive = true
|
||||
viewModel.setDialogActive(true)
|
||||
viewModel.markVisibleMessagesAsRead()
|
||||
// 🔥 Убираем уведомление этого чата из шторки
|
||||
com.rosetta.messenger.push.RosettaFirebaseMessagingService
|
||||
.cancelNotificationForChat(context, user.publicKey)
|
||||
@@ -488,6 +480,7 @@ fun ChatDetailScreen(
|
||||
LaunchedEffect(user.publicKey, forwardTrigger) {
|
||||
viewModel.setUserKeys(currentUserPublicKey, currentUserPrivateKey)
|
||||
viewModel.openDialog(user.publicKey, user.title, user.username)
|
||||
viewModel.markVisibleMessagesAsRead()
|
||||
// 🔥 Убираем уведомление этого чата из шторки при заходе
|
||||
com.rosetta.messenger.push.RosettaFirebaseMessagingService
|
||||
.cancelNotificationForChat(context, user.publicKey)
|
||||
@@ -1143,12 +1136,6 @@ fun ChatDetailScreen(
|
||||
false
|
||||
showDeleteConfirm =
|
||||
true
|
||||
},
|
||||
onLogsClick = {
|
||||
showMenu =
|
||||
false
|
||||
showDebugLogs =
|
||||
true
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1535,7 +1522,7 @@ fun ChatDetailScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
} else if (!isSystemAccount) {
|
||||
// INPUT BAR
|
||||
Column {
|
||||
MessageInputBar(
|
||||
|
||||
@@ -45,13 +45,14 @@ import com.rosetta.messenger.R
|
||||
import com.rosetta.messenger.BuildConfig
|
||||
import com.rosetta.messenger.data.AccountManager
|
||||
import com.rosetta.messenger.data.EncryptedAccount
|
||||
import com.rosetta.messenger.data.MessageRepository
|
||||
import com.rosetta.messenger.data.RecentSearchesManager
|
||||
import com.rosetta.messenger.data.resolveAccountDisplayName
|
||||
import com.rosetta.messenger.network.DeviceEntry
|
||||
import com.rosetta.messenger.network.ProtocolManager
|
||||
import com.rosetta.messenger.network.ProtocolState
|
||||
import com.rosetta.messenger.repository.AvatarRepository
|
||||
import com.rosetta.messenger.ui.chats.components.AnimatedDotsText
|
||||
import com.rosetta.messenger.ui.chats.components.DebugLogsBottomSheet
|
||||
import com.rosetta.messenger.ui.chats.components.DeviceVerificationBanner
|
||||
import com.rosetta.messenger.ui.components.AppleEmojiText
|
||||
import com.rosetta.messenger.ui.components.AvatarImage
|
||||
@@ -261,7 +262,6 @@ fun ChatsListScreen(
|
||||
|
||||
// Protocol connection state
|
||||
val protocolState by ProtocolManager.state.collectAsState()
|
||||
val syncLogs by ProtocolManager.debugLogs.collectAsState()
|
||||
val pendingDeviceVerification by ProtocolManager.pendingDeviceVerification.collectAsState()
|
||||
|
||||
// 🔥 Пользователи, которые сейчас печатают
|
||||
@@ -288,10 +288,6 @@ fun ChatsListScreen(
|
||||
|
||||
// Status dialog state
|
||||
var showStatusDialog by remember { mutableStateOf(false) }
|
||||
var showSyncLogs by remember { mutableStateOf(false) }
|
||||
|
||||
// Включаем UI логи только когда открыт bottom sheet, чтобы не перегружать композицию
|
||||
LaunchedEffect(showSyncLogs) { ProtocolManager.enableUILogs(showSyncLogs) }
|
||||
|
||||
// 📬 Requests screen state
|
||||
var showRequestsScreen by remember { mutableStateOf(false) }
|
||||
@@ -667,12 +663,15 @@ fun ChatsListScreen(
|
||||
exit = shrinkVertically(animationSpec = tween(250)) + fadeOut(animationSpec = tween(200))
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
// All accounts list
|
||||
allAccounts.forEach { account ->
|
||||
// All accounts list (max 5 like Telegram sidebar behavior)
|
||||
allAccounts.take(5).forEach { account ->
|
||||
val isCurrentAccount = account.publicKey == accountPublicKey
|
||||
val displayName = account.name.ifEmpty {
|
||||
account.username ?: account.publicKey.take(8)
|
||||
}
|
||||
val displayName =
|
||||
resolveAccountDisplayName(
|
||||
account.publicKey,
|
||||
account.name,
|
||||
account.username
|
||||
)
|
||||
Row(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
@@ -1196,18 +1195,6 @@ fun ChatsListScreen(
|
||||
},
|
||||
actions = {
|
||||
if (!showRequestsScreen) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
showSyncLogs = true
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
TablerIcons.Bug,
|
||||
contentDescription = "Sync logs",
|
||||
tint = Color.White.copy(alpha = 0.92f)
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (protocolState ==
|
||||
@@ -1561,8 +1548,6 @@ fun ChatsListScreen(
|
||||
DeviceVerificationBanner(
|
||||
device = pendingDevice,
|
||||
isDarkTheme = isDarkTheme,
|
||||
accountPublicKey = accountPublicKey,
|
||||
avatarRepository = avatarRepository,
|
||||
onAccept = {
|
||||
deviceResolveRequest =
|
||||
pendingDevice to
|
||||
@@ -1627,6 +1612,9 @@ fun ChatsListScreen(
|
||||
val isSavedMessages =
|
||||
dialog.opponentKey ==
|
||||
accountPublicKey
|
||||
val isSystemSafeDialog =
|
||||
dialog.opponentKey ==
|
||||
MessageRepository.SYSTEM_SAFE_PUBLIC_KEY
|
||||
val isBlocked =
|
||||
blockedUsers
|
||||
.contains(
|
||||
@@ -1734,6 +1722,8 @@ fun ChatsListScreen(
|
||||
.contains(
|
||||
dialog.opponentKey
|
||||
),
|
||||
swipeEnabled =
|
||||
!isSystemSafeDialog,
|
||||
onPin = {
|
||||
onTogglePin(
|
||||
dialog.opponentKey
|
||||
@@ -1920,15 +1910,6 @@ fun ChatsListScreen(
|
||||
)
|
||||
}
|
||||
|
||||
if (showSyncLogs) {
|
||||
DebugLogsBottomSheet(
|
||||
logs = syncLogs,
|
||||
isDarkTheme = isDarkTheme,
|
||||
onDismiss = { showSyncLogs = false },
|
||||
onClearLogs = { ProtocolManager.clearLogs() }
|
||||
)
|
||||
}
|
||||
|
||||
} // Close Box
|
||||
}
|
||||
|
||||
@@ -2482,6 +2463,7 @@ fun SwipeableDialogItem(
|
||||
isTyping: Boolean = false,
|
||||
isBlocked: Boolean = false,
|
||||
isSavedMessages: Boolean = false,
|
||||
swipeEnabled: Boolean = true,
|
||||
isMuted: Boolean = false,
|
||||
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
|
||||
isDrawerOpen: Boolean = false,
|
||||
@@ -2513,7 +2495,10 @@ fun SwipeableDialogItem(
|
||||
)
|
||||
var offsetX by remember { mutableStateOf(0f) }
|
||||
// 📌 3 кнопки: Pin + Block/Unblock + Delete (для SavedMessages: Pin + Delete)
|
||||
val buttonCount = if (isSavedMessages) 2 else 3
|
||||
val buttonCount =
|
||||
if (!swipeEnabled) 0
|
||||
else if (isSavedMessages) 2
|
||||
else 3
|
||||
val swipeWidthDp = (buttonCount * 80).dp
|
||||
val density = androidx.compose.ui.platform.LocalDensity.current
|
||||
val swipeWidthPx = with(density) { swipeWidthDp.toPx() }
|
||||
@@ -2545,6 +2530,7 @@ fun SwipeableDialogItem(
|
||||
.clipToBounds()
|
||||
) {
|
||||
// 1. КНОПКИ - позиционированы справа, всегда видны при свайпе
|
||||
if (swipeEnabled) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier.align(Alignment.CenterEnd)
|
||||
@@ -2665,6 +2651,7 @@ fun SwipeableDialogItem(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. КОНТЕНТ - поверх кнопок, сдвигается при свайпе
|
||||
// 🔥 rememberUpdatedState чтобы pointerInput всегда вызывал актуальные callbacks
|
||||
@@ -2762,7 +2749,7 @@ fun SwipeableDialogItem(
|
||||
|
||||
when {
|
||||
// Horizontal left swipe — reveal action buttons
|
||||
dominated && totalDragX < 0 -> {
|
||||
swipeEnabled && dominated && totalDragX < 0 -> {
|
||||
passedSlop = true
|
||||
claimed = true
|
||||
onSwipeStarted()
|
||||
@@ -3073,12 +3060,31 @@ fun DialogItemContent(
|
||||
// 📁 Для Saved Messages ВСЕГДА показываем синие двойные
|
||||
// галочки (прочитано)
|
||||
if (dialog.isSavedMessages) {
|
||||
Icon(
|
||||
painter = TelegramIcons.Done,
|
||||
contentDescription = null,
|
||||
tint = PrimaryBlue,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier.width(20.dp).height(16.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = TelegramIcons.Done,
|
||||
contentDescription = null,
|
||||
tint = PrimaryBlue,
|
||||
modifier =
|
||||
Modifier.size(16.dp)
|
||||
.align(
|
||||
Alignment.CenterStart
|
||||
)
|
||||
)
|
||||
Icon(
|
||||
painter = TelegramIcons.Done,
|
||||
contentDescription = null,
|
||||
tint = PrimaryBlue,
|
||||
modifier =
|
||||
Modifier.size(16.dp)
|
||||
.align(
|
||||
Alignment.CenterStart
|
||||
)
|
||||
.offset(x = 5.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
} else if (dialog.lastMessageFromMe == 1) {
|
||||
// Показываем статус только для исходящих сообщений
|
||||
@@ -3116,14 +3122,44 @@ fun DialogItemContent(
|
||||
3 -> {
|
||||
// READ (delivered=3) - две синие
|
||||
// галочки
|
||||
Icon(
|
||||
painter =
|
||||
TelegramIcons.Done,
|
||||
contentDescription = null,
|
||||
tint = PrimaryBlue,
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.size(16.dp)
|
||||
)
|
||||
Modifier.width(20.dp)
|
||||
.height(16.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter =
|
||||
TelegramIcons.Done,
|
||||
contentDescription =
|
||||
null,
|
||||
tint = PrimaryBlue,
|
||||
modifier =
|
||||
Modifier.size(
|
||||
16.dp
|
||||
)
|
||||
.align(
|
||||
Alignment.CenterStart
|
||||
)
|
||||
)
|
||||
Icon(
|
||||
painter =
|
||||
TelegramIcons.Done,
|
||||
contentDescription =
|
||||
null,
|
||||
tint = PrimaryBlue,
|
||||
modifier =
|
||||
Modifier.size(
|
||||
16.dp
|
||||
)
|
||||
.align(
|
||||
Alignment.CenterStart
|
||||
)
|
||||
.offset(
|
||||
x =
|
||||
5.dp
|
||||
)
|
||||
)
|
||||
}
|
||||
Spacer(
|
||||
modifier =
|
||||
Modifier.width(4.dp)
|
||||
|
||||
@@ -38,7 +38,9 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.airbnb.lottie.compose.*
|
||||
import com.rosetta.messenger.R
|
||||
import com.rosetta.messenger.data.AccountManager
|
||||
import com.rosetta.messenger.data.RecentSearchesManager
|
||||
import com.rosetta.messenger.data.isPlaceholderAccountName
|
||||
import com.rosetta.messenger.network.ProtocolState
|
||||
import com.rosetta.messenger.network.SearchUser
|
||||
|
||||
@@ -96,6 +98,8 @@ fun SearchScreen(
|
||||
val searchQuery by searchViewModel.searchQuery.collectAsState()
|
||||
val searchResults by searchViewModel.searchResults.collectAsState()
|
||||
val isSearching by searchViewModel.isSearching.collectAsState()
|
||||
var ownAccountName by remember(currentUserPublicKey) { mutableStateOf("") }
|
||||
var ownAccountUsername by remember(currentUserPublicKey) { mutableStateOf("") }
|
||||
|
||||
// Easter egg: navigate to CrashLogs when typing "rosettadev1"
|
||||
LaunchedEffect(searchQuery) {
|
||||
@@ -108,6 +112,24 @@ fun SearchScreen(
|
||||
// Always reset query/results when leaving Search screen (back/swipe/navigation).
|
||||
DisposableEffect(Unit) { onDispose { searchViewModel.clearSearchQuery() } }
|
||||
|
||||
// Keep private key hash in sync with active account.
|
||||
LaunchedEffect(privateKeyHash) {
|
||||
searchViewModel.setPrivateKeyHash(privateKeyHash)
|
||||
}
|
||||
|
||||
// Keep own account metadata for local "Saved Messages" search fallback.
|
||||
LaunchedEffect(currentUserPublicKey) {
|
||||
if (currentUserPublicKey.isBlank()) {
|
||||
ownAccountName = ""
|
||||
ownAccountUsername = ""
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
val account = AccountManager(context).getAccount(currentUserPublicKey)
|
||||
ownAccountName = account?.name?.trim().orEmpty()
|
||||
ownAccountUsername = account?.username?.trim().orEmpty()
|
||||
}
|
||||
|
||||
// Recent users - отложенная подписка
|
||||
val recentUsers by RecentSearchesManager.recentUsers.collectAsState()
|
||||
|
||||
@@ -150,11 +172,6 @@ fun SearchScreen(
|
||||
RecentSearchesManager.setAccount(currentUserPublicKey)
|
||||
}
|
||||
|
||||
// Устанавливаем privateKeyHash
|
||||
if (privateKeyHash.isNotEmpty()) {
|
||||
searchViewModel.setPrivateKeyHash(privateKeyHash)
|
||||
}
|
||||
|
||||
// Автофокус с небольшой задержкой
|
||||
kotlinx.coroutines.delay(100)
|
||||
try {
|
||||
@@ -314,15 +331,22 @@ fun SearchScreen(
|
||||
} else {
|
||||
// Search Results
|
||||
// Проверяем, не ищет ли пользователь сам себя (Saved Messages)
|
||||
val normalizedQuery = searchQuery.trim().removePrefix("@").lowercase()
|
||||
val normalizedPublicKey = currentUserPublicKey.lowercase()
|
||||
val normalizedUsername = ownAccountUsername.removePrefix("@").trim().lowercase()
|
||||
val normalizedName = ownAccountName.trim().lowercase()
|
||||
val hasValidOwnName =
|
||||
ownAccountName.isNotBlank() && !isPlaceholderAccountName(ownAccountName)
|
||||
val isSavedMessagesSearch =
|
||||
searchQuery.trim().let { query ->
|
||||
query.equals(currentUserPublicKey, ignoreCase = true) ||
|
||||
query.equals(currentUserPublicKey.take(8), ignoreCase = true) ||
|
||||
query.equals(
|
||||
currentUserPublicKey.takeLast(8),
|
||||
ignoreCase = true
|
||||
)
|
||||
}
|
||||
normalizedQuery.isNotEmpty() &&
|
||||
(normalizedPublicKey == normalizedQuery ||
|
||||
normalizedPublicKey.startsWith(normalizedQuery) ||
|
||||
normalizedPublicKey.take(8) == normalizedQuery ||
|
||||
normalizedPublicKey.takeLast(8) == normalizedQuery ||
|
||||
(normalizedUsername.isNotEmpty() &&
|
||||
normalizedUsername.startsWith(normalizedQuery)) ||
|
||||
(hasValidOwnName &&
|
||||
normalizedName.startsWith(normalizedQuery)))
|
||||
|
||||
// Если ищем себя - показываем Saved Messages как первый результат
|
||||
val resultsWithSavedMessages =
|
||||
|
||||
@@ -4,8 +4,10 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.rosetta.messenger.network.PacketSearch
|
||||
import com.rosetta.messenger.network.ProtocolManager
|
||||
import com.rosetta.messenger.network.ProtocolState
|
||||
import com.rosetta.messenger.network.SearchUser
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -13,8 +15,6 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val TAG = "SearchUsersVM"
|
||||
|
||||
/**
|
||||
* ViewModel для поиска пользователей через протокол
|
||||
* Работает аналогично SearchBar в React Native приложении
|
||||
@@ -33,38 +33,30 @@ class SearchUsersViewModel : ViewModel() {
|
||||
|
||||
private val _isSearchExpanded = MutableStateFlow(false)
|
||||
val isSearchExpanded: StateFlow<Boolean> = _isSearchExpanded.asStateFlow()
|
||||
|
||||
private val _searchLogs = MutableStateFlow<List<String>>(emptyList())
|
||||
val searchLogs: StateFlow<List<String>> = _searchLogs.asStateFlow()
|
||||
|
||||
// Приватные переменные
|
||||
private var searchJob: Job? = null
|
||||
private var lastSearchedText: String = ""
|
||||
private var privateKeyHash: String = ""
|
||||
private val timeFormatter = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
|
||||
|
||||
// Callback для обработки ответа поиска
|
||||
private val searchPacketHandler: (com.rosetta.messenger.network.Packet) -> Unit = handler@{ packet ->
|
||||
if (packet is PacketSearch) {
|
||||
// 🔥 ВАЖНО: Игнорируем ответы с пустым search или не соответствующие нашему запросу
|
||||
// Сервер может слать много пакетов 0x03 по разным причинам
|
||||
val currentQuery = lastSearchedText
|
||||
val responseSearch = packet.search
|
||||
|
||||
|
||||
// Принимаем ответ только если:
|
||||
// 1. search в ответе совпадает с нашим запросом, ИЛИ
|
||||
// 2. search пустой но мы ждём ответ (lastSearchedText не пустой)
|
||||
// НО: если search пустой и мы НЕ ждём ответ - игнорируем
|
||||
if (responseSearch.isEmpty() && currentQuery.isEmpty()) {
|
||||
logSearch(
|
||||
"📥 PacketSearch response: search='${packet.search}', users=${packet.users.size}"
|
||||
)
|
||||
// Desktop parity: любой ответ PacketSearch обновляет результаты
|
||||
// пока в поле есть активный поисковый запрос.
|
||||
if (_searchQuery.value.trim().isEmpty()) {
|
||||
logSearch("⏭ Ignored response: query is empty")
|
||||
return@handler
|
||||
}
|
||||
|
||||
// Если search не пустой и не совпадает с нашим запросом - игнорируем
|
||||
if (responseSearch.isNotEmpty() && responseSearch != currentQuery) {
|
||||
return@handler
|
||||
}
|
||||
|
||||
packet.users.forEachIndexed { index, user ->
|
||||
}
|
||||
_searchResults.value = packet.users
|
||||
_isSearching.value = false
|
||||
logSearch("✅ Results updated")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +76,14 @@ class SearchUsersViewModel : ViewModel() {
|
||||
* Установить приватный ключ для поиска
|
||||
*/
|
||||
fun setPrivateKeyHash(hash: String) {
|
||||
privateKeyHash = hash
|
||||
privateKeyHash = hash.trim()
|
||||
val shortHash =
|
||||
if (privateKeyHash.length > 12) {
|
||||
"${privateKeyHash.take(8)}...${privateKeyHash.takeLast(4)}"
|
||||
} else {
|
||||
privateKeyHash
|
||||
}
|
||||
logSearch("🔑 privateKeyHash set: $shortHash")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,53 +91,51 @@ class SearchUsersViewModel : ViewModel() {
|
||||
* Аналогично handleSearch в React Native
|
||||
*/
|
||||
fun onSearchQueryChange(query: String) {
|
||||
_searchQuery.value = query
|
||||
val normalizedQuery = sanitizeSearchInput(query)
|
||||
_searchQuery.value = normalizedQuery
|
||||
logSearch("⌨️ Query changed: '$query' -> '$normalizedQuery'")
|
||||
|
||||
// Отменяем предыдущий поиск
|
||||
searchJob?.cancel()
|
||||
|
||||
// Если пустой запрос - очищаем результаты
|
||||
if (query.trim().isEmpty()) {
|
||||
if (normalizedQuery.trim().isEmpty()) {
|
||||
_searchResults.value = emptyList()
|
||||
_isSearching.value = false
|
||||
lastSearchedText = ""
|
||||
logSearch("🧹 Cleared results: empty query")
|
||||
return
|
||||
}
|
||||
|
||||
// Если текст уже был найден - не повторяем поиск
|
||||
if (query == lastSearchedText) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Показываем индикатор загрузки
|
||||
_isSearching.value = true
|
||||
logSearch("⏳ Debounce started (1000ms)")
|
||||
|
||||
// Запускаем поиск с задержкой 1 секунда (как в React Native)
|
||||
searchJob = viewModelScope.launch {
|
||||
delay(1000) // debounce
|
||||
|
||||
|
||||
// Проверяем состояние протокола
|
||||
if (ProtocolManager.state.value != ProtocolState.AUTHENTICATED) {
|
||||
_isSearching.value = false
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Проверяем, не изменился ли запрос
|
||||
if (query != _searchQuery.value) {
|
||||
if (normalizedQuery != _searchQuery.value) {
|
||||
logSearch("⏭ Skip send: query changed during debounce")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val effectivePrivateHash =
|
||||
privateKeyHash.ifBlank { ProtocolManager.getProtocol().getPrivateHash().orEmpty() }
|
||||
if (effectivePrivateHash.isBlank()) {
|
||||
_isSearching.value = false
|
||||
logSearch("❌ Skip send: private hash is empty")
|
||||
return@launch
|
||||
}
|
||||
|
||||
lastSearchedText = query
|
||||
|
||||
|
||||
// Создаем и отправляем пакет поиска
|
||||
val packetSearch = PacketSearch().apply {
|
||||
this.privateKey = privateKeyHash
|
||||
this.search = query
|
||||
this.privateKey = effectivePrivateHash
|
||||
this.search = normalizedQuery
|
||||
}
|
||||
|
||||
ProtocolManager.sendPacket(packetSearch)
|
||||
logSearch("📤 PacketSearch sent: '$normalizedQuery'")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,8 +154,8 @@ class SearchUsersViewModel : ViewModel() {
|
||||
_searchQuery.value = ""
|
||||
_searchResults.value = emptyList()
|
||||
_isSearching.value = false
|
||||
lastSearchedText = ""
|
||||
searchJob?.cancel()
|
||||
logSearch("↩️ Search collapsed")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -168,7 +165,18 @@ class SearchUsersViewModel : ViewModel() {
|
||||
_searchQuery.value = ""
|
||||
_searchResults.value = emptyList()
|
||||
_isSearching.value = false
|
||||
lastSearchedText = ""
|
||||
searchJob?.cancel()
|
||||
logSearch("🧹 Query cleared")
|
||||
}
|
||||
|
||||
fun clearSearchLogs() {
|
||||
_searchLogs.value = emptyList()
|
||||
}
|
||||
|
||||
private fun logSearch(message: String) {
|
||||
val timestamp = synchronized(timeFormatter) { timeFormatter.format(Date()) }
|
||||
_searchLogs.value = (_searchLogs.value + "[$timestamp] $message").takeLast(200)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sanitizeSearchInput(input: String): String = input.replace("@", "").trimStart()
|
||||
|
||||
@@ -73,6 +73,16 @@ import kotlin.math.min
|
||||
|
||||
private const val TAG = "AttachmentComponents"
|
||||
|
||||
private fun shortDebugId(value: String): String {
|
||||
if (value.isBlank()) return "empty"
|
||||
val clean = value.trim()
|
||||
return if (clean.length <= 8) clean else "${clean.take(8)}..."
|
||||
}
|
||||
|
||||
private fun logPhotoDebug(message: String) {
|
||||
AttachmentDownloadDebugLogger.log(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Анимированный текст с волнообразными точками.
|
||||
* Три точки плавно подпрыгивают каскадом с изменением прозрачности.
|
||||
@@ -910,6 +920,10 @@ fun ImageAttachment(
|
||||
val download: () -> Unit = {
|
||||
if (downloadTag.isNotEmpty()) {
|
||||
scope.launch {
|
||||
val idShort = shortDebugId(attachment.id)
|
||||
val tagShort = shortDebugId(downloadTag)
|
||||
val server = TransportManager.getTransportServer() ?: "unset"
|
||||
logPhotoDebug("Start image download: id=$idShort, tag=$tagShort, server=$server")
|
||||
try {
|
||||
downloadStatus = DownloadStatus.DOWNLOADING
|
||||
|
||||
@@ -917,6 +931,9 @@ fun ImageAttachment(
|
||||
val startTime = System.currentTimeMillis()
|
||||
val encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag)
|
||||
val downloadTime = System.currentTimeMillis() - startTime
|
||||
logPhotoDebug(
|
||||
"CDN download OK: id=$idShort, bytes=${encryptedContent.length}, time=${downloadTime}ms"
|
||||
)
|
||||
downloadProgress = 0.5f
|
||||
|
||||
downloadStatus = DownloadStatus.DECRYPTING
|
||||
@@ -925,6 +942,9 @@ fun ImageAttachment(
|
||||
// Сначала расшифровываем его, получаем raw bytes
|
||||
val decryptedKeyAndNonce =
|
||||
MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
|
||||
logPhotoDebug(
|
||||
"Key decrypt OK: id=$idShort, keySize=${decryptedKeyAndNonce.size}"
|
||||
)
|
||||
|
||||
// Используем decryptAttachmentBlobWithPlainKey который правильно конвертирует
|
||||
// bytes в password
|
||||
@@ -938,6 +958,7 @@ fun ImageAttachment(
|
||||
downloadProgress = 0.8f
|
||||
|
||||
if (decrypted != null) {
|
||||
logPhotoDebug("Blob decrypt OK: id=$idShort, time=${decryptTime}ms")
|
||||
withContext(Dispatchers.IO) {
|
||||
imageBitmap = base64ToBitmap(decrypted)
|
||||
|
||||
@@ -950,18 +971,25 @@ fun ImageAttachment(
|
||||
publicKey = senderPublicKey,
|
||||
privateKey = privateKey
|
||||
)
|
||||
logPhotoDebug("Cache save result: id=$idShort, saved=$saved")
|
||||
}
|
||||
downloadProgress = 1f
|
||||
downloadStatus = DownloadStatus.DOWNLOADED
|
||||
logPhotoDebug("Image ready: id=$idShort")
|
||||
} else {
|
||||
downloadStatus = DownloadStatus.ERROR
|
||||
logPhotoDebug("Blob decrypt FAILED: id=$idShort, time=${decryptTime}ms")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
downloadStatus = DownloadStatus.ERROR
|
||||
logPhotoDebug(
|
||||
"Image download ERROR: id=$idShort, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logPhotoDebug("Skip image download: empty tag for id=${shortDebugId(attachment.id)}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2167,11 +2195,21 @@ internal suspend fun downloadAndDecryptImage(
|
||||
if (downloadTag.isEmpty() || chachaKey.isEmpty() || privateKey.isEmpty()) return null
|
||||
|
||||
return withContext(Dispatchers.IO) {
|
||||
val idShort = shortDebugId(attachmentId)
|
||||
val tagShort = shortDebugId(downloadTag)
|
||||
val server = TransportManager.getTransportServer() ?: "unset"
|
||||
try {
|
||||
logPhotoDebug("Start helper image download: id=$idShort, tag=$tagShort, server=$server")
|
||||
val encryptedContent = TransportManager.downloadFile(attachmentId, downloadTag)
|
||||
if (encryptedContent.isEmpty()) return@withContext null
|
||||
logPhotoDebug(
|
||||
"Helper CDN download OK: id=$idShort, bytes=${encryptedContent.length}"
|
||||
)
|
||||
|
||||
val plainKeyAndNonce = MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
|
||||
logPhotoDebug(
|
||||
"Helper key decrypt OK: id=$idShort, keySize=${plainKeyAndNonce.size}"
|
||||
)
|
||||
|
||||
// Try decryptReplyBlob first (desktop decodeWithPassword)
|
||||
var decrypted = try {
|
||||
@@ -2192,16 +2230,22 @@ internal suspend fun downloadAndDecryptImage(
|
||||
val bitmap = base64ToBitmap(base64Data) ?: return@withContext null
|
||||
|
||||
ImageBitmapCache.put(cacheKey, bitmap)
|
||||
AttachmentFileManager.saveAttachment(
|
||||
val saved = AttachmentFileManager.saveAttachment(
|
||||
context = context,
|
||||
blob = base64Data,
|
||||
attachmentId = attachmentId,
|
||||
publicKey = senderPublicKey,
|
||||
privateKey = recipientPrivateKey
|
||||
)
|
||||
logPhotoDebug("Helper image ready: id=$idShort, saved=$saved")
|
||||
|
||||
bitmap
|
||||
} catch (_: Exception) { null }
|
||||
} catch (e: Exception) {
|
||||
logPhotoDebug(
|
||||
"Helper image ERROR: id=$idShort, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
|
||||
)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.rosetta.messenger.ui.chats.components
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
object AttachmentDownloadDebugLogger {
|
||||
private const val MAX_LOGS = 200
|
||||
private val dateFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault())
|
||||
|
||||
private val _logs = MutableStateFlow<List<String>>(emptyList())
|
||||
val logs: StateFlow<List<String>> = _logs.asStateFlow()
|
||||
|
||||
fun log(message: String) {
|
||||
val timestamp = dateFormat.format(Date())
|
||||
val line = "[$timestamp] 🖼️ $message"
|
||||
_logs.update { current -> (current + line).takeLast(MAX_LOGS) }
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
_logs.value = emptyList()
|
||||
}
|
||||
}
|
||||
@@ -1148,19 +1148,48 @@ fun AnimatedMessageStatus(
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
painter =
|
||||
when (currentStatus) {
|
||||
MessageStatus.SENDING -> TelegramIcons.Clock
|
||||
MessageStatus.SENT -> TelegramIcons.Done
|
||||
MessageStatus.DELIVERED -> TelegramIcons.Done
|
||||
MessageStatus.READ -> TelegramIcons.Done
|
||||
else -> TelegramIcons.Clock
|
||||
},
|
||||
contentDescription = null,
|
||||
tint = animatedColor,
|
||||
modifier = Modifier.size(iconSize).scale(scale)
|
||||
)
|
||||
if (currentStatus == MessageStatus.READ) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.width(iconSize + 6.dp)
|
||||
.height(iconSize)
|
||||
.scale(scale)
|
||||
) {
|
||||
Icon(
|
||||
painter = TelegramIcons.Done,
|
||||
contentDescription = null,
|
||||
tint = animatedColor,
|
||||
modifier =
|
||||
Modifier.size(iconSize)
|
||||
.align(Alignment.CenterStart)
|
||||
)
|
||||
Icon(
|
||||
painter = TelegramIcons.Done,
|
||||
contentDescription = null,
|
||||
tint = animatedColor,
|
||||
modifier =
|
||||
Modifier.size(iconSize)
|
||||
.align(Alignment.CenterStart)
|
||||
.offset(x = 4.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Icon(
|
||||
painter =
|
||||
when (currentStatus) {
|
||||
MessageStatus.SENDING ->
|
||||
TelegramIcons.Clock
|
||||
MessageStatus.SENT ->
|
||||
TelegramIcons.Done
|
||||
MessageStatus.DELIVERED ->
|
||||
TelegramIcons.Done
|
||||
else -> TelegramIcons.Clock
|
||||
},
|
||||
contentDescription = null,
|
||||
tint = animatedColor,
|
||||
modifier = Modifier.size(iconSize).scale(scale)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1833,8 +1862,7 @@ fun KebabMenu(
|
||||
isBlocked: Boolean,
|
||||
onBlockClick: () -> Unit,
|
||||
onUnblockClick: () -> Unit,
|
||||
onDeleteClick: () -> Unit,
|
||||
onLogsClick: () -> Unit = {}
|
||||
onDeleteClick: () -> Unit
|
||||
) {
|
||||
val menuBgColor = if (isDarkTheme) Color(0xFF272829) else Color.White
|
||||
val textColor = if (isDarkTheme) Color.White else Color(0xFF222222)
|
||||
|
||||
@@ -1,46 +1,44 @@
|
||||
package com.rosetta.messenger.ui.chats.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.rosetta.messenger.network.DeviceEntry
|
||||
import com.rosetta.messenger.repository.AvatarRepository
|
||||
import com.rosetta.messenger.ui.components.AvatarImage
|
||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||
|
||||
@Composable
|
||||
fun DeviceVerificationBanner(
|
||||
device: DeviceEntry,
|
||||
isDarkTheme: Boolean,
|
||||
accountPublicKey: String,
|
||||
avatarRepository: AvatarRepository?,
|
||||
onAccept: () -> Unit,
|
||||
onDecline: () -> Unit
|
||||
) {
|
||||
val itemBackground = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
|
||||
val titleColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val subtitleColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||
val titleColor = if (isDarkTheme) Color(0xFFF2F3F5) else Color(0xFF202124)
|
||||
val subtitleColor = if (isDarkTheme) Color(0xFFB7BAC1) else Color(0xFF6E7781)
|
||||
val acceptColor = PrimaryBlue
|
||||
val declineColor = Color(0xFFFF3B30)
|
||||
|
||||
val loginText =
|
||||
buildString {
|
||||
append("New login from ")
|
||||
append("We detected a new login to your account from ")
|
||||
append(device.deviceName)
|
||||
if (device.deviceOs.isNotBlank()) {
|
||||
append(" (")
|
||||
@@ -56,68 +54,62 @@ fun DeviceVerificationBanner(
|
||||
.background(itemBackground)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
) {
|
||||
Row {
|
||||
AvatarImage(
|
||||
publicKey = accountPublicKey,
|
||||
avatarRepository = avatarRepository,
|
||||
size = 56.dp,
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
Text(
|
||||
text = "Someone just got access to your messages!",
|
||||
color = titleColor,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = loginText,
|
||||
color = subtitleColor,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 18.sp,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
TextButton(
|
||||
onClick = onAccept,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
modifier = Modifier.height(30.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Someone just got access to your messages!",
|
||||
color = titleColor,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
text = "Yes, it's me",
|
||||
color = acceptColor,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(3.dp))
|
||||
Spacer(modifier = Modifier.width(20.dp))
|
||||
|
||||
TextButton(
|
||||
onClick = onDecline,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
modifier = Modifier.height(30.dp)
|
||||
) {
|
||||
Text(
|
||||
text = loginText,
|
||||
color = subtitleColor,
|
||||
fontSize = 13.sp,
|
||||
lineHeight = 17.sp,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
text = "No, it's not me!",
|
||||
color = declineColor,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
|
||||
Row {
|
||||
TextButton(
|
||||
onClick = onAccept,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
modifier = Modifier.height(32.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Yes, it's me",
|
||||
color = acceptColor,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
TextButton(
|
||||
onClick = onDecline,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
modifier = Modifier.height(32.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "No, it's not me!",
|
||||
color = declineColor,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -921,8 +921,22 @@ private suspend fun loadBitmapForViewerImage(
|
||||
val downloadTag = getDownloadTag(image.preview)
|
||||
if (downloadTag.isEmpty()) return null
|
||||
|
||||
val idShort =
|
||||
if (image.attachmentId.length <= 8) image.attachmentId else "${image.attachmentId.take(8)}..."
|
||||
val tagShort = if (downloadTag.length <= 8) downloadTag else "${downloadTag.take(8)}..."
|
||||
val server = TransportManager.getTransportServer() ?: "unset"
|
||||
AttachmentDownloadDebugLogger.log(
|
||||
"Viewer download start: id=$idShort, tag=$tagShort, server=$server"
|
||||
)
|
||||
|
||||
val encryptedContent = TransportManager.downloadFile(image.attachmentId, downloadTag)
|
||||
AttachmentDownloadDebugLogger.log(
|
||||
"Viewer CDN download OK: id=$idShort, bytes=${encryptedContent.length}"
|
||||
)
|
||||
val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(image.chachaKey, privateKey)
|
||||
AttachmentDownloadDebugLogger.log(
|
||||
"Viewer key decrypt OK: id=$idShort, keySize=${decryptedKeyAndNonce.size}"
|
||||
)
|
||||
val decrypted =
|
||||
MessageCrypto.decryptAttachmentBlobWithPlainKey(encryptedContent, decryptedKeyAndNonce)
|
||||
?: return null
|
||||
@@ -937,9 +951,15 @@ private suspend fun loadBitmapForViewerImage(
|
||||
publicKey = image.senderPublicKey,
|
||||
privateKey = privateKey
|
||||
)
|
||||
AttachmentDownloadDebugLogger.log("Viewer image ready: id=$idShort")
|
||||
|
||||
decodedBitmap
|
||||
} catch (_: Exception) {
|
||||
} catch (e: Exception) {
|
||||
val idShort =
|
||||
if (image.attachmentId.length <= 8) image.attachmentId else "${image.attachmentId.take(8)}..."
|
||||
AttachmentDownloadDebugLogger.log(
|
||||
"Viewer image ERROR: id=$idShort, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
|
||||
)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +89,6 @@ import com.rosetta.messenger.ui.chats.components.ViewableImage
|
||||
import com.rosetta.messenger.ui.components.AvatarImage
|
||||
import com.rosetta.messenger.ui.components.BlurredAvatarBackground
|
||||
import com.rosetta.messenger.ui.components.VerifiedBadge
|
||||
import com.rosetta.messenger.ui.components.metaball.ProfileMetaballEffect
|
||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||
import com.rosetta.messenger.utils.AttachmentFileManager
|
||||
import com.rosetta.messenger.utils.AvatarFileManager
|
||||
@@ -545,48 +544,7 @@ fun OtherProfileScreen(
|
||||
) {
|
||||
item {
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 📋 INFORMATION SECTION — первый элемент
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
TelegramSectionTitle(
|
||||
title = "Information",
|
||||
isDarkTheme = isDarkTheme,
|
||||
color = PrimaryBlue
|
||||
)
|
||||
|
||||
if (user.username.isNotBlank()) {
|
||||
TelegramCopyField(
|
||||
value = "@${user.username}",
|
||||
fullValue = user.username,
|
||||
label = "Username",
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
|
||||
Divider(
|
||||
color = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE0E0E0),
|
||||
thickness = 0.5.dp,
|
||||
modifier = Modifier.padding(start = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
TelegramCopyField(
|
||||
value = user.publicKey.take(16) + "..." + user.publicKey.takeLast(6),
|
||||
fullValue = user.publicKey,
|
||||
label = "Public Key",
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Разделитель секций
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(8.dp)
|
||||
.background(if (isDarkTheme) Color(0xFF0F0F0F) else Color(0xFFF0F0F0))
|
||||
)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// ✉️ WRITE MESSAGE + 📞 CALL BUTTONS
|
||||
// ✉️ MESSAGE + 📞 CALL — первые элементы
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
@@ -661,6 +619,47 @@ fun OtherProfileScreen(
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Разделитель секций
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(8.dp)
|
||||
.background(if (isDarkTheme) Color(0xFF0F0F0F) else Color(0xFFF0F0F0))
|
||||
)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 📋 INFORMATION SECTION — после кнопок
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
TelegramSectionTitle(
|
||||
title = "Information",
|
||||
isDarkTheme = isDarkTheme,
|
||||
color = PrimaryBlue
|
||||
)
|
||||
|
||||
if (user.username.isNotBlank()) {
|
||||
TelegramCopyField(
|
||||
value = "@${user.username}",
|
||||
fullValue = user.username,
|
||||
label = "Username",
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
|
||||
Divider(
|
||||
color = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE0E0E0),
|
||||
thickness = 0.5.dp,
|
||||
modifier = Modifier.padding(start = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
TelegramCopyField(
|
||||
value = user.publicKey.take(16) + "..." + user.publicKey.takeLast(6),
|
||||
fullValue = user.publicKey,
|
||||
label = "Public Key",
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 🔔 NOTIFICATIONS SECTION
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
@@ -1825,7 +1824,6 @@ private fun CollapsingOtherProfileHeader(
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 👤 AVATAR — Telegram-style expansion on pull-down
|
||||
// При скролле вверх: metaball merge с Dynamic Island
|
||||
// При свайпе вниз: аватарка раскрывается на весь блок (circle → rect)
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
val avatarSize = androidx.compose.ui.unit.lerp(
|
||||
@@ -1851,80 +1849,37 @@ private fun CollapsingOtherProfileHeader(
|
||||
val expandedAvatarXPx = with(density) { expandedAvatarX.toPx() }
|
||||
val expandedAvatarYPx = with(density) { expandedAvatarY.toPx() }
|
||||
|
||||
// Metaball alpha: visible only when NOT expanding (normal collapse animation)
|
||||
val metaballAlpha = (1f - expandFraction * 10f).coerceIn(0f, 1f)
|
||||
// Expansion avatar alpha: visible when expanding
|
||||
val expansionAvatarAlpha = (expandFraction * 10f).coerceIn(0f, 1f)
|
||||
|
||||
// Layer 1: Metaball effect for normal collapse (fades out when expanding)
|
||||
if (metaballAlpha > 0.01f) {
|
||||
Box(modifier = Modifier.fillMaxSize().graphicsLayer { alpha = metaballAlpha }) {
|
||||
ProfileMetaballEffect(
|
||||
collapseProgress = collapseProgress,
|
||||
expansionProgress = 0f,
|
||||
statusBarHeight = statusBarHeight,
|
||||
headerHeight = headerHeight,
|
||||
hasAvatar = hasAvatar,
|
||||
avatarColor = avatarColors.backgroundColor,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
if (hasAvatar && avatarRepository != null) {
|
||||
OtherProfileFullSizeAvatar(
|
||||
publicKey = publicKey,
|
||||
avatarRepository = avatarRepository,
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().background(avatarColors.backgroundColor),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = getInitials(name),
|
||||
fontSize = avatarFontSize,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = avatarColors.textColor
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = expandedAvatarWidth, height = expandedAvatarHeight)
|
||||
.graphicsLayer {
|
||||
translationX = expandedAvatarXPx
|
||||
translationY = expandedAvatarYPx
|
||||
alpha = avatarAlpha
|
||||
shape = RoundedCornerShape(cornerRadius)
|
||||
clip = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Layer 2: Expanding avatar (fades in when pulling down)
|
||||
if (expansionAvatarAlpha > 0.01f) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = expandedAvatarWidth, height = expandedAvatarHeight)
|
||||
.graphicsLayer {
|
||||
translationX = expandedAvatarXPx
|
||||
translationY = expandedAvatarYPx
|
||||
alpha = avatarAlpha * expansionAvatarAlpha
|
||||
shape = RoundedCornerShape(cornerRadius)
|
||||
clip = true
|
||||
}
|
||||
.background(avatarColors.backgroundColor),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (hasAvatar && avatarRepository != null) {
|
||||
OtherProfileFullSizeAvatar(
|
||||
publicKey = publicKey,
|
||||
avatarRepository = avatarRepository,
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = getInitials(name),
|
||||
fontSize = avatarFontSize,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = avatarColors.textColor
|
||||
)
|
||||
}
|
||||
.background(avatarColors.backgroundColor),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (hasAvatar && avatarRepository != null) {
|
||||
OtherProfileFullSizeAvatar(
|
||||
publicKey = publicKey,
|
||||
avatarRepository = avatarRepository,
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = getInitials(name),
|
||||
fontSize = avatarFontSize,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = avatarColors.textColor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Gradient overlays when avatar is expanded
|
||||
if (expansionAvatarAlpha > 0.01f) {
|
||||
if (expandFraction > 0.01f) {
|
||||
// Top gradient
|
||||
Box(
|
||||
modifier = Modifier
|
||||
|
||||
@@ -1795,7 +1795,7 @@ fun TelegramToggleItem(
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||
val iconColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||
val accentColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF0D8CF4)
|
||||
val accentColor = if (isDarkTheme) PrimaryBlueDark else Color(0xFF0D8CF4)
|
||||
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE0E0E0)
|
||||
|
||||
Column {
|
||||
|
||||
Reference in New Issue
Block a user