Доработал голосовые сообщения и Telegram-подобный UI ввода

This commit is contained in:
2026-04-11 02:06:15 +05:00
parent 8d8b02a3ec
commit 5e5c4c11ac
8 changed files with 1511 additions and 95 deletions

View File

@@ -9,6 +9,7 @@ enum class AttachmentType(val value: Int) {
FILE(2), // Файл
AVATAR(3), // Аватар пользователя
CALL(4), // Событие звонка (пропущен/принят/завершен)
VOICE(5), // Голосовое сообщение
UNKNOWN(-1); // Неизвестный тип
companion object {

View File

@@ -2679,6 +2679,20 @@ fun ChatDetailScreen(
isSendingMessage = false
}
},
onSendVoiceMessage = { voiceHex, durationSec, waves ->
isSendingMessage = true
viewModel.sendVoiceMessage(
voiceHex = voiceHex,
durationSec = durationSec,
waves = waves
)
scope.launch {
delay(120)
listState.animateScrollToItem(0)
delay(220)
isSendingMessage = false
}
},
isDarkTheme = isDarkTheme,
backgroundColor = backgroundColor,
textColor = textColor,
@@ -4258,6 +4272,7 @@ private fun ChatInputBarSection(
viewModel: ChatViewModel,
isSavedMessages: Boolean,
onSend: () -> Unit,
onSendVoiceMessage: (voiceHex: String, durationSec: Int, waves: List<Float>) -> Unit,
isDarkTheme: Boolean,
backgroundColor: Color,
textColor: Color,
@@ -4295,6 +4310,7 @@ private fun ChatInputBarSection(
}
},
onSend = onSend,
onSendVoiceMessage = onSendVoiceMessage,
isDarkTheme = isDarkTheme,
backgroundColor = backgroundColor,
textColor = textColor,

View File

@@ -1625,6 +1625,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
"file" -> AttachmentType.FILE.value
"avatar" -> AttachmentType.AVATAR.value
"call" -> AttachmentType.CALL.value
"voice" -> AttachmentType.VOICE.value
else -> -1
}
}
@@ -1792,9 +1793,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
)
)
// 💾 Для IMAGE и AVATAR - пробуем загрузить blob из файла если пустой
// 💾 Для IMAGE/AVATAR/VOICE - пробуем загрузить blob из файла если пустой
if ((effectiveType == AttachmentType.IMAGE ||
effectiveType == AttachmentType.AVATAR) &&
effectiveType == AttachmentType.AVATAR ||
effectiveType == AttachmentType.VOICE) &&
blob.isEmpty() &&
attachmentId.isNotEmpty()
) {
@@ -2566,6 +2568,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
message.attachments.any { it.type == AttachmentType.FILE } -> "File"
message.attachments.any { it.type == AttachmentType.AVATAR } -> "Avatar"
message.attachments.any { it.type == AttachmentType.CALL } -> "Call"
message.attachments.any { it.type == AttachmentType.VOICE } -> "Voice message"
message.forwardedMessages.isNotEmpty() -> "Forwarded message"
message.replyData != null -> "Reply"
else -> "Pinned message"
@@ -4806,6 +4809,192 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
}
/**
* 🎙️ Отправка голосового сообщения.
* blob хранится как HEX строка opus/webm байт (desktop parity).
* preview формат: "<durationSec>::<wave1,wave2,...>"
*/
fun sendVoiceMessage(
voiceHex: String,
durationSec: Int,
waves: List<Float>
) {
val recipient = opponentKey
val sender = myPublicKey
val privateKey = myPrivateKey
if (recipient == null || sender == null || privateKey == null) {
return
}
if (isSending) {
return
}
val normalizedVoiceHex = voiceHex.trim()
if (normalizedVoiceHex.isEmpty()) {
return
}
val normalizedDuration = durationSec.coerceAtLeast(1)
val normalizedWaves =
waves.asSequence()
.map { it.coerceIn(0f, 1f) }
.take(120)
.toList()
val wavesPreview =
normalizedWaves.joinToString(",") {
String.format(Locale.US, "%.3f", it)
}
val preview = "$normalizedDuration::$wavesPreview"
isSending = true
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
val timestamp = System.currentTimeMillis()
val attachmentId = "voice_$timestamp"
// 1. 🚀 Optimistic UI
val optimisticMessage =
ChatMessage(
id = messageId,
text = "",
isOutgoing = true,
timestamp = Date(timestamp),
status = MessageStatus.SENDING,
attachments =
listOf(
MessageAttachment(
id = attachmentId,
type = AttachmentType.VOICE,
preview = preview,
blob = normalizedVoiceHex
)
)
)
addMessageSafely(optimisticMessage)
_inputText.value = ""
viewModelScope.launch(Dispatchers.IO) {
try {
val encryptionContext =
buildEncryptionContext(
plaintext = "",
recipient = recipient,
privateKey = privateKey
) ?: throw IllegalStateException("Cannot resolve chat encryption context")
val encryptedContent = encryptionContext.encryptedContent
val encryptedKey = encryptionContext.encryptedKey
val aesChachaKey = encryptionContext.aesChachaKey
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
val encryptedVoiceBlob =
encryptAttachmentPayload(normalizedVoiceHex, encryptionContext)
val isSavedMessages = (sender == recipient)
var uploadTag = ""
if (!isSavedMessages) {
uploadTag = TransportManager.uploadFile(attachmentId, encryptedVoiceBlob)
}
val attachmentTransportServer =
if (uploadTag.isNotEmpty()) {
TransportManager.getTransportServer().orEmpty()
} else {
""
}
val voiceAttachment =
MessageAttachment(
id = attachmentId,
blob = "",
type = AttachmentType.VOICE,
preview = preview,
transportTag = uploadTag,
transportServer = attachmentTransportServer
)
val packet =
PacketMessage().apply {
fromPublicKey = sender
toPublicKey = recipient
content = encryptedContent
chachaKey = encryptedKey
this.aesChachaKey = aesChachaKey
this.timestamp = timestamp
this.privateKey = privateKeyHash
this.messageId = messageId
attachments = listOf(voiceAttachment)
}
if (!isSavedMessages) {
ProtocolManager.send(packet)
}
// Для отправителя сохраняем voice blob локально в encrypted cache.
runCatching {
AttachmentFileManager.saveAttachment(
context = getApplication(),
blob = normalizedVoiceHex,
attachmentId = attachmentId,
publicKey = sender,
privateKey = privateKey
)
}
val attachmentsJson =
JSONArray()
.apply {
put(
JSONObject().apply {
put("id", attachmentId)
put("type", AttachmentType.VOICE.value)
put("preview", preview)
put("blob", "")
put("transportTag", uploadTag)
put("transportServer", attachmentTransportServer)
}
)
}
.toString()
saveMessageToDatabase(
messageId = messageId,
text = "",
encryptedContent = encryptedContent,
encryptedKey =
if (encryptionContext.isGroup) {
buildStoredGroupKey(
encryptionContext.attachmentPassword,
privateKey
)
} else {
encryptedKey
},
timestamp = timestamp,
isFromMe = true,
delivered = if (isSavedMessages) 1 else 0,
attachmentsJson = attachmentsJson
)
withContext(Dispatchers.Main) {
if (isSavedMessages) {
updateMessageStatus(messageId, MessageStatus.SENT)
}
}
saveDialog("Voice message", timestamp)
} catch (_: Exception) {
withContext(Dispatchers.Main) {
updateMessageStatus(messageId, MessageStatus.ERROR)
}
updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value)
saveDialog("Voice message", timestamp)
} finally {
isSending = false
}
}
}
/**
* Отправка аватарки пользователя По аналогии с desktop - отправляет текущий аватар как вложение
*/

View File

@@ -4549,6 +4549,8 @@ fun DialogItemContent(
"Avatar" -> "Avatar"
dialog.lastMessageAttachmentType ==
"Call" -> "Call"
dialog.lastMessageAttachmentType ==
"Voice message" -> "Voice message"
dialog.lastMessageAttachmentType ==
"Forwarded" ->
"Forwarded message"

View File

@@ -573,16 +573,50 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
lastMessageAttachments: String
): String? {
val inferredCall = isLikelyCallAttachmentJson(lastMessageAttachments)
return when (attachmentType) {
val effectiveType =
if (attachmentType >= 0) attachmentType
else inferAttachmentTypeFromJson(lastMessageAttachments)
return when (effectiveType) {
0 -> if (inferredCall) "Call" else "Photo" // AttachmentType.IMAGE = 0
1 -> if (decryptedLastMessage.isNotEmpty()) null else "Forwarded" // AttachmentType.MESSAGES = 1 (Reply/Forward)
2 -> "File" // AttachmentType.FILE = 2
3 -> "Avatar" // AttachmentType.AVATAR = 3
4 -> "Call" // AttachmentType.CALL = 4
5 -> "Voice message" // AttachmentType.VOICE = 5
else -> if (inferredCall) "Call" else null
}
}
private fun inferAttachmentTypeFromJson(rawAttachments: String): Int {
if (rawAttachments.isBlank() || rawAttachments == "[]") return -1
return try {
val attachments = parseAttachmentsJsonArray(rawAttachments) ?: return -1
if (attachments.length() <= 0) return -1
val first = attachments.optJSONObject(0) ?: return -1
val rawType = first.opt("type")
when (rawType) {
is Number -> rawType.toInt()
is String -> {
val normalized = rawType.trim()
normalized.toIntOrNull()
?: when (normalized.lowercase(Locale.ROOT)) {
"image" -> 0
"messages", "reply", "forward" -> 1
"file" -> 2
"avatar" -> 3
"call" -> 4
"voice" -> 5
else -> -1
}
}
else -> -1
}
} catch (_: Throwable) {
-1
}
}
private fun isLikelyCallAttachmentJson(rawAttachments: String): Boolean {
if (rawAttachments.isBlank() || rawAttachments == "[]") return false
return try {

View File

@@ -1,10 +1,15 @@
package com.rosetta.messenger.ui.chats.components
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.media.AudioAttributes
import android.media.MediaPlayer
import android.os.SystemClock
import android.util.Base64
import android.util.LruCache
import android.webkit.MimeTypeMap
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
@@ -61,27 +66,31 @@ import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.network.MessageAttachment
import com.rosetta.messenger.network.TransportManager
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.icons.TelegramIcons
import com.rosetta.messenger.ui.chats.models.MessageStatus
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.utils.AttachmentFileManager
import com.rosetta.messenger.utils.AvatarFileManager
import com.vanniktech.blurhash.BlurHash
import com.rosetta.messenger.ui.icons.TelegramIcons
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import androidx.compose.ui.platform.LocalConfiguration
import androidx.core.content.FileProvider
import android.content.Intent
import android.os.SystemClock
import android.webkit.MimeTypeMap
import java.io.ByteArrayInputStream
import java.io.File
import java.security.MessageDigest
import kotlin.math.min
import androidx.compose.ui.platform.LocalConfiguration
import androidx.core.content.FileProvider
private const val TAG = "AttachmentComponents"
private const val MAX_BITMAP_DECODE_DIMENSION = 4096
@@ -126,6 +135,96 @@ private fun decodeBase64Payload(data: String): ByteArray? {
}
}
private fun decodeHexPayload(data: String): ByteArray? {
val raw = data.trim().removePrefix("0x")
if (raw.isBlank() || raw.length % 2 != 0) return null
if (!raw.all { ch -> ch.isDigit() || ch.lowercaseChar() in 'a'..'f' }) return null
return runCatching {
ByteArray(raw.length / 2) { index ->
raw.substring(index * 2, index * 2 + 2).toInt(16).toByte()
}
}.getOrNull()
}
private fun decodeVoicePayload(data: String): ByteArray? {
return decodeHexPayload(data) ?: decodeBase64Payload(data)
}
private object VoicePlaybackCoordinator {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var player: MediaPlayer? = null
private var currentAttachmentId: String? = null
private var progressJob: Job? = null
private val _playingAttachmentId = MutableStateFlow<String?>(null)
val playingAttachmentId: StateFlow<String?> = _playingAttachmentId.asStateFlow()
private val _positionMs = MutableStateFlow(0)
val positionMs: StateFlow<Int> = _positionMs.asStateFlow()
private val _durationMs = MutableStateFlow(0)
val durationMs: StateFlow<Int> = _durationMs.asStateFlow()
fun toggle(attachmentId: String, sourceFile: File, onError: (String) -> Unit = {}) {
if (!sourceFile.exists()) {
onError("Voice file is missing")
return
}
if (currentAttachmentId == attachmentId && player?.isPlaying == true) {
stop()
return
}
stop()
val mediaPlayer = MediaPlayer()
try {
mediaPlayer.setAudioAttributes(
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.setUsage(AudioAttributes.USAGE_MEDIA)
.build()
)
mediaPlayer.setDataSource(sourceFile.absolutePath)
mediaPlayer.setOnCompletionListener { stop() }
mediaPlayer.prepare()
mediaPlayer.start()
player = mediaPlayer
currentAttachmentId = attachmentId
_playingAttachmentId.value = attachmentId
_durationMs.value = mediaPlayer.duration.coerceAtLeast(0)
_positionMs.value = mediaPlayer.currentPosition.coerceAtLeast(0)
progressJob?.cancel()
progressJob =
scope.launch {
while (isActive && currentAttachmentId == attachmentId) {
val active = player
if (active == null || !active.isPlaying) break
_positionMs.value = active.currentPosition.coerceAtLeast(0)
delay(120)
}
}
} catch (e: Exception) {
runCatching { mediaPlayer.release() }
stop()
onError(e.message ?: "Playback failed")
}
}
fun stop() {
val active = player
player = null
currentAttachmentId = null
progressJob?.cancel()
progressJob = null
_playingAttachmentId.value = null
_positionMs.value = 0
_durationMs.value = 0
if (active != null) {
runCatching {
if (active.isPlaying) active.stop()
}
runCatching { active.release() }
}
}
}
private fun shortDebugId(value: String): String {
if (value.isBlank()) return "empty"
val clean = value.trim()
@@ -486,6 +585,7 @@ private fun TelegramFileActionButton(
fun MessageAttachments(
attachments: List<MessageAttachment>,
chachaKey: String,
chachaKeyPlainHex: String = "",
privateKey: String,
isOutgoing: Boolean,
isDarkTheme: Boolean,
@@ -573,6 +673,19 @@ fun MessageAttachments(
isDarkTheme = isDarkTheme
)
}
AttachmentType.VOICE -> {
VoiceAttachment(
attachment = attachment,
chachaKey = chachaKey,
chachaKeyPlainHex = chachaKeyPlainHex,
privateKey = privateKey,
senderPublicKey = senderPublicKey,
isOutgoing = isOutgoing,
isDarkTheme = isDarkTheme,
timestamp = timestamp,
messageStatus = messageStatus
)
}
else -> {
// Desktop parity: unsupported/legacy attachment gets explicit compatibility card.
LegacyAttachmentErrorCard(isDarkTheme = isDarkTheme)
@@ -1679,6 +1792,60 @@ private fun parseCallDurationSeconds(preview: String): Int {
return preview.trim().toIntOrNull()?.coerceAtLeast(0) ?: 0
}
private fun parseVoicePreview(preview: String): Pair<Int, List<Float>> {
if (preview.isBlank()) return 0 to emptyList()
val durationPart = preview.substringBefore("::", preview).trim()
val wavesPart = preview.substringAfter("::", "").trim()
val duration = durationPart.toIntOrNull()?.coerceAtLeast(0) ?: 0
val waves =
if (wavesPart.isBlank()) {
emptyList()
} else {
wavesPart.split(",")
.mapNotNull { it.trim().toFloatOrNull() }
.map { it.coerceIn(0f, 1f) }
}
return duration to waves
}
private fun normalizeVoiceWaves(source: List<Float>, targetLength: Int): List<Float> {
if (targetLength <= 0) return emptyList()
if (source.isEmpty()) return List(targetLength) { 0f }
if (source.size == targetLength) return source
if (source.size > targetLength) {
val bucket = source.size.toFloat() / targetLength.toFloat()
return List(targetLength) { idx ->
val start = kotlin.math.floor(idx * bucket).toInt()
val end = kotlin.math.max(start + 1, kotlin.math.floor((idx + 1) * bucket).toInt())
var maxValue = 0f
var i = start
while (i < end && i < source.size) {
if (source[i] > maxValue) maxValue = source[i]
i++
}
maxValue
}
}
if (targetLength == 1) return listOf(source.first())
val lastIndex = source.lastIndex.toFloat()
return List(targetLength) { idx ->
val pos = idx * lastIndex / (targetLength - 1).toFloat()
val left = kotlin.math.floor(pos).toInt()
val right = kotlin.math.min(kotlin.math.ceil(pos).toInt(), source.lastIndex)
if (left == right) source[left] else {
val t = pos - left.toFloat()
source[left] * (1f - t) + source[right] * t
}
}
}
private fun formatVoiceDuration(seconds: Int): String {
val safe = seconds.coerceAtLeast(0)
val minutes = (safe / 60).toString().padStart(2, '0')
val rem = (safe % 60).toString().padStart(2, '0')
return "$minutes:$rem"
}
private fun formatDesktopCallDuration(durationSec: Int): String {
val minutes = durationSec / 60
val seconds = durationSec % 60
@@ -1790,6 +1957,369 @@ fun CallAttachment(
}
}
private fun ensureVoiceAudioFile(
context: android.content.Context,
attachmentId: String,
payload: String
): File? {
val bytes = decodeVoicePayload(payload) ?: return null
val directory = File(context.cacheDir, "voice_messages").apply { mkdirs() }
val file = File(directory, "$attachmentId.webm")
runCatching { file.writeBytes(bytes) }.getOrNull() ?: return null
return file
}
@Composable
private fun VoiceAttachment(
attachment: MessageAttachment,
chachaKey: String,
chachaKeyPlainHex: String,
privateKey: String,
senderPublicKey: String,
isOutgoing: Boolean,
isDarkTheme: Boolean,
timestamp: java.util.Date,
messageStatus: MessageStatus = MessageStatus.READ
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val playingAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState()
val playbackPositionMs by VoicePlaybackCoordinator.positionMs.collectAsState()
val playbackDurationMs by VoicePlaybackCoordinator.durationMs.collectAsState()
val isPlaying = playingAttachmentId == attachment.id
val (previewDurationSecRaw, previewWavesRaw) =
remember(attachment.preview) { parseVoicePreview(attachment.preview) }
val previewDurationSec = previewDurationSecRaw.coerceAtLeast(1)
val previewWaves =
remember(previewWavesRaw) { normalizeVoiceWaves(previewWavesRaw, 40) }
val waves =
remember(previewWaves) {
if (previewWaves.isEmpty()) List(40) { 0f } else previewWaves
}
var payload by
remember(attachment.id, attachment.blob) {
mutableStateOf(attachment.blob.trim())
}
var downloadStatus by
remember(attachment.id, attachment.blob, attachment.transportTag) {
mutableStateOf(
when {
attachment.blob.isNotBlank() -> DownloadStatus.DOWNLOADED
attachment.transportTag.isNotBlank() -> DownloadStatus.NOT_DOWNLOADED
else -> DownloadStatus.ERROR
}
)
}
var errorText by remember { mutableStateOf("") }
var audioFilePath by remember(attachment.id) { mutableStateOf<String?>(null) }
val effectiveDurationSec =
remember(isPlaying, playbackDurationMs, previewDurationSec) {
val fromPlayer = (playbackDurationMs / 1000).coerceAtLeast(0)
if (isPlaying && fromPlayer > 0) fromPlayer else previewDurationSec
}
val progress =
if (isPlaying && playbackDurationMs > 0) {
(playbackPositionMs.toFloat() / playbackDurationMs.toFloat()).coerceIn(0f, 1f)
} else {
0f
}
val timeText =
if (isPlaying && playbackDurationMs > 0) {
val leftSec = ((playbackDurationMs - playbackPositionMs).coerceAtLeast(0) / 1000)
"-${formatVoiceDuration(leftSec)}"
} else {
formatVoiceDuration(effectiveDurationSec)
}
LaunchedEffect(payload, attachment.id) {
if (payload.isBlank()) return@LaunchedEffect
val prepared = ensureVoiceAudioFile(context, attachment.id, payload)
if (prepared != null) {
audioFilePath = prepared.absolutePath
if (downloadStatus != DownloadStatus.DOWNLOADING &&
downloadStatus != DownloadStatus.DECRYPTING
) {
downloadStatus = DownloadStatus.DOWNLOADED
}
if (errorText.isNotBlank()) errorText = ""
} else {
audioFilePath = null
downloadStatus = DownloadStatus.ERROR
if (errorText.isBlank()) errorText = "Cannot decode voice"
}
}
DisposableEffect(attachment.id) {
onDispose {
if (playingAttachmentId == attachment.id) {
VoicePlaybackCoordinator.stop()
}
}
}
val triggerDownload: () -> Unit = download@{
if (attachment.transportTag.isBlank()) {
downloadStatus = DownloadStatus.ERROR
errorText = "Voice not available"
return@download
}
scope.launch {
downloadStatus = DownloadStatus.DOWNLOADING
errorText = ""
val decrypted =
downloadAndDecryptVoicePayload(
attachmentId = attachment.id,
downloadTag = attachment.transportTag,
chachaKey = chachaKey,
privateKey = privateKey,
transportServer = attachment.transportServer,
chachaKeyPlainHex = chachaKeyPlainHex
)
if (decrypted.isNullOrBlank()) {
downloadStatus = DownloadStatus.ERROR
errorText = "Failed to decrypt"
return@launch
}
val saved =
runCatching {
AttachmentFileManager.saveAttachment(
context = context,
blob = decrypted,
attachmentId = attachment.id,
publicKey = senderPublicKey,
privateKey = privateKey
)
}
.getOrDefault(false)
payload = decrypted
if (!saved) {
// Не блокируем UI, но оставляем маркер в логе.
runCatching { android.util.Log.w(TAG, "Voice cache save failed: ${attachment.id}") }
}
downloadStatus = DownloadStatus.DOWNLOADED
}
}
val onMainAction: () -> Unit = {
when (downloadStatus) {
DownloadStatus.NOT_DOWNLOADED, DownloadStatus.ERROR -> triggerDownload()
DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> Unit
DownloadStatus.DOWNLOADED, DownloadStatus.PENDING -> {
val file = audioFilePath?.let { File(it) }
if (file == null || !file.exists()) {
if (payload.isNotBlank()) {
val prepared = ensureVoiceAudioFile(context, attachment.id, payload)
if (prepared != null) {
audioFilePath = prepared.absolutePath
VoicePlaybackCoordinator.toggle(attachment.id, prepared) { message ->
downloadStatus = DownloadStatus.ERROR
errorText = message
}
} else {
downloadStatus = DownloadStatus.ERROR
errorText = "Cannot decode voice"
}
} else {
triggerDownload()
}
} else {
VoicePlaybackCoordinator.toggle(attachment.id, file) { message ->
downloadStatus = DownloadStatus.ERROR
errorText = message
}
}
}
}
}
val barInactiveColor =
if (isOutgoing) Color.White.copy(alpha = 0.38f)
else if (isDarkTheme) Color(0xFF5D6774)
else Color(0xFFB6C0CC)
val barActiveColor = if (isOutgoing) Color.White else PrimaryBlue
val secondaryTextColor =
if (isOutgoing) Color.White.copy(alpha = 0.72f)
else if (isDarkTheme) Color(0xFF9EAABD)
else Color(0xFF667283)
val actionBackground =
when (downloadStatus) {
DownloadStatus.ERROR -> Color(0xFFE55757)
else -> if (isOutgoing) Color.White.copy(alpha = 0.2f) else PrimaryBlue
}
val actionTint =
when {
downloadStatus == DownloadStatus.ERROR -> Color.White
isOutgoing -> Color.White
else -> Color.White
}
Row(
modifier =
Modifier.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { onMainAction() },
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier =
Modifier.size(40.dp)
.clip(CircleShape)
.background(actionBackground),
contentAlignment = Alignment.Center
) {
if (downloadStatus == DownloadStatus.DOWNLOADING ||
downloadStatus == DownloadStatus.DECRYPTING
) {
CircularProgressIndicator(
modifier = Modifier.size(22.dp),
color = Color.White,
strokeWidth = 2.2.dp
)
} else {
when (downloadStatus) {
DownloadStatus.NOT_DOWNLOADED -> {
Icon(
painter = painterResource(R.drawable.msg_download),
contentDescription = null,
tint = actionTint,
modifier = Modifier.size(20.dp)
)
}
DownloadStatus.ERROR -> {
Icon(
imageVector = Icons.Default.Close,
contentDescription = null,
tint = actionTint,
modifier = Modifier.size(20.dp)
)
}
else -> {
Icon(
imageVector =
if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = null,
tint = actionTint,
modifier = Modifier.size(20.dp)
)
}
}
}
}
Spacer(modifier = Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Row(
modifier = Modifier.fillMaxWidth().height(28.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
waves.forEachIndexed { index, value ->
val normalized = value.coerceIn(0f, 1f)
val passed = (progress * waves.size) - index
val fill = passed.coerceIn(0f, 1f)
val color =
if (fill > 0f) {
barActiveColor
} else {
barInactiveColor
}
Box(
modifier =
Modifier.width(2.dp)
.height((4f + normalized * 18f).dp)
.clip(RoundedCornerShape(100))
.background(color)
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text =
if (downloadStatus == DownloadStatus.ERROR && errorText.isNotBlank())
errorText
else timeText,
fontSize = 12.sp,
color =
if (downloadStatus == DownloadStatus.ERROR) {
Color(0xFFE55757)
} else {
secondaryTextColor
}
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = android.text.format.DateFormat.format("HH:mm", timestamp).toString(),
fontSize = 11.sp,
color = secondaryTextColor
)
if (isOutgoing) {
when (messageStatus) {
MessageStatus.SENDING -> {
Icon(
painter = TelegramIcons.Clock,
contentDescription = null,
tint = secondaryTextColor,
modifier = Modifier.size(14.dp)
)
}
MessageStatus.SENT,
MessageStatus.DELIVERED -> {
Icon(
painter = TelegramIcons.Done,
contentDescription = null,
tint = secondaryTextColor,
modifier = Modifier.size(14.dp)
)
}
MessageStatus.READ -> {
Box(modifier = Modifier.height(14.dp)) {
Icon(
painter = TelegramIcons.Done,
contentDescription = null,
tint = secondaryTextColor,
modifier = Modifier.size(14.dp)
)
Icon(
painter = TelegramIcons.Done,
contentDescription = null,
tint = secondaryTextColor,
modifier = Modifier.size(14.dp).offset(x = 4.dp)
)
}
}
MessageStatus.ERROR -> {
Icon(
imageVector = Icons.Default.Error,
contentDescription = null,
tint = Color(0xFFE55757),
modifier = Modifier.size(14.dp)
)
}
}
}
}
}
}
}
}
/** File attachment - Telegram style */
@Composable
fun FileAttachment(
@@ -2933,6 +3463,60 @@ private suspend fun processDownloadedImage(
}
}
/**
* CDN download + decrypt helper for voice payload (hex string).
*/
internal suspend fun downloadAndDecryptVoicePayload(
attachmentId: String,
downloadTag: String,
chachaKey: String,
privateKey: String,
transportServer: String = "",
chachaKeyPlainHex: String = ""
): String? {
if (downloadTag.isBlank() || privateKey.isBlank()) return null
if (chachaKeyPlainHex.isBlank() && chachaKey.isBlank()) return null
return withContext(Dispatchers.IO) {
runCatching {
val encryptedContent =
TransportManager.downloadFile(
attachmentId,
downloadTag,
transportServer.ifBlank { null }
)
if (encryptedContent.isBlank()) return@withContext null
when {
chachaKeyPlainHex.isNotBlank() -> {
val plainKey =
chachaKeyPlainHex.chunked(2)
.mapNotNull { part -> part.toIntOrNull(16)?.toByte() }
.toByteArray()
if (plainKey.isEmpty()) {
null
} else {
MessageCrypto.decryptAttachmentBlobWithPlainKey(encryptedContent, plainKey)
?: MessageCrypto.decryptReplyBlob(encryptedContent, plainKey)
.takeIf { it.isNotEmpty() }
}
}
isGroupStoredKey(chachaKey) -> {
val groupPassword = decodeGroupPassword(chachaKey, privateKey) ?: return@withContext null
CryptoManager.decryptWithPassword(encryptedContent, groupPassword)
?: run {
val hexKey =
groupPassword.toByteArray(Charsets.ISO_8859_1)
.joinToString("") { "%02x".format(it.toInt() and 0xff) }
CryptoManager.decryptWithPassword(encryptedContent, hexKey)
}
}
else -> MessageCrypto.decryptAttachmentBlob(encryptedContent, chachaKey, privateKey)
}
}.getOrNull()
}
}
/**
* CDN download + decrypt + cache + save.
* Shared between ReplyBubble and ForwardedImagePreview.

View File

@@ -979,6 +979,7 @@ fun MessageBubble(
MessageAttachments(
attachments = message.attachments,
chachaKey = message.chachaKey,
chachaKeyPlainHex = message.chachaKeyPlainHex,
privateKey = privateKey,
isOutgoing = message.isOutgoing,
isDarkTheme = isDarkTheme,

View File

@@ -1,9 +1,19 @@
package com.rosetta.messenger.ui.chats.input
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.media.MediaRecorder
import android.os.Build
import android.view.inputmethod.InputMethodManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Mic
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -34,6 +44,8 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -44,6 +56,7 @@ import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator
import androidx.core.content.ContextCompat
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.rosetta.messenger.network.AttachmentType
@@ -60,7 +73,11 @@ import com.rosetta.messenger.ui.chats.ChatViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import java.io.File
import java.util.Locale
import java.util.UUID
import kotlin.math.PI
import kotlin.math.sin
private fun truncateEmojiSafe(text: String, maxLen: Int): String {
if (text.length <= maxLen) return text
@@ -75,6 +92,284 @@ private fun truncateEmojiSafe(text: String, maxLen: Int): String {
return text.substring(0, cutAt).trimEnd() + "..."
}
private fun bytesToHexLower(bytes: ByteArray): String {
if (bytes.isEmpty()) return ""
val out = StringBuilder(bytes.size * 2)
bytes.forEach { out.append(String.format("%02x", it.toInt() and 0xff)) }
return out.toString()
}
private fun compressVoiceWaves(source: List<Float>, targetLength: Int): List<Float> {
if (targetLength <= 0) return emptyList()
if (source.isEmpty()) return List(targetLength) { 0f }
if (source.size == targetLength) return source
if (source.size > targetLength) {
val bucketSize = source.size / targetLength.toFloat()
return List(targetLength) { index ->
val start = kotlin.math.floor(index * bucketSize).toInt()
val end = kotlin.math.max(start + 1, kotlin.math.floor((index + 1) * bucketSize).toInt())
var maxValue = 0f
for (i in start until end.coerceAtMost(source.size)) {
if (source[i] > maxValue) maxValue = source[i]
}
maxValue
}
}
if (targetLength == 1) return listOf(source.first())
val lastIndex = source.lastIndex.toFloat()
return List(targetLength) { index ->
val pos = index * lastIndex / (targetLength - 1).toFloat()
val left = kotlin.math.floor(pos).toInt()
val right = kotlin.math.min(kotlin.math.ceil(pos).toInt(), source.lastIndex)
if (left == right) {
source[left]
} else {
val t = pos - left.toFloat()
source[left] * (1f - t) + source[right] * t
}
}
}
private fun formatVoiceRecordTimer(elapsedMs: Long): String {
val safeTenths = (elapsedMs.coerceAtLeast(0L) / 100L).toInt()
val totalSeconds = safeTenths / 10
val tenths = safeTenths % 10
val minutes = totalSeconds / 60
val seconds = (totalSeconds % 60).toString().padStart(2, '0')
return "$minutes:$seconds,$tenths"
}
@Composable
private fun RecordBlinkDot(
isDarkTheme: Boolean,
modifier: Modifier = Modifier
) {
var entered by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { entered = true }
val enterScale by animateFloatAsState(
targetValue = if (entered) 1f else 0f,
animationSpec = tween(durationMillis = 180, easing = LinearOutSlowInEasing),
label = "record_dot_enter_scale"
)
val blinkAlpha by rememberInfiniteTransition(label = "record_dot_blink").animateFloat(
initialValue = 1f,
targetValue = 0f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 600, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
),
label = "record_dot_alpha"
)
val dotColor = if (isDarkTheme) Color(0xFFFF5A5A) else Color(0xFFE84D4D)
Box(
modifier = modifier
.size(28.dp)
.graphicsLayer {
scaleX = enterScale
scaleY = enterScale
alpha = blinkAlpha
},
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.size(10.dp) // Telegram fallback dot radius = 5dp
.clip(CircleShape)
.background(dotColor)
)
}
}
@Composable
private fun VoiceMovingBlob(
voiceLevel: Float,
isDarkTheme: Boolean,
modifier: Modifier = Modifier
) {
val rawLevel = voiceLevel.coerceIn(0f, 1f)
val level by animateFloatAsState(
targetValue = rawLevel,
animationSpec = tween(durationMillis = 100, easing = LinearOutSlowInEasing),
label = "voice_blob_level"
)
val transition = rememberInfiniteTransition(label = "voice_blob_motion")
val phase by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1400, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "voice_blob_phase"
)
val waveColor = if (isDarkTheme) Color(0xFF69CCFF) else Color(0xFF2D9CFF)
Canvas(
modifier = modifier
.width(36.dp)
.height(18.dp)
) {
val cxBase = size.width * 0.5f
val cy = size.height * 0.5f
val waveShift = (size.width * 0.16f * sin(phase * 2f * PI.toFloat()))
val cx = cxBase + waveShift
val base = size.minDimension * 0.22f
drawCircle(
color = waveColor.copy(alpha = 0.2f + level * 0.15f),
radius = base * (2.1f + level * 1.0f),
center = Offset(cx - waveShift * 0.55f, cy)
)
drawCircle(
color = waveColor.copy(alpha = 0.38f + level * 0.20f),
radius = base * (1.55f + level * 0.75f),
center = Offset(cx + waveShift * 0.35f, cy)
)
drawCircle(
color = waveColor.copy(alpha = 0.95f),
radius = base * (0.88f + level * 0.15f),
center = Offset(cx, cy)
)
}
}
@Composable
private fun VoiceButtonBlob(
voiceLevel: Float,
isDarkTheme: Boolean,
modifier: Modifier = Modifier
) {
val rawLevel = voiceLevel.coerceIn(0f, 1f)
val bigLevel by animateFloatAsState(
targetValue = rawLevel,
animationSpec = tween(durationMillis = 150, easing = LinearOutSlowInEasing),
label = "voice_btn_blob_big_level"
)
val smallLevel by animateFloatAsState(
targetValue = rawLevel * 0.88f,
animationSpec = tween(durationMillis = 220, easing = LinearOutSlowInEasing),
label = "voice_btn_blob_small_level"
)
val transition = rememberInfiniteTransition(label = "voice_btn_blob_motion")
val morphPhase by transition.animateFloat(
initialValue = 0f,
targetValue = (2f * PI.toFloat()),
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 2200, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "voice_btn_blob_morph"
)
val driftX by transition.animateFloat(
initialValue = -1f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1650, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
),
label = "voice_btn_blob_drift_x"
)
val driftY by transition.animateFloat(
initialValue = 1f,
targetValue = -1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1270, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
),
label = "voice_btn_blob_drift_y"
)
val blobColor = if (isDarkTheme) Color(0xFF52C3FF) else Color(0xFF2D9CFF)
fun createBlobPath(
center: Offset,
baseRadius: Float,
points: Int,
phase: Float,
seed: Float,
level: Float,
formMax: Float
): Path {
val coords = ArrayList<Offset>(points)
val step = (2f * PI.toFloat()) / points.toFloat()
val deform = (0.08f + formMax * 0.34f) * level
for (i in 0 until points) {
val angle = i * step
val p = angle + phase * (0.6f + seed * 0.22f)
val n1 = sin(p * (2.2f + seed * 0.45f))
val n2 = sin(p * (3.4f + seed * 0.28f) - phase * (0.7f + seed * 0.18f))
val mix = n1 * 0.65f + n2 * 0.35f
val radius = baseRadius * (1f + mix * deform)
coords += Offset(
x = center.x + radius * kotlin.math.cos(angle),
y = center.y + radius * kotlin.math.sin(angle)
)
}
val path = Path()
if (coords.isEmpty()) return path
val firstMid = Offset(
(coords.last().x + coords.first().x) * 0.5f,
(coords.last().y + coords.first().y) * 0.5f
)
path.moveTo(firstMid.x, firstMid.y)
for (i in coords.indices) {
val current = coords[i]
val next = coords[(i + 1) % coords.size]
val mid = Offset((current.x + next.x) * 0.5f, (current.y + next.y) * 0.5f)
path.quadraticBezierTo(current.x, current.y, mid.x, mid.y)
}
path.close()
return path
}
Canvas(modifier = modifier) {
val center = Offset(
x = size.width * 0.5f + size.width * 0.05f * driftX,
y = size.height * 0.5f + size.height * 0.04f * driftY
)
val baseRadius = size.minDimension * 0.25f
// Telegram-like constants:
// SCALE_BIG_MIN=0.878, SCALE_SMALL_MIN=0.926, +1.4*amplitude
val bigScale = 0.878f + 1.4f * bigLevel
val smallScale = 0.926f + 1.4f * smallLevel
val bigPath = createBlobPath(
center = center,
baseRadius = baseRadius * bigScale,
points = 12,
phase = morphPhase,
seed = 0.23f,
level = bigLevel,
formMax = 0.6f
)
val smallPath = createBlobPath(
center = Offset(center.x - size.width * 0.01f, center.y + size.height * 0.01f),
baseRadius = baseRadius * smallScale,
points = 11,
phase = morphPhase + 0.7f,
seed = 0.61f,
level = smallLevel,
formMax = 0.6f
)
drawPath(
path = bigPath,
color = blobColor.copy(alpha = 0.30f)
)
drawPath(
path = smallPath,
color = blobColor.copy(alpha = 0.15f)
)
}
}
/**
* Message input bar and related components
* Extracted from ChatDetailScreen.kt for better organization
@@ -102,6 +397,7 @@ fun MessageInputBar(
value: String,
onValueChange: (String) -> Unit,
onSend: () -> Unit,
onSendVoiceMessage: (voiceHex: String, durationSec: Int, waves: List<Float>) -> Unit = { _, _, _ -> },
isDarkTheme: Boolean,
backgroundColor: Color,
textColor: Color,
@@ -245,6 +541,156 @@ fun MessageInputBar(
}
}
var voiceRecorder by remember { mutableStateOf<MediaRecorder?>(null) }
var voiceOutputFile by remember { mutableStateOf<File?>(null) }
var isVoiceRecording by remember { mutableStateOf(false) }
var voiceRecordStartedAtMs by remember { mutableLongStateOf(0L) }
var voiceElapsedMs by remember { mutableLongStateOf(0L) }
var voiceWaves by remember { mutableStateOf<List<Float>>(emptyList()) }
fun stopVoiceRecording(send: Boolean) {
val recorder = voiceRecorder
val outputFile = voiceOutputFile
val elapsedSnapshot =
if (voiceRecordStartedAtMs > 0L) {
maxOf(voiceElapsedMs, System.currentTimeMillis() - voiceRecordStartedAtMs)
} else {
voiceElapsedMs
}
val durationSnapshot = ((elapsedSnapshot + 999L) / 1000L).toInt().coerceAtLeast(1)
val wavesSnapshot = voiceWaves
voiceRecorder = null
voiceOutputFile = null
isVoiceRecording = false
voiceRecordStartedAtMs = 0L
voiceElapsedMs = 0L
voiceWaves = emptyList()
var recordedOk = false
if (recorder != null) {
recordedOk = runCatching {
recorder.stop()
true
}.getOrDefault(false)
runCatching { recorder.reset() }
runCatching { recorder.release() }
}
if (send && recordedOk && outputFile != null && outputFile.exists() && outputFile.length() > 0L) {
val voiceHex =
runCatching { bytesToHexLower(outputFile.readBytes()) }.getOrDefault("")
if (voiceHex.isNotBlank()) {
onSendVoiceMessage(
voiceHex,
durationSnapshot,
compressVoiceWaves(wavesSnapshot, 35)
)
}
}
runCatching { outputFile?.delete() }
}
fun startVoiceRecording() {
if (isVoiceRecording) return
try {
val voiceDir = File(context.cacheDir, "voice_recordings").apply { mkdirs() }
val output = File(voiceDir, "voice_${UUID.randomUUID()}.webm")
val recorder =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaRecorder(context)
} else {
@Suppress("DEPRECATION")
MediaRecorder()
}
recorder.setAudioSource(MediaRecorder.AudioSource.MIC)
recorder.setOutputFormat(MediaRecorder.OutputFormat.WEBM)
recorder.setAudioEncoder(MediaRecorder.AudioEncoder.OPUS)
recorder.setAudioEncodingBitRate(32_000)
recorder.setAudioSamplingRate(48_000)
recorder.setOutputFile(output.absolutePath)
recorder.prepare()
recorder.start()
voiceRecorder = recorder
voiceOutputFile = output
voiceRecordStartedAtMs = System.currentTimeMillis()
voiceElapsedMs = 0L
voiceWaves = emptyList()
isVoiceRecording = true
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
focusManager.clearFocus(force = true)
} catch (_: Exception) {
stopVoiceRecording(send = false)
android.widget.Toast.makeText(
context,
"Voice recording is not supported on this device",
android.widget.Toast.LENGTH_SHORT
).show()
}
}
val recordAudioPermissionLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) {
startVoiceRecording()
} else {
android.widget.Toast.makeText(
context,
"Microphone permission is required for voice messages",
android.widget.Toast.LENGTH_SHORT
).show()
}
}
fun requestVoiceRecording() {
val granted =
ContextCompat.checkSelfPermission(
context,
Manifest.permission.RECORD_AUDIO
) == PackageManager.PERMISSION_GRANTED
if (granted) {
startVoiceRecording()
} else {
recordAudioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}
}
LaunchedEffect(isVoiceRecording, voiceRecorder) {
if (!isVoiceRecording) return@LaunchedEffect
while (isVoiceRecording && voiceRecorder != null) {
if (voiceRecordStartedAtMs > 0L) {
voiceElapsedMs =
(System.currentTimeMillis() - voiceRecordStartedAtMs).coerceAtLeast(0L)
}
delay(100)
}
}
LaunchedEffect(isVoiceRecording, voiceRecorder) {
if (!isVoiceRecording) return@LaunchedEffect
while (isVoiceRecording && voiceRecorder != null) {
val amplitude = runCatching { voiceRecorder?.maxAmplitude ?: 0 }.getOrDefault(0)
val normalized = (amplitude.toFloat() / 32_767f).coerceIn(0f, 1f)
voiceWaves = (voiceWaves + normalized).takeLast(120)
delay(90)
}
}
DisposableEffect(Unit) {
onDispose {
if (isVoiceRecording) {
stopVoiceRecording(send = false)
}
}
}
val canSend = remember(value, hasReply) { value.isNotBlank() || hasReply }
var isSending by remember { mutableStateOf(false) }
var showForwardCancelDialog by remember { mutableStateOf(false) }
@@ -329,6 +775,9 @@ fun MessageInputBar(
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
focusManager.clearFocus(force = true)
if (isVoiceRecording) {
stopVoiceRecording(send = false)
}
}
}
@@ -339,7 +788,7 @@ fun MessageInputBar(
}
fun toggleEmojiPicker() {
if (suppressKeyboard) return
if (suppressKeyboard || isVoiceRecording) return
val currentTime = System.currentTimeMillis()
val timeSinceLastToggle = currentTime - lastToggleTime
@@ -389,6 +838,10 @@ fun MessageInputBar(
}
fun handleSend() {
if (isVoiceRecording) {
stopVoiceRecording(send = true)
return
}
if (value.isNotBlank() || hasReply) {
isSending = true
onSend()
@@ -870,93 +1323,229 @@ fun MessageInputBar(
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.Bottom
) {
IconButton(
onClick = onAttachClick,
modifier = Modifier.size(40.dp)
) {
Icon(
painter = TelegramIcons.Attach,
contentDescription = "Attach",
tint = if (isDarkTheme) Color(0xFF8E8E93).copy(alpha = 0.6f)
else Color(0xFF8E8E93).copy(alpha = 0.6f),
modifier = Modifier.size(24.dp)
)
}
Spacer(modifier = Modifier.width(4.dp))
Box(
modifier = Modifier
.weight(1f)
.heightIn(min = 40.dp, max = 150.dp)
.background(color = backgroundColor)
.padding(horizontal = 12.dp, vertical = 8.dp),
contentAlignment = Alignment.TopStart
) {
AppleEmojiTextField(
value = value,
onValueChange = { newValue -> onValueChange(newValue) },
textColor = textColor,
textSize = 16f,
hint = "Type message...",
hintColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93),
modifier = Modifier.fillMaxWidth(),
requestFocus = hasReply,
onViewCreated = { view -> editTextView = view },
onFocusChanged = { hasFocus ->
if (hasFocus && showEmojiPicker) {
onToggleEmojiPicker(false)
}
},
onSelectionChanged = { start, end ->
selectionStart = start
selectionEnd = end
if (isVoiceRecording) {
val recordingPanelColor =
if (isDarkTheme) Color(0xFF1A2A3A) else Color(0xFFE8F2FD)
val recordingTextColor =
if (isDarkTheme) Color.White.copy(alpha = 0.92f) else Color(0xFF1E2A37)
val voiceLevel = remember(voiceWaves) { voiceWaves.lastOrNull() ?: 0f }
var recordUiEntered by remember { mutableStateOf(false) }
LaunchedEffect(isVoiceRecording) {
if (isVoiceRecording) {
recordUiEntered = false
delay(16)
recordUiEntered = true
} else {
recordUiEntered = false
}
}
val recordUiAlpha by animateFloatAsState(
targetValue = if (recordUiEntered) 1f else 0f,
animationSpec = tween(durationMillis = 180, easing = LinearOutSlowInEasing),
label = "record_ui_alpha"
)
val recordUiShift by animateDpAsState(
targetValue = if (recordUiEntered) 0.dp else 20.dp,
animationSpec = tween(durationMillis = 180, easing = FastOutLinearInEasing),
label = "record_ui_shift"
)
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.padding(horizontal = 12.dp, vertical = 8.dp),
contentAlignment = Alignment.CenterEnd
) {
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 40.dp)
.clip(RoundedCornerShape(20.dp))
.background(recordingPanelColor)
.padding(start = 13.dp, end = 94.dp) // record panel paddings
) {
Row(
modifier = Modifier
.align(Alignment.CenterStart)
.graphicsLayer {
alpha = recordUiAlpha
translationX = with(density) { recordUiShift.toPx() }
},
verticalAlignment = Alignment.CenterVertically
) {
RecordBlinkDot(isDarkTheme = isDarkTheme)
Spacer(modifier = Modifier.width(6.dp)) // TimerView margin from RecordDot
Text(
text = formatVoiceRecordTimer(voiceElapsedMs),
color = recordingTextColor,
fontSize = 15.sp,
fontWeight = FontWeight.Bold
)
}
Text(
text = "CANCEL",
color = PrimaryBlue,
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.align(Alignment.Center)
.graphicsLayer {
alpha = recordUiAlpha
translationX = with(density) { recordUiShift.toPx() }
}
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { stopVoiceRecording(send = false) }
)
}
Box(
modifier = Modifier
.requiredSize(104.dp) // do not affect input row height
.offset(x = 8.dp),
contentAlignment = Alignment.Center
) {
VoiceButtonBlob(
voiceLevel = voiceLevel,
isDarkTheme = isDarkTheme,
modifier = Modifier.fillMaxSize()
)
Box(
modifier = Modifier
.size(82.dp) // Telegram RecordCircle radius 41dp
.shadow(
elevation = 10.dp,
shape = CircleShape,
clip = false
)
.clip(CircleShape)
.background(PrimaryBlue)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { stopVoiceRecording(send = true) },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = TelegramSendIcon,
contentDescription = "Send voice message",
tint = Color.White,
modifier = Modifier.size(30.dp)
)
}
}
}
} else {
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.Bottom
) {
IconButton(
onClick = onAttachClick,
modifier = Modifier.size(40.dp)
) {
Icon(
painter = TelegramIcons.Attach,
contentDescription = "Attach",
tint = if (isDarkTheme) Color(0xFF8E8E93).copy(alpha = 0.6f)
else Color(0xFF8E8E93).copy(alpha = 0.6f),
modifier = Modifier.size(24.dp)
)
}
Spacer(modifier = Modifier.width(4.dp))
Box(
modifier = Modifier
.weight(1f)
.heightIn(min = 40.dp, max = 150.dp)
.background(color = backgroundColor)
.padding(horizontal = 12.dp, vertical = 8.dp),
contentAlignment = Alignment.TopStart
) {
AppleEmojiTextField(
value = value,
onValueChange = { newValue -> onValueChange(newValue) },
textColor = textColor,
textSize = 16f,
hint = "Type message...",
hintColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93),
modifier = Modifier.fillMaxWidth(),
requestFocus = hasReply,
onViewCreated = { view -> editTextView = view },
onFocusChanged = { hasFocus ->
if (hasFocus && showEmojiPicker) {
onToggleEmojiPicker(false)
}
},
onSelectionChanged = { start, end ->
selectionStart = start
selectionEnd = end
}
)
}
Spacer(modifier = Modifier.width(6.dp))
IconButton(
onClick = { toggleEmojiPicker() },
modifier = Modifier.size(40.dp)
) {
Icon(
painter = if (showEmojiPicker) TelegramIcons.Keyboard
else TelegramIcons.Smile,
contentDescription = "Emoji",
tint = if (isDarkTheme) Color(0xFF8E8E93).copy(alpha = 0.6f)
else Color(0xFF8E8E93).copy(alpha = 0.6f),
modifier = Modifier.size(24.dp)
)
}
Spacer(modifier = Modifier.width(2.dp))
AnimatedVisibility(
visible = canSend || isSending,
enter = scaleIn(tween(150)) + fadeIn(tween(150)),
exit = scaleOut(tween(100)) + fadeOut(tween(100))
) {
IconButton(
onClick = { handleSend() },
modifier = Modifier.size(40.dp)
) {
Icon(
imageVector = TelegramSendIcon,
contentDescription = "Send",
tint = PrimaryBlue,
modifier = Modifier.size(24.dp)
)
}
}
AnimatedVisibility(
visible = !canSend && !isSending,
enter = scaleIn(tween(140)) + fadeIn(tween(140)),
exit = scaleOut(tween(100)) + fadeOut(tween(100))
) {
IconButton(
onClick = { requestVoiceRecording() },
modifier = Modifier.size(40.dp)
) {
Icon(
imageVector = Icons.Default.Mic,
contentDescription = "Record voice message",
tint = PrimaryBlue,
modifier = Modifier.size(24.dp)
)
}
}
)
}
Spacer(modifier = Modifier.width(6.dp))
IconButton(
onClick = { toggleEmojiPicker() },
modifier = Modifier.size(40.dp)
) {
Icon(
painter = if (showEmojiPicker) TelegramIcons.Keyboard
else TelegramIcons.Smile,
contentDescription = "Emoji",
tint = if (isDarkTheme) Color(0xFF8E8E93).copy(alpha = 0.6f)
else Color(0xFF8E8E93).copy(alpha = 0.6f),
modifier = Modifier.size(24.dp)
)
}
Spacer(modifier = Modifier.width(2.dp))
AnimatedVisibility(
visible = canSend || isSending,
enter = scaleIn(tween(150)) + fadeIn(tween(150)),
exit = scaleOut(tween(100)) + fadeOut(tween(100))
) {
IconButton(
onClick = { handleSend() },
modifier = Modifier.size(40.dp)
) {
Icon(
imageVector = TelegramSendIcon,
contentDescription = "Send",
tint = PrimaryBlue,
modifier = Modifier.size(24.dp)
)
}
}
}
}
}