Чат/звонки/коннект: Telegram-like UX и ряд фиксов
This commit is contained in:
@@ -56,6 +56,7 @@ import com.rosetta.messenger.repository.AvatarRepository
|
||||
import com.rosetta.messenger.ui.auth.AccountInfo
|
||||
import com.rosetta.messenger.ui.auth.AuthFlow
|
||||
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.ChatsListScreen
|
||||
import com.rosetta.messenger.ui.chats.VoiceTopMiniPlayer
|
||||
@@ -91,6 +92,7 @@ import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainActivity : FragmentActivity() {
|
||||
@@ -302,16 +304,57 @@ class MainActivity : FragmentActivity() {
|
||||
startInCreateMode = startCreateAccountFlow,
|
||||
onAuthComplete = { account ->
|
||||
startCreateAccountFlow = false
|
||||
currentAccount = account
|
||||
cacheSessionAccount(account)
|
||||
val normalizedAccount =
|
||||
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
|
||||
// Save as last logged account
|
||||
account?.let {
|
||||
normalizedAccount?.let {
|
||||
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
|
||||
scope.launch {
|
||||
normalizedAccount?.let {
|
||||
// Синхронно помечаем текущий аккаунт активным в DataStore.
|
||||
runCatching {
|
||||
accountManager.setCurrentAccount(it.publicKey)
|
||||
}
|
||||
}
|
||||
val accounts = accountManager.getAllAccounts()
|
||||
accountInfoList = accounts.map { it.toAccountInfo() }
|
||||
}
|
||||
@@ -1492,9 +1535,18 @@ fun MainScreen(
|
||||
}
|
||||
}.collectAsState(initial = 0)
|
||||
|
||||
var chatSelectionActive by remember { mutableStateOf(false) }
|
||||
val chatClearSelectionRef = remember { mutableStateOf<() -> Unit>({}) }
|
||||
|
||||
SwipeBackContainer(
|
||||
isVisible = selectedUser != null,
|
||||
onBack = { popChatAndChildren() },
|
||||
onInterceptSwipeBack = {
|
||||
if (chatSelectionActive) {
|
||||
chatClearSelectionRef.value()
|
||||
true
|
||||
} else false
|
||||
},
|
||||
isDarkTheme = isDarkTheme,
|
||||
layer = 1,
|
||||
swipeEnabled = !isChatSwipeLocked,
|
||||
@@ -1539,7 +1591,9 @@ fun MainScreen(
|
||||
avatarRepository = avatarRepository,
|
||||
onImageViewerChanged = { isLocked -> isChatSwipeLocked = isLocked },
|
||||
isCallActive = callUiState.isVisible,
|
||||
onOpenCallOverlay = { isCallOverlayExpanded = true }
|
||||
onOpenCallOverlay = { isCallOverlayExpanded = true },
|
||||
onSelectionModeChange = { chatSelectionActive = it },
|
||||
registerClearSelection = { fn -> chatClearSelectionRef.value = fn }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -598,6 +598,12 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
// 🔥 КРИТИЧНО: Обновляем диалог через updateDialogFromMessages
|
||||
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
|
||||
if (isSavedMessages) {
|
||||
val existing = dialogDao.getDialog(account, account)
|
||||
|
||||
@@ -95,7 +95,11 @@ object CallManager {
|
||||
private const val TAIL_LINES = 300
|
||||
private const val PROTOCOL_LOG_TAIL_LINES = 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 val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
@@ -127,6 +131,7 @@ object CallManager {
|
||||
private var protocolStateJob: Job? = null
|
||||
private var disconnectResetJob: Job? = null
|
||||
private var incomingRingTimeoutJob: Job? = null
|
||||
private var outgoingRingTimeoutJob: Job? = null
|
||||
private var connectingTimeoutJob: Job? = null
|
||||
|
||||
private var signalWaiter: ((Packet) -> Unit)? = null
|
||||
@@ -290,6 +295,18 @@ object CallManager {
|
||||
)
|
||||
breadcrumbState("startOutgoingCall")
|
||||
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
|
||||
}
|
||||
|
||||
@@ -551,6 +568,9 @@ object CallManager {
|
||||
breadcrumb("SIG: ACCEPT ignored — role=$role")
|
||||
return
|
||||
}
|
||||
// Callee answered before timeout — cancel outgoing ring timer
|
||||
outgoingRingTimeoutJob?.cancel()
|
||||
outgoingRingTimeoutJob = null
|
||||
if (localPrivateKey == null || localPublicKey == null) {
|
||||
breadcrumb("SIG: ACCEPT — generating local session keys")
|
||||
generateSessionKeys()
|
||||
@@ -1033,9 +1053,14 @@ object CallManager {
|
||||
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 {
|
||||
runCatching {
|
||||
if (role == CallRole.CALLER) {
|
||||
if (capturedRole == CallRole.CALLER) {
|
||||
// CALLER: send call attachment as a message (peer will receive it)
|
||||
MessageRepository.getInstance(context).sendMessage(
|
||||
toPublicKey = peerPublicKey,
|
||||
@@ -1082,6 +1107,8 @@ object CallManager {
|
||||
disconnectResetJob = null
|
||||
incomingRingTimeoutJob?.cancel()
|
||||
incomingRingTimeoutJob = null
|
||||
outgoingRingTimeoutJob?.cancel()
|
||||
outgoingRingTimeoutJob = null
|
||||
// Play end call sound, then stop all
|
||||
if (wasActive) {
|
||||
appContext?.let { CallSoundManager.play(it, CallSoundManager.CallSound.END_CALL) }
|
||||
|
||||
@@ -35,6 +35,7 @@ class Protocol(
|
||||
private const val TAG = "RosettaProtocol"
|
||||
private const val RECONNECT_INTERVAL = 5000L // 5 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 DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 15
|
||||
private const val MIN_HEARTBEAT_SEND_INTERVAL_MS = 2_000L
|
||||
@@ -182,6 +183,7 @@ class Protocol(
|
||||
private var lastSuccessfulConnection = 0L
|
||||
private var reconnectJob: Job? = null // Для отмены запланированных переподключений
|
||||
private var isConnecting = false // Флаг для защиты от одновременных подключений
|
||||
private var connectingSinceMs = 0L
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
@@ -385,6 +387,7 @@ class Protocol(
|
||||
*/
|
||||
fun connect() {
|
||||
val currentState = _state.value
|
||||
val now = System.currentTimeMillis()
|
||||
log("🔌 CONNECT CALLED: currentState=$currentState, reconnectAttempts=$reconnectAttempts, isConnecting=$isConnecting")
|
||||
|
||||
// КРИТИЧНО: Если уже подключены и аутентифицированы - не переподключаемся!
|
||||
@@ -403,10 +406,20 @@ class Protocol(
|
||||
return
|
||||
}
|
||||
|
||||
// КРИТИЧНО: проверяем флаг isConnecting, а не только состояние
|
||||
// КРИТИЧНО: проверяем флаг isConnecting, а не только состояние.
|
||||
// Дополнительно защищаемся от "залипшего CONNECTING", который ранее снимался только рестартом приложения.
|
||||
if (isConnecting || currentState == ProtocolState.CONNECTING) {
|
||||
log("⚠️ Already connecting, skipping... (preventing duplicate connect)")
|
||||
return
|
||||
val elapsed = if (connectingSinceMs > 0L) now - connectingSinceMs else 0L
|
||||
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
|
||||
@@ -424,6 +437,7 @@ class Protocol(
|
||||
|
||||
// Устанавливаем флаг ПЕРЕД любыми операциями
|
||||
isConnecting = true
|
||||
connectingSinceMs = now
|
||||
|
||||
reconnectAttempts++
|
||||
log("📊 RECONNECT ATTEMPT #$reconnectAttempts")
|
||||
@@ -455,6 +469,7 @@ class Protocol(
|
||||
|
||||
// Сбрасываем флаг подключения
|
||||
isConnecting = false
|
||||
connectingSinceMs = 0L
|
||||
|
||||
setState(ProtocolState.CONNECTED, "WebSocket onOpen callback")
|
||||
// Flush queue as soon as socket is open.
|
||||
@@ -500,6 +515,7 @@ class Protocol(
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
log("❌ WebSocket CLOSED: code=$code reason='$reason' state=${_state.value} manuallyClosed=$isManuallyClosed")
|
||||
isConnecting = false // Сбрасываем флаг
|
||||
connectingSinceMs = 0L
|
||||
handleDisconnect()
|
||||
}
|
||||
|
||||
@@ -511,6 +527,7 @@ class Protocol(
|
||||
log(" Reconnect attempts: $reconnectAttempts")
|
||||
t.printStackTrace()
|
||||
isConnecting = false // Сбрасываем флаг
|
||||
connectingSinceMs = 0L
|
||||
_lastError.value = t.message
|
||||
handleDisconnect()
|
||||
}
|
||||
@@ -801,6 +818,7 @@ class Protocol(
|
||||
log("🔌 Manual disconnect requested")
|
||||
isManuallyClosed = true
|
||||
isConnecting = false // Сбрасываем флаг
|
||||
connectingSinceMs = 0L
|
||||
reconnectJob?.cancel() // Отменяем запланированные переподключения
|
||||
reconnectJob = null
|
||||
handshakeJob?.cancel()
|
||||
@@ -823,6 +841,7 @@ class Protocol(
|
||||
fun reconnectNowIfNeeded(reason: String = "foreground") {
|
||||
val currentState = _state.value
|
||||
val hasCredentials = !lastPublicKey.isNullOrBlank() && !lastPrivateHash.isNullOrBlank()
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
log(
|
||||
"⚡ FAST RECONNECT CHECK: state=$currentState, hasCredentials=$hasCredentials, isConnecting=$isConnecting, reason=$reason"
|
||||
@@ -830,12 +849,22 @@ class Protocol(
|
||||
|
||||
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.HANDSHAKING ||
|
||||
currentState == ProtocolState.DEVICE_VERIFICATION_REQUIRED ||
|
||||
currentState == ProtocolState.CONNECTED ||
|
||||
(currentState == ProtocolState.CONNECTING && isConnecting)
|
||||
currentState == ProtocolState.CONNECTED
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -325,7 +325,9 @@ fun ChatDetailScreen(
|
||||
avatarRepository: AvatarRepository? = null,
|
||||
onImageViewerChanged: (Boolean) -> Unit = {},
|
||||
isCallActive: Boolean = false,
|
||||
onOpenCallOverlay: () -> Unit = {}
|
||||
onOpenCallOverlay: () -> Unit = {},
|
||||
onSelectionModeChange: (Boolean) -> Unit = {},
|
||||
registerClearSelection: (() -> Unit) -> Unit = {}
|
||||
) {
|
||||
val viewModel: ChatViewModel = viewModel(key = "chat_${user.publicKey}")
|
||||
val context = LocalContext.current
|
||||
@@ -390,6 +392,14 @@ fun ChatDetailScreen(
|
||||
// 🔥 MESSAGE SELECTION STATE - для Reply/Forward
|
||||
var selectedMessages by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
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 на отпускание.
|
||||
// В этом окне игнорируем tap по этому же сообщению, чтобы selection не снимался.
|
||||
var longPressSuppressedMessageId by remember { mutableStateOf<String?>(null) }
|
||||
@@ -1360,10 +1370,10 @@ fun ChatDetailScreen(
|
||||
|
||||
// 🔥 Обработка системной кнопки назад
|
||||
BackHandler {
|
||||
if (isInChatSearchMode) {
|
||||
closeInChatSearch()
|
||||
} else {
|
||||
handleBackWithInputPriority()
|
||||
when {
|
||||
isSelectionMode -> selectedMessages = emptySet()
|
||||
isInChatSearchMode -> closeInChatSearch()
|
||||
else -> handleBackWithInputPriority()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2326,11 +2336,21 @@ fun ChatDetailScreen(
|
||||
}
|
||||
// Voice mini player — shown right under the chat header when audio is playing
|
||||
val playingVoiceAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState()
|
||||
if (!playingVoiceAttachmentId.isNullOrBlank()) {
|
||||
val isVoicePlaybackRunning by VoicePlaybackCoordinator.isPlaying.collectAsState()
|
||||
val voicePlaybackSpeed by VoicePlaybackCoordinator.playbackSpeed.collectAsState()
|
||||
val playingVoiceSenderLabel by VoicePlaybackCoordinator.playingSenderLabel.collectAsState()
|
||||
val playingVoiceTimeLabel by VoicePlaybackCoordinator.playingTimeLabel.collectAsState()
|
||||
val isVoicePlaybackRunning by VoicePlaybackCoordinator.isPlaying.collectAsState()
|
||||
val voicePlaybackSpeed by VoicePlaybackCoordinator.playbackSpeed.collectAsState()
|
||||
val playingVoiceSenderLabel by VoicePlaybackCoordinator.playingSenderLabel.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 time = playingVoiceTimeLabel.trim()
|
||||
val voiceTitle = if (time.isBlank()) sender else "$sender at $time"
|
||||
|
||||
@@ -5882,6 +5882,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
return
|
||||
}
|
||||
|
||||
// ⚡ Оптимизация: не отправляем typing indicator если собеседник офлайн
|
||||
// (для групп продолжаем отправлять — кто-то из участников может быть в сети)
|
||||
if (!isGroupDialogKey(opponent) && !_opponentOnline.value) {
|
||||
return
|
||||
}
|
||||
|
||||
val privateKey =
|
||||
myPrivateKey
|
||||
?: run {
|
||||
|
||||
@@ -66,6 +66,7 @@ import com.rosetta.messenger.data.AccountManager
|
||||
import com.rosetta.messenger.data.EncryptedAccount
|
||||
import com.rosetta.messenger.data.MessageRepository
|
||||
import com.rosetta.messenger.data.RecentSearchesManager
|
||||
import com.rosetta.messenger.data.isPlaceholderAccountName
|
||||
import com.rosetta.messenger.data.resolveAccountDisplayName
|
||||
import com.rosetta.messenger.database.RosettaDatabase
|
||||
import com.rosetta.messenger.network.CallPhase
|
||||
@@ -254,6 +255,15 @@ private fun resolveTypingDisplayName(publicKey: String): String {
|
||||
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_TEXT_START = 72.dp
|
||||
private val TELEGRAM_DIALOG_AVATAR_SIZE = 54.dp
|
||||
@@ -314,9 +324,6 @@ fun ChatsListScreen(
|
||||
|
||||
val view = androidx.compose.ui.platform.LocalView.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 drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -491,22 +498,37 @@ fun ChatsListScreen(
|
||||
val playingVoiceSenderLabel by VoicePlaybackCoordinator.playingSenderLabel.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) {
|
||||
if (accountPublicKey.isNotEmpty() && accountPrivateKey.isNotEmpty()) {
|
||||
val launchStart = System.currentTimeMillis()
|
||||
chatsViewModel.setAccount(accountPublicKey, accountPrivateKey)
|
||||
// Устанавливаем аккаунт для RecentSearchesManager
|
||||
RecentSearchesManager.setAccount(accountPublicKey)
|
||||
val normalizedPublicKey = accountPublicKey.trim()
|
||||
if (normalizedPublicKey.isEmpty()) return@LaunchedEffect
|
||||
|
||||
// 🔥 КРИТИЧНО: Инициализируем MessageRepository для обработки входящих
|
||||
// сообщений
|
||||
ProtocolManager.initializeAccount(accountPublicKey, accountPrivateKey)
|
||||
android.util.Log.d(
|
||||
"ChatsListScreen",
|
||||
"✅ Total LaunchedEffect: ${System.currentTimeMillis() - launchStart}ms"
|
||||
)
|
||||
val normalizedPrivateKey = accountPrivateKey.trim()
|
||||
val launchStart = System.currentTimeMillis()
|
||||
|
||||
chatsViewModel.setAccount(normalizedPublicKey, normalizedPrivateKey)
|
||||
// Устанавливаем аккаунт для RecentSearchesManager
|
||||
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
|
||||
@@ -604,9 +626,44 @@ fun ChatsListScreen(
|
||||
LaunchedEffect(accountPublicKey) {
|
||||
val accountManager = AccountManager(context)
|
||||
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
|
||||
var dialogsToDelete by remember { mutableStateOf<List<DialogUiModel>>(emptyList()) }
|
||||
var dialogToLeave by remember { mutableStateOf<DialogUiModel?>(null) }
|
||||
@@ -625,7 +682,7 @@ fun ChatsListScreen(
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
var showSelectionMenu by remember { mutableStateOf(false) }
|
||||
val preferencesManager = remember { com.rosetta.messenger.data.PreferencesManager(context) }
|
||||
val mutedChats by preferencesManager.mutedChatsForAccount(accountPublicKey)
|
||||
val mutedChats by preferencesManager.mutedChatsForAccount(effectiveCurrentPublicKey)
|
||||
.collectAsState(initial = emptySet())
|
||||
|
||||
// Back: drawer → закрыть, selection → сбросить
|
||||
@@ -656,6 +713,31 @@ fun ChatsListScreen(
|
||||
val topLevelRequestsCount = topLevelChatsState.requestsCount
|
||||
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
|
||||
/*
|
||||
if (showDevConsole) {
|
||||
@@ -806,10 +888,6 @@ fun ChatsListScreen(
|
||||
Modifier.fillMaxSize()
|
||||
.onSizeChanged { rootSize = it }
|
||||
.background(backgroundColor)
|
||||
.then(
|
||||
if (hasNativeNavigationBar) Modifier.navigationBarsPadding()
|
||||
else Modifier
|
||||
)
|
||||
) {
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
@@ -892,16 +970,16 @@ fun ChatsListScreen(
|
||||
) {
|
||||
AvatarImage(
|
||||
publicKey =
|
||||
accountPublicKey,
|
||||
effectiveCurrentPublicKey,
|
||||
avatarRepository =
|
||||
avatarRepository,
|
||||
size = 72.dp,
|
||||
isDarkTheme =
|
||||
isDarkTheme,
|
||||
displayName =
|
||||
accountName
|
||||
sidebarAccountName
|
||||
.ifEmpty {
|
||||
accountUsername
|
||||
sidebarAccountUsername
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -953,13 +1031,13 @@ fun ChatsListScreen(
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
// Display name
|
||||
if (accountName.isNotEmpty()) {
|
||||
if (sidebarAccountName.isNotEmpty()) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = accountName,
|
||||
text = sidebarAccountName,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White
|
||||
@@ -978,10 +1056,10 @@ fun ChatsListScreen(
|
||||
}
|
||||
|
||||
// Username
|
||||
if (accountUsername.isNotEmpty()) {
|
||||
if (sidebarAccountUsername.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = "@$accountUsername",
|
||||
text = "@$sidebarAccountUsername",
|
||||
fontSize = 13.sp,
|
||||
color = Color.White.copy(alpha = 0.7f)
|
||||
)
|
||||
@@ -1022,7 +1100,9 @@ fun ChatsListScreen(
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
// All accounts list (max 5 like Telegram sidebar behavior)
|
||||
allAccounts.take(5).forEach { account ->
|
||||
val isCurrentAccount = account.publicKey == accountPublicKey
|
||||
val isCurrentAccount =
|
||||
account.publicKey ==
|
||||
effectiveCurrentPublicKey
|
||||
val displayName =
|
||||
resolveAccountDisplayName(
|
||||
account.publicKey,
|
||||
@@ -1310,9 +1390,12 @@ fun ChatsListScreen(
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.then(
|
||||
if (hasNativeNavigationBar) Modifier.navigationBarsPadding()
|
||||
else Modifier
|
||||
.windowInsetsPadding(
|
||||
WindowInsets
|
||||
.navigationBars
|
||||
.only(
|
||||
WindowInsetsSides.Bottom
|
||||
)
|
||||
)
|
||||
) {
|
||||
// Telegram-style update banner
|
||||
@@ -2842,7 +2925,17 @@ fun ChatsListScreen(
|
||||
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(
|
||||
title = voiceMiniPlayerTitle,
|
||||
isDarkTheme = isDarkTheme,
|
||||
|
||||
@@ -14,11 +14,14 @@ import com.rosetta.messenger.network.PacketOnlineSubscribe
|
||||
import com.rosetta.messenger.network.PacketSearch
|
||||
import com.rosetta.messenger.network.ProtocolManager
|
||||
import com.rosetta.messenger.network.SearchUser
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -92,6 +95,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
|
||||
// Job для отмены подписок при смене аккаунта
|
||||
private var accountSubscriptionsJob: Job? = null
|
||||
private var loadingFailSafeJob: Job? = null
|
||||
|
||||
// Список диалогов с расшифрованными сообщениями
|
||||
private val _dialogs = MutableStateFlow<List<DialogUiModel>>(emptyList())
|
||||
@@ -132,9 +136,11 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
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()
|
||||
private val loadingFailSafeTimeoutMs = 4500L
|
||||
|
||||
private val TAG = "ChatsListVM"
|
||||
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:")
|
||||
}
|
||||
|
||||
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(
|
||||
val senderPrefix: String,
|
||||
val senderKey: String
|
||||
@@ -345,15 +361,39 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
|
||||
/** Установить текущий аккаунт и загрузить диалоги */
|
||||
fun setAccount(publicKey: String, privateKey: String) {
|
||||
val setAccountStart = System.currentTimeMillis()
|
||||
if (currentAccount == publicKey) {
|
||||
val resolvedPrivateKey =
|
||||
when {
|
||||
privateKey.isNotBlank() -> privateKey
|
||||
currentAccount == publicKey -> currentPrivateKey.orEmpty()
|
||||
else -> ""
|
||||
}
|
||||
|
||||
if (currentAccount == publicKey && currentPrivateKey == resolvedPrivateKey) {
|
||||
// 🔥 Сбрасываем skeleton если он ещё показан (при повторном заходе)
|
||||
if (_isLoading.value) _isLoading.value = false
|
||||
if (_isLoading.value) {
|
||||
_isLoading.value = false
|
||||
}
|
||||
loadingFailSafeJob?.cancel()
|
||||
return
|
||||
}
|
||||
|
||||
// 🔥 Показываем skeleton пока данные грузятся
|
||||
_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 при смене аккаунта
|
||||
requestedUserInfoKeys.clear()
|
||||
@@ -369,7 +409,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
accountSubscriptionsJob?.cancel()
|
||||
|
||||
currentAccount = publicKey
|
||||
currentPrivateKey = privateKey
|
||||
currentPrivateKey = resolvedPrivateKey
|
||||
|
||||
// <20> Устанавливаем аккаунт для DraftManager (загрузит черновики из SharedPreferences)
|
||||
DraftManager.setAccount(publicKey)
|
||||
@@ -380,7 +420,9 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
_requestsCount.value = 0
|
||||
|
||||
// 🚀 Pre-warm PBKDF2 ключ — чтобы к моменту дешифровки кэш был горячий
|
||||
viewModelScope.launch(Dispatchers.Default) { CryptoManager.getPbkdf2Key(privateKey) }
|
||||
if (resolvedPrivateKey.isNotEmpty()) {
|
||||
viewModelScope.launch(Dispatchers.Default) { CryptoManager.getPbkdf2Key(resolvedPrivateKey) }
|
||||
}
|
||||
|
||||
// Запускаем все подписки в одном родительском Job для отмены при смене аккаунта
|
||||
accountSubscriptionsJob = viewModelScope.launch {
|
||||
@@ -410,7 +452,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
} else {
|
||||
mapDialogListIncremental(
|
||||
dialogsList = dialogsList,
|
||||
privateKey = privateKey,
|
||||
privateKey = resolvedPrivateKey,
|
||||
cache = dialogsUiCache,
|
||||
isRequestsFlow = false
|
||||
)
|
||||
@@ -418,10 +460,19 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
}
|
||||
.filterNotNull()
|
||||
.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 ->
|
||||
_dialogs.value = decryptedDialogs
|
||||
// 🚀 Убираем skeleton после первой загрузки
|
||||
if (_isLoading.value) _isLoading.value = false
|
||||
if (_isLoading.value) {
|
||||
_isLoading.value = false
|
||||
loadingFailSafeJob?.cancel()
|
||||
}
|
||||
|
||||
// 🟢 Подписываемся на онлайн-статусы всех собеседников
|
||||
// 📁 Исключаем Saved Messages - не нужно подписываться на свой собственный
|
||||
@@ -430,7 +481,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
decryptedDialogs.filter { !it.isSavedMessages }.map {
|
||||
it.opponentKey
|
||||
}
|
||||
subscribeToOnlineStatuses(opponentsToSubscribe, privateKey)
|
||||
subscribeToOnlineStatuses(opponentsToSubscribe, resolvedPrivateKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,7 +501,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
} else {
|
||||
mapDialogListIncremental(
|
||||
dialogsList = requestsList,
|
||||
privateKey = privateKey,
|
||||
privateKey = resolvedPrivateKey,
|
||||
cache = requestsUiCache,
|
||||
isRequestsFlow = true
|
||||
)
|
||||
@@ -498,6 +549,24 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
}
|
||||
|
||||
} // 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) {
|
||||
if (opponentKeys.isEmpty()) return
|
||||
if (privateKey.isBlank()) return
|
||||
|
||||
// 🔥 КРИТИЧНО: Фильтруем уже подписанные ключи!
|
||||
val newKeys =
|
||||
|
||||
@@ -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(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
color = sectionColor,
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = sectionColor
|
||||
) {
|
||||
Column {
|
||||
// Add Members
|
||||
// Add Members — flat Telegram style, edge-to-edge, white text
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -1200,27 +1216,28 @@ fun GroupInfoScreen(
|
||||
.padding(horizontal = 16.dp, vertical = 13.dp),
|
||||
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 = "Add Members",
|
||||
color = primaryText,
|
||||
fontSize = 16.sp,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Default.PersonAdd,
|
||||
contentDescription = null,
|
||||
tint = accentColor,
|
||||
modifier = Modifier.size(groupMenuTrailingIconSize)
|
||||
)
|
||||
}
|
||||
|
||||
Divider(
|
||||
color = borderColor,
|
||||
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(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -1228,6 +1245,13 @@ fun GroupInfoScreen(
|
||||
.padding(horizontal = 16.dp, vertical = 13.dp),
|
||||
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 = "Encryption Key",
|
||||
color = primaryText,
|
||||
|
||||
@@ -14,6 +14,7 @@ import android.util.LruCache
|
||||
import android.webkit.MimeTypeMap
|
||||
import android.widget.VideoView
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.StartOffset
|
||||
@@ -42,6 +43,8 @@ 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.BlendMode
|
||||
import androidx.compose.ui.graphics.CompositingStrategy
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
@@ -96,6 +99,7 @@ import java.security.MessageDigest
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import kotlin.math.min
|
||||
import kotlin.math.PI
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.core.content.FileProvider
|
||||
|
||||
@@ -2027,6 +2031,76 @@ private fun formatVoiceDuration(seconds: Int): String {
|
||||
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 {
|
||||
val minutes = durationSec / 60
|
||||
val seconds = durationSec % 60
|
||||
@@ -2178,6 +2252,9 @@ private fun VoiceAttachment(
|
||||
val playbackIsPlaying by
|
||||
(if (isActiveTrack) VoicePlaybackCoordinator.isPlaying else flowOf(false))
|
||||
.collectAsState(initial = false)
|
||||
val playbackSpeed by
|
||||
(if (isActiveTrack) VoicePlaybackCoordinator.playbackSpeed else flowOf(1f))
|
||||
.collectAsState(initial = 1f)
|
||||
val isPlaying = isActiveTrack && playbackIsPlaying
|
||||
|
||||
val (previewDurationSecRaw, previewWavesRaw) =
|
||||
@@ -2224,6 +2301,16 @@ private fun VoiceAttachment(
|
||||
} else {
|
||||
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 =
|
||||
if (isActiveTrack && playbackDurationMs > 0) {
|
||||
val leftSec = ((playbackDurationMs - playbackPositionMs).coerceAtLeast(0) / 1000)
|
||||
@@ -2389,12 +2476,29 @@ private fun VoiceAttachment(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(actionBackground),
|
||||
modifier = Modifier.size(40.dp),
|
||||
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 ||
|
||||
downloadStatus == DownloadStatus.DECRYPTING
|
||||
) {
|
||||
@@ -2432,6 +2536,7 @@ private fun VoiceAttachment(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
@@ -2484,6 +2589,38 @@ private fun VoiceAttachment(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
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 = android.text.format.DateFormat.format("HH:mm", timestamp).toString(),
|
||||
fontSize = 11.sp,
|
||||
|
||||
@@ -735,6 +735,8 @@ fun MessageBubble(
|
||||
message.attachments.all {
|
||||
it.type == AttachmentType.CALL
|
||||
}
|
||||
val hasVoiceAttachment =
|
||||
message.attachments.any { it.type == AttachmentType.VOICE }
|
||||
|
||||
val isStandaloneGroupInvite =
|
||||
message.attachments.isEmpty() &&
|
||||
@@ -874,6 +876,21 @@ fun MessageBubble(
|
||||
if (isCallMessage) {
|
||||
// Звонки без фонового пузырька — у них свой контейнер внутри CallAttachment
|
||||
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 {
|
||||
Modifier.clip(bubbleShape)
|
||||
.then(
|
||||
@@ -1094,7 +1111,8 @@ fun MessageBubble(
|
||||
info = info,
|
||||
touchX = touchX,
|
||||
touchY = touchY,
|
||||
view = textViewRef
|
||||
view = textViewRef,
|
||||
isOwnMessage = message.isOutgoing
|
||||
)
|
||||
}
|
||||
} else null,
|
||||
@@ -1209,7 +1227,8 @@ fun MessageBubble(
|
||||
info = info,
|
||||
touchX = touchX,
|
||||
touchY = touchY,
|
||||
view = textViewRef
|
||||
view = textViewRef,
|
||||
isOwnMessage = message.isOutgoing
|
||||
)
|
||||
}
|
||||
} else null
|
||||
@@ -1322,7 +1341,8 @@ fun MessageBubble(
|
||||
info = info,
|
||||
touchX = touchX,
|
||||
touchY = touchY,
|
||||
view = textViewRef
|
||||
view = textViewRef,
|
||||
isOwnMessage = message.isOutgoing
|
||||
)
|
||||
}
|
||||
} else null,
|
||||
|
||||
@@ -31,8 +31,10 @@ import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.RoundRect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
@@ -62,6 +64,10 @@ class TextSelectionHelper {
|
||||
private set
|
||||
var selectedMessageId by mutableStateOf<String?>(null)
|
||||
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)
|
||||
private set
|
||||
var isActive by mutableStateOf(false)
|
||||
@@ -99,8 +105,10 @@ class TextSelectionHelper {
|
||||
info: LayoutInfo,
|
||||
touchX: Int,
|
||||
touchY: Int,
|
||||
view: View?
|
||||
view: View?,
|
||||
isOwnMessage: Boolean = false
|
||||
) {
|
||||
this.isOwnMessage = isOwnMessage
|
||||
val layout = info.layout
|
||||
val localX = touchX - info.windowX
|
||||
val localY = touchY - info.windowY
|
||||
@@ -398,6 +406,10 @@ fun TextSelectionOverlay(
|
||||
val handleSizePx = with(density) { HandleSize.toPx() }
|
||||
val handleInsetPx = with(density) { HandleInset.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.
|
||||
// BackHandler alone doesn't prevent the swipe animation on Android 13+
|
||||
@@ -515,9 +527,16 @@ fun TextSelectionOverlay(
|
||||
val padH = 3.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) {
|
||||
val lineTop = layout.getLineTop(line).toFloat() + offsetY - padV
|
||||
val lineBottom = layout.getLineBottom(line).toFloat() + offsetY + padV
|
||||
// Only pad the outer edges (top of first line, bottom of last line).
|
||||
// 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) {
|
||||
layout.getPrimaryHorizontal(startOffset) + offsetX - padH
|
||||
} else {
|
||||
@@ -528,13 +547,14 @@ fun TextSelectionOverlay(
|
||||
} else {
|
||||
layout.getLineRight(line) + offsetX + padH
|
||||
}
|
||||
drawRoundRect(
|
||||
color = HighlightColor,
|
||||
topLeft = Offset(left, lineTop),
|
||||
size = Size(right - left, lineBottom - lineTop),
|
||||
cornerRadius = CornerRadius(highlightCornerPx)
|
||||
highlightPath.addRoundRect(
|
||||
RoundRect(
|
||||
rect = Rect(left, lineTop, right, lineBottom),
|
||||
cornerRadius = CornerRadius(highlightCornerPx)
|
||||
)
|
||||
)
|
||||
}
|
||||
drawPath(path = highlightPath, color = highlightColor)
|
||||
|
||||
val startHx = layout.getPrimaryHorizontal(startOffset) + offsetX
|
||||
val startHy = layout.getLineBottom(startLine).toFloat() + offsetY
|
||||
@@ -546,35 +566,35 @@ fun TextSelectionOverlay(
|
||||
helper.endHandleX = endHx
|
||||
helper.endHandleY = endHy
|
||||
|
||||
drawStartHandle(startHx, startHy, handleSizePx)
|
||||
drawEndHandle(endHx, endHy, handleSizePx)
|
||||
drawStartHandle(startHx, startHy, handleSizePx, handleColor)
|
||||
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
|
||||
drawCircle(
|
||||
color = HandleColor,
|
||||
color = color,
|
||||
radius = half,
|
||||
center = Offset(x, y + half)
|
||||
)
|
||||
drawRect(
|
||||
color = HandleColor,
|
||||
color = color,
|
||||
topLeft = Offset(x, y),
|
||||
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
|
||||
drawCircle(
|
||||
color = HandleColor,
|
||||
color = color,
|
||||
radius = half,
|
||||
center = Offset(x, y + half)
|
||||
)
|
||||
drawRect(
|
||||
color = HandleColor,
|
||||
color = color,
|
||||
topLeft = Offset(x - half, y),
|
||||
size = Size(half, half)
|
||||
)
|
||||
|
||||
@@ -8,6 +8,12 @@ import android.os.Build
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
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.filled.Close
|
||||
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.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
@@ -72,6 +79,7 @@ import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.rosetta.messenger.network.AttachmentType
|
||||
import com.rosetta.messenger.repository.AvatarRepository
|
||||
import com.rosetta.messenger.R
|
||||
import com.rosetta.messenger.ui.components.AppleEmojiText
|
||||
import com.rosetta.messenger.ui.components.AppleEmojiTextField
|
||||
import com.rosetta.messenger.ui.components.AvatarImage
|
||||
@@ -225,130 +233,83 @@ private fun TelegramVoiceDeleteIndicator(
|
||||
isDarkTheme: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val progress = cancelProgress.coerceIn(0f, 1f)
|
||||
val appear = FastOutSlowInEasing.transform(progress)
|
||||
val openPhase = FastOutSlowInEasing.transform((progress / 0.45f).coerceIn(0f, 1f))
|
||||
val closePhase = FastOutSlowInEasing.transform(((progress - 0.55f) / 0.45f).coerceIn(0f, 1f))
|
||||
val lidAngle = -26f * openPhase * (1f - closePhase)
|
||||
val dotFlight = FastOutSlowInEasing.transform((progress / 0.82f).coerceIn(0f, 1f))
|
||||
|
||||
// Ensure red dot is clearly visible first; trash appears with a delayed phase.
|
||||
val trashStart = 0.28f
|
||||
val reveal = ((progress - trashStart) / (1f - trashStart)).coerceIn(0f, 1f)
|
||||
val lottieProgress = FastOutSlowInEasing.transform(reveal)
|
||||
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 dotStartX = with(density) { (-8).dp.toPx() }
|
||||
val dotEndX = with(density) { 0.dp.toPx() }
|
||||
val dotEndY = with(density) { 6.dp.toPx() }
|
||||
val dotX = lerpFloat(dotStartX, dotEndX, dotFlight)
|
||||
val dotY = dotEndY * dotFlight * dotFlight
|
||||
val dotScale = (1f - 0.72f * dotFlight).coerceAtLeast(0f)
|
||||
val dotAlpha = (1f - dotFlight).coerceIn(0f, 1f)
|
||||
val neutralColor = if (isDarkTheme) Color(0xFF8EA2B4) else Color(0xFF8FA2B3)
|
||||
val panelBlendColor = if (isDarkTheme) Color(0xFF1A2A3A) else Color(0xFFE8F2FD)
|
||||
val dynamicProperties = rememberLottieDynamicProperties(
|
||||
rememberLottieDynamicProperty(
|
||||
property = LottieProperty.COLOR,
|
||||
value = dangerColor.toArgb(),
|
||||
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(
|
||||
modifier = modifier.size(28.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
// Single recording dot (no duplicate red indicators).
|
||||
RecordBlinkDot(
|
||||
isDarkTheme = isDarkTheme,
|
||||
modifier = Modifier.graphicsLayer {
|
||||
alpha = 1f - appear
|
||||
scaleX = 1f - 0.14f * appear
|
||||
scaleY = 1f - 0.14f * appear
|
||||
alpha = 1f - lottieAlpha
|
||||
scaleX = 1f - 0.12f * lottieAlpha
|
||||
scaleY = 1f - 0.12f * lottieAlpha
|
||||
}
|
||||
)
|
||||
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.graphicsLayer {
|
||||
alpha = appear
|
||||
scaleX = 0.84f + 0.16f * appear
|
||||
scaleY = 0.84f + 0.16f * appear
|
||||
}
|
||||
) {
|
||||
val stroke = 1.7.dp.toPx()
|
||||
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
|
||||
)
|
||||
if (composition != null) {
|
||||
LottieAnimation(
|
||||
composition = composition,
|
||||
progress = { lottieProgress },
|
||||
dynamicProperties = dynamicProperties,
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.graphicsLayer {
|
||||
alpha = lottieAlpha
|
||||
scaleX = 0.92f + 0.08f * lottieAlpha
|
||||
scaleY = 0.92f + 0.08f * lottieAlpha
|
||||
}
|
||||
)
|
||||
|
||||
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
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 48.dp)
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
.zIndex(2f)
|
||||
.onGloballyPositioned { coordinates ->
|
||||
recordingInputRowHeightPx = coordinates.size.height
|
||||
recordingInputRowY = coordinates.positionInWindow().y
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.BottomStart
|
||||
) {
|
||||
val isLockedOrPaused = recordUiState == RecordUiState.LOCKED || recordUiState == RecordUiState.PAUSED
|
||||
// iOS parity (VoiceRecordingOverlay.applyCurrentTransforms):
|
||||
@@ -2371,7 +2333,7 @@ fun MessageInputBar(
|
||||
targetState = isLockedOrPaused,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp)
|
||||
.height(40.dp)
|
||||
.padding(end = recordingActionInset), // keep panel under large circle (Telegram-like overlap)
|
||||
transitionSpec = {
|
||||
fadeIn(tween(200)) togetherWith fadeOut(tween(200))
|
||||
@@ -2431,6 +2393,16 @@ fun MessageInputBar(
|
||||
(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(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -2465,12 +2437,18 @@ fun MessageInputBar(
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.graphicsLayer {
|
||||
alpha = recordUiAlpha * (1f - leftDeleteProgress * 0.22f)
|
||||
translationX = with(density) { recordUiShift.toPx() }
|
||||
alpha =
|
||||
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
|
||||
SlideToCancel(
|
||||
@@ -2479,8 +2457,14 @@ fun MessageInputBar(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.graphicsLayer {
|
||||
alpha = recordUiAlpha * (1f - leftDeleteProgress)
|
||||
translationX = with(density) { recordUiShift.toPx() }
|
||||
alpha =
|
||||
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) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 48.dp)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
.then(
|
||||
if (gestureCaptureOnly) {
|
||||
Modifier.requiredHeight(0.dp)
|
||||
} else {
|
||||
Modifier
|
||||
.heightIn(min = 48.dp)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
}
|
||||
)
|
||||
.zIndex(1f)
|
||||
.graphicsLayer {
|
||||
// Keep gesture layer alive during hold, but never show base input under recording panel.
|
||||
@@ -2908,9 +2900,11 @@ fun MessageInputBar(
|
||||
didCancelHaptic = true
|
||||
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(
|
||||
"gesture CANCEL dx=${rawDx.toInt()} threshold=${cancelDragThresholdPx.toInt()} mode=$recordMode"
|
||||
"gesture CANCEL dx=${rawDx.toInt()} threshold=${releaseCancelThresholdPx.toInt()} mode=$recordMode"
|
||||
)
|
||||
cancelVoiceRecordingWithAnimation("slide-cancel")
|
||||
finished = true
|
||||
|
||||
@@ -139,6 +139,8 @@ fun SwipeBackContainer(
|
||||
propagateBackgroundProgress: Boolean = true,
|
||||
deferToChildren: Boolean = false,
|
||||
enterAnimation: SwipeBackEnterAnimation = SwipeBackEnterAnimation.Fade,
|
||||
// Return true to cancel the swipe — screen bounces back and onBack is NOT called.
|
||||
onInterceptSwipeBack: () -> Boolean = { false },
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
// 🚀 Lazy composition: skip ALL setup until the screen is opened for the first time.
|
||||
@@ -523,6 +525,32 @@ fun SwipeBackContainer(
|
||||
)
|
||||
|
||||
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(
|
||||
targetValue = screenWidthPx,
|
||||
animationSpec =
|
||||
|
||||
1
app/src/main/res/raw/chat_audio_record_delete_2.json
Normal file
1
app/src/main/res/raw/chat_audio_record_delete_2.json
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user