Доработал голосовые сообщения и Telegram-подобный UI ввода
This commit is contained in:
@@ -9,6 +9,7 @@ enum class AttachmentType(val value: Int) {
|
||||
FILE(2), // Файл
|
||||
AVATAR(3), // Аватар пользователя
|
||||
CALL(4), // Событие звонка (пропущен/принят/завершен)
|
||||
VOICE(5), // Голосовое сообщение
|
||||
UNKNOWN(-1); // Неизвестный тип
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 - отправляет текущий аватар как вложение
|
||||
*/
|
||||
|
||||
@@ -4549,6 +4549,8 @@ fun DialogItemContent(
|
||||
"Avatar" -> "Avatar"
|
||||
dialog.lastMessageAttachmentType ==
|
||||
"Call" -> "Call"
|
||||
dialog.lastMessageAttachmentType ==
|
||||
"Voice message" -> "Voice message"
|
||||
dialog.lastMessageAttachmentType ==
|
||||
"Forwarded" ->
|
||||
"Forwarded message"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -979,6 +979,7 @@ fun MessageBubble(
|
||||
MessageAttachments(
|
||||
attachments = message.attachments,
|
||||
chachaKey = message.chachaKey,
|
||||
chachaKeyPlainHex = message.chachaKeyPlainHex,
|
||||
privateKey = privateKey,
|
||||
isOutgoing = message.isOutgoing,
|
||||
isDarkTheme = isDarkTheme,
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user