Чат/звонки/коннект: Telegram-like UX и ряд фиксов

This commit is contained in:
2026-04-15 02:29:08 +05:00
parent 4396611355
commit 060d0cbd12
15 changed files with 767 additions and 238 deletions

View File

@@ -56,6 +56,7 @@ import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.auth.AccountInfo import com.rosetta.messenger.ui.auth.AccountInfo
import com.rosetta.messenger.ui.auth.AuthFlow import com.rosetta.messenger.ui.auth.AuthFlow
import com.rosetta.messenger.ui.auth.DeviceConfirmScreen import com.rosetta.messenger.ui.auth.DeviceConfirmScreen
import com.rosetta.messenger.ui.auth.startAuthHandshakeFast
import com.rosetta.messenger.ui.chats.ChatDetailScreen import com.rosetta.messenger.ui.chats.ChatDetailScreen
import com.rosetta.messenger.ui.chats.ChatsListScreen import com.rosetta.messenger.ui.chats.ChatsListScreen
import com.rosetta.messenger.ui.chats.VoiceTopMiniPlayer import com.rosetta.messenger.ui.chats.VoiceTopMiniPlayer
@@ -91,6 +92,7 @@ import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class MainActivity : FragmentActivity() { class MainActivity : FragmentActivity() {
@@ -302,16 +304,57 @@ class MainActivity : FragmentActivity() {
startInCreateMode = startCreateAccountFlow, startInCreateMode = startCreateAccountFlow,
onAuthComplete = { account -> onAuthComplete = { account ->
startCreateAccountFlow = false startCreateAccountFlow = false
currentAccount = account val normalizedAccount =
cacheSessionAccount(account) account?.let {
val normalizedName =
resolveAccountDisplayName(
it.publicKey,
it.name,
null
)
if (it.name == normalizedName) it
else it.copy(name = normalizedName)
}
currentAccount = normalizedAccount
cacheSessionAccount(normalizedAccount)
hasExistingAccount = true hasExistingAccount = true
// Save as last logged account // Save as last logged account
account?.let { normalizedAccount?.let {
accountManager.setLastLoggedPublicKey(it.publicKey) accountManager.setLastLoggedPublicKey(it.publicKey)
} }
// Первый запуск после регистрации:
// дополнительно перезапускаем auth/connect, чтобы не оставаться
// в "залипшем CONNECTING" до ручного рестарта приложения.
normalizedAccount?.let { authAccount ->
startAuthHandshakeFast(
authAccount.publicKey,
authAccount.privateKeyHash
)
scope.launch {
repeat(3) { attempt ->
if (ProtocolManager.isAuthenticated()) return@launch
delay(2000L * (attempt + 1))
if (ProtocolManager.isAuthenticated()) return@launch
ProtocolManager.reconnectNowIfNeeded(
"post_auth_complete_retry_${attempt + 1}"
)
startAuthHandshakeFast(
authAccount.publicKey,
authAccount.privateKeyHash
)
}
}
}
// Reload accounts list // Reload accounts list
scope.launch { scope.launch {
normalizedAccount?.let {
// Синхронно помечаем текущий аккаунт активным в DataStore.
runCatching {
accountManager.setCurrentAccount(it.publicKey)
}
}
val accounts = accountManager.getAllAccounts() val accounts = accountManager.getAllAccounts()
accountInfoList = accounts.map { it.toAccountInfo() } accountInfoList = accounts.map { it.toAccountInfo() }
} }
@@ -1492,9 +1535,18 @@ fun MainScreen(
} }
}.collectAsState(initial = 0) }.collectAsState(initial = 0)
var chatSelectionActive by remember { mutableStateOf(false) }
val chatClearSelectionRef = remember { mutableStateOf<() -> Unit>({}) }
SwipeBackContainer( SwipeBackContainer(
isVisible = selectedUser != null, isVisible = selectedUser != null,
onBack = { popChatAndChildren() }, onBack = { popChatAndChildren() },
onInterceptSwipeBack = {
if (chatSelectionActive) {
chatClearSelectionRef.value()
true
} else false
},
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
layer = 1, layer = 1,
swipeEnabled = !isChatSwipeLocked, swipeEnabled = !isChatSwipeLocked,
@@ -1539,7 +1591,9 @@ fun MainScreen(
avatarRepository = avatarRepository, avatarRepository = avatarRepository,
onImageViewerChanged = { isLocked -> isChatSwipeLocked = isLocked }, onImageViewerChanged = { isLocked -> isChatSwipeLocked = isLocked },
isCallActive = callUiState.isVisible, isCallActive = callUiState.isVisible,
onOpenCallOverlay = { isCallOverlayExpanded = true } onOpenCallOverlay = { isCallOverlayExpanded = true },
onSelectionModeChange = { chatSelectionActive = it },
registerClearSelection = { fn -> chatClearSelectionRef.value = fn }
) )
} }
} }

View File

@@ -598,6 +598,12 @@ class MessageRepository private constructor(private val context: Context) {
// 🔥 КРИТИЧНО: Обновляем диалог через updateDialogFromMessages // 🔥 КРИТИЧНО: Обновляем диалог через updateDialogFromMessages
dialogDao.updateDialogFromMessages(account, toPublicKey) dialogDao.updateDialogFromMessages(account, toPublicKey)
// Notify listeners (ChatViewModel) that a new message was persisted
// so the chat UI reloads from DB. Without this, messages produced by
// non-input flows (e.g. CallManager's missed-call attachment) only
// appear after the user re-enters the chat.
_newMessageEvents.tryEmit(dialogKey)
// 📁 Для saved messages - гарантируем создание/обновление dialog // 📁 Для saved messages - гарантируем создание/обновление dialog
if (isSavedMessages) { if (isSavedMessages) {
val existing = dialogDao.getDialog(account, account) val existing = dialogDao.getDialog(account, account)

View File

@@ -95,7 +95,11 @@ object CallManager {
private const val TAIL_LINES = 300 private const val TAIL_LINES = 300
private const val PROTOCOL_LOG_TAIL_LINES = 180 private const val PROTOCOL_LOG_TAIL_LINES = 180
private const val MAX_LOG_PREFIX = 180 private const val MAX_LOG_PREFIX = 180
private const val INCOMING_RING_TIMEOUT_MS = 45_000L // Backend's CallManager.java uses RINGING_TIMEOUT = 30s. Local timeouts are
// slightly larger so the server's RINGING_TIMEOUT signal takes precedence when
// the network is healthy; local jobs are a fallback when the signal is lost.
private const val INCOMING_RING_TIMEOUT_MS = 35_000L
private const val OUTGOING_RING_TIMEOUT_MS = 35_000L
private const val CONNECTING_TIMEOUT_MS = 30_000L private const val CONNECTING_TIMEOUT_MS = 30_000L
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@@ -127,6 +131,7 @@ object CallManager {
private var protocolStateJob: Job? = null private var protocolStateJob: Job? = null
private var disconnectResetJob: Job? = null private var disconnectResetJob: Job? = null
private var incomingRingTimeoutJob: Job? = null private var incomingRingTimeoutJob: Job? = null
private var outgoingRingTimeoutJob: Job? = null
private var connectingTimeoutJob: Job? = null private var connectingTimeoutJob: Job? = null
private var signalWaiter: ((Packet) -> Unit)? = null private var signalWaiter: ((Packet) -> Unit)? = null
@@ -290,6 +295,18 @@ object CallManager {
) )
breadcrumbState("startOutgoingCall") breadcrumbState("startOutgoingCall")
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.CALLING) } appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.CALLING) }
// Local fallback for caller: if RINGING_TIMEOUT signal from the server is lost,
// stop ringing after the same window the server uses (~30s + small buffer).
outgoingRingTimeoutJob?.cancel()
outgoingRingTimeoutJob = scope.launch {
delay(OUTGOING_RING_TIMEOUT_MS)
val snap = _state.value
if (snap.phase == CallPhase.OUTGOING && snap.peerPublicKey == targetKey) {
breadcrumb("startOutgoingCall: local ring timeout (${OUTGOING_RING_TIMEOUT_MS}ms) → reset")
resetSession(reason = "No answer", notifyPeer = true)
}
}
return CallActionResult.STARTED return CallActionResult.STARTED
} }
@@ -551,6 +568,9 @@ object CallManager {
breadcrumb("SIG: ACCEPT ignored — role=$role") breadcrumb("SIG: ACCEPT ignored — role=$role")
return return
} }
// Callee answered before timeout — cancel outgoing ring timer
outgoingRingTimeoutJob?.cancel()
outgoingRingTimeoutJob = null
if (localPrivateKey == null || localPublicKey == null) { if (localPrivateKey == null || localPublicKey == null) {
breadcrumb("SIG: ACCEPT — generating local session keys") breadcrumb("SIG: ACCEPT — generating local session keys")
generateSessionKeys() generateSessionKeys()
@@ -1033,9 +1053,14 @@ object CallManager {
preview = durationSec.toString() preview = durationSec.toString()
) )
// Capture role synchronously before the coroutine launches, because
// resetSession() sets role = null right after calling this function —
// otherwise the async check below would fall through to the callee branch.
val capturedRole = role
scope.launch { scope.launch {
runCatching { runCatching {
if (role == CallRole.CALLER) { if (capturedRole == CallRole.CALLER) {
// CALLER: send call attachment as a message (peer will receive it) // CALLER: send call attachment as a message (peer will receive it)
MessageRepository.getInstance(context).sendMessage( MessageRepository.getInstance(context).sendMessage(
toPublicKey = peerPublicKey, toPublicKey = peerPublicKey,
@@ -1082,6 +1107,8 @@ object CallManager {
disconnectResetJob = null disconnectResetJob = null
incomingRingTimeoutJob?.cancel() incomingRingTimeoutJob?.cancel()
incomingRingTimeoutJob = null incomingRingTimeoutJob = null
outgoingRingTimeoutJob?.cancel()
outgoingRingTimeoutJob = null
// Play end call sound, then stop all // Play end call sound, then stop all
if (wasActive) { if (wasActive) {
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.END_CALL) } appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.END_CALL) }

View File

@@ -35,6 +35,7 @@ class Protocol(
private const val TAG = "RosettaProtocol" private const val TAG = "RosettaProtocol"
private const val RECONNECT_INTERVAL = 5000L // 5 seconds (как в Архиве) private const val RECONNECT_INTERVAL = 5000L // 5 seconds (как в Архиве)
private const val HANDSHAKE_TIMEOUT = 10000L // 10 seconds private const val HANDSHAKE_TIMEOUT = 10000L // 10 seconds
private const val CONNECTING_STUCK_TIMEOUT_MS = 15_000L
private const val MIN_PACKET_ID_BITS = 18 // Stream.readInt16() = 2 * readInt8() (9 bits each) private const val MIN_PACKET_ID_BITS = 18 // Stream.readInt16() = 2 * readInt8() (9 bits each)
private const val DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 15 private const val DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 15
private const val MIN_HEARTBEAT_SEND_INTERVAL_MS = 2_000L private const val MIN_HEARTBEAT_SEND_INTERVAL_MS = 2_000L
@@ -182,6 +183,7 @@ class Protocol(
private var lastSuccessfulConnection = 0L private var lastSuccessfulConnection = 0L
private var reconnectJob: Job? = null // Для отмены запланированных переподключений private var reconnectJob: Job? = null // Для отмены запланированных переподключений
private var isConnecting = false // Флаг для защиты от одновременных подключений private var isConnecting = false // Флаг для защиты от одновременных подключений
private var connectingSinceMs = 0L
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@@ -385,6 +387,7 @@ class Protocol(
*/ */
fun connect() { fun connect() {
val currentState = _state.value val currentState = _state.value
val now = System.currentTimeMillis()
log("🔌 CONNECT CALLED: currentState=$currentState, reconnectAttempts=$reconnectAttempts, isConnecting=$isConnecting") log("🔌 CONNECT CALLED: currentState=$currentState, reconnectAttempts=$reconnectAttempts, isConnecting=$isConnecting")
// КРИТИЧНО: Если уже подключены и аутентифицированы - не переподключаемся! // КРИТИЧНО: Если уже подключены и аутентифицированы - не переподключаемся!
@@ -403,10 +406,20 @@ class Protocol(
return return
} }
// КРИТИЧНО: проверяем флаг isConnecting, а не только состояние // КРИТИЧНО: проверяем флаг isConnecting, а не только состояние.
// Дополнительно защищаемся от "залипшего CONNECTING", который ранее снимался только рестартом приложения.
if (isConnecting || currentState == ProtocolState.CONNECTING) { if (isConnecting || currentState == ProtocolState.CONNECTING) {
log("⚠️ Already connecting, skipping... (preventing duplicate connect)") val elapsed = if (connectingSinceMs > 0L) now - connectingSinceMs else 0L
return if (elapsed in 1 until CONNECTING_STUCK_TIMEOUT_MS) {
log("⚠️ Already connecting, skipping... (elapsed=${elapsed}ms)")
return
}
log("🧯 CONNECTING STUCK detected (elapsed=${elapsed}ms) -> forcing reconnect reset")
isConnecting = false
connectingSinceMs = 0L
runCatching { webSocket?.cancel() }
webSocket = null
setState(ProtocolState.DISCONNECTED, "Reset stuck CONNECTING (${elapsed}ms)")
} }
val networkReady = isNetworkAvailable?.invoke() ?: true val networkReady = isNetworkAvailable?.invoke() ?: true
@@ -424,6 +437,7 @@ class Protocol(
// Устанавливаем флаг ПЕРЕД любыми операциями // Устанавливаем флаг ПЕРЕД любыми операциями
isConnecting = true isConnecting = true
connectingSinceMs = now
reconnectAttempts++ reconnectAttempts++
log("📊 RECONNECT ATTEMPT #$reconnectAttempts") log("📊 RECONNECT ATTEMPT #$reconnectAttempts")
@@ -455,6 +469,7 @@ class Protocol(
// Сбрасываем флаг подключения // Сбрасываем флаг подключения
isConnecting = false isConnecting = false
connectingSinceMs = 0L
setState(ProtocolState.CONNECTED, "WebSocket onOpen callback") setState(ProtocolState.CONNECTED, "WebSocket onOpen callback")
// Flush queue as soon as socket is open. // Flush queue as soon as socket is open.
@@ -500,6 +515,7 @@ class Protocol(
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
log("❌ WebSocket CLOSED: code=$code reason='$reason' state=${_state.value} manuallyClosed=$isManuallyClosed") log("❌ WebSocket CLOSED: code=$code reason='$reason' state=${_state.value} manuallyClosed=$isManuallyClosed")
isConnecting = false // Сбрасываем флаг isConnecting = false // Сбрасываем флаг
connectingSinceMs = 0L
handleDisconnect() handleDisconnect()
} }
@@ -511,6 +527,7 @@ class Protocol(
log(" Reconnect attempts: $reconnectAttempts") log(" Reconnect attempts: $reconnectAttempts")
t.printStackTrace() t.printStackTrace()
isConnecting = false // Сбрасываем флаг isConnecting = false // Сбрасываем флаг
connectingSinceMs = 0L
_lastError.value = t.message _lastError.value = t.message
handleDisconnect() handleDisconnect()
} }
@@ -801,6 +818,7 @@ class Protocol(
log("🔌 Manual disconnect requested") log("🔌 Manual disconnect requested")
isManuallyClosed = true isManuallyClosed = true
isConnecting = false // Сбрасываем флаг isConnecting = false // Сбрасываем флаг
connectingSinceMs = 0L
reconnectJob?.cancel() // Отменяем запланированные переподключения reconnectJob?.cancel() // Отменяем запланированные переподключения
reconnectJob = null reconnectJob = null
handshakeJob?.cancel() handshakeJob?.cancel()
@@ -823,6 +841,7 @@ class Protocol(
fun reconnectNowIfNeeded(reason: String = "foreground") { fun reconnectNowIfNeeded(reason: String = "foreground") {
val currentState = _state.value val currentState = _state.value
val hasCredentials = !lastPublicKey.isNullOrBlank() && !lastPrivateHash.isNullOrBlank() val hasCredentials = !lastPublicKey.isNullOrBlank() && !lastPrivateHash.isNullOrBlank()
val now = System.currentTimeMillis()
log( log(
"⚡ FAST RECONNECT CHECK: state=$currentState, hasCredentials=$hasCredentials, isConnecting=$isConnecting, reason=$reason" "⚡ FAST RECONNECT CHECK: state=$currentState, hasCredentials=$hasCredentials, isConnecting=$isConnecting, reason=$reason"
@@ -830,12 +849,22 @@ class Protocol(
if (!hasCredentials) return if (!hasCredentials) return
if ( if (currentState == ProtocolState.CONNECTING && isConnecting) {
val elapsed = if (connectingSinceMs > 0L) now - connectingSinceMs else 0L
if (elapsed in 1 until CONNECTING_STUCK_TIMEOUT_MS) {
return
}
log("🧯 FAST RECONNECT: stuck CONNECTING (${elapsed}ms) -> reset and reconnect")
isConnecting = false
connectingSinceMs = 0L
runCatching { webSocket?.cancel() }
webSocket = null
setState(ProtocolState.DISCONNECTED, "Fast reconnect reset stuck CONNECTING")
} else if (
currentState == ProtocolState.AUTHENTICATED || currentState == ProtocolState.AUTHENTICATED ||
currentState == ProtocolState.HANDSHAKING || currentState == ProtocolState.HANDSHAKING ||
currentState == ProtocolState.DEVICE_VERIFICATION_REQUIRED || currentState == ProtocolState.DEVICE_VERIFICATION_REQUIRED ||
currentState == ProtocolState.CONNECTED || currentState == ProtocolState.CONNECTED
(currentState == ProtocolState.CONNECTING && isConnecting)
) { ) {
return return
} }

View File

@@ -325,7 +325,9 @@ fun ChatDetailScreen(
avatarRepository: AvatarRepository? = null, avatarRepository: AvatarRepository? = null,
onImageViewerChanged: (Boolean) -> Unit = {}, onImageViewerChanged: (Boolean) -> Unit = {},
isCallActive: Boolean = false, isCallActive: Boolean = false,
onOpenCallOverlay: () -> Unit = {} onOpenCallOverlay: () -> Unit = {},
onSelectionModeChange: (Boolean) -> Unit = {},
registerClearSelection: (() -> Unit) -> Unit = {}
) { ) {
val viewModel: ChatViewModel = viewModel(key = "chat_${user.publicKey}") val viewModel: ChatViewModel = viewModel(key = "chat_${user.publicKey}")
val context = LocalContext.current val context = LocalContext.current
@@ -390,6 +392,14 @@ fun ChatDetailScreen(
// 🔥 MESSAGE SELECTION STATE - для Reply/Forward // 🔥 MESSAGE SELECTION STATE - для Reply/Forward
var selectedMessages by remember { mutableStateOf<Set<String>>(emptySet()) } var selectedMessages by remember { mutableStateOf<Set<String>>(emptySet()) }
val isSelectionMode = selectedMessages.isNotEmpty() val isSelectionMode = selectedMessages.isNotEmpty()
// Notify parent about selection mode changes so it can intercept swipe-back
LaunchedEffect(isSelectionMode) { onSelectionModeChange(isSelectionMode) }
// Register selection-clear callback so parent can cancel selection on swipe-back
DisposableEffect(Unit) {
registerClearSelection { selectedMessages = emptySet() }
onDispose { registerClearSelection {} }
}
// После long press AndroidView текста может прислать tap на отпускание. // После long press AndroidView текста может прислать tap на отпускание.
// В этом окне игнорируем tap по этому же сообщению, чтобы selection не снимался. // В этом окне игнорируем tap по этому же сообщению, чтобы selection не снимался.
var longPressSuppressedMessageId by remember { mutableStateOf<String?>(null) } var longPressSuppressedMessageId by remember { mutableStateOf<String?>(null) }
@@ -1360,10 +1370,10 @@ fun ChatDetailScreen(
// 🔥 Обработка системной кнопки назад // 🔥 Обработка системной кнопки назад
BackHandler { BackHandler {
if (isInChatSearchMode) { when {
closeInChatSearch() isSelectionMode -> selectedMessages = emptySet()
} else { isInChatSearchMode -> closeInChatSearch()
handleBackWithInputPriority() else -> handleBackWithInputPriority()
} }
} }
@@ -2326,11 +2336,21 @@ fun ChatDetailScreen(
} }
// Voice mini player — shown right under the chat header when audio is playing // Voice mini player — shown right under the chat header when audio is playing
val playingVoiceAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState() val playingVoiceAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState()
if (!playingVoiceAttachmentId.isNullOrBlank()) { val isVoicePlaybackRunning by VoicePlaybackCoordinator.isPlaying.collectAsState()
val isVoicePlaybackRunning by VoicePlaybackCoordinator.isPlaying.collectAsState() val voicePlaybackSpeed by VoicePlaybackCoordinator.playbackSpeed.collectAsState()
val voicePlaybackSpeed by VoicePlaybackCoordinator.playbackSpeed.collectAsState() val playingVoiceSenderLabel by VoicePlaybackCoordinator.playingSenderLabel.collectAsState()
val playingVoiceSenderLabel by VoicePlaybackCoordinator.playingSenderLabel.collectAsState() val playingVoiceTimeLabel by VoicePlaybackCoordinator.playingTimeLabel.collectAsState()
val playingVoiceTimeLabel by VoicePlaybackCoordinator.playingTimeLabel.collectAsState() AnimatedVisibility(
visible = !playingVoiceAttachmentId.isNullOrBlank(),
enter = expandVertically(
animationSpec = tween(220, easing = androidx.compose.animation.core.FastOutSlowInEasing),
expandFrom = Alignment.Top
) + fadeIn(animationSpec = tween(220)),
exit = shrinkVertically(
animationSpec = tween(260, easing = androidx.compose.animation.core.FastOutSlowInEasing),
shrinkTowards = Alignment.Top
) + fadeOut(animationSpec = tween(180))
) {
val sender = playingVoiceSenderLabel.trim().ifBlank { "Voice" } val sender = playingVoiceSenderLabel.trim().ifBlank { "Voice" }
val time = playingVoiceTimeLabel.trim() val time = playingVoiceTimeLabel.trim()
val voiceTitle = if (time.isBlank()) sender else "$sender at $time" val voiceTitle = if (time.isBlank()) sender else "$sender at $time"

View File

@@ -5882,6 +5882,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
return return
} }
// ⚡ Оптимизация: не отправляем typing indicator если собеседник офлайн
// (для групп продолжаем отправлять — кто-то из участников может быть в сети)
if (!isGroupDialogKey(opponent) && !_opponentOnline.value) {
return
}
val privateKey = val privateKey =
myPrivateKey myPrivateKey
?: run { ?: run {

View File

@@ -66,6 +66,7 @@ import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.EncryptedAccount import com.rosetta.messenger.data.EncryptedAccount
import com.rosetta.messenger.data.MessageRepository import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.data.RecentSearchesManager import com.rosetta.messenger.data.RecentSearchesManager
import com.rosetta.messenger.data.isPlaceholderAccountName
import com.rosetta.messenger.data.resolveAccountDisplayName import com.rosetta.messenger.data.resolveAccountDisplayName
import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.CallPhase import com.rosetta.messenger.network.CallPhase
@@ -254,6 +255,15 @@ private fun resolveTypingDisplayName(publicKey: String): String {
return if (resolvedName.isNotBlank()) resolvedName else shortPublicKey(normalized) return if (resolvedName.isNotBlank()) resolvedName else shortPublicKey(normalized)
} }
private fun rosettaDev1Log(context: Context, tag: String, message: String) {
runCatching {
val dir = java.io.File(context.filesDir, "crash_reports")
if (!dir.exists()) dir.mkdirs()
val ts = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()).format(Date())
java.io.File(dir, "rosettadev1.txt").appendText("$ts [$tag] $message\n")
}
}
private val TELEGRAM_DIALOG_AVATAR_START = 10.dp private val TELEGRAM_DIALOG_AVATAR_START = 10.dp
private val TELEGRAM_DIALOG_TEXT_START = 72.dp private val TELEGRAM_DIALOG_TEXT_START = 72.dp
private val TELEGRAM_DIALOG_AVATAR_SIZE = 54.dp private val TELEGRAM_DIALOG_AVATAR_SIZE = 54.dp
@@ -314,9 +324,6 @@ fun ChatsListScreen(
val view = androidx.compose.ui.platform.LocalView.current val view = androidx.compose.ui.platform.LocalView.current
val context = androidx.compose.ui.platform.LocalContext.current val context = androidx.compose.ui.platform.LocalContext.current
val hasNativeNavigationBar = remember(context) {
com.rosetta.messenger.ui.utils.NavigationModeUtils.hasNativeNavigationBar(context)
}
val focusManager = androidx.compose.ui.platform.LocalFocusManager.current val focusManager = androidx.compose.ui.platform.LocalFocusManager.current
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -491,22 +498,37 @@ fun ChatsListScreen(
val playingVoiceSenderLabel by VoicePlaybackCoordinator.playingSenderLabel.collectAsState() val playingVoiceSenderLabel by VoicePlaybackCoordinator.playingSenderLabel.collectAsState()
val playingVoiceTimeLabel by VoicePlaybackCoordinator.playingTimeLabel.collectAsState() val playingVoiceTimeLabel by VoicePlaybackCoordinator.playingTimeLabel.collectAsState()
// Load dialogs when account is available // Load dialogs as soon as public key is available.
// Private key may appear a bit later right after fresh registration on some devices.
LaunchedEffect(accountPublicKey, accountPrivateKey) { LaunchedEffect(accountPublicKey, accountPrivateKey) {
if (accountPublicKey.isNotEmpty() && accountPrivateKey.isNotEmpty()) { val normalizedPublicKey = accountPublicKey.trim()
val launchStart = System.currentTimeMillis() if (normalizedPublicKey.isEmpty()) return@LaunchedEffect
chatsViewModel.setAccount(accountPublicKey, accountPrivateKey)
// Устанавливаем аккаунт для RecentSearchesManager
RecentSearchesManager.setAccount(accountPublicKey)
// 🔥 КРИТИЧНО: Инициализируем MessageRepository для обработки входящих val normalizedPrivateKey = accountPrivateKey.trim()
// сообщений val launchStart = System.currentTimeMillis()
ProtocolManager.initializeAccount(accountPublicKey, accountPrivateKey)
android.util.Log.d( chatsViewModel.setAccount(normalizedPublicKey, normalizedPrivateKey)
"ChatsListScreen", // Устанавливаем аккаунт для RecentSearchesManager
"✅ Total LaunchedEffect: ${System.currentTimeMillis() - launchStart}ms" RecentSearchesManager.setAccount(normalizedPublicKey)
)
// 🔥 КРИТИЧНО: Инициализируем MessageRepository для обработки входящих
// сообщений только когда приватный ключ уже доступен.
if (normalizedPrivateKey.isNotEmpty()) {
ProtocolManager.initializeAccount(normalizedPublicKey, normalizedPrivateKey)
} }
android.util.Log.d(
"ChatsListScreen",
"✅ Account init effect: pubReady=true privReady=${normalizedPrivateKey.isNotEmpty()} " +
"in ${System.currentTimeMillis() - launchStart}ms"
)
rosettaDev1Log(
context = context,
tag = "ChatsListScreen",
message =
"Account init effect pub=${shortPublicKey(normalizedPublicKey)} " +
"privReady=${normalizedPrivateKey.isNotEmpty()}"
)
} }
// Status dialog state // Status dialog state
@@ -604,9 +626,44 @@ fun ChatsListScreen(
LaunchedEffect(accountPublicKey) { LaunchedEffect(accountPublicKey) {
val accountManager = AccountManager(context) val accountManager = AccountManager(context)
val accounts = accountManager.getAllAccounts() val accounts = accountManager.getAllAccounts()
allAccounts = accounts.sortedByDescending { it.publicKey == accountPublicKey } val preferredPublicKey =
accountPublicKey.trim().ifBlank {
accountManager.getLastLoggedPublicKey().orEmpty()
}
allAccounts = accounts.sortedByDescending { it.publicKey == preferredPublicKey }
} }
val effectiveCurrentPublicKey =
remember(accountPublicKey, allAccounts) {
accountPublicKey.trim().ifBlank { allAccounts.firstOrNull()?.publicKey.orEmpty() }
}
val currentSidebarAccount =
remember(allAccounts, effectiveCurrentPublicKey) {
allAccounts.firstOrNull {
it.publicKey.equals(effectiveCurrentPublicKey, ignoreCase = true)
} ?: allAccounts.firstOrNull()
}
val sidebarAccountUsername =
remember(accountUsername, currentSidebarAccount) {
accountUsername.ifBlank { currentSidebarAccount?.username.orEmpty() }
}
val sidebarAccountName =
remember(accountName, sidebarAccountUsername, currentSidebarAccount, effectiveCurrentPublicKey) {
val preferredName =
when {
accountName.isNotBlank() &&
!isPlaceholderAccountName(accountName) -> accountName
!currentSidebarAccount?.name.isNullOrBlank() ->
currentSidebarAccount?.name.orEmpty()
else -> accountName
}
resolveAccountDisplayName(
effectiveCurrentPublicKey,
preferredName,
sidebarAccountUsername
)
}
// Confirmation dialogs state // Confirmation dialogs state
var dialogsToDelete by remember { mutableStateOf<List<DialogUiModel>>(emptyList()) } var dialogsToDelete by remember { mutableStateOf<List<DialogUiModel>>(emptyList()) }
var dialogToLeave by remember { mutableStateOf<DialogUiModel?>(null) } var dialogToLeave by remember { mutableStateOf<DialogUiModel?>(null) }
@@ -625,7 +682,7 @@ fun ChatsListScreen(
val hapticFeedback = LocalHapticFeedback.current val hapticFeedback = LocalHapticFeedback.current
var showSelectionMenu by remember { mutableStateOf(false) } var showSelectionMenu by remember { mutableStateOf(false) }
val preferencesManager = remember { com.rosetta.messenger.data.PreferencesManager(context) } val preferencesManager = remember { com.rosetta.messenger.data.PreferencesManager(context) }
val mutedChats by preferencesManager.mutedChatsForAccount(accountPublicKey) val mutedChats by preferencesManager.mutedChatsForAccount(effectiveCurrentPublicKey)
.collectAsState(initial = emptySet()) .collectAsState(initial = emptySet())
// Back: drawer → закрыть, selection → сбросить // Back: drawer → закрыть, selection → сбросить
@@ -656,6 +713,31 @@ fun ChatsListScreen(
val topLevelRequestsCount = topLevelChatsState.requestsCount val topLevelRequestsCount = topLevelChatsState.requestsCount
val visibleTopLevelRequestsCount = if (syncInProgress) 0 else topLevelRequestsCount val visibleTopLevelRequestsCount = if (syncInProgress) 0 else topLevelRequestsCount
// Anti-stuck guard:
// если соединение уже AUTHENTICATED и синхронизация завершена,
// loading не должен висеть бесконечно.
LaunchedEffect(accountPublicKey, protocolState, syncInProgress, topLevelIsLoading) {
val normalizedPublicKey = accountPublicKey.trim()
if (normalizedPublicKey.isBlank()) return@LaunchedEffect
if (!topLevelIsLoading) return@LaunchedEffect
if (protocolState != ProtocolState.AUTHENTICATED || syncInProgress) return@LaunchedEffect
delay(1200)
if (
topLevelIsLoading &&
protocolState == ProtocolState.AUTHENTICATED &&
!syncInProgress
) {
rosettaDev1Log(
context = context,
tag = "ChatsListScreen",
message =
"loading guard fired pub=${shortPublicKey(normalizedPublicKey)}"
)
chatsViewModel.forceStopLoading("ui_guard_authenticated_no_sync")
}
}
// Dev console dialog - commented out for now // Dev console dialog - commented out for now
/* /*
if (showDevConsole) { if (showDevConsole) {
@@ -806,10 +888,6 @@ fun ChatsListScreen(
Modifier.fillMaxSize() Modifier.fillMaxSize()
.onSizeChanged { rootSize = it } .onSizeChanged { rootSize = it }
.background(backgroundColor) .background(backgroundColor)
.then(
if (hasNativeNavigationBar) Modifier.navigationBarsPadding()
else Modifier
)
) { ) {
ModalNavigationDrawer( ModalNavigationDrawer(
drawerState = drawerState, drawerState = drawerState,
@@ -892,16 +970,16 @@ fun ChatsListScreen(
) { ) {
AvatarImage( AvatarImage(
publicKey = publicKey =
accountPublicKey, effectiveCurrentPublicKey,
avatarRepository = avatarRepository =
avatarRepository, avatarRepository,
size = 72.dp, size = 72.dp,
isDarkTheme = isDarkTheme =
isDarkTheme, isDarkTheme,
displayName = displayName =
accountName sidebarAccountName
.ifEmpty { .ifEmpty {
accountUsername sidebarAccountUsername
} }
) )
} }
@@ -953,13 +1031,13 @@ fun ChatsListScreen(
) { ) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
// Display name // Display name
if (accountName.isNotEmpty()) { if (sidebarAccountName.isNotEmpty()) {
Row( Row(
horizontalArrangement = Arrangement.Start, horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
text = accountName, text = sidebarAccountName,
fontSize = 15.sp, fontSize = 15.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = Color.White color = Color.White
@@ -978,10 +1056,10 @@ fun ChatsListScreen(
} }
// Username // Username
if (accountUsername.isNotEmpty()) { if (sidebarAccountUsername.isNotEmpty()) {
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(2.dp))
Text( Text(
text = "@$accountUsername", text = "@$sidebarAccountUsername",
fontSize = 13.sp, fontSize = 13.sp,
color = Color.White.copy(alpha = 0.7f) color = Color.White.copy(alpha = 0.7f)
) )
@@ -1022,7 +1100,9 @@ fun ChatsListScreen(
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
// All accounts list (max 5 like Telegram sidebar behavior) // All accounts list (max 5 like Telegram sidebar behavior)
allAccounts.take(5).forEach { account -> allAccounts.take(5).forEach { account ->
val isCurrentAccount = account.publicKey == accountPublicKey val isCurrentAccount =
account.publicKey ==
effectiveCurrentPublicKey
val displayName = val displayName =
resolveAccountDisplayName( resolveAccountDisplayName(
account.publicKey, account.publicKey,
@@ -1310,9 +1390,12 @@ fun ChatsListScreen(
Column( Column(
modifier = modifier =
Modifier.fillMaxWidth() Modifier.fillMaxWidth()
.then( .windowInsetsPadding(
if (hasNativeNavigationBar) Modifier.navigationBarsPadding() WindowInsets
else Modifier .navigationBars
.only(
WindowInsetsSides.Bottom
)
) )
) { ) {
// Telegram-style update banner // Telegram-style update banner
@@ -2842,7 +2925,17 @@ fun ChatsListScreen(
onOpenCall = onOpenCallOverlay onOpenCall = onOpenCallOverlay
) )
} }
if (showVoiceMiniPlayer) { AnimatedVisibility(
visible = showVoiceMiniPlayer,
enter = expandVertically(
animationSpec = tween(220, easing = FastOutSlowInEasing),
expandFrom = Alignment.Top
) + fadeIn(animationSpec = tween(220)),
exit = shrinkVertically(
animationSpec = tween(260, easing = FastOutSlowInEasing),
shrinkTowards = Alignment.Top
) + fadeOut(animationSpec = tween(180))
) {
VoiceTopMiniPlayer( VoiceTopMiniPlayer(
title = voiceMiniPlayerTitle, title = voiceMiniPlayerTitle,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,

View File

@@ -14,11 +14,14 @@ import com.rosetta.messenger.network.PacketOnlineSubscribe
import com.rosetta.messenger.network.PacketSearch import com.rosetta.messenger.network.PacketSearch
import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.network.SearchUser
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale import java.util.Locale
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -92,6 +95,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
// Job для отмены подписок при смене аккаунта // Job для отмены подписок при смене аккаунта
private var accountSubscriptionsJob: Job? = null private var accountSubscriptionsJob: Job? = null
private var loadingFailSafeJob: Job? = null
// Список диалогов с расшифрованными сообщениями // Список диалогов с расшифрованными сообщениями
private val _dialogs = MutableStateFlow<List<DialogUiModel>>(emptyList()) private val _dialogs = MutableStateFlow<List<DialogUiModel>>(emptyList())
@@ -132,9 +136,11 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
ChatsUiState() ChatsUiState()
) )
// Загрузка (🔥 true по умолчанию — skeleton на первом кадре, чтобы не мигало empty→skeleton→empty) // Загрузка
private val _isLoading = MutableStateFlow(true) // Важно: false по умолчанию, чтобы исключить "вечный skeleton", если setAccount не был вызван.
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow() val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private val loadingFailSafeTimeoutMs = 4500L
private val TAG = "ChatsListVM" private val TAG = "ChatsListVM"
private val groupInviteRegex = Regex("^#group:[A-Za-z0-9+/=:]+$") private val groupInviteRegex = Regex("^#group:[A-Za-z0-9+/=:]+$")
@@ -146,6 +152,16 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
return normalized.startsWith("#group:") || normalized.startsWith("group:") return normalized.startsWith("#group:") || normalized.startsWith("group:")
} }
private fun rosettaDev1Log(msg: String) {
runCatching {
val app = getApplication<Application>()
val dir = java.io.File(app.filesDir, "crash_reports")
if (!dir.exists()) dir.mkdirs()
val ts = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()).format(Date())
java.io.File(dir, "rosettadev1.txt").appendText("$ts [ChatsListVM] $msg\n")
}
}
private data class GroupLastSenderInfo( private data class GroupLastSenderInfo(
val senderPrefix: String, val senderPrefix: String,
val senderKey: String val senderKey: String
@@ -345,15 +361,39 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
/** Установить текущий аккаунт и загрузить диалоги */ /** Установить текущий аккаунт и загрузить диалоги */
fun setAccount(publicKey: String, privateKey: String) { fun setAccount(publicKey: String, privateKey: String) {
val setAccountStart = System.currentTimeMillis() val resolvedPrivateKey =
if (currentAccount == publicKey) { when {
privateKey.isNotBlank() -> privateKey
currentAccount == publicKey -> currentPrivateKey.orEmpty()
else -> ""
}
if (currentAccount == publicKey && currentPrivateKey == resolvedPrivateKey) {
// 🔥 Сбрасываем skeleton если он ещё показан (при повторном заходе) // 🔥 Сбрасываем skeleton если он ещё показан (при повторном заходе)
if (_isLoading.value) _isLoading.value = false if (_isLoading.value) {
_isLoading.value = false
}
loadingFailSafeJob?.cancel()
return return
} }
// 🔥 Показываем skeleton пока данные грузятся // 🔥 Показываем skeleton пока данные грузятся
_isLoading.value = true _isLoading.value = true
loadingFailSafeJob?.cancel()
loadingFailSafeJob =
viewModelScope.launch {
delay(loadingFailSafeTimeoutMs)
if (_isLoading.value) {
_isLoading.value = false
android.util.Log.w(
TAG,
"Fail-safe: forced isLoading=false after ${loadingFailSafeTimeoutMs}ms for account=${publicKey.take(8)}..."
)
rosettaDev1Log(
"Fail-safe isLoading=false account=${publicKey.take(8)} timeoutMs=$loadingFailSafeTimeoutMs"
)
}
}
// 🔥 Очищаем кэш запрошенных user info при смене аккаунта // 🔥 Очищаем кэш запрошенных user info при смене аккаунта
requestedUserInfoKeys.clear() requestedUserInfoKeys.clear()
@@ -369,7 +409,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
accountSubscriptionsJob?.cancel() accountSubscriptionsJob?.cancel()
currentAccount = publicKey currentAccount = publicKey
currentPrivateKey = privateKey currentPrivateKey = resolvedPrivateKey
// <20> Устанавливаем аккаунт для DraftManager (загрузит черновики из SharedPreferences) // <20> Устанавливаем аккаунт для DraftManager (загрузит черновики из SharedPreferences)
DraftManager.setAccount(publicKey) DraftManager.setAccount(publicKey)
@@ -380,7 +420,9 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
_requestsCount.value = 0 _requestsCount.value = 0
// 🚀 Pre-warm PBKDF2 ключ — чтобы к моменту дешифровки кэш был горячий // 🚀 Pre-warm PBKDF2 ключ — чтобы к моменту дешифровки кэш был горячий
viewModelScope.launch(Dispatchers.Default) { CryptoManager.getPbkdf2Key(privateKey) } if (resolvedPrivateKey.isNotEmpty()) {
viewModelScope.launch(Dispatchers.Default) { CryptoManager.getPbkdf2Key(resolvedPrivateKey) }
}
// Запускаем все подписки в одном родительском Job для отмены при смене аккаунта // Запускаем все подписки в одном родительском Job для отмены при смене аккаунта
accountSubscriptionsJob = viewModelScope.launch { accountSubscriptionsJob = viewModelScope.launch {
@@ -410,7 +452,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
} else { } else {
mapDialogListIncremental( mapDialogListIncremental(
dialogsList = dialogsList, dialogsList = dialogsList,
privateKey = privateKey, privateKey = resolvedPrivateKey,
cache = dialogsUiCache, cache = dialogsUiCache,
isRequestsFlow = false isRequestsFlow = false
) )
@@ -418,10 +460,19 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
} }
.filterNotNull() .filterNotNull()
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки .distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
.catch { e ->
android.util.Log.e(TAG, "Dialogs flow failed in setAccount()", e)
rosettaDev1Log("Dialogs flow failed: ${e.message}")
if (_isLoading.value) _isLoading.value = false
emit(emptyList())
}
.collect { decryptedDialogs -> .collect { decryptedDialogs ->
_dialogs.value = decryptedDialogs _dialogs.value = decryptedDialogs
// 🚀 Убираем skeleton после первой загрузки // 🚀 Убираем skeleton после первой загрузки
if (_isLoading.value) _isLoading.value = false if (_isLoading.value) {
_isLoading.value = false
loadingFailSafeJob?.cancel()
}
// 🟢 Подписываемся на онлайн-статусы всех собеседников // 🟢 Подписываемся на онлайн-статусы всех собеседников
// 📁 Исключаем Saved Messages - не нужно подписываться на свой собственный // 📁 Исключаем Saved Messages - не нужно подписываться на свой собственный
@@ -430,7 +481,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
decryptedDialogs.filter { !it.isSavedMessages }.map { decryptedDialogs.filter { !it.isSavedMessages }.map {
it.opponentKey it.opponentKey
} }
subscribeToOnlineStatuses(opponentsToSubscribe, privateKey) subscribeToOnlineStatuses(opponentsToSubscribe, resolvedPrivateKey)
} }
} }
@@ -450,7 +501,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
} else { } else {
mapDialogListIncremental( mapDialogListIncremental(
dialogsList = requestsList, dialogsList = requestsList,
privateKey = privateKey, privateKey = resolvedPrivateKey,
cache = requestsUiCache, cache = requestsUiCache,
isRequestsFlow = true isRequestsFlow = true
) )
@@ -498,6 +549,24 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
} }
} // end accountSubscriptionsJob } // end accountSubscriptionsJob
accountSubscriptionsJob?.invokeOnCompletion { cause ->
if (cause != null && _isLoading.value) {
_isLoading.value = false
loadingFailSafeJob?.cancel()
android.util.Log.e(TAG, "accountSubscriptionsJob completed with error", cause)
rosettaDev1Log("accountSubscriptionsJob error: ${cause.message}")
}
}
}
fun forceStopLoading(reason: String) {
if (_isLoading.value) {
_isLoading.value = false
loadingFailSafeJob?.cancel()
android.util.Log.w(TAG, "forceStopLoading: $reason")
rosettaDev1Log("forceStopLoading: $reason")
}
} }
/** /**
@@ -506,6 +575,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
*/ */
private fun subscribeToOnlineStatuses(opponentKeys: List<String>, privateKey: String) { private fun subscribeToOnlineStatuses(opponentKeys: List<String>, privateKey: String) {
if (opponentKeys.isEmpty()) return if (opponentKeys.isEmpty()) return
if (privateKey.isBlank()) return
// 🔥 КРИТИЧНО: Фильтруем уже подписанные ключи! // 🔥 КРИТИЧНО: Фильтруем уже подписанные ключи!
val newKeys = val newKeys =

View File

@@ -1169,30 +1169,46 @@ fun GroupInfoScreen(
) )
} }
if (groupDescription.isNotBlank()) {
Spacer(modifier = Modifier.height(10.dp))
AppleEmojiText(
text = groupDescription,
color = Color.White.copy(alpha = 0.7f),
fontSize = 12.sp,
maxLines = 2,
overflow = android.text.TextUtils.TruncateAt.END,
enableLinks = false
)
}
} }
} }
Spacer(modifier = Modifier.height(8.dp)) if (groupDescription.isNotBlank()) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = sectionColor
) {
Column(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
) {
AppleEmojiText(
text = groupDescription,
color = primaryText,
fontSize = 16.sp,
maxLines = 8,
overflow = android.text.TextUtils.TruncateAt.END,
enableLinks = true
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Description",
color = Color(0xFF8E8E93),
fontSize = 13.sp
)
}
}
Divider(
color = borderColor,
thickness = 0.5.dp,
modifier = Modifier.fillMaxWidth()
)
}
Surface( Surface(
modifier = Modifier modifier = Modifier.fillMaxWidth(),
.fillMaxWidth() color = sectionColor
.padding(horizontal = 16.dp),
color = sectionColor,
shape = RoundedCornerShape(12.dp)
) { ) {
Column { Column {
// Add Members // Add Members — flat Telegram style, edge-to-edge, white text
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -1200,27 +1216,28 @@ fun GroupInfoScreen(
.padding(horizontal = 16.dp, vertical = 13.dp), .padding(horizontal = 16.dp, vertical = 13.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon(
imageVector = Icons.Default.PersonAdd,
contentDescription = null,
tint = accentColor,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(28.dp))
Text( Text(
text = "Add Members", text = "Add Members",
color = primaryText, color = primaryText,
fontSize = 16.sp, fontSize = 16.sp,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
Icon(
imageVector = Icons.Default.PersonAdd,
contentDescription = null,
tint = accentColor,
modifier = Modifier.size(groupMenuTrailingIconSize)
)
} }
Divider( Divider(
color = borderColor, color = borderColor,
thickness = 0.5.dp, thickness = 0.5.dp,
modifier = Modifier.padding(start = 16.dp) modifier = Modifier.fillMaxWidth()
) )
// Encryption Key // Encryption Key — flat Telegram style, edge-to-edge, white text
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -1228,6 +1245,13 @@ fun GroupInfoScreen(
.padding(horizontal = 16.dp, vertical = 13.dp), .padding(horizontal = 16.dp, vertical = 13.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon(
imageVector = Icons.Default.Lock,
contentDescription = null,
tint = accentColor,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(28.dp))
Text( Text(
text = "Encryption Key", text = "Encryption Key",
color = primaryText, color = primaryText,

View File

@@ -14,6 +14,7 @@ import android.util.LruCache
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import android.widget.VideoView import android.widget.VideoView
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.StartOffset import androidx.compose.animation.core.StartOffset
@@ -42,6 +43,8 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Rect
@@ -96,6 +99,7 @@ import java.security.MessageDigest
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Locale import java.util.Locale
import kotlin.math.min import kotlin.math.min
import kotlin.math.PI
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
@@ -2027,6 +2031,76 @@ private fun formatVoiceDuration(seconds: Int): String {
return "$minutes:$rem" return "$minutes:$rem"
} }
private fun formatVoicePlaybackSpeedLabel(speed: Float): String {
val normalized = kotlin.math.round(speed * 10f) / 10f
return if (kotlin.math.abs(normalized - normalized.toInt().toFloat()) < 0.01f) {
"${normalized.toInt()}x"
} else {
"${normalized}x"
}
}
@Composable
private fun VoicePlaybackButtonBlob(
level: Float,
isOutgoing: Boolean,
isDarkTheme: Boolean,
modifier: Modifier = Modifier
) {
val clampedLevel = level.coerceIn(0f, 1f)
val animatedLevel by animateFloatAsState(
targetValue = clampedLevel,
animationSpec = tween(durationMillis = 140),
label = "voice_blob_level"
)
val transition = rememberInfiniteTransition(label = "voice_blob_motion")
val pulse by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1420, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
),
label = "voice_blob_pulse"
)
val blobColor =
if (isOutgoing) {
Color.White
} else if (isDarkTheme) {
Color(0xFF5DB8FF)
} else {
PrimaryBlue
}
Canvas(modifier = modifier) {
val center = Offset(x = size.width * 0.5f, y = size.height * 0.5f)
val buttonRadius = 20.dp.toPx() // Play button is 40dp.
val amp = animatedLevel.coerceIn(0f, 1f)
// Telegram-like: soft concentric glow, centered, no geometry distortion.
val r1 = buttonRadius + 4.2.dp.toPx() + amp * 4.0.dp.toPx() + pulse * 1.6.dp.toPx()
val r2 = buttonRadius + 2.6.dp.toPx() + amp * 2.9.dp.toPx() + pulse * 0.9.dp.toPx()
val r3 = buttonRadius + 1.3.dp.toPx() + amp * 1.8.dp.toPx() + pulse * 0.5.dp.toPx()
drawCircle(
color = blobColor.copy(alpha = (0.14f + amp * 0.08f).coerceAtMost(0.24f)),
radius = r1,
center = center
)
drawCircle(
color = blobColor.copy(alpha = (0.11f + amp * 0.06f).coerceAtMost(0.18f)),
radius = r2,
center = center
)
drawCircle(
color = blobColor.copy(alpha = (0.08f + amp * 0.05f).coerceAtMost(0.14f)),
radius = r3,
center = center
)
}
}
private fun formatDesktopCallDuration(durationSec: Int): String { private fun formatDesktopCallDuration(durationSec: Int): String {
val minutes = durationSec / 60 val minutes = durationSec / 60
val seconds = durationSec % 60 val seconds = durationSec % 60
@@ -2178,6 +2252,9 @@ private fun VoiceAttachment(
val playbackIsPlaying by val playbackIsPlaying by
(if (isActiveTrack) VoicePlaybackCoordinator.isPlaying else flowOf(false)) (if (isActiveTrack) VoicePlaybackCoordinator.isPlaying else flowOf(false))
.collectAsState(initial = false) .collectAsState(initial = false)
val playbackSpeed by
(if (isActiveTrack) VoicePlaybackCoordinator.playbackSpeed else flowOf(1f))
.collectAsState(initial = 1f)
val isPlaying = isActiveTrack && playbackIsPlaying val isPlaying = isActiveTrack && playbackIsPlaying
val (previewDurationSecRaw, previewWavesRaw) = val (previewDurationSecRaw, previewWavesRaw) =
@@ -2224,6 +2301,16 @@ private fun VoiceAttachment(
} else { } else {
0f 0f
} }
val liveWaveLevel =
remember(isPlaying, progress, waves) {
if (!isPlaying || waves.isEmpty()) {
0f
} else {
val maxIndex = waves.lastIndex.coerceAtLeast(0)
val sampleIndex = (progress * maxIndex.toFloat()).toInt().coerceIn(0, maxIndex)
waves[sampleIndex].coerceIn(0f, 1f)
}
}
val timeText = val timeText =
if (isActiveTrack && playbackDurationMs > 0) { if (isActiveTrack && playbackDurationMs > 0) {
val leftSec = ((playbackDurationMs - playbackPositionMs).coerceAtLeast(0) / 1000) val leftSec = ((playbackDurationMs - playbackPositionMs).coerceAtLeast(0) / 1000)
@@ -2389,12 +2476,29 @@ private fun VoiceAttachment(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Box( Box(
modifier = modifier = Modifier.size(40.dp),
Modifier.size(40.dp)
.clip(CircleShape)
.background(actionBackground),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
if (downloadStatus == DownloadStatus.DOWNLOADED && isPlaying) {
VoicePlaybackButtonBlob(
level = liveWaveLevel,
isOutgoing = isOutgoing,
isDarkTheme = isDarkTheme,
modifier =
Modifier.requiredSize(64.dp).graphicsLayer {
// Blob lives strictly behind the button; keep button geometry untouched.
alpha = 0.96f
compositingStrategy = CompositingStrategy.Offscreen
}
)
}
Box(
modifier =
Modifier.size(40.dp)
.clip(CircleShape)
.background(actionBackground),
contentAlignment = Alignment.Center
) {
if (downloadStatus == DownloadStatus.DOWNLOADING || if (downloadStatus == DownloadStatus.DOWNLOADING ||
downloadStatus == DownloadStatus.DECRYPTING downloadStatus == DownloadStatus.DECRYPTING
) { ) {
@@ -2432,6 +2536,7 @@ private fun VoiceAttachment(
} }
} }
} }
}
} }
Spacer(modifier = Modifier.width(10.dp)) Spacer(modifier = Modifier.width(10.dp))
@@ -2484,6 +2589,38 @@ private fun VoiceAttachment(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp) horizontalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
if (isActiveTrack) {
val speedChipBackground =
if (isOutgoing) {
Color.White.copy(alpha = 0.2f)
} else if (isDarkTheme) {
Color(0xFF31435A)
} else {
Color(0xFFDCEBFD)
}
val speedChipTextColor = if (isOutgoing) Color.White else PrimaryBlue
Box(
modifier =
Modifier.clip(RoundedCornerShape(10.dp))
.background(speedChipBackground)
.clickable(
interactionSource =
remember { MutableInteractionSource() },
indication = null
) {
VoicePlaybackCoordinator.cycleSpeed()
}
.padding(horizontal = 6.dp, vertical = 2.dp),
contentAlignment = Alignment.Center
) {
Text(
text = formatVoicePlaybackSpeedLabel(playbackSpeed),
fontSize = 10.sp,
fontWeight = FontWeight.SemiBold,
color = speedChipTextColor
)
}
}
Text( Text(
text = android.text.format.DateFormat.format("HH:mm", timestamp).toString(), text = android.text.format.DateFormat.format("HH:mm", timestamp).toString(),
fontSize = 11.sp, fontSize = 11.sp,

View File

@@ -735,6 +735,8 @@ fun MessageBubble(
message.attachments.all { message.attachments.all {
it.type == AttachmentType.CALL it.type == AttachmentType.CALL
} }
val hasVoiceAttachment =
message.attachments.any { it.type == AttachmentType.VOICE }
val isStandaloneGroupInvite = val isStandaloneGroupInvite =
message.attachments.isEmpty() && message.attachments.isEmpty() &&
@@ -874,6 +876,21 @@ fun MessageBubble(
if (isCallMessage) { if (isCallMessage) {
// Звонки без фонового пузырька — у них свой контейнер внутри CallAttachment // Звонки без фонового пузырька — у них свой контейнер внутри CallAttachment
Modifier Modifier
} else if (hasVoiceAttachment) {
// Для voice не клипуем содержимое пузыря:
// playback-blob может выходить за границы, как в Telegram.
Modifier.background(
color =
if (isSafeSystemMessage) {
if (isDarkTheme)
Color(0xFF2A2A2D)
else Color(0xFFF0F0F4)
} else {
bubbleColor
},
shape = bubbleShape
)
.padding(bubblePadding)
} else { } else {
Modifier.clip(bubbleShape) Modifier.clip(bubbleShape)
.then( .then(
@@ -1094,7 +1111,8 @@ fun MessageBubble(
info = info, info = info,
touchX = touchX, touchX = touchX,
touchY = touchY, touchY = touchY,
view = textViewRef view = textViewRef,
isOwnMessage = message.isOutgoing
) )
} }
} else null, } else null,
@@ -1209,7 +1227,8 @@ fun MessageBubble(
info = info, info = info,
touchX = touchX, touchX = touchX,
touchY = touchY, touchY = touchY,
view = textViewRef view = textViewRef,
isOwnMessage = message.isOutgoing
) )
} }
} else null } else null
@@ -1322,7 +1341,8 @@ fun MessageBubble(
info = info, info = info,
touchX = touchX, touchX = touchX,
touchY = touchY, touchY = touchY,
view = textViewRef view = textViewRef,
isOwnMessage = message.isOutgoing
) )
} }
} else null, } else null,

View File

@@ -31,8 +31,10 @@ import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
@@ -62,6 +64,10 @@ class TextSelectionHelper {
private set private set
var selectedMessageId by mutableStateOf<String?>(null) var selectedMessageId by mutableStateOf<String?>(null)
private set private set
// True when the selected message is the user's own (blue bubble) — used to pick
// white handles against the blue background instead of the default blue handles.
var isOwnMessage by mutableStateOf(false)
private set
var layoutInfo by mutableStateOf<LayoutInfo?>(null) var layoutInfo by mutableStateOf<LayoutInfo?>(null)
private set private set
var isActive by mutableStateOf(false) var isActive by mutableStateOf(false)
@@ -99,8 +105,10 @@ class TextSelectionHelper {
info: LayoutInfo, info: LayoutInfo,
touchX: Int, touchX: Int,
touchY: Int, touchY: Int,
view: View? view: View?,
isOwnMessage: Boolean = false
) { ) {
this.isOwnMessage = isOwnMessage
val layout = info.layout val layout = info.layout
val localX = touchX - info.windowX val localX = touchX - info.windowX
val localY = touchY - info.windowY val localY = touchY - info.windowY
@@ -398,6 +406,10 @@ fun TextSelectionOverlay(
val handleSizePx = with(density) { HandleSize.toPx() } val handleSizePx = with(density) { HandleSize.toPx() }
val handleInsetPx = with(density) { HandleInset.toPx() } val handleInsetPx = with(density) { HandleInset.toPx() }
val highlightCornerPx = with(density) { HighlightCorner.toPx() } val highlightCornerPx = with(density) { HighlightCorner.toPx() }
// Read isOwnMessage at composition level so Canvas properly invalidates on change.
// On own (blue) bubbles use the light-blue typing color — reads better than pure white.
val handleColor = if (helper.isOwnMessage) Color(0xFF54A9EB) else HandleColor
val highlightColor = if (helper.isOwnMessage) Color(0xFF54A9EB).copy(alpha = 0.45f) else HighlightColor
// Block predictive back gesture completely during text selection. // Block predictive back gesture completely during text selection.
// BackHandler alone doesn't prevent the swipe animation on Android 13+ // BackHandler alone doesn't prevent the swipe animation on Android 13+
@@ -515,9 +527,16 @@ fun TextSelectionOverlay(
val padH = 3.dp.toPx() val padH = 3.dp.toPx()
val padV = 2.dp.toPx() val padV = 2.dp.toPx()
// Build a single unified Path from all per-line rects, then fill once.
// This avoids double-alpha artifacts where adjacent lines' padding overlaps.
val highlightPath = Path()
for (line in startLine..endLine) { for (line in startLine..endLine) {
val lineTop = layout.getLineTop(line).toFloat() + offsetY - padV // Only pad the outer edges (top of first line, bottom of last line).
val lineBottom = layout.getLineBottom(line).toFloat() + offsetY + padV // Inner edges meet at lineBottom == nextLineTop so the union fills fully.
val topPad = if (line == startLine) padV else 0f
val bottomPad = if (line == endLine) padV else 0f
val lineTop = layout.getLineTop(line).toFloat() + offsetY - topPad
val lineBottom = layout.getLineBottom(line).toFloat() + offsetY + bottomPad
val left = if (line == startLine) { val left = if (line == startLine) {
layout.getPrimaryHorizontal(startOffset) + offsetX - padH layout.getPrimaryHorizontal(startOffset) + offsetX - padH
} else { } else {
@@ -528,13 +547,14 @@ fun TextSelectionOverlay(
} else { } else {
layout.getLineRight(line) + offsetX + padH layout.getLineRight(line) + offsetX + padH
} }
drawRoundRect( highlightPath.addRoundRect(
color = HighlightColor, RoundRect(
topLeft = Offset(left, lineTop), rect = Rect(left, lineTop, right, lineBottom),
size = Size(right - left, lineBottom - lineTop), cornerRadius = CornerRadius(highlightCornerPx)
cornerRadius = CornerRadius(highlightCornerPx) )
) )
} }
drawPath(path = highlightPath, color = highlightColor)
val startHx = layout.getPrimaryHorizontal(startOffset) + offsetX val startHx = layout.getPrimaryHorizontal(startOffset) + offsetX
val startHy = layout.getLineBottom(startLine).toFloat() + offsetY val startHy = layout.getLineBottom(startLine).toFloat() + offsetY
@@ -546,35 +566,35 @@ fun TextSelectionOverlay(
helper.endHandleX = endHx helper.endHandleX = endHx
helper.endHandleY = endHy helper.endHandleY = endHy
drawStartHandle(startHx, startHy, handleSizePx) drawStartHandle(startHx, startHy, handleSizePx, handleColor)
drawEndHandle(endHx, endHy, handleSizePx) drawEndHandle(endHx, endHy, handleSizePx, handleColor)
} }
} }
} }
private fun DrawScope.drawStartHandle(x: Float, y: Float, size: Float) { private fun DrawScope.drawStartHandle(x: Float, y: Float, size: Float, color: Color) {
val half = size / 2f val half = size / 2f
drawCircle( drawCircle(
color = HandleColor, color = color,
radius = half, radius = half,
center = Offset(x, y + half) center = Offset(x, y + half)
) )
drawRect( drawRect(
color = HandleColor, color = color,
topLeft = Offset(x, y), topLeft = Offset(x, y),
size = Size(half, half) size = Size(half, half)
) )
} }
private fun DrawScope.drawEndHandle(x: Float, y: Float, size: Float) { private fun DrawScope.drawEndHandle(x: Float, y: Float, size: Float, color: Color) {
val half = size / 2f val half = size / 2f
drawCircle( drawCircle(
color = HandleColor, color = color,
radius = half, radius = half,
center = Offset(x, y + half) center = Offset(x, y + half)
) )
drawRect( drawRect(
color = HandleColor, color = color,
topLeft = Offset(x - half, y), topLeft = Offset(x - half, y),
size = Size(half, half) size = Size(half, half)
) )

View File

@@ -8,6 +8,12 @@ import android.os.Build
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import com.airbnb.lottie.LottieProperty
import com.airbnb.lottie.compose.LottieAnimation
import com.airbnb.lottie.compose.LottieCompositionSpec
import com.airbnb.lottie.compose.rememberLottieDynamicProperties
import com.airbnb.lottie.compose.rememberLottieDynamicProperty
import com.airbnb.lottie.compose.rememberLottieComposition
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
@@ -42,6 +48,7 @@ import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.toArgb
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
@@ -72,6 +79,7 @@ import coil.compose.AsyncImage
import coil.request.ImageRequest import coil.request.ImageRequest
import com.rosetta.messenger.network.AttachmentType import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.R
import com.rosetta.messenger.ui.components.AppleEmojiText import com.rosetta.messenger.ui.components.AppleEmojiText
import com.rosetta.messenger.ui.components.AppleEmojiTextField import com.rosetta.messenger.ui.components.AppleEmojiTextField
import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.components.AvatarImage
@@ -225,130 +233,83 @@ private fun TelegramVoiceDeleteIndicator(
isDarkTheme: Boolean, isDarkTheme: Boolean,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val density = LocalDensity.current
val progress = cancelProgress.coerceIn(0f, 1f) val progress = cancelProgress.coerceIn(0f, 1f)
val appear = FastOutSlowInEasing.transform(progress) // Ensure red dot is clearly visible first; trash appears with a delayed phase.
val openPhase = FastOutSlowInEasing.transform((progress / 0.45f).coerceIn(0f, 1f)) val trashStart = 0.28f
val closePhase = FastOutSlowInEasing.transform(((progress - 0.55f) / 0.45f).coerceIn(0f, 1f)) val reveal = ((progress - trashStart) / (1f - trashStart)).coerceIn(0f, 1f)
val lidAngle = -26f * openPhase * (1f - closePhase) val lottieProgress = FastOutSlowInEasing.transform(reveal)
val dotFlight = FastOutSlowInEasing.transform((progress / 0.82f).coerceIn(0f, 1f)) val lottieAlpha = FastOutSlowInEasing.transform(reveal)
val composition by rememberLottieComposition(
LottieCompositionSpec.RawRes(R.raw.chat_audio_record_delete_2)
)
val dangerColor = if (isDarkTheme) Color(0xFFFF5A5A) else Color(0xFFE84D4D) val dangerColor = if (isDarkTheme) Color(0xFFFF5A5A) else Color(0xFFE84D4D)
val dotStartX = with(density) { (-8).dp.toPx() } val neutralColor = if (isDarkTheme) Color(0xFF8EA2B4) else Color(0xFF8FA2B3)
val dotEndX = with(density) { 0.dp.toPx() } val panelBlendColor = if (isDarkTheme) Color(0xFF1A2A3A) else Color(0xFFE8F2FD)
val dotEndY = with(density) { 6.dp.toPx() } val dynamicProperties = rememberLottieDynamicProperties(
val dotX = lerpFloat(dotStartX, dotEndX, dotFlight) rememberLottieDynamicProperty(
val dotY = dotEndY * dotFlight * dotFlight property = LottieProperty.COLOR,
val dotScale = (1f - 0.72f * dotFlight).coerceAtLeast(0f) value = dangerColor.toArgb(),
val dotAlpha = (1f - dotFlight).coerceIn(0f, 1f) keyPath = arrayOf("Cup Red", "**")
),
rememberLottieDynamicProperty(
property = LottieProperty.COLOR,
value = dangerColor.toArgb(),
keyPath = arrayOf("Box Red", "**")
),
rememberLottieDynamicProperty(
property = LottieProperty.COLOR,
value = neutralColor.toArgb(),
keyPath = arrayOf("Cup Grey", "**")
),
rememberLottieDynamicProperty(
property = LottieProperty.COLOR,
value = neutralColor.toArgb(),
keyPath = arrayOf("Box Grey", "**")
),
rememberLottieDynamicProperty(
property = LottieProperty.COLOR,
value = panelBlendColor.toArgb(),
keyPath = arrayOf("Line 1", "**")
),
rememberLottieDynamicProperty(
property = LottieProperty.COLOR,
value = panelBlendColor.toArgb(),
keyPath = arrayOf("Line 2", "**")
),
rememberLottieDynamicProperty(
property = LottieProperty.COLOR,
value = panelBlendColor.toArgb(),
keyPath = arrayOf("Line 3", "**")
)
)
Box( Box(
modifier = modifier.size(28.dp), modifier = modifier.size(28.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
// Single recording dot (no duplicate red indicators).
RecordBlinkDot( RecordBlinkDot(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
modifier = Modifier.graphicsLayer { modifier = Modifier.graphicsLayer {
alpha = 1f - appear alpha = 1f - lottieAlpha
scaleX = 1f - 0.14f * appear scaleX = 1f - 0.12f * lottieAlpha
scaleY = 1f - 0.14f * appear scaleY = 1f - 0.12f * lottieAlpha
} }
) )
if (composition != null) {
Canvas( LottieAnimation(
modifier = Modifier composition = composition,
.matchParentSize() progress = { lottieProgress },
.graphicsLayer { dynamicProperties = dynamicProperties,
alpha = appear modifier = Modifier
scaleX = 0.84f + 0.16f * appear .matchParentSize()
scaleY = 0.84f + 0.16f * appear .graphicsLayer {
} alpha = lottieAlpha
) { scaleX = 0.92f + 0.08f * lottieAlpha
val stroke = 1.7.dp.toPx() scaleY = 0.92f + 0.08f * lottieAlpha
val cx = size.width / 2f }
val bodyW = size.width * 0.36f
val bodyH = size.height * 0.34f
val bodyLeft = cx - bodyW / 2f
val bodyTop = size.height * 0.45f
val bodyRadius = bodyW * 0.16f
val bodyRight = bodyLeft + bodyW
drawRoundRect(
color = dangerColor,
topLeft = Offset(bodyLeft, bodyTop),
size = androidx.compose.ui.geometry.Size(bodyW, bodyH),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(bodyRadius),
style = androidx.compose.ui.graphics.drawscope.Stroke(
width = stroke,
cap = androidx.compose.ui.graphics.StrokeCap.Round,
join = androidx.compose.ui.graphics.StrokeJoin.Round
)
) )
val slatYStart = bodyTop + bodyH * 0.18f
val slatYEnd = bodyTop + bodyH * 0.82f
drawLine(
color = dangerColor,
start = Offset(cx - bodyW * 0.18f, slatYStart),
end = Offset(cx - bodyW * 0.18f, slatYEnd),
strokeWidth = stroke * 0.85f,
cap = androidx.compose.ui.graphics.StrokeCap.Round
)
drawLine(
color = dangerColor,
start = Offset(cx + bodyW * 0.18f, slatYStart),
end = Offset(cx + bodyW * 0.18f, slatYEnd),
strokeWidth = stroke * 0.85f,
cap = androidx.compose.ui.graphics.StrokeCap.Round
)
val rimY = bodyTop - 2.4.dp.toPx()
drawLine(
color = dangerColor,
start = Offset(bodyLeft - bodyW * 0.09f, rimY),
end = Offset(bodyRight + bodyW * 0.09f, rimY),
strokeWidth = stroke,
cap = androidx.compose.ui.graphics.StrokeCap.Round
)
val lidY = rimY - 1.4.dp.toPx()
val lidLeft = bodyLeft - bodyW * 0.05f
val lidRight = bodyRight + bodyW * 0.05f
val lidPivot = Offset(bodyLeft + bodyW * 0.22f, lidY)
rotate(
degrees = lidAngle,
pivot = lidPivot
) {
drawLine(
color = dangerColor,
start = Offset(lidLeft, lidY),
end = Offset(lidRight, lidY),
strokeWidth = stroke,
cap = androidx.compose.ui.graphics.StrokeCap.Round
)
drawLine(
color = dangerColor,
start = Offset(cx - bodyW * 0.1f, lidY - 2.dp.toPx()),
end = Offset(cx + bodyW * 0.1f, lidY - 2.dp.toPx()),
strokeWidth = stroke,
cap = androidx.compose.ui.graphics.StrokeCap.Round
)
}
} }
Box(
modifier = Modifier
.size(10.dp)
.graphicsLayer {
translationX = dotX
translationY = dotY
alpha = if (progress > 0f) dotAlpha else 0f
scaleX = dotScale
scaleY = dotScale
}
.clip(CircleShape)
.background(dangerColor)
)
} }
} }
@@ -2348,12 +2309,13 @@ fun MessageInputBar(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = 48.dp) .heightIn(min = 48.dp)
.padding(horizontal = 8.dp, vertical = 8.dp) .padding(horizontal = 12.dp, vertical = 8.dp)
.zIndex(2f) .zIndex(2f)
.onGloballyPositioned { coordinates -> .onGloballyPositioned { coordinates ->
recordingInputRowHeightPx = coordinates.size.height recordingInputRowHeightPx = coordinates.size.height
recordingInputRowY = coordinates.positionInWindow().y recordingInputRowY = coordinates.positionInWindow().y
} },
contentAlignment = Alignment.BottomStart
) { ) {
val isLockedOrPaused = recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.PAUSED val isLockedOrPaused = recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.PAUSED
// iOS parity (VoiceRecordingOverlay.applyCurrentTransforms): // iOS parity (VoiceRecordingOverlay.applyCurrentTransforms):
@@ -2371,7 +2333,7 @@ fun MessageInputBar(
targetState = isLockedOrPaused, targetState = isLockedOrPaused,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(48.dp) .height(40.dp)
.padding(end = recordingActionInset), // keep panel under large circle (Telegram-like overlap) .padding(end = recordingActionInset), // keep panel under large circle (Telegram-like overlap)
transitionSpec = { transitionSpec = {
fadeIn(tween(200)) togetherWith fadeOut(tween(200)) fadeIn(tween(200)) togetherWith fadeOut(tween(200))
@@ -2431,6 +2393,16 @@ fun MessageInputBar(
(dragCancelProgress * 0.85f).coerceIn(0f, 1f) (dragCancelProgress * 0.85f).coerceIn(0f, 1f)
) )
) )
val collapseToTrash =
FastOutSlowInEasing.transform(
((leftDeleteProgress - 0.14f) / 0.86f).coerceIn(0f, 1f)
)
val collapseShiftPx =
with(density) { (-58).dp.toPx() * collapseToTrash }
val collapseScale = 1f - 0.14f * collapseToTrash
val collapseAlpha = 1f - 0.55f * collapseToTrash
val timerToTrashShiftPx = collapseShiftPx * 0.35f
val timerSpacerDp = lerpFloat(10f, 2f, collapseToTrash).dp
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -2465,12 +2437,18 @@ fun MessageInputBar(
fontSize = 15.sp, fontSize = 15.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
modifier = Modifier.graphicsLayer { modifier = Modifier.graphicsLayer {
alpha = recordUiAlpha * (1f - leftDeleteProgress * 0.22f) alpha =
translationX = with(density) { recordUiShift.toPx() } recordUiAlpha *
(1f - leftDeleteProgress * 0.22f) *
collapseAlpha
translationX =
with(density) { recordUiShift.toPx() } + timerToTrashShiftPx
scaleX = collapseScale
scaleY = collapseScale
} }
) )
Spacer(modifier = Modifier.width(10.dp)) Spacer(modifier = Modifier.width(timerSpacerDp))
// Slide to cancel // Slide to cancel
SlideToCancel( SlideToCancel(
@@ -2479,8 +2457,14 @@ fun MessageInputBar(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.graphicsLayer { .graphicsLayer {
alpha = recordUiAlpha * (1f - leftDeleteProgress) alpha =
translationX = with(density) { recordUiShift.toPx() } recordUiAlpha *
(1f - leftDeleteProgress) *
collapseAlpha
translationX =
with(density) { recordUiShift.toPx() } + collapseShiftPx
scaleX = collapseScale
scaleY = collapseScale
} }
) )
} }
@@ -2614,12 +2598,20 @@ fun MessageInputBar(
} }
} }
} }
val gestureCaptureOnly = isRecordingPanelComposed && keepMicGestureCapture
if (!isRecordingPanelComposed || keepMicGestureCapture) { if (!isRecordingPanelComposed || keepMicGestureCapture) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = 48.dp) .then(
.padding(horizontal = 12.dp, vertical = 8.dp) if (gestureCaptureOnly) {
Modifier.requiredHeight(0.dp)
} else {
Modifier
.heightIn(min = 48.dp)
.padding(horizontal = 12.dp, vertical = 8.dp)
}
)
.zIndex(1f) .zIndex(1f)
.graphicsLayer { .graphicsLayer {
// Keep gesture layer alive during hold, but never show base input under recording panel. // Keep gesture layer alive during hold, but never show base input under recording panel.
@@ -2908,9 +2900,11 @@ fun MessageInputBar(
didCancelHaptic = true didCancelHaptic = true
view.performHapticFeedback(android.view.HapticFeedbackConstants.KEYBOARD_TAP) view.performHapticFeedback(android.view.HapticFeedbackConstants.KEYBOARD_TAP)
} }
if (rawDx <= -cancelDragThresholdPx) { // Trigger cancel immediately while finger is still down,
// using the same threshold as release-cancel behavior.
if (rawDx <= -releaseCancelThresholdPx) {
if (INPUT_JUMP_LOG_ENABLED) inputJumpLog( if (INPUT_JUMP_LOG_ENABLED) inputJumpLog(
"gesture CANCEL dx=${rawDx.toInt()} threshold=${cancelDragThresholdPx.toInt()} mode=$recordMode" "gesture CANCEL dx=${rawDx.toInt()} threshold=${releaseCancelThresholdPx.toInt()} mode=$recordMode"
) )
cancelVoiceRecordingWithAnimation("slide-cancel") cancelVoiceRecordingWithAnimation("slide-cancel")
finished = true finished = true

View File

@@ -139,6 +139,8 @@ fun SwipeBackContainer(
propagateBackgroundProgress: Boolean = true, propagateBackgroundProgress: Boolean = true,
deferToChildren: Boolean = false, deferToChildren: Boolean = false,
enterAnimation: SwipeBackEnterAnimation = SwipeBackEnterAnimation.Fade, enterAnimation: SwipeBackEnterAnimation = SwipeBackEnterAnimation.Fade,
// Return true to cancel the swipe — screen bounces back and onBack is NOT called.
onInterceptSwipeBack: () -> Boolean = { false },
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
// 🚀 Lazy composition: skip ALL setup until the screen is opened for the first time. // 🚀 Lazy composition: skip ALL setup until the screen is opened for the first time.
@@ -523,6 +525,32 @@ fun SwipeBackContainer(
) )
if (shouldComplete) { if (shouldComplete) {
// Intercept: if owner handled back locally (e.g. clear
// message selection), bounce back without exiting.
if (onInterceptSwipeBack()) {
dismissKeyboard()
offsetAnimatable.animateTo(
targetValue = 0f,
animationSpec =
tween(
durationMillis =
ANIMATION_DURATION_EXIT,
easing =
TelegramEasing
),
block = {
updateSharedSwipeProgress(
progress =
value /
screenWidthPx,
active = true
)
}
)
dragOffset = 0f
clearSharedSwipeProgressIfOwner()
return@launch
}
offsetAnimatable.animateTo( offsetAnimatable.animateTo(
targetValue = screenWidthPx, targetValue = screenWidthPx,
animationSpec = animationSpec =

File diff suppressed because one or more lines are too long