android: Audio messages (#1070)

* Audio messages testing

* Without Vorbis

* Naming

* Voice message auto-receive, voice message composing

* Experiments with audio

* More recording features

* Unused code

* Merge master

* UI

* Stability

* Size limitation

* Tap and hold && tap and wait and click logics

* Deleted unused lib

* Voice type

* Refactoring

* Refactoring

* Adapting to the latest changes

* Mini player in preview

* Different UI for some elements

* send msg view style

* *** in translation

* Animation

* Fixes animation performance

* Smaller font for recording time

* File names

* Renaming

* No edit possible for audio messages

* Prevent adding text to edittext

* Bubble layout

* Layout

* Refactor

* Paddings

* No crash, please

* Draw progress as a ring

* Padding

* Faster status updates while listening voice

* Faster status updates while listening voice

* Quote

* backend comment

* Align

* Stability

* Review

* Strings

* Just better

* Sync of recorder and players

* Replaced Icon's with ImageButton's

* Icons size

* Error processing

* Update apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt

* rename composable

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
This commit is contained in:
Stanislav Dmitrenko 2022-11-18 20:02:24 +03:00 committed by GitHub
parent 0cb8f8ad82
commit a4be68f4bd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 990 additions and 120 deletions

View File

@ -357,6 +357,13 @@ fun MainPage(
.collect { .collect {
if (it != null) currentChatId = it if (it != null) currentChatId = it
else onComposed() else onComposed()
// Deletes files that were not sent but already stored in files directory.
// Currently, it's voice records only
if (it == null && chatModel.filesToDelete.isNotEmpty()) {
chatModel.filesToDelete.forEach { it.delete() }
chatModel.filesToDelete.clear()
}
} }
} }
launch { launch {

View File

@ -20,7 +20,11 @@ import kotlinx.serialization.descriptors.*
import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.* import kotlinx.serialization.json.*
import java.io.File
/*
* Without this annotation an animation from ChatList to ChatView has 1 frame per the whole animation. Don't delete it
* */
@Stable @Stable
class ChatModel(val controller: ChatController) { class ChatModel(val controller: ChatController) {
val onboardingStage = mutableStateOf<OnboardingStage?>(null) val onboardingStage = mutableStateOf<OnboardingStage?>(null)
@ -71,6 +75,8 @@ class ChatModel(val controller: ChatController) {
// working with external intents // working with external intents
val sharedContent = mutableStateOf(null as SharedContent?) val sharedContent = mutableStateOf(null as SharedContent?)
val filesToDelete = mutableSetOf<File>()
fun updateUserProfile(profile: LocalProfile) { fun updateUserProfile(profile: LocalProfile) {
val user = currentUser.value val user = currentUser.value
if (user != null) { if (user != null) {
@ -219,6 +225,7 @@ class ChatModel(val controller: ChatController) {
if (chatId.value == cInfo.id) { if (chatId.value == cInfo.id) {
val itemIndex = chatItems.indexOfFirst { it.id == cItem.id } val itemIndex = chatItems.indexOfFirst { it.id == cItem.id }
if (itemIndex >= 0) { if (itemIndex >= 0) {
AudioPlayer.stop(chatItems[itemIndex])
chatItems.removeAt(itemIndex) chatItems.removeAt(itemIndex)
} }
} }
@ -1030,6 +1037,9 @@ data class ChatItem (
val text: String get() = val text: String get() =
when { when {
content.text == "" && file != null && content.msgContent is MsgContent.MCVoice -> {
(content.msgContent as MsgContent.MCVoice).toTextWithDuration(false)
}
content.text == "" && file != null -> file.fileName content.text == "" && file != null -> file.fileName
else -> content.text else -> content.text
} }
@ -1302,6 +1312,8 @@ class CIFile(
CIFileStatus.RcvComplete -> true CIFileStatus.RcvComplete -> true
} }
val audioInfo: MutableState<ProgressAndDuration> by lazy { mutableStateOf(ProgressAndDuration()) }
companion object { companion object {
fun getSample( fun getSample(
fileId: Long = 1, fileId: Long = 1,
@ -1335,6 +1347,7 @@ sealed class MsgContent {
@Serializable(with = MsgContentSerializer::class) class MCText(override val text: String): MsgContent() @Serializable(with = MsgContentSerializer::class) class MCText(override val text: String): MsgContent()
@Serializable(with = MsgContentSerializer::class) class MCLink(override val text: String, val preview: LinkPreview): MsgContent() @Serializable(with = MsgContentSerializer::class) class MCLink(override val text: String, val preview: LinkPreview): MsgContent()
@Serializable(with = MsgContentSerializer::class) class MCImage(override val text: String, val image: String): MsgContent() @Serializable(with = MsgContentSerializer::class) class MCImage(override val text: String, val image: String): MsgContent()
@Serializable(with = MsgContentSerializer::class) class MCVoice(override val text: String, val duration: Int): MsgContent()
@Serializable(with = MsgContentSerializer::class) class MCFile(override val text: String): MsgContent() @Serializable(with = MsgContentSerializer::class) class MCFile(override val text: String): MsgContent()
@Serializable(with = MsgContentSerializer::class) class MCUnknown(val type: String? = null, override val text: String, val json: JsonElement): MsgContent() @Serializable(with = MsgContentSerializer::class) class MCUnknown(val type: String? = null, override val text: String, val json: JsonElement): MsgContent()
@ -1342,11 +1355,17 @@ sealed class MsgContent {
is MCText -> "text $text" is MCText -> "text $text"
is MCLink -> "json ${json.encodeToString(this)}" is MCLink -> "json ${json.encodeToString(this)}"
is MCImage -> "json ${json.encodeToString(this)}" is MCImage -> "json ${json.encodeToString(this)}"
is MCVoice-> "json ${json.encodeToString(this)}"
is MCFile -> "json ${json.encodeToString(this)}" is MCFile -> "json ${json.encodeToString(this)}"
is MCUnknown -> "json $json" is MCUnknown -> "json $json"
} }
} }
fun MsgContent.MCVoice.toTextWithDuration(short: Boolean): String {
val time = String.format("%02d:%02d", duration / 60, duration % 60)
return if (short) time else generalGetString(R.string.voice_message) + " ($time)"
}
@Serializable @Serializable
class CIGroupInvitation ( class CIGroupInvitation (
val groupId: Long, val groupId: Long,
@ -1415,6 +1434,10 @@ object MsgContentSerializer : KSerializer<MsgContent> {
val image = json["image"]?.jsonPrimitive?.content ?: "unknown message format" val image = json["image"]?.jsonPrimitive?.content ?: "unknown message format"
MsgContent.MCImage(text, image) MsgContent.MCImage(text, image)
} }
"voice" -> {
val duration = json["duration"]?.jsonPrimitive?.intOrNull ?: 0
MsgContent.MCVoice(text, duration)
}
"file" -> MsgContent.MCFile(text) "file" -> MsgContent.MCFile(text)
else -> MsgContent.MCUnknown(t, text, json) else -> MsgContent.MCUnknown(t, text, json)
} }
@ -1446,6 +1469,12 @@ object MsgContentSerializer : KSerializer<MsgContent> {
put("text", value.text) put("text", value.text)
put("image", value.image) put("image", value.image)
} }
is MsgContent.MCVoice ->
buildJsonObject {
put("type", "voice")
put("text", value.text)
put("duration", value.duration)
}
is MsgContent.MCFile -> is MsgContent.MCFile ->
buildJsonObject { buildJsonObject {
put("type", "file") put("type", "file")

View File

@ -212,7 +212,7 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
if (cItem.content.text != "") { if (cItem.content.text != "") {
cItem.content.text cItem.content.text
} else { } else {
cItem.file?.fileName ?: "" if (cItem.content.msgContent is MsgContent.MCVoice) generalGetString(R.string.voice_message) else cItem.file?.fileName ?: ""
} }
} else { } else {
var res = "" var res = ""

View File

@ -1014,6 +1014,8 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
val file = cItem.file val file = cItem.file
if (cItem.content.msgContent is MsgContent.MCImage && file != null && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV && appPrefs.privacyAcceptImages.get()) { if (cItem.content.msgContent is MsgContent.MCImage && file != null && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV && appPrefs.privacyAcceptImages.get()) {
withApi { receiveFile(file.fileId) } withApi { receiveFile(file.fileId) }
} else if (cItem.content.msgContent is MsgContent.MCVoice && file != null && file.fileSize <= MAX_VOICE_SIZE_AUTO_RCV && appPrefs.privacyAcceptImages.get()) {
withApi { receiveFile(file.fileId) }
} }
if (!cItem.chatDir.sent && !cItem.isCall && !cItem.isMutedMemberEvent && (!isAppOnForeground(appContext) || chatModel.chatId.value != cInfo.id)) { if (!cItem.chatDir.sent && !cItem.isCall && !cItem.isMutedMemberEvent && (!isAppOnForeground(appContext) || chatModel.chatId.value != cInfo.id)) {
ntfManager.notifyMessageReceived(cInfo, cItem) ntfManager.notifyMessageReceived(cInfo, cItem)
@ -1039,6 +1041,7 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a
chatModel.removeChatItem(cInfo, cItem) chatModel.removeChatItem(cInfo, cItem)
} else { } else {
// currently only broadcast deletion of rcv message can be received, and only this case should happen // currently only broadcast deletion of rcv message can be received, and only this case should happen
AudioPlayer.stop(cItem)
chatModel.upsertChatItem(cInfo, cItem) chatModel.upsertChatItem(cInfo, cItem)
} }
} }

View File

@ -135,7 +135,7 @@ fun TerminalLayout(
topBar = { CloseSheetBar(close) }, topBar = { CloseSheetBar(close) },
bottomBar = { bottomBar = {
Box(Modifier.padding(horizontal = 8.dp)) { Box(Modifier.padding(horizontal = 8.dp)) {
SendMsgView(composeState, sendCommand, ::onMessageChange, textStyle) SendMsgView(composeState, false, sendCommand, ::onMessageChange, { _, _, _ -> }, textStyle)
} }
}, },
modifier = Modifier.navigationBarsWithImePadding() modifier = Modifier.navigationBarsWithImePadding()

View File

@ -94,7 +94,6 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
chatModel.chatId.value = null chatModel.chatId.value = null
} else { } else {
val chat = activeChat.value!! val chat = activeChat.value!!
BackHandler { chatModel.chatId.value = null }
// We need to have real unreadCount value for displaying it inside top right button // We need to have real unreadCount value for displaying it inside top right button
// Having activeChat reloaded on every change in it is inefficient (UI lags) // Having activeChat reloaded on every change in it is inefficient (UI lags)
val unreadCount = remember { val unreadCount = remember {
@ -123,6 +122,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
chatModelIncognito = chatModel.incognito.value, chatModelIncognito = chatModel.incognito.value,
back = { back = {
hideKeyboard(view) hideKeyboard(view)
AudioPlayer.stop()
chatModel.chatId.value = null chatModel.chatId.value = null
}, },
info = { info = {

View File

@ -1,5 +1,6 @@
package chat.simplex.app.views.chat package chat.simplex.app.views.chat
import ComposeVoiceView
import ComposeFileView import ComposeFileView
import android.Manifest import android.Manifest
import android.content.* import android.content.*
@ -14,11 +15,9 @@ import android.util.Log
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContract import androidx.activity.result.contract.ActivityResultContract
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon import androidx.compose.material.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AttachFile import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
@ -29,26 +28,30 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.net.toFile
import androidx.core.net.toUri
import chat.simplex.app.* import chat.simplex.app.*
import chat.simplex.app.R import chat.simplex.app.R
import chat.simplex.app.model.* import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.views.chat.item.* import chat.simplex.app.views.chat.item.*
import chat.simplex.app.views.helpers.* import chat.simplex.app.views.helpers.*
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import java.io.File
@Serializable @Serializable
sealed class ComposePreview { sealed class ComposePreview {
@Serializable object NoPreview: ComposePreview() @Serializable object NoPreview: ComposePreview()
@Serializable class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview() @Serializable class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview()
@Serializable class ImagePreview(val images: List<String>): ComposePreview() @Serializable class ImagePreview(val images: List<String>): ComposePreview()
@Serializable class VoicePreview(val voice: String, val durationMs: Int, val finished: Boolean): ComposePreview()
@Serializable class FilePreview(val fileName: String): ComposePreview() @Serializable class FilePreview(val fileName: String): ComposePreview()
} }
@ -84,6 +87,7 @@ data class ComposeState(
get() = { get() = {
val hasContent = when (preview) { val hasContent = when (preview) {
is ComposePreview.ImagePreview -> true is ComposePreview.ImagePreview -> true
is ComposePreview.VoicePreview -> true
is ComposePreview.FilePreview -> true is ComposePreview.FilePreview -> true
else -> message.isNotEmpty() else -> message.isNotEmpty()
} }
@ -93,6 +97,7 @@ data class ComposeState(
get() = get() =
when (preview) { when (preview) {
is ComposePreview.ImagePreview -> false is ComposePreview.ImagePreview -> false
is ComposePreview.VoicePreview -> false
is ComposePreview.FilePreview -> false is ComposePreview.FilePreview -> false
else -> useLinkPreviews else -> useLinkPreviews
} }
@ -118,11 +123,12 @@ fun chatItemPreview(chatItem: ChatItem): ComposePreview {
is MsgContent.MCText -> ComposePreview.NoPreview is MsgContent.MCText -> ComposePreview.NoPreview
is MsgContent.MCLink -> ComposePreview.CLinkPreview(linkPreview = mc.preview) is MsgContent.MCLink -> ComposePreview.CLinkPreview(linkPreview = mc.preview)
is MsgContent.MCImage -> ComposePreview.ImagePreview(images = listOf(mc.image)) is MsgContent.MCImage -> ComposePreview.ImagePreview(images = listOf(mc.image))
is MsgContent.MCVoice -> ComposePreview.VoicePreview(voice = chatItem.file?.fileName ?: "", mc.duration / 1000, true)
is MsgContent.MCFile -> { is MsgContent.MCFile -> {
val fileName = chatItem.file?.fileName ?: "" val fileName = chatItem.file?.fileName ?: ""
ComposePreview.FilePreview(fileName) ComposePreview.FilePreview(fileName)
} }
else -> ComposePreview.NoPreview is MsgContent.MCUnknown, null -> ComposePreview.NoPreview
} }
} }
@ -144,6 +150,11 @@ fun ComposeView(
val textStyle = remember { mutableStateOf(smallFont) } val textStyle = remember { mutableStateOf(smallFont) }
// attachments // attachments
val chosenContent = rememberSaveable { mutableStateOf<List<UploadContent>>(emptyList()) } val chosenContent = rememberSaveable { mutableStateOf<List<UploadContent>>(emptyList()) }
val audioSaver = Saver<MutableState<Pair<Uri, Int>?>, Pair<String, Int>> (
save = { it.value.let { if (it == null) null else it.first.toString() to it.second } },
restore = { mutableStateOf(Uri.parse(it.first) to it.second) }
)
val chosenAudio = rememberSaveable(saver = audioSaver) { mutableStateOf(null) }
val chosenFile = rememberSaveable { mutableStateOf<Uri?>(null) } val chosenFile = rememberSaveable { mutableStateOf<Uri?>(null) }
val cameraLauncher = rememberCameraLauncher { uri: Uri? -> val cameraLauncher = rememberCameraLauncher { uri: Uri? ->
if (uri != null) { if (uri != null) {
@ -321,6 +332,7 @@ fun ComposeView(
is MsgContent.MCText -> checkLinkPreview() is MsgContent.MCText -> checkLinkPreview()
is MsgContent.MCLink -> checkLinkPreview() is MsgContent.MCLink -> checkLinkPreview()
is MsgContent.MCImage -> MsgContent.MCImage(cs.message, image = msgContent.image) is MsgContent.MCImage -> MsgContent.MCImage(cs.message, image = msgContent.image)
is MsgContent.MCVoice -> MsgContent.MCVoice(cs.message, duration = msgContent.duration)
is MsgContent.MCFile -> MsgContent.MCFile(cs.message) is MsgContent.MCFile -> MsgContent.MCFile(cs.message)
is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = cs.message, json = msgContent.json) is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = cs.message, json = msgContent.json)
} }
@ -330,6 +342,7 @@ fun ComposeView(
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews) composeState.value = ComposeState(useLinkPreviews = useLinkPreviews)
textStyle.value = smallFont textStyle.value = smallFont
chosenContent.value = emptyList() chosenContent.value = emptyList()
chosenAudio.value = null
chosenFile.value = null chosenFile.value = null
linkUrl.value = null linkUrl.value = null
prevLinkUrl.value = null prevLinkUrl.value = null
@ -376,6 +389,15 @@ fun ComposeView(
} }
} }
} }
is ComposePreview.VoicePreview -> {
val chosenAudioVal = chosenAudio.value
if (chosenAudioVal != null) {
val file = chosenAudioVal.first.toFile().name
files.add((file))
chatModel.filesToDelete.remove(chosenAudioVal.first.toFile())
msgs.add(MsgContent.MCVoice(if (msgs.isEmpty()) cs.message else "", chosenAudioVal.second / 1000))
}
}
is ComposePreview.FilePreview -> { is ComposePreview.FilePreview -> {
val chosenFileVal = chosenFile.value val chosenFileVal = chosenFile.value
if (chosenFileVal != null) { if (chosenFileVal != null) {
@ -426,6 +448,13 @@ fun ComposeView(
} }
} }
fun onAudioAdded(filePath: String, durationMs: Int, finished: Boolean) {
val file = File(filePath)
chosenAudio.value = file.toUri() to durationMs
chatModel.filesToDelete.add(file)
composeState.value = composeState.value.copy(preview = ComposePreview.VoicePreview(filePath, durationMs, finished))
}
fun cancelLinkPreview() { fun cancelLinkPreview() {
val uri = composeState.value.linkPreview?.uri val uri = composeState.value.linkPreview?.uri
if (uri != null) { if (uri != null) {
@ -440,6 +469,11 @@ fun ComposeView(
chosenContent.value = emptyList() chosenContent.value = emptyList()
} }
fun cancelVoice() {
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
chosenContent.value = emptyList()
}
fun cancelFile() { fun cancelFile() {
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview) composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
chosenFile.value = null chosenFile.value = null
@ -455,6 +489,13 @@ fun ComposeView(
::cancelImages, ::cancelImages,
cancelEnabled = !composeState.value.editing cancelEnabled = !composeState.value.editing
) )
is ComposePreview.VoicePreview -> ComposeVoiceView(
preview.voice,
preview.durationMs,
preview.finished,
cancelEnabled = !composeState.value.editing,
::cancelVoice
)
is ComposePreview.FilePreview -> ComposeFileView( is ComposePreview.FilePreview -> ComposeFileView(
preview.fileName, preview.fileName,
::cancelFile, ::cancelFile,
@ -489,37 +530,34 @@ fun ComposeView(
Column { Column {
contextItemView() contextItemView()
when { when {
composeState.value.editing && composeState.value.preview is ComposePreview.VoicePreview -> {}
composeState.value.editing && composeState.value.preview is ComposePreview.FilePreview -> {} composeState.value.editing && composeState.value.preview is ComposePreview.FilePreview -> {}
else -> previewView() else -> previewView()
} }
Row( Row(
modifier = Modifier.padding(start = 4.dp, end = 8.dp), modifier = Modifier.padding(end = 8.dp),
verticalAlignment = Alignment.Bottom, verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(2.dp)
) { ) {
val attachEnabled = !composeState.value.editing val attachEnabled = !composeState.value.editing && composeState.value.preview !is ComposePreview.VoicePreview
Box(Modifier.padding(bottom = 12.dp)) { IconButton(showChooseAttachment, enabled = attachEnabled) {
Icon( Icon(
Icons.Filled.AttachFile, Icons.Filled.AttachFile,
contentDescription = stringResource(R.string.attach), contentDescription = stringResource(R.string.attach),
tint = if (attachEnabled) MaterialTheme.colors.primary else Color.Gray, tint = if (attachEnabled) MaterialTheme.colors.primary else HighOrLowlight,
modifier = Modifier modifier = Modifier
.size(28.dp) .size(28.dp)
.clip(CircleShape) .clip(CircleShape)
.clickable {
if (attachEnabled) {
showChooseAttachment()
}
}
) )
} }
SendMsgView( SendMsgView(
composeState, composeState,
allowVoiceRecord = true,
sendMessage = { sendMessage = {
sendMessage() sendMessage()
resetLinkPreview() resetLinkPreview()
}, },
::onMessageChange, ::onMessageChange,
::onAudioAdded,
textStyle textStyle
) )
} }

View File

@ -0,0 +1,124 @@
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.Close
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.chat.item.AudioInfoUpdater
import chat.simplex.app.views.chat.item.SentColorLight
import chat.simplex.app.views.helpers.*
import kotlinx.coroutines.flow.distinctUntilChanged
@Composable
fun ComposeVoiceView(filePath: String, durationMs: Int, finished: Boolean, cancelEnabled: Boolean, cancelVoice: () -> Unit) {
BoxWithConstraints(Modifier
.fillMaxWidth()
) {
val audioPlaying = rememberSaveable { mutableStateOf(false) }
val audioInfo = rememberSaveable(saver = ProgressAndDuration.Saver) {
mutableStateOf(ProgressAndDuration(durationMs = durationMs))
}
LaunchedEffect(durationMs) {
audioInfo.value = audioInfo.value.copy(durationMs = durationMs)
}
val progressBarWidth = remember { Animatable(0f) }
LaunchedEffect(durationMs, finished) {
snapshotFlow { audioInfo.value }
.distinctUntilChanged()
.collect {
val number = if (audioPlaying.value) audioInfo.value.progressMs else if (!finished) durationMs else 0
val new = if (audioPlaying.value || finished)
((number.toDouble() / durationMs) * maxWidth.value).dp
else
(((number.toDouble()) / MAX_VOICE_MILLIS_FOR_SENDING) * maxWidth.value).dp
progressBarWidth.animateTo(new.value, audioProgressBarAnimationSpec())
}
}
Spacer(
Modifier
.requiredWidth(progressBarWidth.value.dp)
.padding(top = 58.dp)
.height(2.dp)
.background(MaterialTheme.colors.primary)
)
Row(
Modifier
.height(60.dp)
.fillMaxWidth()
.padding(top = 8.dp)
.background(SentColorLight),
verticalAlignment = Alignment.CenterVertically
) {
val play = play@{
audioPlaying.value = AudioPlayer.start(filePath, audioInfo.value.progressMs) {
audioPlaying.value = false
}
}
val pause = {
audioInfo.value = ProgressAndDuration(AudioPlayer.pause(), audioInfo.value.durationMs)
audioPlaying.value = false
}
AudioInfoUpdater(filePath, audioPlaying, audioInfo)
IconButton({ if (!audioPlaying.value) play() else pause() }, enabled = finished) {
Icon(
if (audioPlaying.value) Icons.Filled.Pause else Icons.Filled.PlayArrow,
stringResource(R.string.icon_descr_file),
Modifier
.padding(start = 4.dp, end = 2.dp)
.size(36.dp),
tint = if (finished) MaterialTheme.colors.primary else HighOrLowlight
)
}
val numberInText = remember(durationMs, audioInfo.value) {
derivedStateOf { if (audioPlaying.value) audioInfo.value.progressMs / 1000 else durationMs / 1000 }
}
val text = "%02d:%02d".format(numberInText.value / 60, numberInText.value % 60)
Text(
text,
fontSize = 18.sp,
color = HighOrLowlight,
)
Spacer(Modifier.weight(1f))
if (cancelEnabled) {
IconButton(
onClick = {
AudioPlayer.stop(filePath)
cancelVoice()
},
modifier = Modifier.padding(0.dp)
) {
Icon(
Icons.Outlined.Close,
contentDescription = stringResource(R.string.icon_descr_cancel_file_preview),
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp)
)
}
}
}
}
}
@Preview
@Composable
fun PreviewComposeAudioView() {
SimpleXTheme {
ComposeFileView(
"test.txt",
cancelFile = {},
cancelEnabled = true
)
}
}

View File

@ -1,7 +1,10 @@
package chat.simplex.app.views.chat package chat.simplex.app.views.chat
import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context import android.content.Context
import android.content.pm.ActivityInfo
import android.content.res.Configuration import android.content.res.Configuration
import android.text.InputType import android.text.InputType
import android.view.ViewGroup import android.view.ViewGroup
@ -12,38 +15,198 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.ArrowUpward import androidx.compose.material.icons.outlined.*
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.* import androidx.compose.ui.*
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.*
import androidx.compose.ui.platform.* import androidx.compose.ui.platform.*
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.graphics.drawable.DrawableCompat import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.view.inputmethod.EditorInfoCompat import androidx.core.view.inputmethod.EditorInfoCompat
import androidx.core.view.inputmethod.InputConnectionCompat import androidx.core.view.inputmethod.InputConnectionCompat
import androidx.core.widget.doOnTextChanged import androidx.core.widget.*
import chat.simplex.app.R import chat.simplex.app.R
import chat.simplex.app.SimplexApp import chat.simplex.app.SimplexApp
import chat.simplex.app.model.ChatItem import chat.simplex.app.model.ChatItem
import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.SharedContent import chat.simplex.app.views.helpers.*
import kotlinx.coroutines.delay import com.google.accompanist.permissions.rememberMultiplePermissionsState
import kotlinx.coroutines.*
import java.io.*
@Composable @Composable
fun SendMsgView( fun SendMsgView(
composeState: MutableState<ComposeState>, composeState: MutableState<ComposeState>,
allowVoiceRecord: Boolean,
sendMessage: () -> Unit, sendMessage: () -> Unit,
onMessageChange: (String) -> Unit, onMessageChange: (String) -> Unit,
onAudioAdded: (String, Int, Boolean) -> Unit,
textStyle: MutableState<TextStyle> textStyle: MutableState<TextStyle>
) {
Column(Modifier.padding(vertical = 8.dp)) {
Box {
val cs = composeState.value
val attachEnabled = !composeState.value.editing
val filePath = rememberSaveable { mutableStateOf(null as String?) }
var recordingTimeRange by rememberSaveable(saver = LongRange.saver) { mutableStateOf(0L..0L) } // since..to
val showVoiceButton = ((cs.message.isEmpty() || recordingTimeRange.first > 0L) && allowVoiceRecord && attachEnabled && cs.preview is ComposePreview.NoPreview) || filePath.value != null
Box(if (recordingTimeRange.first == 0L)
Modifier
else
Modifier.clickable(false, onClick = {})
) {
NativeKeyboard(composeState, textStyle, onMessageChange)
}
Box(Modifier.align(Alignment.BottomEnd)) {
val icon = if (cs.editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward
val color = if (cs.sendEnabled()) MaterialTheme.colors.primary else HighOrLowlight
if (cs.inProgress && (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.VoicePreview || cs.preview is ComposePreview.FilePreview)) {
CircularProgressIndicator(Modifier.size(36.dp).padding(4.dp), color = HighOrLowlight, strokeWidth = 3.dp)
} else if (!showVoiceButton) {
IconButton(sendMessage, Modifier.size(36.dp), enabled = cs.sendEnabled()) {
Icon(
icon,
stringResource(R.string.icon_descr_send_message),
tint = Color.White,
modifier = Modifier
.size(36.dp)
.padding(4.dp)
.clip(CircleShape)
.background(color)
)
}
} else {
val permissionsState = rememberMultiplePermissionsState(
permissions = listOf(
Manifest.permission.RECORD_AUDIO,
)
)
val rec: Recorder = remember { RecorderNative(MAX_VOICE_SIZE_FOR_SENDING) }
val recordingInProgress: State<Boolean> = remember { rec.recordingInProgress }
var now by remember { mutableStateOf(System.currentTimeMillis()) }
LaunchedEffect(Unit) {
while (isActive) {
now = System.currentTimeMillis()
if (recordingTimeRange.first != 0L && recordingInProgress.value && composeState.value.preview is ComposePreview.VoicePreview) {
filePath.value?.let { onAudioAdded(it, (now - recordingTimeRange.first).toInt(), false) }
}
delay(100)
}
}
val stopRecordingAndAddAudio: () -> Unit = {
rec.stop()
recordingTimeRange = recordingTimeRange.first..System.currentTimeMillis()
filePath.value?.let { onAudioAdded(it, (recordingTimeRange.last - recordingTimeRange.first).toInt(), true) }
}
val startStopRecording: () -> Unit = {
when {
!permissionsState.allPermissionsGranted -> permissionsState.launchMultiplePermissionRequest()
recordingInProgress.value -> stopRecordingAndAddAudio()
filePath.value == null -> {
recordingTimeRange = System.currentTimeMillis()..0L
filePath.value = rec.start(stopRecordingAndAddAudio)
filePath.value?.let { onAudioAdded(it, (now - recordingTimeRange.first).toInt(), false) }
}
}
}
var stopRecOnNextClick by remember { mutableStateOf(false) }
val context = LocalContext.current
DisposableEffect(stopRecOnNextClick) {
val activity = context as? Activity ?: return@DisposableEffect onDispose {}
if (stopRecOnNextClick) {
// Lock orientation to current orientation because screen rotation will break the recording
activity.requestedOrientation = if (activity.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT)
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
else
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
}
// Unlock orientation
onDispose { activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED }
}
val cleanUp = { remove: Boolean ->
rec.stop()
if (remove) filePath.value?.let { File(it).delete() }
filePath.value = null
stopRecOnNextClick = false
recordingTimeRange = 0L..0L
}
LaunchedEffect(cs.preview) {
if (cs.preview !is ComposePreview.VoicePreview && filePath.value != null) {
// Pressed on X icon in preview
cleanUp(true)
}
}
val interactionSource = interactionSourceWithTapDetection(
onPress = {
if (filePath.value == null) startStopRecording()
},
onClick = {
if (!recordingInProgress.value && filePath.value != null) {
sendMessage()
cleanUp(false)
} else if (stopRecOnNextClick) {
stopRecordingAndAddAudio()
stopRecOnNextClick = false
} else {
// tapped and didn't hold a finger
stopRecOnNextClick = true
}
},
onCancel = startStopRecording,
onRelease = startStopRecording
)
val sendButtonModifier = if (recordingTimeRange.last != 0L)
Modifier.clip(CircleShape).background(color)
else
Modifier
IconButton({}, Modifier.size(36.dp), enabled = !cs.inProgress, interactionSource = interactionSource) {
Icon(
if (recordingTimeRange.last != 0L) Icons.Outlined.ArrowUpward else if (stopRecOnNextClick) Icons.Default.Stop else Icons.Default.Mic,
stringResource(R.string.icon_descr_record_voice_message),
tint = if (recordingTimeRange.last != 0L) Color.White else if (!cs.inProgress) MaterialTheme.colors.primary else HighOrLowlight,
modifier = Modifier
.size(36.dp)
.padding(4.dp)
.then(sendButtonModifier)
)
}
DisposableEffect(Unit) {
onDispose {
rec.stop()
}
}
}
}
}
}
}
@Composable
private fun NativeKeyboard(
composeState: MutableState<ComposeState>,
textStyle: MutableState<TextStyle>,
onMessageChange: (String) -> Unit
) { ) {
val cs = composeState.value val cs = composeState.value
val textColor = MaterialTheme.colors.onBackground
val tintColor = MaterialTheme.colors.secondary
val padding = PaddingValues(12.dp, 7.dp, 45.dp, 0.dp)
val paddingStart = with(LocalDensity.current) { 12.dp.roundToPx() }
val paddingTop = with(LocalDensity.current) { 7.dp.roundToPx() }
val paddingEnd = with(LocalDensity.current) { 45.dp.roundToPx() }
val paddingBottom = with(LocalDensity.current) { 7.dp.roundToPx() }
var showKeyboard by remember { mutableStateOf(false) } var showKeyboard by remember { mutableStateOf(false) }
LaunchedEffect(cs.contextItem) { LaunchedEffect(cs.contextItem) {
when (cs.contextItem) { when (cs.contextItem) {
@ -58,99 +221,69 @@ fun SendMsgView(
} }
} }
} }
val textColor = MaterialTheme.colors.onBackground
val tintColor = MaterialTheme.colors.secondary
val paddingStart = with(LocalDensity.current) { 12.dp.roundToPx() }
val paddingTop = with(LocalDensity.current) { 7.dp.roundToPx() }
val paddingEnd = with(LocalDensity.current) { 45.dp.roundToPx() }
val paddingBottom = with(LocalDensity.current) { 7.dp.roundToPx() }
Column(Modifier.padding(vertical = 8.dp)) { AndroidView(modifier = Modifier, factory = {
Box { val editText = @SuppressLint("AppCompatCustomView") object: EditText(it) {
AndroidView(modifier = Modifier, factory = { override fun setOnReceiveContentListener(
val editText = @SuppressLint("AppCompatCustomView") object: EditText(it) { mimeTypes: Array<out String>?,
override fun setOnReceiveContentListener( listener: android.view.OnReceiveContentListener?
mimeTypes: Array<out String>?, ) {
listener: android.view.OnReceiveContentListener? super.setOnReceiveContentListener(mimeTypes, listener)
) {
super.setOnReceiveContentListener(mimeTypes, listener)
}
override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection {
val connection = super.onCreateInputConnection(editorInfo)
EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*"))
val onCommit = InputConnectionCompat.OnCommitContentListener { inputContentInfo, _, _ ->
try {
inputContentInfo.requestPermission()
} catch (e: Exception) {
return@OnCommitContentListener false
}
SimplexApp.context.chatModel.sharedContent.value = SharedContent.Images("", listOf(inputContentInfo.contentUri))
true
}
return InputConnectionCompat.createWrapper(connection, editorInfo, onCommit)
}
}
editText.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
editText.maxLines = 16
editText.inputType = InputType.TYPE_TEXT_FLAG_CAP_SENTENCES or editText.inputType
editText.setTextColor(textColor.toArgb())
editText.textSize = textStyle.value.fontSize.value
val drawable = it.getDrawable(R.drawable.send_msg_view_background)!!
DrawableCompat.setTint(drawable, tintColor.toArgb())
editText.background = drawable
editText.setPadding(paddingStart, paddingTop, paddingEnd, paddingBottom)
editText.setText(cs.message)
editText.textCursorDrawable?.let { DrawableCompat.setTint(it, HighOrLowlight.toArgb()) }
editText.doOnTextChanged { text, _, _, _ -> onMessageChange(text.toString()) }
editText
}) {
it.setTextColor(textColor.toArgb())
it.textSize = textStyle.value.fontSize.value
DrawableCompat.setTint(it.background, tintColor.toArgb())
if (cs.message != it.text.toString()) {
it.setText(cs.message)
// Set cursor to the end of the text
it.setSelection(it.text.length)
}
if (showKeyboard) {
it.requestFocus()
val imm: InputMethodManager = SimplexApp.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(it, InputMethodManager.SHOW_IMPLICIT)
showKeyboard = false
}
} }
Box(Modifier.align(Alignment.BottomEnd)) { override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection {
val icon = if (cs.editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward val connection = super.onCreateInputConnection(editorInfo)
val color = if (cs.sendEnabled()) MaterialTheme.colors.primary else HighOrLowlight EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*"))
if (cs.inProgress val onCommit = InputConnectionCompat.OnCommitContentListener { inputContentInfo, _, _ ->
&& (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.FilePreview) try {
) { inputContentInfo.requestPermission()
CircularProgressIndicator( } catch (e: Exception) {
Modifier return@OnCommitContentListener false
.size(36.dp) }
.padding(4.dp), SimplexApp.context.chatModel.sharedContent.value = SharedContent.Images("", listOf(inputContentInfo.contentUri))
color = HighOrLowlight, true
strokeWidth = 3.dp
)
} else {
Icon(
icon,
stringResource(R.string.icon_descr_send_message),
tint = Color.White,
modifier = Modifier
.size(36.dp)
.padding(4.dp)
.clip(CircleShape)
.background(color)
.clickable {
if (cs.sendEnabled()) {
sendMessage()
}
}
)
} }
return InputConnectionCompat.createWrapper(connection, editorInfo, onCommit)
} }
} }
editText.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
editText.maxLines = 16
editText.inputType = InputType.TYPE_TEXT_FLAG_CAP_SENTENCES or editText.inputType
editText.setTextColor(textColor.toArgb())
editText.textSize = textStyle.value.fontSize.value
val drawable = it.getDrawable(R.drawable.send_msg_view_background)!!
DrawableCompat.setTint(drawable, tintColor.toArgb())
editText.background = drawable
editText.setPadding(paddingStart, paddingTop, paddingEnd, paddingBottom)
editText.setText(cs.message)
editText.textCursorDrawable?.let { DrawableCompat.setTint(it, HighOrLowlight.toArgb()) }
editText.doOnTextChanged { text, _, _, _ -> onMessageChange(text.toString()) }
editText.doAfterTextChanged { text -> if (composeState.value.preview is ComposePreview.VoicePreview && text.toString() != "") editText.setText("") }
editText
}) {
it.setTextColor(textColor.toArgb())
it.textSize = textStyle.value.fontSize.value
DrawableCompat.setTint(it.background, tintColor.toArgb())
it.isFocusable = composeState.value.preview !is ComposePreview.VoicePreview
it.isFocusableInTouchMode = it.isFocusable
if (cs.message != it.text.toString()) {
it.setText(cs.message)
// Set cursor to the end of the text
it.setSelection(it.text.length)
}
if (showKeyboard) {
it.requestFocus()
val imm: InputMethodManager = SimplexApp.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(it, InputMethodManager.SHOW_IMPLICIT)
showKeyboard = false
}
}
if (composeState.value.preview is ComposePreview.VoicePreview) {
Text(
generalGetString(R.string.voice_message_send_text),
Modifier.padding(padding),
color = HighOrLowlight,
style = textStyle.value.copy(fontStyle = FontStyle.Italic)
)
} }
} }
@ -167,8 +300,10 @@ fun PreviewSendMsgView() {
SimpleXTheme { SimpleXTheme {
SendMsgView( SendMsgView(
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
allowVoiceRecord = false,
sendMessage = {}, sendMessage = {},
onMessageChange = { _ -> }, onMessageChange = { _ -> },
onAudioAdded = { _, _, _ -> },
textStyle = textStyle textStyle = textStyle
) )
} }
@ -188,8 +323,10 @@ fun PreviewSendMsgViewEditing() {
SimpleXTheme { SimpleXTheme {
SendMsgView( SendMsgView(
composeState = remember { mutableStateOf(composeStateEditing) }, composeState = remember { mutableStateOf(composeStateEditing) },
allowVoiceRecord = false,
sendMessage = {}, sendMessage = {},
onMessageChange = { _ -> }, onMessageChange = { _ -> },
onAudioAdded = { _, _, _ -> },
textStyle = textStyle textStyle = textStyle
) )
} }
@ -209,8 +346,10 @@ fun PreviewSendMsgViewInProgress() {
SimpleXTheme { SimpleXTheme {
SendMsgView( SendMsgView(
composeState = remember { mutableStateOf(composeStateInProgress) }, composeState = remember { mutableStateOf(composeStateInProgress) },
allowVoiceRecord = false,
sendMessage = {}, sendMessage = {},
onMessageChange = { _ -> }, onMessageChange = { _ -> },
onAudioAdded = { _, _, _ -> },
textStyle = textStyle textStyle = textStyle
) )
} }

View File

@ -0,0 +1,267 @@
package chat.simplex.app.views.chat.item
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.*
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
@Composable
fun CIVoiceView(
durationSec: Int,
file: CIFile?,
edited: Boolean,
sent: Boolean,
hasText: Boolean,
ci: ChatItem,
metaColor: Color
) {
Row(
Modifier.padding(top = 4.dp, bottom = 6.dp, start = 6.dp, end = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (file != null) {
val context = LocalContext.current
val filePath = remember(file.filePath, file.fileStatus) { getLoadedFilePath(context, file) }
var brokenAudio by rememberSaveable(file.filePath) { mutableStateOf(false) }
val audioPlaying = rememberSaveable(file.filePath) { mutableStateOf(false) }
val audioInfo = remember(file.filePath) {
file.audioInfo.value = file.audioInfo.value.copy(durationMs = durationSec * 1000)
file.audioInfo
}
val play = play@{
audioPlaying.value = AudioPlayer.start(filePath ?: return@play, audioInfo.value.progressMs) {
// If you want to preserve the position after switching a track, remove this line
audioInfo.value = ProgressAndDuration(0, audioInfo.value.durationMs)
audioPlaying.value = false
}
brokenAudio = !audioPlaying.value
}
val pause = {
audioInfo.value = ProgressAndDuration(AudioPlayer.pause(), audioInfo.value.durationMs)
audioPlaying.value = false
}
AudioInfoUpdater(filePath, audioPlaying, audioInfo)
val time = if (audioPlaying.value) audioInfo.value.progressMs else audioInfo.value.durationMs
val minWidth = with(LocalDensity.current) { 45.sp.toDp() }
val text = String.format("%02d:%02d", time / 1000 / 60, time / 1000 % 60)
if (hasText) {
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, audioInfo, brokenAudio, play, pause)
Text(
text,
Modifier
.padding(start = 12.dp, end = 5.dp)
.widthIn(min = minWidth),
color = HighOrLowlight,
fontSize = 16.sp,
textAlign = TextAlign.Start,
maxLines = 1
)
} else {
if (sent) {
Row {
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.height(56.dp))
Text(
text,
Modifier
.padding(end = 12.dp)
.widthIn(min = minWidth),
color = HighOrLowlight,
fontSize = 16.sp,
maxLines = 1
)
}
Column {
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, audioInfo, brokenAudio, play, pause)
Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) {
CIMetaView(ci, metaColor)
}
}
}
} else {
Row {
Column {
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, audioInfo, brokenAudio, play, pause)
Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) {
CIMetaView(ci, metaColor)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text,
Modifier
.padding(start = 12.dp)
.widthIn(min = minWidth),
color = HighOrLowlight,
fontSize = 16.sp,
maxLines = 1
)
Spacer(Modifier.height(56.dp))
}
}
}
}
} else {
VoiceMsgIndicator(null, false, sent, hasText, null, false, {}, {})
val metaReserve = if (edited)
" "
else
" "
Text(metaReserve)
}
}
}
@Composable
private fun PlayPauseButton(
audioPlaying: Boolean,
sent: Boolean,
angle: Float,
strokeWidth: Float,
strokeColor: Color,
enabled: Boolean,
error: Boolean,
play: () -> Unit,
pause: () -> Unit
) {
Surface(
onClick = { if (!audioPlaying) play() else pause() },
Modifier.drawRingModifier(angle, strokeColor, strokeWidth),
color = if (sent) SentColorLight else ReceivedColorLight,
shape = MaterialTheme.shapes.small.copy(CornerSize(percent = 50))
) {
Box(
Modifier
.defaultMinSize(minWidth = 56.dp, minHeight = 56.dp),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = if (audioPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
contentDescription = null,
Modifier.size(36.dp),
tint = if (error) WarningOrange else if (!enabled) HighOrLowlight else MaterialTheme.colors.primary
)
}
}
}
@Composable
private fun VoiceMsgIndicator(
file: CIFile?,
audioPlaying: Boolean,
sent: Boolean,
hasText: Boolean,
audioInfo: State<ProgressAndDuration>?,
error: Boolean,
play: () -> Unit,
pause: () -> Unit
) {
val strokeWidth = with(LocalDensity.current){ 3.dp.toPx() }
val strokeColor = MaterialTheme.colors.primary
if (file != null && file.loaded && audioInfo != null) {
val angle = 360f * (audioInfo.value.progressMs.toDouble() / audioInfo.value.durationMs).toFloat()
if (hasText) {
IconButton({ if (!audioPlaying) play() else pause() }, Modifier.drawRingModifier(angle, strokeColor, strokeWidth)) {
Icon(
imageVector = if (audioPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
contentDescription = null,
Modifier.size(36.dp),
tint = MaterialTheme.colors.primary
)
}
} else {
PlayPauseButton(audioPlaying, sent, angle, strokeWidth, strokeColor, true, error, play, pause)
}
} else {
if (file?.fileStatus == CIFileStatus.RcvInvitation
|| file?.fileStatus == CIFileStatus.RcvTransfer
|| file?.fileStatus == CIFileStatus.RcvAccepted) {
Box(
Modifier
.size(56.dp)
.clip(RoundedCornerShape(4.dp)),
contentAlignment = Alignment.Center
) {
ProgressIndicator()
}
} else {
PlayPauseButton(audioPlaying, sent, 0f, strokeWidth, strokeColor, false, false, {}, {})
}
}
}
private fun Modifier.drawRingModifier(angle: Float, color: Color, strokeWidth: Float) = drawWithCache {
val brush = Brush.linearGradient(
0f to Color.Transparent,
0f to color,
start = Offset(0f, 0f),
end = Offset(strokeWidth, strokeWidth),
tileMode = TileMode.Clamp
)
onDrawWithContent {
drawContent()
drawArc(
brush = brush,
startAngle = -90f,
sweepAngle = angle,
useCenter = false,
topLeft = Offset(strokeWidth / 2, strokeWidth / 2),
size = Size(size.width - strokeWidth, size.height - strokeWidth),
style = Stroke(width = strokeWidth, cap = StrokeCap.Square)
)
}
}
@Composable
private fun ProgressIndicator() {
CircularProgressIndicator(
Modifier.size(32.dp),
color = if (isInDarkTheme()) FileDark else FileLight,
strokeWidth = 4.dp
)
}
@Composable
fun AudioInfoUpdater(
filePath: String?,
audioPlaying: MutableState<Boolean>,
audioInfo: MutableState<ProgressAndDuration>
) {
LaunchedEffect(filePath) {
if (filePath != null && audioInfo.value.durationMs == 0) {
audioInfo.value = ProgressAndDuration(audioInfo.value.progressMs, AudioPlayer.duration(filePath))
}
}
LaunchedEffect(audioPlaying.value) {
while (isActive && audioPlaying.value) {
audioInfo.value = AudioPlayer.progressAndDurationOrEnded()
if (audioInfo.value.progressMs == audioInfo.value.durationMs) {
audioInfo.value = ProgressAndDuration(0, audioInfo.value.durationMs)
audioPlaying.value = false
}
delay(50)
}
}
}

View File

@ -100,20 +100,21 @@ fun ChatItemView(
copyText(context, cItem.content.text) copyText(context, cItem.content.text)
showMenu.value = false showMenu.value = false
}) })
if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCFile) { if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) {
val filePath = getLoadedFilePath(context, cItem.file) val filePath = getLoadedFilePath(context, cItem.file)
if (filePath != null) { if (filePath != null) {
ItemAction(stringResource(R.string.save_verb), Icons.Outlined.SaveAlt, onClick = { ItemAction(stringResource(R.string.save_verb), Icons.Outlined.SaveAlt, onClick = {
when (cItem.content.msgContent) { when (cItem.content.msgContent) {
is MsgContent.MCImage -> saveImage(context, cItem.file) is MsgContent.MCImage -> saveImage(context, cItem.file)
is MsgContent.MCFile -> saveFileLauncher.launch(cItem.file?.fileName) is MsgContent.MCFile -> saveFileLauncher.launch(cItem.file?.fileName)
is MsgContent.MCVoice -> saveFileLauncher.launch(cItem.file?.fileName)
else -> {} else -> {}
} }
showMenu.value = false showMenu.value = false
}) })
} }
} }
if (cItem.meta.editable) { if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice) {
ItemAction(stringResource(R.string.edit_verb), Icons.Filled.Edit, onClick = { ItemAction(stringResource(R.string.edit_verb), Icons.Filled.Edit, onClick = {
composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews) composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews)
showMenu.value = false showMenu.value = false

View File

@ -6,6 +6,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.InsertDriveFile import androidx.compose.material.icons.filled.InsertDriveFile
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -56,8 +57,12 @@ fun FramedItemView(
Modifier.padding(vertical = 6.dp, horizontal = 12.dp), Modifier.padding(vertical = 6.dp, horizontal = 12.dp),
contentAlignment = Alignment.TopStart contentAlignment = Alignment.TopStart
) { ) {
val text = if (qi.content is MsgContent.MCVoice && qi.text.isEmpty())
qi.content.toTextWithDuration(true)
else
qi.text
MarkdownText( MarkdownText(
qi.text, qi.formattedText, sender = qi.sender(membership()), senderBold = true, maxLines = 3, text, qi.formattedText, sender = qi.sender(membership()), senderBold = true, maxLines = 3,
style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface) style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface)
) )
} }
@ -87,13 +92,13 @@ fun FramedItemView(
modifier = Modifier.size(68.dp).clipToBounds() modifier = Modifier.size(68.dp).clipToBounds()
) )
} }
is MsgContent.MCFile -> { is MsgContent.MCFile, is MsgContent.MCVoice -> {
Box(Modifier.fillMaxWidth().weight(1f)) { Box(Modifier.fillMaxWidth().weight(1f)) {
ciQuotedMsgView(qi) ciQuotedMsgView(qi)
} }
Icon( Icon(
Icons.Filled.InsertDriveFile, if (qi.content is MsgContent.MCFile) Icons.Filled.InsertDriveFile else Icons.Filled.PlayArrow,
stringResource(R.string.icon_descr_file), if (qi.content is MsgContent.MCFile) stringResource(R.string.icon_descr_file) else stringResource(R.string.voice_message),
Modifier Modifier
.padding(top = 6.dp, end = 4.dp) .padding(top = 6.dp, end = 4.dp)
.size(22.dp), .size(22.dp),
@ -105,7 +110,7 @@ fun FramedItemView(
} }
} }
val transparentBackground = ci.content.msgContent is MsgContent.MCImage && ci.content.text.isEmpty() && ci.quotedItem == null val transparentBackground = (ci.content.msgContent is MsgContent.MCImage || ci.content.msgContent is MsgContent.MCVoice) && ci.content.text.isEmpty() && ci.quotedItem == null
Box(Modifier Box(Modifier
.clip(RoundedCornerShape(18.dp)) .clip(RoundedCornerShape(18.dp))
.background( .background(
@ -142,6 +147,12 @@ fun FramedItemView(
CIMarkdownText(ci, showMember, uriHandler) CIMarkdownText(ci, showMember, uriHandler)
} }
} }
is MsgContent.MCVoice -> {
CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, mc.text != "" || ci.quotedItem != null, ci, metaColor)
if (mc.text != "") {
CIMarkdownText(ci, showMember, uriHandler)
}
}
is MsgContent.MCFile -> { is MsgContent.MCFile -> {
CIFileView(ci.file, ci.meta.itemEdited, receiveFile) CIFileView(ci.file, ci.meta.itemEdited, receiveFile)
if (mc.text != "") { if (mc.text != "") {
@ -157,8 +168,10 @@ fun FramedItemView(
} }
} }
} }
Box(Modifier.padding(bottom = 6.dp, end = 12.dp)) { if (ci.content.msgContent !is MsgContent.MCVoice || ci.content.text.isNotEmpty()) {
CIMetaView(ci, metaColor) Box(Modifier.padding(bottom = 6.dp, end = 12.dp)) {
CIMetaView(ci, metaColor)
}
} }
} }
} }

View File

@ -5,3 +5,5 @@ import androidx.compose.animation.core.*
fun <T> chatListAnimationSpec() = tween<T>(durationMillis = 250, easing = FastOutSlowInEasing) fun <T> chatListAnimationSpec() = tween<T>(durationMillis = 250, easing = FastOutSlowInEasing)
fun <T> newChatSheetAnimSpec() = tween<T>(256, 0, LinearEasing) fun <T> newChatSheetAnimSpec() = tween<T>(256, 0, LinearEasing)
fun <T> audioProgressBarAnimationSpec() = tween<T>(durationMillis = 30, easing = LinearEasing)

View File

@ -213,6 +213,24 @@ fun interactionSourceWithDetection(onClick: () -> Unit, onLongClick: () -> Unit)
return interactionSource return interactionSource
} }
@Composable
fun interactionSourceWithTapDetection(onPress: () -> Unit, onClick: () -> Unit, onCancel: () -> Unit, onRelease: ()-> Unit): MutableInteractionSource {
val interactionSource = remember { MutableInteractionSource() }
LaunchedEffect(interactionSource) {
var firstTapTime = 0L
interactionSource.interactions.collect { interaction ->
when (interaction) {
is PressInteraction.Press -> {
firstTapTime = System.currentTimeMillis(); onPress()
}
is PressInteraction.Release -> if (firstTapTime + 1000L < System.currentTimeMillis()) onRelease() else onClick()
is PressInteraction.Cancel -> onCancel()
}
}
}
return interactionSource
}
suspend fun PointerInputScope.detectTransformGestures( suspend fun PointerInputScope.detectTransformGestures(
allowIntercept: () -> Boolean, allowIntercept: () -> Boolean,
panZoomLock: Boolean = false, panZoomLock: Boolean = false,

View File

@ -0,0 +1,199 @@
package chat.simplex.app.views.helpers
import android.media.*
import android.media.MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED
import android.os.Build
import android.util.Log
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.Saver
import chat.simplex.app.*
import chat.simplex.app.R
import chat.simplex.app.model.ChatItem
import java.io.*
import java.text.SimpleDateFormat
import java.util.*
interface Recorder {
val recordingInProgress: MutableState<Boolean>
fun start(onStop: () -> Unit): String
fun stop()
fun cancel(filePath: String, recordingInProgress: MutableState<Boolean>)
}
data class ProgressAndDuration(
val progressMs: Int = 0,
val durationMs: Int = 0
) {
companion object {
val Saver
get() = Saver<MutableState<ProgressAndDuration>, Pair<Int, Int>>(
save = { it.value.progressMs to it.value.durationMs },
restore = { mutableStateOf(ProgressAndDuration(it.first, it.second)) }
)
}
}
class RecorderNative(private val recordedBytesLimit: Long): Recorder {
companion object {
// Allows to stop the recorder from outside without having the recorder in a variable
var stopRecording: (() -> Unit)? = null
}
override val recordingInProgress = mutableStateOf(false)
private var recorder: MediaRecorder? = null
private fun initRecorder() =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaRecorder(SimplexApp.context)
} else {
MediaRecorder()
}
override fun start(onStop: () -> Unit): String {
AudioPlayer.stop()
recordingInProgress.value = true
val rec: MediaRecorder
recorder = initRecorder().also { rec = it }
rec.setAudioSource(MediaRecorder.AudioSource.MIC)
rec.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
rec.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
rec.setAudioChannels(1)
rec.setAudioSamplingRate(16000)
rec.setAudioEncodingBitRate(16000)
rec.setMaxDuration(-1)
rec.setMaxFileSize(recordedBytesLimit)
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
val filePath = getAppFilePath(SimplexApp.context, uniqueCombine(SimplexApp.context, getAppFilePath(SimplexApp.context, "voice_${timestamp}.m4a")))
rec.setOutputFile(filePath)
rec.prepare()
rec.start()
rec.setOnInfoListener { mr, what, extra ->
if (what == MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED) {
stop()
onStop()
}
}
stopRecording = { stop(); onStop() }
return filePath
}
override fun stop() {
if (!recordingInProgress.value) return
stopRecording = null
recordingInProgress.value = false
recorder?.metrics?.
runCatching {
recorder?.stop()
}
runCatching {
recorder?.reset()
}
runCatching {
// release all resources
recorder?.release()
}
recorder = null
}
override fun cancel(filePath: String, recordingInProgress: MutableState<Boolean>) {
stop()
runCatching { File(filePath).delete() }.getOrElse { Log.d(TAG, "Unable to delete a file: ${it.stackTraceToString()}") }
}
}
object AudioPlayer {
private val player = MediaPlayer().apply {
setAudioAttributes(
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.setUsage(AudioAttributes.USAGE_MEDIA)
.build()
)
}
private val helperPlayer: MediaPlayer = MediaPlayer().apply {
setAudioAttributes(
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.setUsage(AudioAttributes.USAGE_MEDIA)
.build()
)
}
// Filepath: String, onStop: () -> Unit
private val currentlyPlaying: MutableState<Pair<String, () -> Unit>?> = mutableStateOf(null)
fun start(filePath: String, seek: Int? = null, onStop: () -> Unit): Boolean {
if (!File(filePath).exists()) {
Log.e(TAG, "No such file: $filePath")
return false
}
RecorderNative.stopRecording?.invoke()
val current = currentlyPlaying.value
if (current == null || current.first != filePath) {
player.reset()
// Notify prev audio listener about stop
current?.second?.invoke()
runCatching {
player.setDataSource(filePath)
}.onFailure {
Log.e(TAG, it.stackTraceToString())
AlertManager.shared.showAlertMsg(generalGetString(R.string.unknown_error), it.message)
return false
}
runCatching { player.prepare() }.onFailure {
// Can happen when audio file is broken
Log.e(TAG, it.stackTraceToString())
AlertManager.shared.showAlertMsg(generalGetString(R.string.unknown_error), it.message)
return false
}
}
if (seek != null) player.seekTo(seek)
player.start()
// Repeated calls to play/pause on the same track will not recompose all dependent views
if (currentlyPlaying.value?.first != filePath) {
currentlyPlaying.value = filePath to onStop
}
return true
}
fun pause(): Int {
player.pause()
return player.currentPosition
}
fun stop() {
if (!player.isPlaying) return
// Notify prev audio listener about stop
currentlyPlaying.value?.second?.invoke()
currentlyPlaying.value = null
player.stop()
}
fun stop(item: ChatItem) = stop(item.file?.fileName)
// FileName or filePath are ok
fun stop(fileName: String?) {
if (fileName != null && currentlyPlaying.value?.first?.endsWith(fileName) == true) {
stop()
}
}
/**
* If player starts playing at 2637 ms in a track 2816 ms long (these numbers are just an example),
* it will stop immediately after start but will not change currentPosition, so it will not be equal to duration.
* However, it sets isPlaying to false. Let's do it ourselves in order to prevent endless waiting loop
* */
fun progressAndDurationOrEnded(): ProgressAndDuration =
ProgressAndDuration(if (player.isPlaying) player.currentPosition else player.duration, player.duration)
fun duration(filePath: String): Int {
var res = 0
kotlin.runCatching {
helperPlayer.setDataSource(filePath)
helperPlayer.prepare()
helperPlayer.start()
helperPlayer.stop()
res = helperPlayer.duration
helperPlayer.reset()
}
return res
}
}

View File

@ -19,6 +19,7 @@ import android.view.ViewTreeObserver
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.Saver
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.* import androidx.compose.ui.platform.*
import androidx.compose.ui.text.* import androidx.compose.ui.text.*
@ -27,6 +28,7 @@ import androidx.compose.ui.text.style.BaselineShift
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.* import androidx.compose.ui.unit.*
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.net.toFile
import androidx.core.text.HtmlCompat import androidx.core.text.HtmlCompat
import chat.simplex.app.* import chat.simplex.app.*
import chat.simplex.app.model.CIFile import chat.simplex.app.model.CIFile
@ -220,6 +222,11 @@ private fun spannableStringToAnnotatedString(
// maximum image file size to be auto-accepted // maximum image file size to be auto-accepted
const val MAX_IMAGE_SIZE: Long = 236700 const val MAX_IMAGE_SIZE: Long = 236700
const val MAX_IMAGE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE * 2 const val MAX_IMAGE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE * 2
const val MAX_VOICE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE
const val MAX_VOICE_SIZE_FOR_SENDING: Long = 94680 // 6 chunks * 15780 bytes per chunk
const val MAX_VOICE_MILLIS_FOR_SENDING: Long = 43_000 // approximately is ok
const val MAX_FILE_SIZE: Long = 8000000 const val MAX_FILE_SIZE: Long = 8000000
fun getFilesDirectory(context: Context): String { fun getFilesDirectory(context: Context): String {
@ -449,3 +456,9 @@ fun Color.darker(factor: Float = 0.1f): Color =
fun ByteArray.toBase64String() = Base64.encodeToString(this, Base64.DEFAULT) fun ByteArray.toBase64String() = Base64.encodeToString(this, Base64.DEFAULT)
fun String.toByteArrayFromBase64() = Base64.decode(this, Base64.DEFAULT) fun String.toByteArrayFromBase64() = Base64.decode(this, Base64.DEFAULT)
val LongRange.Companion.saver
get() = Saver<MutableState<LongRange>, Pair<Long, Long>>(
save = { it.value.first to it.value.last },
restore = { mutableStateOf(it.first..it.second) }
)

View File

@ -208,6 +208,10 @@
<string name="file_not_found">Datei nicht gefunden</string> <string name="file_not_found">Datei nicht gefunden</string>
<string name="error_saving_file">Fehler beim Speichern der Datei</string> <string name="error_saving_file">Fehler beim Speichern der Datei</string>
<!-- Voice messages -->
<string name="voice_message">***Voice message</string>
<string name="voice_message_send_text">***Voice message…</string>
<!-- Chat Info Settings - ChatInfoView.kt --> <!-- Chat Info Settings - ChatInfoView.kt -->
<string name="notifications">Benachrichtigungen</string> <string name="notifications">Benachrichtigungen</string>
@ -225,6 +229,7 @@
<!-- Message Actions - SendMsgView.kt --> <!-- Message Actions - SendMsgView.kt -->
<string name="icon_descr_send_message">Nachricht senden</string> <string name="icon_descr_send_message">Nachricht senden</string>
<string name="icon_descr_record_voice_message">***Record voice message</string>
<!-- General Actions / Responses --> <!-- General Actions / Responses -->
<string name="back">Zurück</string> <string name="back">Zurück</string>

View File

@ -208,6 +208,10 @@
<string name="file_not_found">Файл не найден</string> <string name="file_not_found">Файл не найден</string>
<string name="error_saving_file">Ошибка сохранения файла</string> <string name="error_saving_file">Ошибка сохранения файла</string>
<!-- Voice messages -->
<string name="voice_message">Голосовое сообщение</string>
<string name="voice_message_send_text">Голосовое сообщение…</string>
<!-- Chat Info Settings - ChatInfoView.kt --> <!-- Chat Info Settings - ChatInfoView.kt -->
<string name="notifications">Уведомления</string> <string name="notifications">Уведомления</string>
@ -225,6 +229,7 @@
<!-- Message Actions - SendMsgView.kt --> <!-- Message Actions - SendMsgView.kt -->
<string name="icon_descr_send_message">Отправить сообщение</string> <string name="icon_descr_send_message">Отправить сообщение</string>
<string name="icon_descr_record_voice_message">Записать голосовое сообщение</string>
<!-- General Actions / Responses --> <!-- General Actions / Responses -->
<string name="back">Назад</string> <string name="back">Назад</string>

View File

@ -208,6 +208,10 @@
<string name="file_not_found">File not found</string> <string name="file_not_found">File not found</string>
<string name="error_saving_file">Error saving file</string> <string name="error_saving_file">Error saving file</string>
<!-- Voice messages -->
<string name="voice_message">Voice message</string>
<string name="voice_message_send_text">Voice message…</string>
<!-- Chat Info Settings - ChatInfoView.kt --> <!-- Chat Info Settings - ChatInfoView.kt -->
<string name="notifications">Notifications</string> <string name="notifications">Notifications</string>
@ -225,6 +229,7 @@
<!-- Message Actions - SendMsgView.kt --> <!-- Message Actions - SendMsgView.kt -->
<string name="icon_descr_send_message">Send Message</string> <string name="icon_descr_send_message">Send Message</string>
<string name="icon_descr_record_voice_message">Record voice message</string>
<!-- General Actions / Responses --> <!-- General Actions / Responses -->
<string name="back">Back</string> <string name="back">Back</string>

View File

@ -386,6 +386,8 @@ processChatCommand = \case
| otherwise = case qmc of | otherwise = case qmc of
MCImage _ image -> MCImage qTextOrFile image MCImage _ image -> MCImage qTextOrFile image
MCFile _ -> MCFile qTextOrFile MCFile _ -> MCFile qTextOrFile
-- consider same for voice messages
-- MCVoice _ voice -> MCVoice qTextOrFile voice
_ -> qmc _ -> qmc
where where
-- if the message we're quoting with is one of the "large" MsgContents -- if the message we're quoting with is one of the "large" MsgContents