mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2025-06-28 20:29:53 +00:00
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:
parent
38be27271f
commit
cc95fa6b30
10 changed files with 97 additions and 19 deletions
|
@ -50,6 +50,7 @@ actual fun PlatformTextField(
|
|||
userIsObserver: Boolean,
|
||||
onMessageChange: (String) -> Unit,
|
||||
onUpArrow: () -> Unit,
|
||||
onFilesPasted: (List<URI>) -> Unit,
|
||||
onDone: () -> Unit,
|
||||
) {
|
||||
val cs = composeState.value
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -97,6 +97,7 @@ fun TerminalLayout(
|
|||
updateLiveMessage = null,
|
||||
editPrevMessage = {},
|
||||
onMessageChange = ::onMessageChange,
|
||||
onFilesPasted = {},
|
||||
textStyle = textStyle
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue