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>
This commit is contained in:
Stanislav Dmitrenko 2023-10-03 20:46:17 +08:00 committed by GitHub
parent 38be27271f
commit cc95fa6b30
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 97 additions and 19 deletions

View file

@ -50,6 +50,7 @@ actual fun PlatformTextField(
userIsObserver: Boolean,
onMessageChange: (String) -> Unit,
onUpArrow: () -> Unit,
onFilesPasted: (List<URI>) -> Unit,
onDone: () -> Unit,
) {
val cs = composeState.value

View file

@ -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))
}

View file

@ -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<URI>) -> Unit,
onDone: () -> Unit,
)

View file

@ -97,6 +97,7 @@ fun TerminalLayout(
updateLiveMessage = null,
editPrevMessage = {},
onMessageChange = ::onMessageChange,
onFilesPasted = {},
textStyle = textStyle
)
}

View file

@ -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()

View file

@ -159,6 +159,17 @@ expect fun AttachmentSelection(
processPickedMedia: (List<URI>, String?) -> Unit
)
fun MutableState<ComposeState>.onFilesAttached(uris: List<URI>) {
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<ComposeState>.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
)

View file

@ -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<URI>) -> Unit,
onMessageChange: (String) -> Unit,
textStyle: MutableState<TextStyle>
) {
@ -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
)
}

View file

@ -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() {

View file

@ -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<URI>) -> 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<TextS
style = textStyle.value.copy(fontStyle = FontStyle.Italic)
)
}
private fun parseToFiles(text: AnnotatedString?): List<URI> {
text ?: return emptyList()
val files = ArrayList<URI>()
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
}

View file

@ -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))
}
}