From 96c1c1d439b27193f45633999c2d6f76ab6db093 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Sat, 28 Jan 2023 13:35:31 +0000 Subject: [PATCH] android: drafts (#1849) * android: drafts * empty line * delete unused voice files * finish recording properly * refactor * fix --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../java/chat/simplex/app/MainActivity.kt | 7 -- .../main/java/chat/simplex/app/SimplexApp.kt | 1 + .../java/chat/simplex/app/model/ChatModel.kt | 4 + .../chat/simplex/app/views/chat/ChatView.kt | 8 +- .../simplex/app/views/chat/ComposeView.kt | 116 +++++++++--------- .../simplex/app/views/chat/SendMsgView.kt | 20 ++- .../app/views/chat/item/TextItemView.kt | 8 +- .../app/views/chatlist/ChatListNavLinkView.kt | 8 +- .../app/views/chatlist/ChatPreviewView.kt | 76 ++++++++++-- .../chat/simplex/app/views/helpers/Enums.kt | 20 ++- .../simplex/app/views/helpers/RecAndPlay.kt | 10 +- .../chat/simplex/app/views/helpers/Util.kt | 5 + 12 files changed, 196 insertions(+), 87 deletions(-) diff --git a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt index f7ee66fbc4..e26fa02ebd 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt @@ -374,13 +374,6 @@ fun MainPage( .collect { if (it != null) currentChatId = it 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() - } } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt index 85cbd94ced..ed7a0ae166 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt @@ -90,6 +90,7 @@ class SimplexApp: Application(), LifecycleEventObserver { context = this initChatController() ProcessLifecycleOwner.get().lifecycle.addObserver(this) + context.getDir("temp", MODE_PRIVATE).deleteRecursively() } override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index d8411ecc3d..269716564a 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.text.style.TextDecoration import chat.simplex.app.R import chat.simplex.app.ui.theme.* import chat.simplex.app.views.call.* +import chat.simplex.app.views.chat.ComposeState import chat.simplex.app.views.helpers.* import chat.simplex.app.views.onboarding.OnboardingStage import chat.simplex.app.views.usersettings.NotificationPreviewMode @@ -84,6 +85,9 @@ class ChatModel(val controller: ChatController) { // currently showing QR code val connReqInv = mutableStateOf(null as String?) + var draft = mutableStateOf(null as ComposeState?) + var draftChatId = mutableStateOf(null as String?) + // working with external intents val sharedContent = mutableStateOf(null as SharedContent?) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt index 34edf87a75..b155f33f26 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt @@ -56,7 +56,13 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) { val user = chatModel.currentUser.value val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get() val composeState = rememberSaveable(saver = ComposeState.saver()) { - mutableStateOf(ComposeState(useLinkPreviews = useLinkPreviews)) + mutableStateOf( + if (chatModel.draftChatId.value == chatId && chatModel.draft.value != null) { + chatModel.draft.value ?: ComposeState(useLinkPreviews = useLinkPreviews) + } else { + ComposeState(useLinkPreviews = useLinkPreviews) + } + ) } val attachmentOption = rememberSaveable { mutableStateOf(null) } val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt index 7d712a8a7b..8e5a135e4f 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt @@ -1,7 +1,8 @@ +@file:UseSerializers(UriSerializer::class) package chat.simplex.app.views.chat -import ComposeVoiceView import ComposeFileView +import ComposeVoiceView import android.Manifest import android.app.Activity import android.content.* @@ -20,7 +21,8 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.* import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.filled.AttachFile +import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.outlined.Reply import androidx.compose.runtime.* import androidx.compose.runtime.saveable.Saver @@ -32,28 +34,25 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat -import androidx.core.net.toFile -import androidx.core.net.toUri import chat.simplex.app.* import chat.simplex.app.R import chat.simplex.app.model.* import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.views.chat.item.* import chat.simplex.app.views.helpers.* -import kotlinx.coroutines.delay +import kotlinx.coroutines.* import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.runBlocking -import kotlinx.serialization.Serializable -import kotlinx.serialization.decodeFromString +import kotlinx.serialization.* import java.io.File +import java.nio.file.Files @Serializable sealed class ComposePreview { @Serializable object NoPreview: ComposePreview() @Serializable class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview() - @Serializable class ImagePreview(val images: List): ComposePreview() - @Serializable class VoicePreview(val voice: String, val durationMs: Int, val finished: Boolean): ComposePreview() - @Serializable class FilePreview(val fileName: String): ComposePreview() + @Serializable class ImagePreview(val images: List, val content: List): ComposePreview() + @Serializable data class VoicePreview(val voice: String, val durationMs: Int, val finished: Boolean): ComposePreview() + @Serializable class FilePreview(val fileName: String, val uri: Uri): ComposePreview() } @Serializable @@ -132,6 +131,9 @@ data class ComposeState( } } + val empty: Boolean + get() = message.isEmpty() && preview is ComposePreview.NoPreview + companion object { fun saver(): Saver, *> = Saver( save = { json.encodeToString(serializer(), it.value) }, @@ -152,15 +154,14 @@ sealed class RecordingState { } fun chatItemPreview(chatItem: ChatItem): ComposePreview { + val fileName = chatItem.file?.fileName ?: "" return when (val mc = chatItem.content.msgContent) { is MsgContent.MCText -> ComposePreview.NoPreview is MsgContent.MCLink -> ComposePreview.CLinkPreview(linkPreview = mc.preview) - 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 -> { - val fileName = chatItem.file?.fileName ?: "" - ComposePreview.FilePreview(fileName) - } + // TODO: include correct type + is MsgContent.MCImage -> ComposePreview.ImagePreview(images = listOf(mc.image), listOf(UploadContent.SimpleImage(getAppFileUri(fileName)))) + is MsgContent.MCVoice -> ComposePreview.VoicePreview(voice = fileName, mc.duration / 1000, true) + is MsgContent.MCFile -> ComposePreview.FilePreview(fileName, getAppFileUri(fileName)) is MsgContent.MCUnknown, null -> ComposePreview.NoPreview } } @@ -181,21 +182,12 @@ fun ComposeView( val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get() val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) val textStyle = remember { mutableStateOf(smallFont) } - // attachments - val chosenContent = rememberSaveable { mutableStateOf>(emptyList()) } - val audioSaver = Saver?>, Pair>( - 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(null) } val cameraLauncher = rememberCameraLauncher { uri: Uri? -> if (uri != null) { val source = ImageDecoder.createSource(SimplexApp.context.contentResolver, uri) val bitmap = ImageDecoder.decodeBitmap(source) val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000) - chosenContent.value = listOf(UploadContent.SimpleImage(uri)) - composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(listOf(imagePreview))) + composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(listOf(imagePreview), listOf(UploadContent.SimpleImage(uri)))) } } val cameraPermissionLauncher = rememberPermissionLauncher { isGranted: Boolean -> @@ -242,8 +234,7 @@ fun ComposeView( } if (imagesPreview.isNotEmpty()) { - chosenContent.value = content - composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.ImagePreview(imagesPreview)) + composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.ImagePreview(imagesPreview, content)) } } val processPickedFile = { uri: Uri?, text: String? -> @@ -252,8 +243,7 @@ fun ComposeView( if (fileSize != null && fileSize <= MAX_FILE_SIZE) { val fileName = getFileName(SimplexApp.context, uri) if (fileName != null) { - chosenFile.value = uri - composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.FilePreview(fileName)) + composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.FilePreview(fileName, uri)) } } else { AlertManager.shared.showAlertMsg( @@ -353,12 +343,14 @@ fun ComposeView( } recState.value = RecordingState.NotStarted textStyle.value = smallFont - chosenContent.value = emptyList() - chosenAudio.value = null - chosenFile.value = null chatModel.removeLiveDummy() } + fun deleteUnusedFiles() { + chatModel.filesToDelete.forEach { it.delete() } + chatModel.filesToDelete.clear() + } + suspend fun send(cInfo: ChatInfo, mc: MsgContent, quoted: Long?, file: String? = null, live: Boolean = false): ChatItem? { val aChatItem = chatModel.controller.apiSendMessage( type = cInfo.chatType, @@ -444,35 +436,33 @@ fun ComposeView( ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(msgText)) is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview()) is ComposePreview.ImagePreview -> { - chosenContent.value.forEachIndexed { index, it -> + preview.content.forEachIndexed { index, it -> val file = when (it) { is UploadContent.SimpleImage -> saveImage(context, it.uri) is UploadContent.AnimatedImage -> saveAnimImage(context, it.uri) } if (file != null) { files.add(file) - msgs.add(MsgContent.MCImage(if (chosenContent.value.lastIndex == index) msgText else "", preview.images[index])) + msgs.add(MsgContent.MCImage(if (preview.content.lastIndex == index) msgText else "", preview.images[index])) } } } 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()) - AudioPlayer.stop(chosenAudioVal.first.toFile().absolutePath) - msgs.add(MsgContent.MCVoice(if (msgs.isEmpty()) msgText else "", chosenAudioVal.second / 1000)) + val tmpFile = File(preview.voice) + AudioPlayer.stop(tmpFile.absolutePath) + val actualFile = File(getAppFilePath(SimplexApp.context, tmpFile.name.replaceAfter(RecorderNative.extension, ""))) + withContext(Dispatchers.IO) { + Files.move(tmpFile.toPath(), actualFile.toPath()) } + files.add(actualFile.name) + deleteUnusedFiles() + msgs.add(MsgContent.MCVoice(if (msgs.isEmpty()) msgText else "", preview.durationMs / 1000)) } is ComposePreview.FilePreview -> { - val chosenFileVal = chosenFile.value - if (chosenFileVal != null) { - val file = saveFileFromUri(context, chosenFileVal) - if (file != null) { - files.add((file)) - msgs.add(MsgContent.MCFile(if (msgs.isEmpty()) msgText else "")) - } + val file = saveFileFromUri(context, preview.uri) + if (file != null) { + files.add((file)) + msgs.add(MsgContent.MCFile(if (msgs.isEmpty()) msgText else "")) } } } @@ -487,7 +477,7 @@ fun ComposeView( if (content !is MsgContent.MCVoice && index == msgs.lastIndex) live else false ) } - if (sent == null && chosenContent.value.isNotEmpty()) { + if (sent == null && (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.FilePreview || cs.preview is ComposePreview.VoicePreview)) { sent = send(cInfo, MsgContent.MCText(msgText), quotedItemId, null, live) } } @@ -516,7 +506,6 @@ 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)) } @@ -539,7 +528,6 @@ fun ComposeView( fun cancelImages() { composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview) - chosenContent.value = emptyList() } fun cancelVoice() { @@ -551,12 +539,10 @@ fun ComposeView( AudioPlayer.stop(filePath) filePath?.let { File(it).delete() } } - chosenAudio.value = null } fun cancelFile() { composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview) - chosenFile.value = null } fun truncateToWords(s: String): String { @@ -682,7 +668,7 @@ fun ComposeView( } val allowedVoiceByPrefs = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.Voice) } LaunchedEffect(allowedVoiceByPrefs) { - if (!allowedVoiceByPrefs && chosenAudio.value != null) { + if (!allowedVoiceByPrefs && composeState.value.preview is ComposePreview.VoicePreview) { // Voice was disabled right when this user records it, just cancel it cancelVoice() } @@ -705,6 +691,13 @@ fun ComposeView( } } + fun clearCurrentDraft() { + if (chatModel.draftChatId.value == chat.id) { + chatModel.draft.value = null + chatModel.draftChatId.value = null + } + } + val activity = LocalContext.current as Activity DisposableEffect(Unit) { val orientation = activity.resources.configuration.orientation @@ -714,6 +707,17 @@ fun ComposeView( if (cs.liveMessage != null && (cs.message.isNotEmpty() || cs.liveMessage.sent)) { sendMessage() resetLinkPreview() + clearCurrentDraft() + deleteUnusedFiles() + } else if (!composeState.value.empty) { + if (cs.preview is ComposePreview.VoicePreview && !cs.preview.finished) { + composeState.value = cs.copy(preview = cs.preview.copy(finished = true)) + } + chatModel.draft.value = composeState.value + chatModel.draftChatId.value = chat.id + } else { + clearCurrentDraft() + deleteUnusedFiles() } chatModel.removeLiveDummy() } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt index e84af43f0b..d7d555ad42 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt @@ -73,11 +73,15 @@ fun SendMsgView( val showProgress = cs.inProgress && (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.FilePreview) val showVoiceButton = cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing && cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started) - NativeKeyboard(composeState, textStyle, onMessageChange) + val showDeleteTextButton = rememberSaveable { mutableStateOf(false) } + NativeKeyboard(composeState, textStyle, showDeleteTextButton, onMessageChange) // Disable clicks on text field if (cs.preview is ComposePreview.VoicePreview) { Box(Modifier.matchParentSize().clickable(enabled = false, onClick = { })) } + if (showDeleteTextButton.value) { + DeleteTextButton(composeState) + } Box(Modifier.align(Alignment.BottomEnd)) { val sendButtonSize = remember { Animatable(36f) } val sendButtonAlpha = remember { Animatable(1f) } @@ -168,6 +172,7 @@ fun SendMsgView( private fun NativeKeyboard( composeState: MutableState, textStyle: MutableState, + showDeleteTextButton: MutableState, onMessageChange: (String) -> Unit ) { val cs = composeState.value @@ -245,6 +250,7 @@ private fun NativeKeyboard( imm.showSoftInput(it, InputMethodManager.SHOW_IMPLICIT) showKeyboard = false } + showDeleteTextButton.value = it.lineCount >= 4 } if (composeState.value.preview is ComposePreview.VoicePreview) { Text( @@ -256,6 +262,16 @@ private fun NativeKeyboard( } } +@Composable +private fun BoxScope.DeleteTextButton(composeState: MutableState) { + IconButton( + { composeState.value = composeState.value.copy(message = "") }, + Modifier.align(Alignment.TopEnd).size(36.dp) + ) { + Icon(Icons.Filled.Close, null, Modifier.padding(7.dp).size(36.dp), tint = HighOrLowlight) + } +} + @Composable private fun RecordVoiceView(recState: MutableState, stopRecOnNextClick: MutableState) { val rec: Recorder = remember { RecorderNative(MAX_VOICE_SIZE_FOR_SENDING) } @@ -601,7 +617,7 @@ fun PreviewSendMsgViewEditing() { fun PreviewSendMsgViewInProgress() { val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) val textStyle = remember { mutableStateOf(smallFont) } - val composeStateInProgress = ComposeState(preview = ComposePreview.FilePreview("test.txt"), inProgress = true, useLinkPreviews = true) + val composeStateInProgress = ComposeState(preview = ComposePreview.FilePreview("test.txt", getAppFileUri("test.txt")), inProgress = true, useLinkPreviews = true) SimpleXTheme { SendMsgView( composeState = remember { mutableStateOf(composeStateInProgress) }, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt index f9007acdf5..dafc328cf5 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt @@ -66,7 +66,7 @@ private fun typing(w: FontWeight = FontWeight.Light): AnnotatedString = @Composable fun MarkdownText ( - text: String, + text: CharSequence, formattedText: List? = null, sender: String? = null, meta: CIMeta? = null, @@ -78,6 +78,7 @@ fun MarkdownText ( senderBold: Boolean = false, modifier: Modifier = Modifier, linkMode: SimplexLinkMode, + inlineContent: Map? = null, onLinkLongClick: (link: String) -> Unit = {} ) { val textLayoutDirection = remember (text) { @@ -132,13 +133,14 @@ fun MarkdownText ( if (formattedText == null) { val annotatedText = buildAnnotatedString { appendSender(this, sender, senderBold) - append(text) + if (text is String) append(text) + else if (text is AnnotatedString) append(text) if (meta?.isLive == true) { append(typingIndicator(meta.recent, typingIdx)) } if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) } } - Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow) + Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent ?: mapOf()) } else { var hasLinks = false val annotatedText = buildAnnotatedString { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt index a5f3cc8afe..af412de608 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt @@ -47,7 +47,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { is ChatInfo.Direct -> { val contactNetworkStatus = chatModel.contactNetworkStatus(chat.chatInfo.contact) ChatListNavLinkLayout( - chatLinkPreview = { ChatPreviewView(chat, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, stopped, linkMode) }, + chatLinkPreview = { ChatPreviewView(chat, chatModel.draft.value, chatModel.draftChatId.value, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, stopped, linkMode) }, click = { directChatAction(chat.chatInfo, chatModel) }, dropdownMenuItems = { ContactMenuItems(chat, chatModel, showMenu, showMarkRead) }, showMenu, @@ -56,7 +56,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { } is ChatInfo.Group -> ChatListNavLinkLayout( - chatLinkPreview = { ChatPreviewView(chat, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, null, stopped, linkMode) }, + chatLinkPreview = { ChatPreviewView(chat, chatModel.draft.value, chatModel.draftChatId.value, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, null, stopped, linkMode) }, click = { groupChatAction(chat.chatInfo.groupInfo, chatModel) }, dropdownMenuItems = { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, showMarkRead) }, showMenu, @@ -627,6 +627,8 @@ fun PreviewChatListNavLinkDirect() { ), chatStats = Chat.ChatStats() ), + null, + null, false, null, null, @@ -666,6 +668,8 @@ fun PreviewChatListNavLinkGroup() { ), chatStats = Chat.ChatStats() ), + null, + null, false, null, null, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt index a385efda46..f4868a8390 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt @@ -4,16 +4,20 @@ import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.outlined.* -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -21,12 +25,16 @@ import androidx.compose.ui.unit.* import chat.simplex.app.R import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* +import chat.simplex.app.views.chat.ComposePreview +import chat.simplex.app.views.chat.ComposeState import chat.simplex.app.views.chat.item.MarkdownText import chat.simplex.app.views.helpers.* @Composable fun ChatPreviewView( chat: Chat, + chatModelDraft: ComposeState?, + chatModelDraftChatId: ChatId?, chatModelIncognito: Boolean, currentUserProfileDisplayName: String?, contactNetworkStatus: NetworkStatus?, @@ -74,6 +82,43 @@ fun ChatPreviewView( Icon(Icons.Outlined.VerifiedUser, null, Modifier.size(19.dp).padding(end = 3.dp, top = 1.dp), tint = HighOrLowlight) } + fun messageDraft(draft: ComposeState): Pair> { + fun attachment(): Pair? = + when (draft.preview) { + is ComposePreview.FilePreview -> Icons.Filled.InsertDriveFile to draft.preview.fileName + is ComposePreview.ImagePreview -> Icons.Outlined.Image to null + is ComposePreview.VoicePreview -> Icons.Filled.PlayArrow to durationText(draft.preview.durationMs / 1000) + else -> null + } + + val attachment = attachment() + val text = buildAnnotatedString { + appendInlineContent(id = "editIcon") + append(" ") + if (attachment != null) { + appendInlineContent(id = "attachmentIcon") + if (attachment.second != null) { + append(attachment.second as String) + } + append(" ") + } + append(draft.message) + } + val inlineContent: Map = mapOf( + "editIcon" to InlineTextContent( + Placeholder(20.sp, 20.sp, PlaceholderVerticalAlign.TextCenter) + ) { + Icon(Icons.Outlined.EditNote, null, tint = MaterialTheme.colors.primary) + }, + "attachmentIcon" to InlineTextContent( + Placeholder(20.sp, 20.sp, PlaceholderVerticalAlign.TextCenter) + ) { + Icon(attachment?.first ?: Icons.Outlined.EditNote, null, tint = HighOrLowlight) + } + ) + return text to inlineContent + } + @Composable fun chatPreviewTitle() { when (cInfo) { @@ -98,15 +143,30 @@ fun ChatPreviewView( fun chatPreviewText(chatModelIncognito: Boolean) { val ci = chat.chatItems.lastOrNull() if (ci != null) { + val (text: CharSequence, inlineTextContent) = when { + chatModelDraftChatId == chat.id && chatModelDraft != null -> remember(chatModelDraft) { messageDraft(chatModelDraft) } + !ci.meta.itemDeleted -> ci.text to null + else -> generalGetString(R.string.marked_deleted_description) to null + } + val formattedText = when { + chatModelDraftChatId == chat.id && chatModelDraft != null -> null + !ci.meta.itemDeleted -> ci.formattedText + else -> null + } MarkdownText( - if (!ci.meta.itemDeleted) ci.text else generalGetString(R.string.marked_deleted_description), - if (!ci.meta.itemDeleted) ci.formattedText else null, - sender = if (cInfo is ChatInfo.Group && !ci.chatDir.sent) ci.memberDisplayName else null, + text, + formattedText, + sender = when { + chatModelDraftChatId == chat.id && chatModelDraft != null -> null + cInfo is ChatInfo.Group && !ci.chatDir.sent -> ci.memberDisplayName + else -> null + }, linkMode = linkMode, senderBold = true, maxLines = 2, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.body1.copy(color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight, lineHeight = 22.sp), + inlineContent = inlineTextContent, modifier = Modifier.fillMaxWidth(), ) } else { @@ -194,9 +254,7 @@ fun ChatPreviewView( Modifier.padding(top = 52.dp), contentAlignment = Alignment.Center ) { - if (chat.chatInfo is ChatInfo.Direct) { - ChatStatusImage(chat, contactNetworkStatus) - } + ChatStatusImage(contactNetworkStatus) } } } @@ -219,7 +277,7 @@ fun unreadCountStr(n: Int): String { } @Composable -fun ChatStatusImage(chat: Chat, s: NetworkStatus?) { +fun ChatStatusImage(s: NetworkStatus?) { val descr = s?.statusString if (s is NetworkStatus.Error) { Icon( @@ -249,6 +307,6 @@ fun ChatStatusImage(chat: Chat, s: NetworkStatus?) { @Composable fun PreviewChatPreviewView() { SimpleXTheme { - ChatPreviewView(Chat.sampleData, false, "", contactNetworkStatus = NetworkStatus.Connected(), stopped = false, linkMode = SimplexLinkMode.DESCRIPTION) + ChatPreviewView(Chat.sampleData, null, null, false, "", contactNetworkStatus = NetworkStatus.Connected(), stopped = false, linkMode = SimplexLinkMode.DESCRIPTION) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Enums.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Enums.kt index d6729ac68d..d9a24b70d2 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Enums.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Enums.kt @@ -1,8 +1,13 @@ +@file:UseSerializers(UriSerializer::class) package chat.simplex.app.views.helpers import android.net.Uri import androidx.compose.runtime.saveable.Saver import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.serialization.* +import kotlinx.serialization.descriptors.* +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder sealed class SharedContent { data class Text(val text: String): SharedContent() @@ -31,7 +36,16 @@ enum class AnimatedViewState { } } -sealed class UploadContent { - data class SimpleImage(val uri: Uri): UploadContent() - data class AnimatedImage(val uri: Uri): UploadContent() + +@Serializer(forClass = Uri::class) +object UriSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Uri", PrimitiveKind.STRING) + override fun serialize(encoder: Encoder, value: Uri) = encoder.encodeString(value.toString()) + override fun deserialize(decoder: Decoder): Uri = Uri.parse(decoder.decodeString()) +} + +@Serializable +sealed class UploadContent { + @Serializable data class SimpleImage(val uri: Uri): UploadContent() + @Serializable data class AnimatedImage(val uri: Uri): UploadContent() } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/RecAndPlay.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/RecAndPlay.kt index f0d17d5803..b9ce2b5702 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/RecAndPlay.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/RecAndPlay.kt @@ -1,5 +1,6 @@ package chat.simplex.app.views.helpers +import android.app.Application import android.content.Context import android.media.* import android.media.AudioManager.AudioPlaybackCallback @@ -14,8 +15,6 @@ import chat.simplex.app.model.ChatItem import chat.simplex.app.views.helpers.AudioPlayer.duration import kotlinx.coroutines.* import java.io.* -import java.text.SimpleDateFormat -import java.util.* interface Recorder { fun start(onProgressUpdate: (position: Int?, finished: Boolean) -> Unit): String @@ -26,6 +25,7 @@ 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 + const val extension = "m4a" } private var recorder: MediaRecorder? = null private var progressJob: Job? = null @@ -50,8 +50,10 @@ class RecorderNative(private val recordedBytesLimit: Long): Recorder { rec.setAudioEncodingBitRate(16000) rec.setMaxDuration(MAX_VOICE_MILLIS_FOR_SENDING) rec.setMaxFileSize(recordedBytesLimit) - val fileToSave = generateNewFileName(SimplexApp.context, "voice", "m4a") - val path = getAppFilePath(SimplexApp.context, fileToSave) + val tmpDir = SimplexApp.context.getDir("temp", Application.MODE_PRIVATE) + val fileToSave = File.createTempFile(generateNewFileName(SimplexApp.context, "voice", "${extension}_"), ".tmp", tmpDir) + fileToSave.deleteOnExit() + val path = fileToSave.absolutePath filePath = path rec.setOutputFile(path) rec.prepare() diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt index 27e2f5220f..74a99e03bf 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt @@ -244,6 +244,11 @@ fun getAppFilePath(context: Context, fileName: String): String { return "${getAppFilesDirectory(context)}/$fileName" } +fun getAppFileUri(fileName: String): Uri { + return Uri.parse("${getAppFilesDirectory(SimplexApp.context)}/$fileName") +} + + fun getLoadedFilePath(context: Context, file: CIFile?): String? { return if (file?.filePath != null && file.loaded) { val filePath = getAppFilePath(context, file.filePath)