Чат/звонки/коннект: 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.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 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
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