Релиз 1.4.8: Фиксы мелких UI / UX моментов
All checks were successful
Android Kernel Build / build (push) Successful in 19m24s

This commit is contained in:
2026-04-07 03:27:19 +05:00
32 changed files with 939 additions and 388 deletions

View File

@@ -16,10 +16,15 @@ import androidx.activity.compose.BackHandler
import androidx.compose.animation.*
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -67,6 +72,7 @@ import com.rosetta.messenger.ui.crashlogs.CrashLogsScreen
import com.rosetta.messenger.ui.onboarding.OnboardingScreen
import com.rosetta.messenger.ui.settings.BackupScreen
import com.rosetta.messenger.ui.settings.BiometricEnableScreen
import com.rosetta.messenger.ui.settings.NotificationsScreen
import com.rosetta.messenger.ui.settings.OtherProfileScreen
import com.rosetta.messenger.ui.settings.ProfileScreen
import com.rosetta.messenger.ui.settings.SafetyScreen
@@ -203,6 +209,8 @@ class MainActivity : FragmentActivity() {
var currentAccount by remember { mutableStateOf(getCachedSessionAccount()) }
var accountInfoList by remember { mutableStateOf<List<AccountInfo>>(emptyList()) }
var startCreateAccountFlow by remember { mutableStateOf(false) }
var preservedMainNavStack by remember { mutableStateOf<List<Screen>>(emptyList()) }
var preservedMainNavAccountKey by remember { mutableStateOf("") }
// Check for existing accounts and build AccountInfo list
LaunchedEffect(Unit) {
@@ -303,6 +311,8 @@ class MainActivity : FragmentActivity() {
},
onLogout = {
startCreateAccountFlow = false
preservedMainNavStack = emptyList()
preservedMainNavAccountKey = ""
// Set currentAccount to null immediately to prevent UI
// lag
currentAccount = null
@@ -316,8 +326,27 @@ class MainActivity : FragmentActivity() {
)
}
"main" -> {
val activeAccountKey = currentAccount?.publicKey.orEmpty()
MainScreen(
account = currentAccount,
initialNavStack =
if (
activeAccountKey.isNotBlank() &&
preservedMainNavAccountKey.equals(
activeAccountKey,
ignoreCase = true
)
) {
preservedMainNavStack
} else {
emptyList()
},
onNavStackChanged = { stack ->
if (activeAccountKey.isNotBlank()) {
preservedMainNavAccountKey = activeAccountKey
preservedMainNavStack = stack
}
},
isDarkTheme = isDarkTheme,
themeMode = themeMode,
onToggleTheme = {
@@ -331,6 +360,8 @@ class MainActivity : FragmentActivity() {
},
onLogout = {
startCreateAccountFlow = false
preservedMainNavStack = emptyList()
preservedMainNavAccountKey = ""
// Set currentAccount to null immediately to prevent UI
// lag
currentAccount = null
@@ -343,6 +374,8 @@ class MainActivity : FragmentActivity() {
},
onDeleteAccount = {
startCreateAccountFlow = false
preservedMainNavStack = emptyList()
preservedMainNavAccountKey = ""
val publicKey = currentAccount?.publicKey ?: return@MainScreen
scope.launch {
try {
@@ -405,6 +438,8 @@ class MainActivity : FragmentActivity() {
hasExistingAccount = accounts.isNotEmpty()
// 9. If current account is deleted, return to main login screen
if (currentAccount?.publicKey == targetPublicKey) {
preservedMainNavStack = emptyList()
preservedMainNavAccountKey = ""
currentAccount = null
clearCachedSessionAccount()
}
@@ -415,6 +450,8 @@ class MainActivity : FragmentActivity() {
},
onSwitchAccount = { targetPublicKey ->
startCreateAccountFlow = false
preservedMainNavStack = emptyList()
preservedMainNavAccountKey = ""
// Save target account before leaving main screen so Unlock
// screen preselects the account the user tapped.
accountManager.setLastLoggedPublicKey(targetPublicKey)
@@ -429,6 +466,8 @@ class MainActivity : FragmentActivity() {
},
onAddAccount = {
startCreateAccountFlow = true
preservedMainNavStack = emptyList()
preservedMainNavAccountKey = ""
currentAccount = null
clearCachedSessionAccount()
scope.launch {
@@ -442,6 +481,8 @@ class MainActivity : FragmentActivity() {
DeviceConfirmScreen(
isDarkTheme = isDarkTheme,
onExit = {
preservedMainNavStack = emptyList()
preservedMainNavAccountKey = ""
currentAccount = null
clearCachedSessionAccount()
scope.launch {
@@ -614,6 +655,7 @@ private fun EncryptedAccount.toAccountInfo(): AccountInfo {
sealed class Screen {
data object Profile : Screen()
data object ProfileFromChat : Screen()
data object Notifications : Screen()
data object Requests : Screen()
data object Search : Screen()
data object GroupSetup : Screen()
@@ -634,6 +676,8 @@ sealed class Screen {
@Composable
fun MainScreen(
account: DecryptedAccount? = null,
initialNavStack: List<Screen> = emptyList(),
onNavStackChanged: (List<Screen>) -> Unit = {},
isDarkTheme: Boolean = true,
themeMode: String = "dark",
onToggleTheme: () -> Unit = {},
@@ -941,7 +985,8 @@ fun MainScreen(
// navigation change. This eliminates the massive recomposition
// that happened when ANY boolean flag changed.
// ═══════════════════════════════════════════════════════════
var navStack by remember { mutableStateOf<List<Screen>>(emptyList()) }
var navStack by remember(accountPublicKey) { mutableStateOf(initialNavStack) }
LaunchedEffect(navStack) { onNavStackChanged(navStack) }
// Derived visibility — only triggers recomposition when THIS screen changes
val isProfileVisible by remember { derivedStateOf { navStack.any { it is Screen.Profile } } }
@@ -949,6 +994,9 @@ fun MainScreen(
derivedStateOf { navStack.any { it is Screen.ProfileFromChat } }
}
val isRequestsVisible by remember { derivedStateOf { navStack.any { it is Screen.Requests } } }
val isNotificationsVisible by remember {
derivedStateOf { navStack.any { it is Screen.Notifications } }
}
val isSearchVisible by remember { derivedStateOf { navStack.any { it is Screen.Search } } }
val isGroupSetupVisible by remember { derivedStateOf { navStack.any { it is Screen.GroupSetup } } }
val chatDetailScreen by remember {
@@ -980,6 +1028,9 @@ fun MainScreen(
val isAppearanceVisible by remember {
derivedStateOf { navStack.any { it is Screen.Appearance } }
}
var profileHasUnsavedChanges by remember(accountPublicKey) { mutableStateOf(false) }
var showDiscardProfileChangesDialog by remember { mutableStateOf(false) }
var discardProfileChangesFromChat by remember { mutableStateOf(false) }
// Navigation helpers
fun pushScreen(screen: Screen) {
@@ -1027,7 +1078,8 @@ fun MainScreen(
navStack =
navStack.filterNot {
it is Screen.Profile ||
it is Screen.Theme ||
it is Screen.Theme ||
it is Screen.Notifications ||
it is Screen.Safety ||
it is Screen.Backup ||
it is Screen.Logs ||
@@ -1036,6 +1088,21 @@ fun MainScreen(
it is Screen.Appearance
}
}
fun performProfileBack(fromChat: Boolean) {
if (fromChat) {
navStack = navStack.filterNot { it is Screen.ProfileFromChat }
} else {
popProfileAndChildren()
}
}
fun requestProfileBack(fromChat: Boolean) {
if (profileHasUnsavedChanges) {
discardProfileChangesFromChat = fromChat
showDiscardProfileChangesDialog = true
} else {
performProfileBack(fromChat)
}
}
fun popChatAndChildren() {
navStack =
navStack.filterNot {
@@ -1043,6 +1110,13 @@ fun MainScreen(
}
}
LaunchedEffect(isProfileVisible, isProfileFromChatVisible) {
if (!isProfileVisible && !isProfileFromChatVisible) {
profileHasUnsavedChanges = false
showDiscardProfileChangesDialog = false
}
}
// ProfileViewModel для логов
val profileViewModel: com.rosetta.messenger.ui.settings.ProfileViewModel =
androidx.lifecycle.viewmodel.compose.viewModel()
@@ -1196,9 +1270,10 @@ fun MainScreen(
// ═══════════════════════════════════════════════════════════
SwipeBackContainer(
isVisible = isProfileVisible,
onBack = { popProfileAndChildren() },
onBack = { requestProfileBack(fromChat = false) },
isDarkTheme = isDarkTheme,
layer = 1,
swipeEnabled = !profileHasUnsavedChanges,
propagateBackgroundProgress = false
) {
// Экран профиля
@@ -1209,13 +1284,15 @@ fun MainScreen(
accountVerified = accountVerified,
accountPublicKey = accountPublicKey,
accountPrivateKeyHash = privateKeyHash,
onBack = { popProfileAndChildren() },
onBack = { requestProfileBack(fromChat = false) },
onHasChangesChanged = { profileHasUnsavedChanges = it },
onSaveProfile = { name, username ->
accountName = name
accountUsername = username
mainScreenScope.launch { onAccountInfoUpdated() }
},
onLogout = onLogout,
onNavigateToNotifications = { pushScreen(Screen.Notifications) },
onNavigateToTheme = { pushScreen(Screen.Theme) },
onNavigateToAppearance = { pushScreen(Screen.Appearance) },
onNavigateToSafety = { pushScreen(Screen.Safety) },
@@ -1229,6 +1306,18 @@ fun MainScreen(
}
// Other screens with swipe back
SwipeBackContainer(
isVisible = isNotificationsVisible,
onBack = { navStack = navStack.filterNot { it is Screen.Notifications } },
isDarkTheme = isDarkTheme,
layer = 2
) {
NotificationsScreen(
isDarkTheme = isDarkTheme,
onBack = { navStack = navStack.filterNot { it is Screen.Notifications } }
)
}
SwipeBackContainer(
isVisible = isSafetyVisible,
onBack = { navStack = navStack.filterNot { it is Screen.Safety } },
@@ -1420,9 +1509,10 @@ fun MainScreen(
SwipeBackContainer(
isVisible = isProfileFromChatVisible,
onBack = { navStack = navStack.filterNot { it is Screen.ProfileFromChat } },
onBack = { requestProfileBack(fromChat = true) },
isDarkTheme = isDarkTheme,
layer = 1,
swipeEnabled = !profileHasUnsavedChanges,
propagateBackgroundProgress = false
) {
ProfileScreen(
@@ -1432,13 +1522,15 @@ fun MainScreen(
accountVerified = accountVerified,
accountPublicKey = accountPublicKey,
accountPrivateKeyHash = privateKeyHash,
onBack = { navStack = navStack.filterNot { it is Screen.ProfileFromChat } },
onBack = { requestProfileBack(fromChat = true) },
onHasChangesChanged = { profileHasUnsavedChanges = it },
onSaveProfile = { name, username ->
accountName = name
accountUsername = username
mainScreenScope.launch { onAccountInfoUpdated() }
},
onLogout = onLogout,
onNavigateToNotifications = { pushScreen(Screen.Notifications) },
onNavigateToTheme = { pushScreen(Screen.Theme) },
onNavigateToAppearance = { pushScreen(Screen.Appearance) },
onNavigateToSafety = { pushScreen(Screen.Safety) },
@@ -1498,8 +1590,7 @@ fun MainScreen(
isVisible = isSearchVisible,
onBack = { navStack = navStack.filterNot { it is Screen.Search } },
isDarkTheme = isDarkTheme,
layer = 1,
deferToChildren = true
layer = 1
) {
// Экран поиска
SearchScreen(
@@ -1690,6 +1781,21 @@ fun MainScreen(
)
}
if (isCallScreenVisible) {
// Блокируем любой ввод по нижележащим экранам, пока открыт полноэкранный CallOverlay.
// Иначе тапы могут "пробивать" в чат (иконка звонка, kebab, input и т.д.).
val blockerInteraction = remember { MutableInteractionSource() }
Box(
modifier = Modifier
.fillMaxSize()
.clickable(
interactionSource = blockerInteraction,
indication = null,
onClick = {}
)
)
}
CallOverlay(
state = callUiState,
isDarkTheme = isDarkTheme,
@@ -1706,5 +1812,43 @@ fun MainScreen(
}
}
)
if (showDiscardProfileChangesDialog) {
AlertDialog(
onDismissRequest = { showDiscardProfileChangesDialog = false },
containerColor = if (isDarkTheme) Color(0xFF1E1E20) else Color.White,
title = {
Text(
text = "Discard changes?",
color = if (isDarkTheme) Color.White else Color.Black
)
},
text = {
Text(
text = "You have unsaved profile changes. If you leave now, they will be lost.",
color = if (isDarkTheme) Color(0xFFB5B5BC) else Color(0xFF5A5A66)
)
},
confirmButton = {
TextButton(
onClick = {
showDiscardProfileChangesDialog = false
profileHasUnsavedChanges = false
performProfileBack(discardProfileChangesFromChat)
}
) {
Text("Discard", color = Color(0xFFFF3B30))
}
},
dismissButton = {
TextButton(onClick = { showDiscardProfileChangesDialog = false }) {
Text(
"Stay",
color = if (isDarkTheme) Color(0xFF5FA8FF) else Color(0xFF0D8CF4)
)
}
}
)
}
}
}

View File

@@ -448,6 +448,18 @@ class MessageRepository private constructor(private val context: Context) {
return if (raw < 1_000_000_000_000L) raw * 1000L else raw
}
/**
* Normalize incoming message timestamp for chat ordering:
* 1) accept both seconds and milliseconds;
* 2) never allow a message timestamp from the future on this device.
*/
private fun normalizeIncomingPacketTimestamp(rawTimestamp: Long, receivedAtMs: Long): Long {
val normalizedRaw =
if (rawTimestamp in 1..999_999_999_999L) rawTimestamp * 1000L else rawTimestamp
if (normalizedRaw <= 0L) return receivedAtMs
return minOf(normalizedRaw, receivedAtMs)
}
/** Получить поток сообщений для диалога */
fun getMessagesFlow(opponentKey: String): StateFlow<List<Message>> {
val dialogKey = getDialogKey(opponentKey)
@@ -711,6 +723,13 @@ class MessageRepository private constructor(private val context: Context) {
val isOwnMessage = packet.fromPublicKey == account
val isGroupMessage = isGroupDialogKey(packet.toPublicKey)
val normalizedPacketTimestamp =
normalizeIncomingPacketTimestamp(packet.timestamp, startTime)
if (normalizedPacketTimestamp != packet.timestamp) {
MessageLogger.debug(
"📥 TIMESTAMP normalized: raw=${packet.timestamp} -> local=$normalizedPacketTimestamp"
)
}
// 🔥 Проверяем, не заблокирован ли отправитель
if (!isOwnMessage && !isGroupDialogKey(packet.fromPublicKey)) {
@@ -911,7 +930,7 @@ class MessageRepository private constructor(private val context: Context) {
fromPublicKey = packet.fromPublicKey,
toPublicKey = packet.toPublicKey,
content = packet.content,
timestamp = packet.timestamp,
timestamp = normalizedPacketTimestamp,
chachaKey = storedChachaKey,
read = 0,
fromMe = if (isOwnMessage) 1 else 0,

View File

@@ -17,20 +17,19 @@ object ReleaseNotes {
val RELEASE_NOTICE = """
Update v$VERSION_PLACEHOLDER
Звонки и lockscreen
- MainActivity больше не открывается поверх экрана блокировки: чаты не раскрываются без разблокировки устройства
- Во входящем полноэкранном звонке отключено автоматическое снятие keyguard
- Исправлено краткое появление "Unknown" при завершении полноэкранного звонка
- При принятии звонка из push добавлено восстановление auth из локального кеша и ускорена отправка ACCEPT
Синхронизация (как на Desktop)
- Во время sync экран чатов показывает "Updating..." и скрывает шумящие промежуточные индикаторы
- На период синхронизации скрываются badge'ы непрочитанного и requests, чтобы список не "прыгал"
Сеть и протокол
- Добавлено ожидание активной сети перед reconnect (ConnectivityManager callback + timeout fallback)
- Разрешена pre-auth отправка call/WebRTC/ICE пакетов после открытия сокета
- Очередь исходящих пакетов теперь сбрасывается сразу в onOpen и отправляется state-aware
Медиа и вложения
- Исправлен кейс, когда фото уже отправлено, но локально оставалось в ERROR с красным индикатором
- Для исходящих медиа стабилизирован переход статусов: после успешной отправки фиксируется SENT без ложного timeout->ERROR
- Таймаут/ретрай WAITING из БД больше не портит медиа-вложения (применяется только к обычным текстовым ожиданиям)
- Для legacy/неподдерживаемых attachment добавлен desktop-style fallback:
"This attachment is no longer available because it was sent for a previous version of the app."
Стабильность UI
- Crash Details защищён от очень больших логов (без падений при открытии тяжёлых отчётов)
- SharedMedia fast-scroll overlay стабилизирован от NaN/Infinity координат
Группы и UI
- Исправлена геометрия входящих фото в группах: пузырь больше не прилипает к аватарке
""".trimIndent()
fun getNotice(version: String): String =

View File

@@ -238,7 +238,7 @@ interface MessageDao {
"""
SELECT * FROM messages
WHERE account = :account AND dialog_key = :dialogKey
ORDER BY timestamp DESC, message_id DESC
ORDER BY timestamp DESC, id DESC
LIMIT :limit OFFSET :offset
"""
)
@@ -260,7 +260,7 @@ interface MessageDao {
WHERE account = :account
AND from_public_key = :account
AND to_public_key = :account
ORDER BY timestamp DESC, message_id DESC
ORDER BY timestamp DESC, id DESC
LIMIT :limit OFFSET :offset
"""
)
@@ -286,7 +286,7 @@ interface MessageDao {
"""
SELECT * FROM messages
WHERE account = :account AND dialog_key = :dialogKey
ORDER BY timestamp ASC, message_id ASC
ORDER BY timestamp ASC, id ASC
"""
)
fun getMessagesFlow(account: String, dialogKey: String): Flow<List<MessageEntity>>
@@ -319,7 +319,7 @@ interface MessageDao {
"""
SELECT * FROM messages
WHERE account = :account AND dialog_key = :dialogKey
ORDER BY timestamp DESC, message_id DESC
ORDER BY timestamp DESC, id DESC
LIMIT :limit
"""
)
@@ -378,7 +378,7 @@ interface MessageDao {
AND dialog_key = :dialogKey
AND from_public_key = :fromPublicKey
AND timestamp BETWEEN :timestampFrom AND :timestampTo
ORDER BY timestamp ASC, message_id ASC
ORDER BY timestamp ASC, id ASC
LIMIT 1
"""
)
@@ -462,7 +462,7 @@ interface MessageDao {
WHERE account = :account
AND ((from_public_key = :opponent AND to_public_key = :account)
OR (from_public_key = :account AND to_public_key = :opponent))
ORDER BY timestamp DESC, message_id DESC LIMIT 1
ORDER BY timestamp DESC, id DESC LIMIT 1
"""
)
suspend fun getLastMessageDebug(account: String, opponent: String): MessageEntity?
@@ -477,7 +477,7 @@ interface MessageDao {
WHERE account = :account
AND ((from_public_key = :opponent AND to_public_key = :account)
OR (from_public_key = :account AND to_public_key = :opponent))
ORDER BY timestamp DESC, message_id DESC LIMIT 1
ORDER BY timestamp DESC, id DESC LIMIT 1
"""
)
suspend fun getLastMessageStatus(account: String, opponent: String): LastMessageStatus?
@@ -492,7 +492,7 @@ interface MessageDao {
WHERE account = :account
AND ((from_public_key = :opponent AND to_public_key = :account)
OR (from_public_key = :account AND to_public_key = :opponent))
ORDER BY timestamp DESC, message_id DESC LIMIT 1
ORDER BY timestamp DESC, id DESC LIMIT 1
"""
)
suspend fun getLastMessageAttachments(account: String, opponent: String): String?
@@ -508,6 +508,7 @@ interface MessageDao {
WHERE account = :account
AND from_me = 1
AND delivered = 0
AND (attachments IS NULL OR TRIM(attachments) = '' OR TRIM(attachments) = '[]')
AND timestamp >= :minTimestamp
ORDER BY timestamp ASC
"""
@@ -524,6 +525,7 @@ interface MessageDao {
WHERE account = :account
AND from_me = 1
AND delivered = 0
AND (attachments IS NULL OR TRIM(attachments) = '' OR TRIM(attachments) = '[]')
AND timestamp < :maxTimestamp
"""
)
@@ -629,7 +631,7 @@ interface MessageDao {
END
WHERE m.account = :account
AND m.primary_attachment_type = 4
ORDER BY m.timestamp DESC, m.message_id DESC
ORDER BY m.timestamp DESC, m.id DESC
LIMIT :limit
"""
)
@@ -978,7 +980,7 @@ interface DialogDao {
"""
SELECT * FROM messages
WHERE account = :account AND dialog_key = :dialogKey
ORDER BY timestamp DESC, message_id DESC LIMIT 1
ORDER BY timestamp DESC, id DESC LIMIT 1
"""
)
suspend fun getLastMessageByDialogKey(account: String, dialogKey: String): MessageEntity?

View File

@@ -332,7 +332,7 @@ abstract class RosettaDatabase : RoomDatabase() {
THEN dialogs.account || ':' || dialogs.opponent_key
ELSE dialogs.opponent_key || ':' || dialogs.account
END
ORDER BY m.timestamp DESC, m.message_id DESC
ORDER BY m.timestamp DESC, m.id DESC
LIMIT 1
),
''

View File

@@ -19,7 +19,6 @@ import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
import kotlin.coroutines.coroutineContext
/**
@@ -44,6 +43,7 @@ object TransportManager {
private val _uploading = MutableStateFlow<List<TransportState>>(emptyList())
val uploading: StateFlow<List<TransportState>> = _uploading.asStateFlow()
private val activeUploadCalls = ConcurrentHashMap<String, Call>()
private val _downloading = MutableStateFlow<List<TransportState>>(emptyList())
val downloading: StateFlow<List<TransportState>> = _downloading.asStateFlow()
@@ -133,6 +133,14 @@ object TransportManager {
_downloading.value = _downloading.value.filter { it.id != id }
}
/**
* Принудительно отменяет активный HTTP call для upload attachment.
*/
fun cancelUpload(id: String) {
activeUploadCalls.remove(id)?.cancel()
_uploading.value = _uploading.value.filter { it.id != id }
}
private suspend fun awaitDownloadResponse(id: String, request: Request): Response =
suspendCancellableCoroutine { cont ->
val call = client.newCall(request)
@@ -224,13 +232,31 @@ object TransportManager {
.post(requestBody)
.build()
val response = suspendCoroutine<Response> { cont ->
client.newCall(request).enqueue(object : Callback {
val response = suspendCancellableCoroutine<Response> { cont ->
val call = client.newCall(request)
activeUploadCalls[id] = call
cont.invokeOnCancellation {
activeUploadCalls.remove(id, call)
call.cancel()
}
call.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
cont.resumeWithException(e)
activeUploadCalls.remove(id, call)
if (call.isCanceled()) {
cont.cancel(CancellationException("Upload cancelled"))
} else {
cont.resumeWithException(e)
}
}
override fun onResponse(call: Call, response: Response) {
activeUploadCalls.remove(id, call)
if (cont.isCancelled) {
response.close()
return
}
cont.resume(response)
}
})
@@ -253,12 +279,16 @@ object TransportManager {
tag
}
} catch (e: CancellationException) {
ProtocolManager.addLog("🛑 Upload cancelled: id=${id.take(8)}")
throw e
} catch (e: Exception) {
ProtocolManager.addLog(
"❌ Upload failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
)
throw e
} finally {
activeUploadCalls.remove(id)?.cancel()
// Удаляем из списка загрузок
_uploading.value = _uploading.value.filter { it.id != id }
}

View File

@@ -12,7 +12,6 @@ import androidx.compose.runtime.*
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.FontFamily
import androidx.compose.ui.text.font.FontWeight
@@ -35,19 +34,18 @@ fun SeedPhraseScreen(
val backgroundColor = if (isDarkTheme) Color(0xFF1E1E1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val cardBackground = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF5F5F5)
var seedPhrase by remember { mutableStateOf<List<String>>(emptyList()) }
var isGenerating by remember { mutableStateOf(true) }
var hasCopied by remember { mutableStateOf(false) }
var visible by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val clipboardManager = LocalClipboardManager.current
LaunchedEffect(Unit) {
delay(100)
// Генерируем фразу сразу, без задержек
seedPhrase = CryptoManager.generateSeedPhrase()
isGenerating = false
// Даем микро-паузу, чтобы верстка отрисовалась, и запускаем анимацию
delay(50)
visible = true
}
@@ -59,7 +57,7 @@ fun SeedPhraseScreen(
.navigationBarsPadding()
) {
Column(modifier = Modifier.fillMaxSize()) {
// Simple top bar
// Top bar
Row(
modifier = Modifier
.fillMaxWidth()
@@ -108,126 +106,106 @@ fun SeedPhraseScreen(
Spacer(modifier = Modifier.height(32.dp))
// Two column layout
if (isGenerating) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(300.dp),
contentAlignment = Alignment.Center
// Сетка со словами (без Crossfade и лоадера)
if (seedPhrase.isNotEmpty()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
CircularProgressIndicator(
color = PrimaryBlue,
strokeWidth = 2.dp,
modifier = Modifier.size(40.dp)
)
}
} else {
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(500, delayMillis = 200))
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
// Левая колонка (1-6)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Left column (words 1-6)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
for (i in 0..5) {
AnimatedWordItem(
number = i + 1,
word = seedPhrase[i],
isDarkTheme = isDarkTheme,
visible = visible,
delay = 300 + (i * 50)
)
}
for (i in 0..5) {
AnimatedWordItem(
number = i + 1,
word = seedPhrase[i],
isDarkTheme = isDarkTheme,
visible = visible,
delay = i * 60
)
}
// Right column (words 7-12)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
for (i in 6..11) {
AnimatedWordItem(
number = i + 1,
word = seedPhrase[i],
isDarkTheme = isDarkTheme,
visible = visible,
delay = 300 + (i * 50)
)
}
}
// Правая колонка (7-12)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
for (i in 6..11) {
AnimatedWordItem(
number = i + 1,
word = seedPhrase[i],
isDarkTheme = isDarkTheme,
visible = visible,
delay = i * 60
)
}
}
}
}
Spacer(modifier = Modifier.height(20.dp))
Spacer(modifier = Modifier.height(24.dp))
// Copy button
if (!isGenerating) {
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(500, delayMillis = 600)) + scaleIn(
initialScale = 0.8f,
animationSpec = tween(500, delayMillis = 600)
)
) {
TextButton(
onClick = {
clipboardManager.setText(AnnotatedString(seedPhrase.joinToString(" ")))
hasCopied = true
scope.launch {
delay(2000)
hasCopied = false
}
// Кнопка Copy
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(400, delayMillis = 800)) + scaleIn(
initialScale = 0.8f,
animationSpec = tween(400, delayMillis = 800, easing = LinearOutSlowInEasing)
)
) {
TextButton(
onClick = {
clipboardManager.setText(AnnotatedString(seedPhrase.joinToString(" ")))
hasCopied = true
scope.launch {
delay(2000)
hasCopied = false
}
) {
Icon(
imageVector = if (hasCopied) Icons.Default.Check else Icons.Default.ContentCopy,
contentDescription = null,
tint = if (hasCopied) Color(0xFF4CAF50) else PrimaryBlue,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = if (hasCopied) "Copied" else "Copy to clipboard",
color = if (hasCopied) Color(0xFF4CAF50) else PrimaryBlue,
fontSize = 15.sp
)
}
) {
Icon(
imageVector = if (hasCopied) Icons.Default.Check else Icons.Default.ContentCopy,
contentDescription = null,
tint = if (hasCopied) Color(0xFF4CAF50) else PrimaryBlue,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = if (hasCopied) "Copied" else "Copy to clipboard",
color = if (hasCopied) Color(0xFF4CAF50) else PrimaryBlue,
fontSize = 15.sp
)
}
}
Spacer(modifier = Modifier.weight(1f))
// Continue button
// Кнопка Continue
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(400, delayMillis = 700))
enter = fadeIn(tween(400, delayMillis = 900)) + slideInVertically(
initialOffsetY = { 20 },
animationSpec = tween(400, delayMillis = 900)
)
) {
Button(
onClick = { onConfirm(seedPhrase) },
enabled = !isGenerating,
modifier = Modifier
.fillMaxWidth()
.height(50.dp),
.height(52.dp),
colors = ButtonDefaults.buttonColors(
containerColor = PrimaryBlue,
contentColor = Color.White,
disabledContainerColor = PrimaryBlue.copy(alpha = 0.5f),
disabledContentColor = Color.White.copy(alpha = 0.5f)
contentColor = Color.White
),
shape = RoundedCornerShape(12.dp)
shape = RoundedCornerShape(14.dp)
) {
Text(
text = "Continue",
fontSize = 17.sp,
fontWeight = FontWeight.Medium
fontWeight = FontWeight.SemiBold
)
}
}
@@ -246,21 +224,11 @@ private fun WordItem(
modifier: Modifier = Modifier
) {
val numberColor = if (isDarkTheme) Color(0xFF888888) else Color(0xFF999999)
// Beautiful solid colors that fit the theme
val wordColors = listOf(
Color(0xFF5E9FFF), // Soft blue
Color(0xFFFF7EB3), // Soft pink
Color(0xFF7B68EE), // Medium purple
Color(0xFF50C878), // Emerald green
Color(0xFFFF6B6B), // Coral red
Color(0xFF4ECDC4), // Teal
Color(0xFFFFB347), // Pastel orange
Color(0xFFBA55D3), // Medium orchid
Color(0xFF87CEEB), // Sky blue
Color(0xFFDDA0DD), // Plum
Color(0xFF98D8C8), // Mint
Color(0xFFF7DC6F) // Soft yellow
Color(0xFF5E9FFF), Color(0xFFFF7EB3), Color(0xFF7B68EE),
Color(0xFF50C878), Color(0xFFFF6B6B), Color(0xFF4ECDC4),
Color(0xFFFFB347), Color(0xFFBA55D3), Color(0xFF87CEEB),
Color(0xFFDDA0DD), Color(0xFF98D8C8), Color(0xFFF7DC6F)
)
val wordColor = wordColors[(number - 1) % wordColors.size]
@@ -271,21 +239,18 @@ private fun WordItem(
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(bgColor)
.padding(horizontal = 16.dp, vertical = 14.dp)
.padding(horizontal = 14.dp, vertical = 12.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "$number.",
fontSize = 15.sp,
fontSize = 13.sp,
color = numberColor,
modifier = Modifier.width(28.dp)
modifier = Modifier.width(26.dp)
)
Text(
text = word,
fontSize = 17.sp,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = wordColor,
fontFamily = FontFamily.Monospace
@@ -303,15 +268,21 @@ private fun AnimatedWordItem(
delay: Int,
modifier: Modifier = Modifier
) {
val overshootEasing = remember { CubicBezierEasing(0.175f, 0.885f, 0.32f, 1.275f) }
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(400, delayMillis = delay))
enter = fadeIn(animationSpec = tween(300, delayMillis = delay)) +
slideInVertically(
initialOffsetY = { 30 },
animationSpec = tween(400, delayMillis = delay, easing = FastOutSlowInEasing)
) +
scaleIn(
initialScale = 0.85f,
animationSpec = tween(400, delayMillis = delay, easing = overshootEasing)
),
modifier = modifier
) {
WordItem(
number = number,
word = word,
isDarkTheme = isDarkTheme,
modifier = modifier
)
WordItem(number, word, isDarkTheme)
}
}
}

View File

@@ -98,7 +98,7 @@ fun SelectAccountScreen(
DisposableEffect(isDarkTheme) {
val window = (view.context as android.app.Activity).window
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
insetsController.isAppearanceLightStatusBars = !isDarkTheme
insetsController.isAppearanceLightStatusBars = false
onDispose { }
}

View File

@@ -3306,6 +3306,14 @@ fun ChatDetailScreen(
message.id
)
},
onCancelPhotoUpload = {
attachmentId ->
viewModel
.cancelOutgoingImageUpload(
message.id,
attachmentId
)
},
onImageClick = {
attachmentId,
bounds

View File

@@ -45,10 +45,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private const val MAX_CACHE_SIZE = 500 // 🔥 Максимум сообщений в кэше для защиты от OOM
private const val DRAFT_SAVE_DEBOUNCE_MS = 250L
private val backgroundUploadScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val chatMessageAscComparator =
compareBy<ChatMessage>({ it.timestamp.time }, { it.id })
// Keep sort stable for equal timestamps: tie order comes from existing list/insertion order.
private val chatMessageAscComparator = compareBy<ChatMessage> { it.timestamp.time }
private val chatMessageDescComparator =
compareByDescending<ChatMessage> { it.timestamp.time }.thenByDescending { it.id }
compareByDescending<ChatMessage> { it.timestamp.time }
private fun sortMessagesAsc(messages: List<ChatMessage>): List<ChatMessage> =
messages.sortedWith(chatMessageAscComparator)
@@ -227,6 +227,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Job для отмены загрузки при смене диалога
private var loadingJob: Job? = null
private var draftSaveJob: Job? = null
private val outgoingImageUploadJobs = ConcurrentHashMap<String, Job>()
// 🔥 Throttling для typing индикатора
private var lastTypingSentTime = 0L
@@ -619,6 +620,50 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
updateCacheFromCurrentMessages()
}
/**
* Для исходящих media-сообщений (фото/файл/аватар) не держим "часики" после фактической отправки:
* optimistic WAITING в БД должен отображаться как SENT, если localUri уже очищен.
*/
private fun shouldTreatWaitingAsSent(entity: MessageEntity): Boolean {
if (entity.fromMe != 1 || entity.primaryAttachmentType < 0) return false
val attachments = parseAttachmentsJsonArray(entity.attachments) ?: return false
if (attachments.length() == 0) return false
var hasMediaAttachment = false
for (index in 0 until attachments.length()) {
val attachment = attachments.optJSONObject(index) ?: continue
when (parseAttachmentType(attachment)) {
AttachmentType.IMAGE,
AttachmentType.FILE,
AttachmentType.AVATAR -> {
hasMediaAttachment = true
if (attachment.optString("localUri", "").isNotBlank()) {
// Локальный URI ещё есть => загрузка/подготовка не завершена.
return false
}
}
AttachmentType.UNKNOWN -> continue
else -> return false
}
}
return hasMediaAttachment
}
private fun mapEntityDeliveryStatus(entity: MessageEntity): MessageStatus {
return when (entity.delivered) {
DeliveryStatus.WAITING.value ->
if (shouldTreatWaitingAsSent(entity)) MessageStatus.SENT
else MessageStatus.SENDING
DeliveryStatus.DELIVERED.value ->
if (entity.read == 1) MessageStatus.READ else MessageStatus.DELIVERED
DeliveryStatus.ERROR.value -> MessageStatus.ERROR
DeliveryStatus.READ.value -> MessageStatus.READ
else -> MessageStatus.SENT
}
}
private fun shortPhotoId(value: String, limit: Int = 8): String {
val trimmed = value.trim()
if (trimmed.isEmpty()) return "unknown"
@@ -1044,14 +1089,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
currentMessages.map { message ->
val entity = entitiesById[message.id] ?: return@map message
val dbStatus =
when (entity.delivered) {
0 -> MessageStatus.SENDING
1 -> if (entity.read == 1) MessageStatus.READ else MessageStatus.DELIVERED
2 -> MessageStatus.ERROR
3 -> MessageStatus.READ
else -> MessageStatus.SENT
}
val dbStatus = mapEntityDeliveryStatus(entity)
var updatedMessage = message
if (updatedMessage.status != dbStatus) {
@@ -1370,14 +1408,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
text = displayText,
isOutgoing = entity.fromMe == 1,
timestamp = Date(entity.timestamp),
status =
when (entity.delivered) {
0 -> MessageStatus.SENDING
1 -> if (entity.read == 1) MessageStatus.READ else MessageStatus.DELIVERED
2 -> MessageStatus.ERROR
3 -> MessageStatus.READ
else -> MessageStatus.SENT
},
status = mapEntityDeliveryStatus(entity),
replyData = if (forwardedMessages.isNotEmpty()) null else replyData,
forwardedMessages = forwardedMessages,
attachments = finalAttachments,
@@ -2579,6 +2610,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
}
/** 🛑 Отменить исходящую отправку фото во время загрузки */
fun cancelOutgoingImageUpload(messageId: String, attachmentId: String) {
ProtocolManager.addLog(
"🛑 IMG cancel requested: msg=${messageId.take(8)}, att=${attachmentId.take(12)}"
)
outgoingImageUploadJobs.remove(messageId)?.cancel(
CancellationException("User cancelled image upload")
)
TransportManager.cancelUpload(attachmentId)
ProtocolManager.resolveOutgoingRetry(messageId)
deleteMessage(messageId)
}
/** 🔥 Удалить сообщение (для ошибки отправки) */
fun deleteMessage(messageId: String) {
val account = myPublicKey ?: return
@@ -3514,7 +3558,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 2. 🔄 В фоне, независимо от жизненного цикла экрана:
// сохраняем optimistic в БД -> конвертируем -> загружаем -> отправляем пакет.
backgroundUploadScope.launch {
val uploadJob = backgroundUploadScope.launch {
try {
logPhotoPipeline(messageId, "persist optimistic message in DB")
val optimisticAttachmentsJson =
@@ -3554,6 +3598,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
opponentPublicKey = recipient
)
logPhotoPipeline(messageId, "optimistic dialog updated")
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
logPhotoPipeline(messageId, "optimistic DB save skipped (non-fatal)")
}
@@ -3603,6 +3649,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
privateKey = privateKey
)
logPhotoPipeline(messageId, "pipeline completed")
} catch (e: CancellationException) {
logPhotoPipeline(messageId, "pipeline cancelled by user")
throw e
} catch (e: Exception) {
logPhotoPipelineError(messageId, "prepare+convert", e)
if (!isCleared) {
@@ -3612,6 +3661,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
}
}
outgoingImageUploadJobs[messageId] = uploadJob
uploadJob.invokeOnCompletion { outgoingImageUploadJobs.remove(messageId) }
}
/** 🔄 Обновляет optimistic сообщение с реальными данными изображения */
@@ -3655,6 +3706,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
sender: String,
privateKey: String
) {
var packetSentToProtocol = false
try {
val context = getApplication<Application>()
val pipelineStartedAt = System.currentTimeMillis()
@@ -3746,6 +3798,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Отправляем пакет
if (!isSavedMessages) {
ProtocolManager.send(packet)
packetSentToProtocol = true
logPhotoPipeline(messageId, "packet sent to protocol")
} else {
logPhotoPipeline(messageId, "saved-messages mode: packet send skipped")
@@ -3790,17 +3843,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
logPhotoPipeline(messageId, "db status+attachments updated")
withContext(Dispatchers.Main) {
if (isSavedMessages) {
updateMessageStatus(messageId, MessageStatus.SENT)
}
updateMessageStatus(messageId, MessageStatus.SENT)
// Также очищаем localUri в UI
updateMessageAttachments(messageId, null)
}
logPhotoPipeline(
messageId,
if (isSavedMessages) "ui status switched to SENT"
else "ui status kept at SENDING until delivery ACK"
)
logPhotoPipeline(messageId, "ui status switched to SENT")
saveDialog(
lastMessage = if (caption.isNotEmpty()) caption else "photo",
@@ -3811,9 +3858,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
messageId,
"dialog updated; totalElapsed=${System.currentTimeMillis() - pipelineStartedAt}ms"
)
} catch (e: CancellationException) {
logPhotoPipeline(messageId, "internal-send cancelled")
throw e
} catch (e: Exception) {
logPhotoPipelineError(messageId, "internal-send", e)
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) }
if (packetSentToProtocol) {
// Packet already sent to server: local post-send failure (cache/DB/UI) must not mark message as failed.
withContext(Dispatchers.Main) {
updateMessageStatus(messageId, MessageStatus.SENT)
}
logPhotoPipeline(messageId, "post-send non-fatal error: status kept as SENT")
} else {
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) }
}
}
}
@@ -3989,11 +4047,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
opponentPublicKey = recipient
)
// Для обычных диалогов остаёмся в SENDING до PacketDelivery(messageId).
// После успешной отправки пакета фиксируем SENT (без ложного timeout->ERROR).
withContext(Dispatchers.Main) {
if (isSavedMessages) {
updateMessageStatus(messageId, MessageStatus.SENT)
}
updateMessageStatus(messageId, MessageStatus.SENT)
}
saveDialog(
@@ -4277,9 +4333,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
_messages.value.map { msg ->
if (msg.id != messageId) return@map msg
msg.copy(
status =
if (isSavedMessages) MessageStatus.SENT
else MessageStatus.SENDING,
status = MessageStatus.SENT,
attachments =
msg.attachments.map { current ->
val final = finalAttachmentsById[current.id]
@@ -4510,11 +4564,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
opponentPublicKey = recipient
)
// Обновляем UI: для обычных чатов оставляем SENDING до PacketDelivery(messageId).
// После успешной отправки медиа переводим в SENT.
withContext(Dispatchers.Main) {
if (isSavedMessages) {
updateMessageStatus(messageId, MessageStatus.SENT)
}
updateMessageStatus(messageId, MessageStatus.SENT)
}
saveDialog(
@@ -5469,6 +5521,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
typingNameResolveJob?.cancel()
draftSaveJob?.cancel()
pinnedCollectionJob?.cancel()
outgoingImageUploadJobs.values.forEach { it.cancel() }
outgoingImageUploadJobs.clear()
// 🔥 КРИТИЧНО: Удаляем обработчики пакетов чтобы избежать накопления и дубликатов
ProtocolManager.unwaitPacket(0x0B, typingPacketHandler)

View File

@@ -610,6 +610,7 @@ fun ChatsListScreen(
val topLevelChatsState by chatsViewModel.chatsState.collectAsState()
val topLevelIsLoading by chatsViewModel.isLoading.collectAsState()
val topLevelRequestsCount = topLevelChatsState.requestsCount
val visibleTopLevelRequestsCount = if (syncInProgress) 0 else topLevelRequestsCount
// Dev console dialog - commented out for now
/*
@@ -1163,7 +1164,7 @@ fun ChatsListScreen(
text = "Requests",
iconColor = menuIconColor,
textColor = menuTextColor,
badge = if (topLevelRequestsCount > 0) topLevelRequestsCount.toString() else null,
badge = if (visibleTopLevelRequestsCount > 0) visibleTopLevelRequestsCount.toString() else null,
badgeColor = accentColor,
onClick = {
scope.launch {
@@ -1598,7 +1599,7 @@ fun ChatsListScreen(
)
// Badge с числом запросов
androidx.compose.animation.AnimatedVisibility(
visible = topLevelRequestsCount > 0,
visible = visibleTopLevelRequestsCount > 0,
enter = scaleIn(
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
@@ -1608,10 +1609,10 @@ fun ChatsListScreen(
exit = scaleOut() + fadeOut(),
modifier = Modifier.align(Alignment.TopEnd)
) {
val badgeText = remember(topLevelRequestsCount) {
val badgeText = remember(visibleTopLevelRequestsCount) {
when {
topLevelRequestsCount > 99 -> "99+"
else -> topLevelRequestsCount.toString()
visibleTopLevelRequestsCount > 99 -> "99+"
else -> visibleTopLevelRequestsCount.toString()
}
}
val badgeBg = Color.White
@@ -1679,7 +1680,7 @@ fun ChatsListScreen(
)
} else if (syncInProgress) {
AnimatedDotsText(
baseText = "Synchronizing",
baseText = "Updating",
color = Color.White,
fontSize = 20.sp,
fontWeight = FontWeight.Bold
@@ -1858,8 +1859,8 @@ fun ChatsListScreen(
// независимо
val chatsState = topLevelChatsState
val isLoading = topLevelIsLoading
val requests = chatsState.requests
val requestsCount = chatsState.requestsCount
val requests = if (syncInProgress) emptyList() else chatsState.requests
val requestsCount = if (syncInProgress) 0 else chatsState.requestsCount
val showSkeleton by
produceState(
@@ -3464,7 +3465,8 @@ fun ChatItem(
isDarkTheme = isDarkTheme,
showOnlineIndicator = true,
isOnline = chat.isOnline,
displayName = chat.name // 🔥 Для инициалов
displayName = chat.name, // 🔥 Для инициалов
enableBlurPrewarm = true
)
Spacer(modifier = Modifier.width(TELEGRAM_DIALOG_AVATAR_GAP))
@@ -4117,6 +4119,7 @@ fun DialogItemContent(
val textColor = remember(isDarkTheme) { if (isDarkTheme) Color.White else Color.Black }
val secondaryTextColor =
remember(isDarkTheme) { if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) }
val visibleUnreadCount = if (syncInProgress) 0 else dialog.unreadCount
val isGroupDialog = remember(dialog.opponentKey) { isGroupDialogKey(dialog.opponentKey) }
@@ -4208,7 +4211,8 @@ fun DialogItemContent(
avatarRepository = avatarRepository,
size = TELEGRAM_DIALOG_AVATAR_SIZE,
isDarkTheme = isDarkTheme,
displayName = avatarDisplayName
displayName = avatarDisplayName,
enableBlurPrewarm = true
)
}
@@ -4448,7 +4452,7 @@ fun DialogItemContent(
text = formattedTime,
fontSize = 13.sp,
color =
if (dialog.unreadCount > 0) PrimaryBlue
if (visibleUnreadCount > 0) PrimaryBlue
else secondaryTextColor
)
}
@@ -4588,7 +4592,7 @@ fun DialogItemContent(
baseDisplayText,
fontSize = 14.sp,
color =
if (dialog.unreadCount >
if (visibleUnreadCount >
0
)
textColor.copy(
@@ -4598,7 +4602,7 @@ fun DialogItemContent(
else
secondaryTextColor,
fontWeight =
if (dialog.unreadCount >
if (visibleUnreadCount >
0
)
FontWeight.Medium
@@ -4619,11 +4623,11 @@ fun DialogItemContent(
text = baseDisplayText,
fontSize = 14.sp,
color =
if (dialog.unreadCount > 0)
if (visibleUnreadCount > 0)
textColor.copy(alpha = 0.85f)
else secondaryTextColor,
fontWeight =
if (dialog.unreadCount > 0)
if (visibleUnreadCount > 0)
FontWeight.Medium
else FontWeight.Normal,
maxLines = 1,
@@ -4658,15 +4662,15 @@ fun DialogItemContent(
}
// Unread badge
if (dialog.unreadCount > 0) {
if (visibleUnreadCount > 0) {
Spacer(modifier = Modifier.width(8.dp))
val unreadText =
remember(dialog.unreadCount) {
remember(visibleUnreadCount) {
when {
dialog.unreadCount > 999 -> "999+"
dialog.unreadCount > 99 -> "99+"
visibleUnreadCount > 999 -> "999+"
visibleUnreadCount > 99 -> "99+"
else ->
dialog.unreadCount
visibleUnreadCount
.toString()
}
}
@@ -4736,7 +4740,7 @@ fun TypingIndicatorSmall(
typingDisplayName: String = "",
typingSenderPublicKey: String = ""
) {
val typingColor = PrimaryBlue
val typingColor = if (isDarkTheme) PrimaryBlue else Color(0xFF34C759)
val senderTypingColor =
remember(typingSenderPublicKey, isDarkTheme) {
if (typingSenderPublicKey.isBlank()) {

View File

@@ -16,6 +16,7 @@ import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.SearchUser
import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.*
@@ -391,20 +392,31 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
}
// Подписываемся на обычные диалоги
@OptIn(FlowPreview::class)
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
launch {
dialogDao
.getDialogsFlow(publicKey)
.flowOn(Dispatchers.IO) // 🚀 Flow работает на IO
.debounce(100) // 🚀 Батчим быстрые обновления (N сообщений → 1 обработка)
.map { dialogsList ->
mapDialogListIncremental(
dialogsList = dialogsList,
privateKey = privateKey,
cache = dialogsUiCache,
isRequestsFlow = false
)
.combine(ProtocolManager.syncInProgress) { dialogsList, syncing ->
dialogsList to syncing
}
.mapLatest { (dialogsList, syncing) ->
// Desktop behavior parity:
// while sync is active we keep current chats list stable (no per-message UI churn),
// then apply one consolidated update when sync finishes.
if (syncing && _dialogs.value.isNotEmpty()) {
null
} else {
mapDialogListIncremental(
dialogsList = dialogsList,
privateKey = privateKey,
cache = dialogsUiCache,
isRequestsFlow = false
)
}
}
.filterNotNull()
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
.collect { decryptedDialogs ->
_dialogs.value = decryptedDialogs
@@ -423,19 +435,26 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
}
// 📬 Подписываемся на requests (запросы от новых пользователей)
@OptIn(FlowPreview::class)
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
launch {
dialogDao
.getRequestsFlow(publicKey)
.flowOn(Dispatchers.IO)
.debounce(100) // 🚀 Батчим быстрые обновления
.map { requestsList ->
mapDialogListIncremental(
dialogsList = requestsList,
privateKey = privateKey,
cache = requestsUiCache,
isRequestsFlow = true
)
.combine(ProtocolManager.syncInProgress) { requestsList, syncing ->
requestsList to syncing
}
.mapLatest { (requestsList, syncing) ->
if (syncing) {
emptyList()
} else {
mapDialogListIncremental(
dialogsList = requestsList,
privateKey = privateKey,
cache = requestsUiCache,
isRequestsFlow = true
)
}
}
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
.collect { decryptedRequests -> _requests.value = decryptedRequests }
@@ -446,6 +465,9 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
dialogDao
.getRequestsCountFlow(publicKey)
.flowOn(Dispatchers.IO)
.combine(ProtocolManager.syncInProgress) { count, syncing ->
if (syncing) 0 else count
}
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся значения
.collect { count -> _requestsCount.value = count }
}

View File

@@ -150,6 +150,8 @@ fun GroupSetupScreen(
val primaryTextColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = Color(0xFF8E8E93)
val accentColor = if (isDarkTheme) Color(0xFF5AA5FF) else Color(0xFF228BE6)
val disabledActionColor = if (isDarkTheme) Color(0xFF5A5A5E) else Color(0xFFC7C7CC)
val disabledActionContentColor = if (isDarkTheme) Color(0xFFAAAAAF) else Color(0xFF8E8E93)
val groupAvatarCameraButtonColor =
if (isDarkTheme) sectionColor else Color(0xFF8CC9F6)
val groupAvatarCameraIconColor =
@@ -745,8 +747,8 @@ fun GroupSetupScreen(
isLoading = false
}
},
containerColor = if (actionEnabled) accentColor else accentColor.copy(alpha = 0.42f),
contentColor = Color.White,
containerColor = if (actionEnabled) accentColor else disabledActionColor,
contentColor = if (actionEnabled) Color.White else disabledActionContentColor,
shape = CircleShape,
modifier = run {
// Берём максимум из всех позиций — при переключении keyboard↔emoji
@@ -762,7 +764,7 @@ fun GroupSetupScreen(
) {
if (isLoading && step == GroupSetupStep.DESCRIPTION) {
CircularProgressIndicator(
color = Color.White,
color = if (actionEnabled) Color.White else disabledActionContentColor,
strokeWidth = 2.dp,
modifier = Modifier.size(22.dp)
)

View File

@@ -19,6 +19,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
@@ -39,12 +40,12 @@ fun RequestsListScreen(
avatarRepository: AvatarRepository? = null
) {
val chatsState by chatsViewModel.chatsState.collectAsState()
val requests = chatsState.requests
val syncInProgress by ProtocolManager.syncInProgress.collectAsState()
val requests = if (syncInProgress) emptyList() else chatsState.requests
val blockedUsers by chatsViewModel.blockedUsers.collectAsState()
val scope = rememberCoroutineScope()
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
val headerColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF0D8CF4)
val textColor = if (isDarkTheme) Color.White else Color.Black
Scaffold(
topBar = {
@@ -60,7 +61,7 @@ fun RequestsListScreen(
},
title = {
Text(
text = "Requests",
text = if (syncInProgress) "Updating..." else "Requests",
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
color = Color.White

View File

@@ -9,6 +9,7 @@ import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
@@ -109,7 +110,7 @@ fun SearchScreen(
DisposableEffect(isDarkTheme) {
val window = (view.context as android.app.Activity).window
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
insetsController.isAppearanceLightStatusBars = !isDarkTheme
insetsController.isAppearanceLightStatusBars = false
window.statusBarColor = android.graphics.Color.TRANSPARENT
onDispose {
// Restore white status bar icons for chat list header
@@ -238,14 +239,10 @@ fun SearchScreen(
}
}
Box(modifier = Modifier.fillMaxSize().pointerInput(Unit) {
detectHorizontalDragGestures { _, dragAmount ->
if (dragAmount > 10f) {
hideKeyboardInstantly()
}
}
}) {
Box(modifier = Modifier.fillMaxSize()) {
val hideKbScrollConnection = remember { HideKeyboardNestedScroll(view, focusManager) }
Scaffold(
modifier = Modifier.nestedScroll(hideKbScrollConnection),
topBar = {
// Хедер как в Telegram: стрелка назад + поле ввода
Surface(
@@ -538,7 +535,7 @@ private fun ChatsTabContent(
if (searchQuery.isEmpty()) {
// ═══ Idle state: frequent contacts + recent searches ═══
LazyColumn(
modifier = Modifier.fillMaxSize()
modifier = Modifier.fillMaxSize().imePadding()
) {
// ─── Горизонтальный ряд частых контактов (как в Telegram) ───
if (visibleFrequentContacts.isNotEmpty()) {
@@ -1183,7 +1180,7 @@ private fun MessagesTabContent(
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
modifier = Modifier.fillMaxSize().imePadding(),
contentPadding = PaddingValues(vertical = 4.dp)
) {
items(results, key = { it.messageId }) { result ->
@@ -1757,7 +1754,7 @@ private fun DownloadsTabContent(
)
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
modifier = Modifier.fillMaxSize().imePadding(),
contentPadding = PaddingValues(vertical = 4.dp)
) {
items(files, key = { it.name }) { downloadedFile ->
@@ -1909,7 +1906,7 @@ private fun FilesTabContent(
} else {
val dateFormat = remember { SimpleDateFormat("dd MMM yyyy", Locale.getDefault()) }
LazyColumn(
modifier = Modifier.fillMaxSize(),
modifier = Modifier.fillMaxSize().imePadding(),
contentPadding = PaddingValues(vertical = 4.dp)
) {
items(fileItems, key = { "${it.messageId}_${it.attachmentId}" }) { item ->
@@ -2163,3 +2160,21 @@ private fun RecentUserItem(
}
}
}
/** NestedScrollConnection который скрывает клавиатуру при любом вертикальном скролле */
private class HideKeyboardNestedScroll(
private val view: android.view.View,
private val focusManager: androidx.compose.ui.focus.FocusManager
) : androidx.compose.ui.input.nestedscroll.NestedScrollConnection {
override fun onPreScroll(
available: androidx.compose.ui.geometry.Offset,
source: androidx.compose.ui.input.nestedscroll.NestedScrollSource
): androidx.compose.ui.geometry.Offset {
if (kotlin.math.abs(available.y) > 0.5f) {
val imm = view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
focusManager.clearFocus()
}
return androidx.compose.ui.geometry.Offset.Zero
}
}

View File

@@ -765,7 +765,7 @@ fun ChatAttachAlert(
// as the popup overlay, so top area and content overlay always match.
if (fullScreen) {
window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
insetsController?.isAppearanceLightStatusBars = !dark
insetsController?.isAppearanceLightStatusBars = false
} else {
insetsController?.isAppearanceLightStatusBars = false
var lastAppliedAlpha = -1

View File

@@ -86,6 +86,7 @@ import kotlin.math.min
private const val TAG = "AttachmentComponents"
private const val MAX_BITMAP_DECODE_DIMENSION = 4096
private val whitespaceRegex = "\\s+".toRegex()
private val LocalOnCancelImageUpload = staticCompositionLocalOf<(String) -> Unit> { {} }
private fun isGroupStoredKey(value: String): Boolean = value.startsWith("group:")
@@ -144,6 +145,42 @@ private fun shortDebugHash(bytes: ByteArray): String {
}
}
private const val LEGACY_ATTACHMENT_ERROR_TEXT =
"This attachment is no longer available because it was sent for a previous version of the app."
@Composable
private fun LegacyAttachmentErrorCard(
isDarkTheme: Boolean,
modifier: Modifier = Modifier
) {
val borderColor = if (isDarkTheme) Color(0xFF2A2F38) else Color(0xFFE3E7EF)
val backgroundColor = if (isDarkTheme) Color(0xFF1E232B) else Color(0xFFF7F9FC)
val textColor = if (isDarkTheme) Color(0xFFE9EDF5) else Color(0xFF2B3340)
Row(
modifier =
modifier.fillMaxWidth()
.border(1.dp, borderColor, RoundedCornerShape(8.dp))
.clip(RoundedCornerShape(8.dp))
.background(backgroundColor)
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = null,
tint = Color(0xFFE55A5A),
modifier = Modifier.size(28.dp)
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = LEGACY_ATTACHMENT_ERROR_TEXT,
color = textColor,
fontSize = 12.sp
)
}
}
/**
* Анимированный текст с волнообразными точками.
* Три точки плавно подпрыгивают каскадом с изменением прозрачности.
@@ -463,6 +500,7 @@ fun MessageAttachments(
showTail: Boolean = true, // Показывать хвостик пузырька
isSelectionMode: Boolean = false, // Блокировать клик на фото в selection mode
onLongClick: () -> Unit = {}, // Long press на фото — запускает selection mode
onCancelUpload: (attachmentId: String) -> Unit = {},
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
modifier: Modifier = Modifier
) {
@@ -478,21 +516,23 @@ fun MessageAttachments(
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) {
// 🖼️ Коллаж для изображений (если больше 1)
if (imageAttachments.isNotEmpty()) {
ImageCollage(
attachments = imageAttachments,
chachaKey = chachaKey,
privateKey = privateKey,
senderPublicKey = senderPublicKey,
isOutgoing = isOutgoing,
isDarkTheme = isDarkTheme,
timestamp = timestamp,
messageStatus = messageStatus,
hasCaption = hasCaption,
showTail = showTail,
isSelectionMode = isSelectionMode,
onLongClick = onLongClick,
onImageClick = onImageClick
)
CompositionLocalProvider(LocalOnCancelImageUpload provides onCancelUpload) {
ImageCollage(
attachments = imageAttachments,
chachaKey = chachaKey,
privateKey = privateKey,
senderPublicKey = senderPublicKey,
isOutgoing = isOutgoing,
isDarkTheme = isDarkTheme,
timestamp = timestamp,
messageStatus = messageStatus,
hasCaption = hasCaption,
showTail = showTail,
isSelectionMode = isSelectionMode,
onLongClick = onLongClick,
onImageClick = onImageClick
)
}
}
// Остальные attachments по отдельности
@@ -534,7 +574,8 @@ fun MessageAttachments(
)
}
else -> {
/* MESSAGES обрабатываются отдельно */
// Desktop parity: unsupported/legacy attachment gets explicit compatibility card.
LegacyAttachmentErrorCard(isDarkTheme = isDarkTheme)
}
}
}
@@ -926,6 +967,11 @@ fun ImageAttachment(
var blurhashBitmap by remember(attachment.id) { mutableStateOf<Bitmap?>(null) }
var downloadProgress by remember(attachment.id) { mutableStateOf(0f) }
var errorLabel by remember(attachment.id) { mutableStateOf("Error") }
val uploadingState by TransportManager.uploading.collectAsState()
val uploadEntry = uploadingState.firstOrNull { it.id == attachment.id }
val uploadProgress = uploadEntry?.progress ?: 0
val isUploadInProgress = uploadEntry != null
val onCancelImageUpload = LocalOnCancelImageUpload.current
val preview = getPreview(attachment)
val downloadTag = getDownloadTag(attachment)
@@ -1470,26 +1516,75 @@ fun ImageAttachment(
}
}
// ✈️ Telegram-style: Loader при отправке фото (самолётик/кружок)
// Показываем когда сообщение отправляется И это исходящее сообщение
if (isOutgoing && messageStatus == MessageStatus.SENDING && downloadStatus == DownloadStatus.DOWNLOADED) {
// Desktop-style upload state: Encrypting... (0%) / progress ring (>0%)
if (isOutgoing &&
messageStatus == MessageStatus.SENDING &&
downloadStatus == DownloadStatus.DOWNLOADED &&
(isUploadInProgress || attachment.localUri.isNotEmpty())
) {
Box(
modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.35f)),
contentAlignment = Alignment.Center
) {
// Круглый индикатор отправки
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.5f)),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(28.dp),
color = Color.White,
strokeWidth = 2.5.dp
)
if (uploadProgress > 0) {
val cappedProgress = uploadProgress.coerceIn(1, 95) / 100f
Box(
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.5f))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
enabled = !isSelectionMode
) {
onCancelImageUpload(attachment.id)
},
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
progress = cappedProgress,
modifier = Modifier.size(36.dp),
color = Color.White,
strokeWidth = 2.5.dp,
trackColor = Color.White.copy(alpha = 0.25f),
strokeCap = StrokeCap.Round
)
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Cancel upload",
tint = Color.White,
modifier = Modifier.size(14.dp)
)
}
} else {
Row(
modifier =
Modifier.clip(RoundedCornerShape(8.dp))
.background(Color.Black.copy(alpha = 0.5f))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
enabled = !isSelectionMode
) {
onCancelImageUpload(attachment.id)
}
.padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
color = Color.White,
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(6.dp))
AnimatedDotsText(
baseText = "Encrypting",
color = Color.White,
fontSize = 12.sp,
fontWeight = FontWeight.Medium
)
}
}
}
}

View File

@@ -233,7 +233,8 @@ fun TypingIndicator(
typingDisplayName: String = "",
typingSenderPublicKey: String = ""
) {
val typingColor = Color(0xFF54A9EB)
val typingColor =
if (isDarkTheme) Color(0xFF54A9EB) else Color.White.copy(alpha = 0.7f)
val senderTypingColor =
remember(typingSenderPublicKey, isDarkTheme) {
if (typingSenderPublicKey.isBlank()) {
@@ -345,6 +346,7 @@ fun MessageBubble(
onReplyClick: (String) -> Unit = {},
onRetry: () -> Unit = {},
onDelete: () -> Unit = {},
onCancelPhotoUpload: (attachmentId: String) -> Unit = {},
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
onAvatarClick: (senderPublicKey: String) -> Unit = {},
onForwardedSenderClick: (senderPublicKey: String) -> Unit = {},
@@ -571,6 +573,7 @@ fun MessageBubble(
val telegramIncomingAvatarSize = 42.dp
val telegramIncomingAvatarLane = 48.dp
val telegramIncomingAvatarInset = 6.dp
val telegramIncomingBubbleGap = 6.dp
val shouldShowIncomingGroupAvatar =
showIncomingGroupAvatar
?: (isGroupChat &&
@@ -811,7 +814,13 @@ fun MessageBubble(
Box(
modifier =
Modifier.align(Alignment.Bottom)
.padding(end = 12.dp)
.padding(
start =
if (!message.isOutgoing && isGroupChat)
telegramIncomingBubbleGap
else 0.dp,
end = 12.dp
)
.then(bubbleWidthModifier)
.graphicsLayer {
this.alpha = selectionAlpha
@@ -984,6 +993,7 @@ fun MessageBubble(
// пузырька
isSelectionMode = isSelectionMode,
onLongClick = onLongClick,
onCancelUpload = onCancelPhotoUpload,
// В selection mode блокируем открытие фото
onImageClick = if (isSelectionMode) { _, _ -> } else onImageClick
)

View File

@@ -631,7 +631,7 @@ fun MediaPickerBottomSheet(
// Telegram-like natural dim: status-bar tint follows picker scrim alpha.
if (fullScreen) {
window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
insetsController?.isAppearanceLightStatusBars = !dark
insetsController?.isAppearanceLightStatusBars = false
} else {
insetsController?.isAppearanceLightStatusBars = false
var lastAppliedAlpha = -1

View File

@@ -18,6 +18,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
@@ -76,8 +77,10 @@ fun AvatarImage(
showOnlineIndicator: Boolean = false,
isOnline: Boolean = false,
shape: Shape = CircleShape,
displayName: String? = null // 🔥 Имя для инициалов (title/username)
displayName: String? = null, // 🔥 Имя для инициалов (title/username)
enableBlurPrewarm: Boolean = false
) {
val context = LocalContext.current
val isSystemSafeAccount = publicKey == MessageRepository.SYSTEM_SAFE_PUBLIC_KEY
val isSystemUpdatesAccount = publicKey == MessageRepository.SYSTEM_UPDATES_PUBLIC_KEY
@@ -117,6 +120,18 @@ fun AvatarImage(
bitmap = null
}
}
LaunchedEffect(enableBlurPrewarm, publicKey, avatarKey, bitmap) {
if (!enableBlurPrewarm) return@LaunchedEffect
val source = bitmap ?: return@LaunchedEffect
prewarmBlurredAvatarFromBitmap(
context = context,
publicKey = publicKey,
avatarTimestamp = avatarKey,
sourceBitmap = source,
blurRadius = 20f
)
}
Box(
modifier = Modifier

View File

@@ -7,6 +7,7 @@ import android.graphics.Canvas
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.Shader
import android.util.LruCache
import android.os.Build
import android.renderscript.Allocation
import android.renderscript.Element
@@ -38,6 +39,51 @@ import com.rosetta.messenger.utils.AvatarFileManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.math.abs
import java.util.Collections
private object BlurredAvatarMemoryCache {
private val cache = object : LruCache<String, Bitmap>(18 * 1024) {
override fun sizeOf(key: String, value: Bitmap): Int = (value.byteCount / 1024).coerceAtLeast(1)
}
private val latestByPublicKey = object : LruCache<String, Bitmap>(12 * 1024) {
override fun sizeOf(key: String, value: Bitmap): Int = (value.byteCount / 1024).coerceAtLeast(1)
}
fun get(key: String): Bitmap? = cache.get(key)
fun getLatest(publicKey: String): Bitmap? = latestByPublicKey.get(publicKey)
fun put(key: String, bitmap: Bitmap) {
if (bitmap.isRecycled) return
cache.put(key, bitmap)
val publicKey = key.substringBeforeLast(':', "")
if (publicKey.isNotEmpty()) {
latestByPublicKey.put(publicKey, bitmap)
}
}
}
private val blurPrewarmInFlight: MutableSet<String> = Collections.synchronizedSet(mutableSetOf())
internal suspend fun prewarmBlurredAvatarFromBitmap(
context: Context,
publicKey: String,
avatarTimestamp: Long,
sourceBitmap: Bitmap,
blurRadius: Float = 20f
) {
if (sourceBitmap.isRecycled || publicKey.isBlank()) return
val cacheKey = "$publicKey:$avatarTimestamp"
if (BlurredAvatarMemoryCache.get(cacheKey) != null) return
if (!blurPrewarmInFlight.add(cacheKey)) return
try {
val blurred = withContext(Dispatchers.Default) {
gaussianBlur(context, sourceBitmap, radius = blurRadius, passes = 2)
}
BlurredAvatarMemoryCache.put(cacheKey, blurred)
} finally {
blurPrewarmInFlight.remove(cacheKey)
}
}
/**
* Компонент для отображения размытого фона аватарки
@@ -55,6 +101,7 @@ fun BoxScope.BlurredAvatarBackground(
isDarkTheme: Boolean = true
) {
val context = LocalContext.current
val hasColorOverlay = overlayColors != null && overlayColors.isNotEmpty()
val avatars by avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState()
?: remember { mutableStateOf(emptyList()) }
@@ -62,11 +109,24 @@ fun BoxScope.BlurredAvatarBackground(
val avatarKey = remember(avatars) {
avatars.firstOrNull()?.timestamp ?: 0L
}
val blurCacheKey = remember(publicKey, avatarKey) { "$publicKey:$avatarKey" }
var originalBitmap by remember { mutableStateOf<Bitmap?>(null) }
var blurredBitmap by remember { mutableStateOf<Bitmap?>(null) }
var originalBitmap by remember(publicKey) { mutableStateOf<Bitmap?>(null) }
var blurredBitmap by remember(publicKey) {
mutableStateOf(BlurredAvatarMemoryCache.getLatest(publicKey))
}
LaunchedEffect(publicKey, avatarKey, blurCacheKey, hasColorOverlay) {
// For custom color/gradient background we intentionally disable avatar blur layer.
if (hasColorOverlay) {
return@LaunchedEffect
}
BlurredAvatarMemoryCache.get(blurCacheKey)?.let { cached ->
blurredBitmap = cached
return@LaunchedEffect
}
LaunchedEffect(avatarKey, publicKey) {
val currentAvatars = avatars
val newOriginal = withContext(Dispatchers.IO) {
// Keep system account blur source identical to the visible avatar drawable.
@@ -81,46 +141,36 @@ fun BoxScope.BlurredAvatarBackground(
}
if (newOriginal != null) {
originalBitmap = newOriginal
blurredBitmap = withContext(Dispatchers.Default) {
gaussianBlur(context, newOriginal, radius = 20f, passes = 2)
val blurred = withContext(Dispatchers.Default) {
gaussianBlur(context, newOriginal, radius = blurRadius, passes = 2)
}
} else {
blurredBitmap = blurred
BlurredAvatarMemoryCache.put(blurCacheKey, blurred)
} else if (blurredBitmap == null) {
originalBitmap = null
blurredBitmap = null
}
}
// ═══════════════════════════════════════════════════════════
// Режим с overlay-цветом — blur + пастельный overlay
// ═══════════════════════════════════════════════════════════
if (overlayColors != null && overlayColors.isNotEmpty()) {
// LAYER 1: Blurred avatar (яркий, видный)
if (blurredBitmap != null) {
Image(
bitmap = blurredBitmap!!.asImageBitmap(),
contentDescription = null,
modifier = Modifier.matchParentSize()
.graphicsLayer { this.alpha = 0.85f },
contentScale = ContentScale.Crop
)
}
// LAYER 2: Цвет/градиент overlay
val colorAlpha = if (blurredBitmap != null) 0.4f else 0.85f
val overlayMod = if (overlayColors.size == 1) {
if (hasColorOverlay) {
val overlayPalette = overlayColors.orEmpty()
// LAYER 1: Pure color/gradient background (no avatar blur behind avatar).
val overlayMod = if (overlayPalette.size == 1) {
Modifier.matchParentSize()
.background(overlayColors[0].copy(alpha = colorAlpha))
.background(overlayPalette[0])
} else {
Modifier.matchParentSize()
.background(
Brush.linearGradient(
colors = overlayColors.map { it.copy(alpha = colorAlpha) }
colors = overlayPalette
)
)
}
Box(modifier = overlayMod)
// LAYER 3: Нижний градиент для читаемости текста
// LAYER 2: Нижний градиент для читаемости текста
Box(
modifier = Modifier
.matchParentSize()
@@ -141,13 +191,14 @@ fun BoxScope.BlurredAvatarBackground(
// Стандартный режим (нет overlay-цвета) — blur аватарки
// Telegram-style: яркий видный блюр + мягкое затемнение
// ═══════════════════════════════════════════════════════════
if (blurredBitmap != null) {
val displayBitmap = blurredBitmap ?: originalBitmap
if (displayBitmap != null) {
// LAYER 1: Размытая аватарка — яркая и видная
Image(
bitmap = blurredBitmap!!.asImageBitmap(),
bitmap = displayBitmap.asImageBitmap(),
contentDescription = null,
modifier = Modifier.matchParentSize()
.graphicsLayer { this.alpha = 0.9f },
.graphicsLayer { this.alpha = if (blurredBitmap != null) 0.9f else 0.72f },
contentScale = ContentScale.Crop
)
// LAYER 2: Лёгкое тонирование цветом аватара

View File

@@ -159,7 +159,7 @@ fun OnboardingScreen(
if (shouldUpdateStatusBar && !view.isInEditMode) {
val window = (view.context as android.app.Activity).window
val insetsController = WindowCompat.getInsetsController(window, view)
insetsController.isAppearanceLightStatusBars = !isDarkTheme
insetsController.isAppearanceLightStatusBars = false
window.statusBarColor = android.graphics.Color.TRANSPARENT
// Navigation bar: показываем только если есть нативные кнопки

View File

@@ -87,7 +87,7 @@ fun AppearanceScreen(
SideEffect {
val window = (view.context as android.app.Activity).window
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
insetsController.isAppearanceLightStatusBars = !isDarkTheme
insetsController.isAppearanceLightStatusBars = false
window.statusBarColor = android.graphics.Color.TRANSPARENT
}
}
@@ -333,6 +333,7 @@ private fun ProfileBlurPreview(
) {
val option = BackgroundBlurPresets.findById(selectedId)
val overlayColors = BackgroundBlurPresets.getOverlayColors(selectedId)
val shouldUseAvatarBlur = overlayColors.isNullOrEmpty() && selectedId != "none"
val avatarColors = getAvatarColor(publicKey, isDarkTheme)
// Загрузка аватарки
@@ -344,7 +345,7 @@ private fun ProfileBlurPreview(
var blurredBitmap by remember { mutableStateOf<Bitmap?>(null) }
val blurContext = LocalContext.current
LaunchedEffect(avatarKey) {
LaunchedEffect(avatarKey, shouldUseAvatarBlur) {
val current = avatars
if (current.isNotEmpty()) {
val decoded = withContext(Dispatchers.IO) {
@@ -352,9 +353,14 @@ private fun ProfileBlurPreview(
}
if (decoded != null) {
avatarBitmap = decoded
blurredBitmap = withContext(Dispatchers.Default) {
appearanceGaussianBlur(blurContext, decoded, radius = 20f, passes = 2)
}
blurredBitmap =
if (shouldUseAvatarBlur) {
withContext(Dispatchers.Default) {
appearanceGaussianBlur(blurContext, decoded, radius = 20f, passes = 2)
}
} else {
null
}
}
} else {
avatarBitmap = null
@@ -375,9 +381,8 @@ private fun ProfileBlurPreview(
// ═══════════════════════════════════════════════════
// LAYER 1: Blurred avatar background (идентично BlurredAvatarBackground)
// ═══════════════════════════════════════════════════
if (blurredBitmap != null) {
// overlay-режим: 0.85f, стандартный: 0.9f (как в BlurredAvatarBackground)
val blurImgAlpha = if (overlayColors != null && overlayColors.isNotEmpty()) 0.85f else 0.9f
if (shouldUseAvatarBlur && blurredBitmap != null) {
val blurImgAlpha = 0.9f
Image(
bitmap = blurredBitmap!!.asImageBitmap(),
contentDescription = null,
@@ -402,20 +407,18 @@ private fun ProfileBlurPreview(
val overlayMod = if (overlayColors.size == 1) {
Modifier
.fillMaxSize()
.background(overlayColors[0].copy(alpha = if (blurredBitmap != null) 0.4f else 0.85f))
.background(overlayColors[0])
} else {
Modifier
.fillMaxSize()
.background(
Brush.linearGradient(
colors = overlayColors.map {
it.copy(alpha = if (blurredBitmap != null) 0.4f else 0.85f)
}
colors = overlayColors
)
)
}
Box(modifier = overlayMod)
} else if (blurredBitmap != null) {
} else if (shouldUseAvatarBlur && blurredBitmap != null) {
// Стандартный тинт (идентичен BlurredAvatarBackground)
Box(
modifier = Modifier
@@ -782,4 +785,3 @@ private fun appearanceGaussianBlur(context: Context, source: Bitmap, radius: Flo
rs.destroy()
return Bitmap.createBitmap(current, pad, pad, w, h)
}

View File

@@ -87,7 +87,7 @@ fun BiometricEnableScreen(
SideEffect {
val window = (view.context as android.app.Activity).window
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
insetsController.isAppearanceLightStatusBars = !isDarkTheme
insetsController.isAppearanceLightStatusBars = false
window.statusBarColor = android.graphics.Color.TRANSPARENT
}
}

View File

@@ -0,0 +1,131 @@
package com.rosetta.messenger.ui.settings
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rosetta.messenger.data.PreferencesManager
import com.rosetta.messenger.ui.icons.TelegramIcons
import compose.icons.TablerIcons
import compose.icons.tablericons.ChevronLeft
import kotlinx.coroutines.launch
import androidx.compose.ui.platform.LocalContext
@Composable
fun NotificationsScreen(
isDarkTheme: Boolean,
onBack: () -> Unit
) {
val context = LocalContext.current
val preferencesManager = remember { PreferencesManager(context) }
val notificationsEnabled by preferencesManager.notificationsEnabled.collectAsState(initial = true)
val avatarInNotifications by preferencesManager.notificationAvatarEnabled.collectAsState(initial = true)
val scope = rememberCoroutineScope()
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
BackHandler { onBack() }
Column(
modifier = Modifier
.fillMaxSize()
.background(backgroundColor)
) {
androidx.compose.material3.Surface(
modifier = Modifier.fillMaxWidth(),
color = backgroundColor
) {
androidx.compose.foundation.layout.Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding())
.padding(horizontal = 4.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBack) {
Icon(
imageVector = TablerIcons.ChevronLeft,
contentDescription = "Back",
tint = textColor
)
}
Text(
text = "Notifications",
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold,
color = textColor,
modifier = Modifier.padding(start = 8.dp)
)
}
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
Spacer(modifier = Modifier.height(8.dp))
TelegramSectionTitle(title = "Notifications", isDarkTheme = isDarkTheme)
TelegramToggleItem(
icon = TelegramIcons.Notifications,
title = "Push Notifications",
subtitle = "Messages and call alerts",
isEnabled = notificationsEnabled,
onToggle = {
scope.launch {
preferencesManager.setNotificationsEnabled(!notificationsEnabled)
}
},
isDarkTheme = isDarkTheme,
showDivider = true
)
TelegramToggleItem(
icon = TelegramIcons.Photos,
title = "Avatars in Notifications",
subtitle = "Show sender avatar in push alerts",
isEnabled = avatarInNotifications,
onToggle = {
scope.launch {
preferencesManager.setNotificationAvatarEnabled(!avatarInNotifications)
}
},
isDarkTheme = isDarkTheme
)
Text(
text = "Changes apply instantly.",
color = secondaryTextColor,
fontSize = 13.sp,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp)
)
}
}
}

View File

@@ -32,7 +32,7 @@ fun ProfileLogsScreen(
SideEffect {
val window = (view.context as android.app.Activity).window
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
insetsController.isAppearanceLightStatusBars = !isDarkTheme
insetsController.isAppearanceLightStatusBars = false
window.statusBarColor = android.graphics.Color.TRANSPARENT
}
}

View File

@@ -275,8 +275,10 @@ fun ProfileScreen(
accountPublicKey: String,
accountPrivateKeyHash: String,
onBack: () -> Unit,
onHasChangesChanged: (Boolean) -> Unit = {},
onSaveProfile: (name: String, username: String) -> Unit,
onLogout: () -> Unit,
onNavigateToNotifications: () -> Unit = {},
onNavigateToTheme: () -> Unit = {},
onNavigateToAppearance: () -> Unit = {},
onNavigateToSafety: () -> Unit = {},
@@ -402,7 +404,7 @@ fun ProfileScreen(
// State for editing - Update when account data changes
var editedName by remember(accountName) { mutableStateOf(accountName) }
var editedUsername by remember(accountUsername) { mutableStateOf(accountUsername) }
var hasChanges by remember { mutableStateOf(false) }
val hasChanges = editedName != accountName || editedUsername != accountUsername
var nameTouched by remember { mutableStateOf(false) }
var usernameTouched by remember { mutableStateOf(false) }
var showValidationErrors by remember { mutableStateOf(false) }
@@ -701,7 +703,6 @@ fun ProfileScreen(
// Following desktop version: update local data AFTER server confirms success
viewModel.updateLocalProfile(accountPublicKey, editedName, editedUsername)
hasChanges = false
serverNameError = null
serverUsernameError = null
serverGeneralError = null
@@ -743,9 +744,14 @@ fun ProfileScreen(
}
}
// Update hasChanges when fields change
LaunchedEffect(editedName, editedUsername) {
hasChanges = editedName != accountName || editedUsername != accountUsername
SideEffect {
onHasChangesChanged(hasChanges)
}
DisposableEffect(Unit) {
onDispose {
onHasChangesChanged(false)
}
}
// Handle back button press - navigate to chat list instead of closing app
@@ -838,49 +844,19 @@ fun ProfileScreen(
Spacer(modifier = Modifier.height(24.dp))
// ═════════════════════════════════════════════════════════════
// 🔔 NOTIFICATIONS SECTION
// ═════════════════════════════════════════════════════════════
TelegramSectionTitle(title = "Notifications", isDarkTheme = isDarkTheme)
run {
val preferencesManager = remember { com.rosetta.messenger.data.PreferencesManager(context) }
val notificationsEnabled by preferencesManager.notificationsEnabled.collectAsState(initial = true)
val avatarInNotifications by preferencesManager.notificationAvatarEnabled.collectAsState(initial = true)
val scope = rememberCoroutineScope()
TelegramToggleItem(
icon = TelegramIcons.Notifications,
title = "Push Notifications",
isEnabled = notificationsEnabled,
onToggle = {
scope.launch {
preferencesManager.setNotificationsEnabled(!notificationsEnabled)
}
},
isDarkTheme = isDarkTheme
)
TelegramToggleItem(
icon = TelegramIcons.Photos,
title = "Avatars in Notifications",
isEnabled = avatarInNotifications,
onToggle = {
scope.launch {
preferencesManager.setNotificationAvatarEnabled(!avatarInNotifications)
}
},
isDarkTheme = isDarkTheme
)
}
Spacer(modifier = Modifier.height(24.dp))
// ═════════════════════════════════════════════════════════════
// ⚙️ SETTINGS SECTION - Telegram style
// ═════════════════════════════════════════════════════════════
TelegramSectionTitle(title = "Settings", isDarkTheme = isDarkTheme)
TelegramSettingsItem(
icon = TelegramIcons.Notifications,
title = "Notifications",
onClick = onNavigateToNotifications,
isDarkTheme = isDarkTheme,
showDivider = true
)
TelegramSettingsItem(
icon = TelegramIcons.Theme,
title = "Theme",

View File

@@ -78,7 +78,7 @@ fun ThemeScreen(
SideEffect {
val window = (view.context as android.app.Activity).window
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
insetsController.isAppearanceLightStatusBars = !isDarkTheme
insetsController.isAppearanceLightStatusBars = false
window.statusBarColor = android.graphics.Color.TRANSPARENT
}
}

View File

@@ -35,7 +35,7 @@ fun UpdatesScreen(
SideEffect {
val window = (view.context as android.app.Activity).window
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
insetsController.isAppearanceLightStatusBars = !isDarkTheme
insetsController.isAppearanceLightStatusBars = false
window.statusBarColor = android.graphics.Color.TRANSPARENT
}
}

View File

@@ -40,7 +40,7 @@ object SystemBarsStyleUtils {
if (window == null || view == null) return
val insetsController = WindowCompat.getInsetsController(window, view)
window.statusBarColor = Color.TRANSPARENT
insetsController.isAppearanceLightStatusBars = !isDarkTheme
insetsController.isAppearanceLightStatusBars = false
}
fun restoreNavigationBar(window: Window?, view: View?, context: Context, state: SystemBarsState?) {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB