From cc95fa6b305c7967b4c0b7a2ac0fac9310fb2ead Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Tue, 3 Oct 2023 20:46:17 +0800 Subject: [PATCH] desktop: paste files/images to attach to message (#3165) * desktop: paste files/images to attach to message * Windows * copy files inside the app * change * encrypted files support --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../platform/PlatformTextField.android.kt | 1 + .../views/chat/item/ChatItemView.android.kt | 6 +++ .../common/platform/PlatformTextField.kt | 3 ++ .../chat/simplex/common/views/TerminalView.kt | 1 + .../simplex/common/views/chat/ChatView.kt | 12 +----- .../simplex/common/views/chat/ComposeView.kt | 12 ++++++ .../simplex/common/views/chat/SendMsgView.kt | 8 +++- .../common/views/chat/item/ChatItemView.kt | 4 +- .../platform/PlatformTextField.desktop.kt | 39 ++++++++++++++++++- .../views/chat/item/ChatItemView.desktop.kt | 30 ++++++++++++-- 10 files changed, 97 insertions(+), 19 deletions(-) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt index 10faa1a82..1bc965849 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/PlatformTextField.android.kt @@ -50,6 +50,7 @@ actual fun PlatformTextField( userIsObserver: Boolean, onMessageChange: (String) -> Unit, onUpArrow: () -> Unit, + onFilesPasted: (List) -> Unit, onDone: () -> Unit, ) { val cs = composeState.value diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt index 8bb70c4a0..3aa4a9261 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.android.kt @@ -5,6 +5,8 @@ import android.os.Build import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState +import androidx.compose.ui.platform.ClipboardManager +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.sp import chat.simplex.common.model.ChatItem @@ -41,3 +43,7 @@ actual fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserL showMenu.value = false }) } + +actual fun copyItemToClipboard(cItem: ChatItem, clipboard: ClipboardManager) { + clipboard.setText(AnnotatedString(cItem.content.text)) +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/PlatformTextField.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/PlatformTextField.kt index 95b6a73ca..fa99d0f93 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/PlatformTextField.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/PlatformTextField.kt @@ -4,6 +4,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.ui.text.TextStyle import chat.simplex.common.views.chat.ComposeState +import java.io.File +import java.net.URI @Composable expect fun PlatformTextField( @@ -14,5 +16,6 @@ expect fun PlatformTextField( userIsObserver: Boolean, onMessageChange: (String) -> Unit, onUpArrow: () -> Unit, + onFilesPasted: (List) -> Unit, onDone: () -> Unit, ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt index c4f2a2cbd..a471b5645 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt @@ -97,6 +97,7 @@ fun TerminalLayout( updateLiveMessage = null, editPrevMessage = {}, onMessageChange = ::onMessageChange, + onFilesPasted = {}, textStyle = textStyle ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index ee9e109a7..4afcdacbd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -458,17 +458,7 @@ fun ChatLayout( .fillMaxWidth() .desktopOnExternalDrag( enabled = !attachmentDisabled.value && rememberUpdatedState(chat.userCanSend).value, - onFiles = { paths -> - val uris = paths.map { URI.create(it) } - val groups = uris.groupBy { isImage(it) } - val images = groups[true] ?: emptyList() - val files = groups[false] ?: emptyList() - if (images.isNotEmpty()) { - CoroutineScope(Dispatchers.IO).launch { composeState.processPickedMedia(images, null) } - } else if (files.isNotEmpty()) { - composeState.processPickedFile(uris.first(), null) - } - }, + onFiles = { paths -> composeState.onFilesAttached(paths.map { URI.create(it) }) }, onImage = { val tmpFile = File.createTempFile("image", ".bmp", tmpDir) tmpFile.deleteOnExit() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index c6e6ca7b7..972bc6621 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -159,6 +159,17 @@ expect fun AttachmentSelection( processPickedMedia: (List, String?) -> Unit ) +fun MutableState.onFilesAttached(uris: List) { + val groups = uris.groupBy { isImage(it) } + val images = groups[true] ?: emptyList() + val files = groups[false] ?: emptyList() + if (images.isNotEmpty()) { + CoroutineScope(Dispatchers.IO).launch { processPickedMedia(images, null) } + } else if (files.isNotEmpty()) { + processPickedFile(uris.first(), null) + } +} + fun MutableState.processPickedFile(uri: URI?, text: String?) { if (uri != null) { val fileSize = getFileSize(uri) @@ -816,6 +827,7 @@ fun ComposeView( chatModel.removeLiveDummy() }, editPrevMessage = ::editPrevMessage, + onFilesPasted = { composeState.onFilesAttached(it) }, onMessageChange = ::onMessageChange, textStyle = textStyle ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt index 2d696b778..28882e6b7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt @@ -29,6 +29,8 @@ import chat.simplex.res.MR import dev.icerock.moko.resources.compose.stringResource import dev.icerock.moko.resources.compose.painterResource import kotlinx.coroutines.* +import java.io.File +import java.net.URI @Composable fun SendMsgView( @@ -52,6 +54,7 @@ fun SendMsgView( updateLiveMessage: (suspend () -> Unit)? = null, cancelLiveMessage: (() -> Unit)? = null, editPrevMessage: () -> Unit, + onFilesPasted: (List) -> Unit, onMessageChange: (String) -> Unit, textStyle: MutableState ) { @@ -79,7 +82,7 @@ fun SendMsgView( val showVoiceButton = !nextSendGrpInv && cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing && cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started) val showDeleteTextButton = rememberSaveable { mutableStateOf(false) } - PlatformTextField(composeState, sendMsgEnabled, textStyle, showDeleteTextButton, userIsObserver, onMessageChange, editPrevMessage) { + PlatformTextField(composeState, sendMsgEnabled, textStyle, showDeleteTextButton, userIsObserver, onMessageChange, editPrevMessage, onFilesPasted) { if (!cs.inProgress) { sendMessage(null) } @@ -612,6 +615,7 @@ fun PreviewSendMsgView() { sendMessage = {}, editPrevMessage = {}, onMessageChange = { _ -> }, + onFilesPasted = {}, textStyle = textStyle ) } @@ -645,6 +649,7 @@ fun PreviewSendMsgViewEditing() { sendMessage = {}, editPrevMessage = {}, onMessageChange = { _ -> }, + onFilesPasted = {}, textStyle = textStyle ) } @@ -678,6 +683,7 @@ fun PreviewSendMsgViewInProgress() { sendMessage = {}, editPrevMessage = {}, onMessageChange = { _ -> }, + onFilesPasted = {}, textStyle = textStyle ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index b5236e249..dd07a3fc1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -201,7 +201,7 @@ fun ChatItemView( showMenu.value = false }) ItemAction(stringResource(MR.strings.copy_verb), painterResource(MR.images.ic_content_copy), onClick = { - clipboard.setText(AnnotatedString(cItem.content.text)) + copyItemToClipboard(cItem, clipboard) showMenu.value = false }) if ((cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) && getLoadedFilePath(cItem.file) != null) { @@ -561,6 +561,8 @@ private fun showMsgDeliveryErrorAlert(description: String) { ) } +expect fun copyItemToClipboard(cItem: ChatItem, clipboard: ClipboardManager) + @Preview @Composable fun PreviewChatItemView() { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt index 3b7ba8486..c677edc06 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/PlatformTextField.desktop.kt @@ -12,7 +12,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.input.key.* import androidx.compose.ui.platform.* @@ -27,6 +26,9 @@ import chat.simplex.common.views.helpers.generalGetString import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.delay +import java.io.File +import java.net.URI +import kotlin.io.path.* import kotlin.math.min import kotlin.text.substring @@ -39,6 +41,7 @@ actual fun PlatformTextField( userIsObserver: Boolean, onMessageChange: (String) -> Unit, onUpArrow: () -> Unit, + onFilesPasted: (List) -> Unit, onDone: () -> Unit, ) { val cs = composeState.value @@ -63,10 +66,20 @@ actual fun PlatformTextField( val isRtl = remember(cs.message) { isRtl(cs.message.subSequence(0, min(50, cs.message.length))) } var textFieldValueState by remember { mutableStateOf(TextFieldValue(text = cs.message)) } val textFieldValue = textFieldValueState.copy(text = cs.message) + val clipboard = LocalClipboardManager.current BasicTextField( value = textFieldValue, - onValueChange = { + onValueChange = onValueChange@ { if (!composeState.value.inProgress && !(composeState.value.preview is ComposePreview.VoicePreview && it.text != "")) { + val diff = textFieldValueState.selection.length + (it.text.length - textFieldValueState.text.length) + if (diff > 1 && it.text != textFieldValueState.text && it.selection.max - diff >= 0) { + val pasted = it.text.substring(it.selection.max - diff, it.selection.max) + val files = parseToFiles(AnnotatedString(pasted)) + if (files.isNotEmpty()) { + onFilesPasted(files) + return@onValueChange + } + } textFieldValueState = it onMessageChange(it.text) } @@ -98,6 +111,12 @@ actual fun PlatformTextField( } else if (it.key == Key.DirectionUp && it.type == KeyEventType.KeyDown && cs.message.isEmpty()) { onUpArrow() true + } else if (it.key == Key.V && + it.type == KeyEventType.KeyDown && + ((it.isCtrlPressed && !desktopPlatform.isMac()) || (it.isMetaPressed && desktopPlatform.isMac())) && + parseToFiles(clipboard.getText()).isNotEmpty()) { + onFilesPasted(parseToFiles(clipboard.getText())) + true } else false }, @@ -142,3 +161,19 @@ private fun ComposeOverlay(textId: StringResource, textStyle: MutableState { + text ?: return emptyList() + val files = ArrayList() + text.lines().forEach { + try { + val uri = File(it.removePrefix("\"").removeSuffix("\"")).toURI() + val path = uri.toPath() + if (!path.exists() || !path.isAbsolute || path.isDirectory()) return emptyList() + files.add(uri) + } catch (e: Exception) { + return emptyList() + } + } + return files +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt index c1d9eeec5..9df5bd0a1 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.desktop.kt @@ -7,17 +7,19 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.ui.Modifier import androidx.compose.foundation.layout.padding +import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.* -import chat.simplex.common.model.ChatItem -import chat.simplex.common.model.MsgContent -import chat.simplex.common.platform.FileChooserLauncher -import chat.simplex.common.platform.desktopPlatform +import chat.simplex.common.model.* +import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.EmojiFont import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource +import java.io.File +import java.util.* @Composable actual fun ReactionIcon(text: String, fontSize: TextUnit) { @@ -39,3 +41,23 @@ actual fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserL showMenu.value = false }) } + +actual fun copyItemToClipboard(cItem: ChatItem, clipboard: ClipboardManager) { + val fileSource = getLoadedFileSource(cItem.file) + if (fileSource != null) { + val filePath: String = if (fileSource.cryptoArgs != null) { + val tmpFile = File(tmpDir, fileSource.filePath) + tmpFile.deleteOnExit() + decryptCryptoFile(getAppFilePath(fileSource.filePath), fileSource.cryptoArgs, tmpFile.absolutePath) + tmpFile.absolutePath + } else { + getAppFilePath(fileSource.filePath) + } + when { + desktopPlatform.isWindows() -> clipboard.setText(AnnotatedString("\"${File(filePath).absolutePath}\"")) + else -> clipboard.setText(AnnotatedString(filePath)) + } + } else { + clipboard.setText(AnnotatedString(cItem.content.text)) + } +}