mobile: files UI (#597)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
This commit is contained in:
@@ -867,6 +867,8 @@ class CIFile(
|
||||
) {
|
||||
val stored: Boolean = when (fileStatus) {
|
||||
CIFileStatus.SndStored -> true
|
||||
CIFileStatus.SndTransfer -> true
|
||||
CIFileStatus.SndComplete -> true
|
||||
CIFileStatus.SndCancelled -> true
|
||||
CIFileStatus.RcvComplete -> true
|
||||
else -> false
|
||||
|
||||
@@ -79,7 +79,11 @@ class NtfManager(val context: Context) {
|
||||
private fun hideSecrets(cItem: ChatItem) : String {
|
||||
val md = cItem.formattedText
|
||||
return if (md == null) {
|
||||
cItem.content.text
|
||||
if (cItem.content.text != "") {
|
||||
cItem.content.text
|
||||
} else {
|
||||
cItem.file?.fileName ?: ""
|
||||
}
|
||||
} else {
|
||||
var res = ""
|
||||
for (ft in md) {
|
||||
|
||||
@@ -429,6 +429,21 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
|
||||
chatItemSimpleUpdate(r.chatItem)
|
||||
is CR.RcvFileComplete ->
|
||||
chatItemSimpleUpdate(r.chatItem)
|
||||
is CR.SndFileStart ->
|
||||
chatItemSimpleUpdate(r.chatItem)
|
||||
is CR.SndFileComplete -> {
|
||||
chatItemSimpleUpdate(r.chatItem)
|
||||
val cItem = r.chatItem.chatItem
|
||||
val mc = cItem.content.msgContent
|
||||
val fileName = cItem.file?.fileName
|
||||
if (
|
||||
r.chatItem.chatInfo.chatType == ChatType.Direct
|
||||
&& mc is MsgContent.MCFile
|
||||
&& fileName != null
|
||||
) {
|
||||
removeFile(appContext, fileName)
|
||||
}
|
||||
}
|
||||
else ->
|
||||
Log.d(TAG , "unsupported event: ${r.responseType}")
|
||||
}
|
||||
@@ -560,15 +575,7 @@ sealed class CC {
|
||||
is SetFilesFolder -> "/_files_folder $filesFolder"
|
||||
is ApiGetChats -> "/_get chats pcc=on"
|
||||
is ApiGetChat -> "/_get chat ${chatRef(type, id)} count=100"
|
||||
is ApiSendMessage -> when {
|
||||
file == null && quotedItemId == null -> "/_send ${chatRef(type, id)} ${mc.cmdString}"
|
||||
file != null && quotedItemId == null -> "/_send ${chatRef(type, id)} file $file ${mc.cmdString}"
|
||||
file == null && quotedItemId != null -> "/_send ${chatRef(type, id)} quoted $quotedItemId ${mc.cmdString}"
|
||||
file != null && quotedItemId != null -> "/_send ${chatRef(type, id)} file $file quoted $quotedItemId ${mc.cmdString}"
|
||||
else -> throw Exception()
|
||||
}
|
||||
// TODO use below
|
||||
// is ApiSendMessage -> "/_send_v2 ${chatRef(type, id)} ${json.encodeToString(ComposedMessage(file, quotedItemId, mc))}"
|
||||
is ApiSendMessage -> "/_send ${chatRef(type, id)} json ${json.encodeToString(ComposedMessage(file, quotedItemId, mc))}"
|
||||
is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId ${mc.cmdString}"
|
||||
is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} $itemId ${mode.deleteMode}"
|
||||
is GetUserSMPServers -> "/smp_servers"
|
||||
|
||||
@@ -16,3 +16,5 @@ val HighOrLowlight = Color(134, 135, 139, 255)
|
||||
val ToolbarLight = Color(220, 220, 220, 20)
|
||||
val ToolbarDark = Color(80, 80, 80, 20)
|
||||
val WarningOrange = Color(255, 149, 0, 255)
|
||||
val FileLight = Color(183, 190, 199, 255)
|
||||
val FileDark = Color(101, 101, 106, 255)
|
||||
|
||||
@@ -2,6 +2,7 @@ package chat.simplex.app.views.chat
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.*
|
||||
@@ -27,6 +28,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.SimplexApp.Companion.context
|
||||
import chat.simplex.app.TAG
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
@@ -46,6 +48,7 @@ fun ChatView(chatModel: ChatModel) {
|
||||
val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
|
||||
val scope = rememberCoroutineScope()
|
||||
val chosenImage = remember { mutableStateOf<Bitmap?>(null) }
|
||||
val chosenFile = remember { mutableStateOf<Uri?>(null) }
|
||||
|
||||
if (chat == null || user == null) {
|
||||
chatModel.chatId.value = null
|
||||
@@ -77,9 +80,11 @@ fun ChatView(chatModel: ChatModel) {
|
||||
chat,
|
||||
composeState,
|
||||
chosenImage,
|
||||
chosenFile,
|
||||
showAttachmentBottomSheet = { scope.launch { attachmentBottomSheetState.show() } })
|
||||
},
|
||||
chosenImage,
|
||||
chosenFile,
|
||||
scope,
|
||||
attachmentBottomSheetState,
|
||||
chatModel.chatItems,
|
||||
@@ -124,6 +129,7 @@ fun ChatLayout(
|
||||
composeState: MutableState<ComposeState>,
|
||||
composeView: (@Composable () -> Unit),
|
||||
chosenImage: MutableState<Bitmap?>,
|
||||
chosenFile: MutableState<Uri?>,
|
||||
scope: CoroutineScope,
|
||||
attachmentBottomSheetState: ModalBottomSheetState,
|
||||
chatItems: List<ChatItem>,
|
||||
@@ -137,6 +143,12 @@ fun ChatLayout(
|
||||
val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000)
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(imagePreview))
|
||||
}
|
||||
fun onFileChange(uri: Uri) {
|
||||
val fileName = getFileName(context, uri)
|
||||
if (fileName != null) {
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.FilePreview(fileName))
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
Modifier
|
||||
@@ -151,6 +163,8 @@ fun ChatLayout(
|
||||
GetImageBottomSheet(
|
||||
chosenImage,
|
||||
::onImageChange,
|
||||
chosenFile,
|
||||
::onFileChange,
|
||||
hideBottomSheet = {
|
||||
scope.launch { attachmentBottomSheetState.hide() }
|
||||
})
|
||||
@@ -355,6 +369,7 @@ fun PreviewChatLayout() {
|
||||
),
|
||||
composeState = remember { mutableStateOf(ComposeState()) },
|
||||
composeView = {},
|
||||
chosenFile = remember { mutableStateOf(null) },
|
||||
chosenImage = remember { mutableStateOf(null) },
|
||||
scope = rememberCoroutineScope(),
|
||||
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
|
||||
@@ -400,6 +415,7 @@ fun PreviewGroupChatLayout() {
|
||||
composeState = remember { mutableStateOf(ComposeState()) },
|
||||
composeView = {},
|
||||
chosenImage = remember { mutableStateOf(null) },
|
||||
chosenFile = remember { mutableStateOf(null) },
|
||||
scope = rememberCoroutineScope(),
|
||||
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
|
||||
chatItems = chatItems,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.InsertDriveFile
|
||||
import androidx.compose.material.icons.outlined.Close
|
||||
import androidx.compose.runtime.*
|
||||
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 chat.simplex.app.R
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.chat.item.SentColorLight
|
||||
|
||||
@Composable
|
||||
fun ComposeFileView(fileName: String, cancelFile: () -> Unit, cancelEnabled: Boolean) {
|
||||
Row(
|
||||
Modifier
|
||||
.height(60.dp)
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp)
|
||||
.background(SentColorLight),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.InsertDriveFile,
|
||||
stringResource(R.string.icon_descr_file),
|
||||
Modifier
|
||||
.padding(start = 4.dp, end = 2.dp)
|
||||
.size(36.dp),
|
||||
tint = if (isSystemInDarkTheme()) FileDark else FileLight
|
||||
)
|
||||
Text(fileName)
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (cancelEnabled) {
|
||||
IconButton(onClick = cancelFile, 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 PreviewComposeFileView() {
|
||||
SimpleXTheme {
|
||||
ComposeFileView(
|
||||
"test.txt",
|
||||
cancelFile = {},
|
||||
cancelEnabled = true
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
package chat.simplex.app.views.chat
|
||||
|
||||
import ComposeFileView
|
||||
import ComposeImageView
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.clickable
|
||||
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.AttachFile
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.outlined.Reply
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -22,13 +25,12 @@ import chat.simplex.app.model.*
|
||||
import chat.simplex.app.views.chat.item.*
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
|
||||
sealed class ComposePreview {
|
||||
object NoPreview: ComposePreview()
|
||||
class CLinkPreview(val linkPreview: LinkPreview): ComposePreview()
|
||||
class ImagePreview(val image: String): ComposePreview()
|
||||
class FilePreview(val fileName: String): ComposePreview()
|
||||
}
|
||||
|
||||
sealed class ComposeContextItem {
|
||||
@@ -55,16 +57,20 @@ data class ComposeState(
|
||||
is ComposeContextItem.EditingItem -> true
|
||||
else -> false
|
||||
}
|
||||
val sendEnabled: Boolean
|
||||
get() =
|
||||
when (preview) {
|
||||
val sendEnabled: () -> Boolean
|
||||
get() = {
|
||||
val hasContent = when (preview) {
|
||||
is ComposePreview.ImagePreview -> true
|
||||
is ComposePreview.FilePreview -> true
|
||||
else -> message.isNotEmpty()
|
||||
}
|
||||
hasContent && !inProgress
|
||||
}
|
||||
val linkPreviewAllowed: Boolean
|
||||
get() =
|
||||
when (preview) {
|
||||
is ComposePreview.ImagePreview -> false
|
||||
is ComposePreview.FilePreview -> false
|
||||
else -> true
|
||||
}
|
||||
val linkPreview: LinkPreview?
|
||||
@@ -77,8 +83,13 @@ data class ComposeState(
|
||||
|
||||
fun chatItemPreview(chatItem: ChatItem): ComposePreview {
|
||||
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(image = mc.image)
|
||||
is MsgContent.MCFile -> {
|
||||
val fileName = chatItem.file?.fileName ?: ""
|
||||
ComposePreview.FilePreview(fileName)
|
||||
}
|
||||
else -> ComposePreview.NoPreview
|
||||
}
|
||||
}
|
||||
@@ -89,6 +100,7 @@ fun ComposeView(
|
||||
chat: Chat,
|
||||
composeState: MutableState<ComposeState>,
|
||||
chosenImage: MutableState<Bitmap?>,
|
||||
chosenFile: MutableState<Uri?>,
|
||||
showAttachmentBottomSheet: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -169,24 +181,19 @@ fun ComposeView(
|
||||
}
|
||||
}
|
||||
|
||||
fun saveImage(context: Context, image: Bitmap): String? {
|
||||
return try {
|
||||
val dataResized = resizeImageToDataSize(image, maxDataSize = MAX_IMAGE_SIZE)
|
||||
val fileToSave = "image_${System.currentTimeMillis()}.jpg"
|
||||
val file = File(getAppFilesDirectory(context) + "/" + fileToSave)
|
||||
val output = FileOutputStream(file)
|
||||
dataResized.writeTo(output)
|
||||
output.flush()
|
||||
output.close()
|
||||
fileToSave
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
fun clearState() {
|
||||
composeState.value = ComposeState()
|
||||
chosenImage.value = null
|
||||
chosenFile.value = null
|
||||
linkUrl.value = null
|
||||
prevLinkUrl.value = null
|
||||
pendingLinkUrl.value = null
|
||||
cancelledLinks.clear()
|
||||
}
|
||||
|
||||
fun sendMessage() {
|
||||
withApi {
|
||||
// show "in progress"
|
||||
composeState.value = composeState.value.copy(inProgress = true)
|
||||
val cInfo = chat.chatInfo
|
||||
val cs = composeState.value
|
||||
when (val contextItem = cs.contextItem) {
|
||||
@@ -218,6 +225,15 @@ fun ComposeView(
|
||||
}
|
||||
}
|
||||
}
|
||||
is ComposePreview.FilePreview -> {
|
||||
val chosenFileVal = chosenFile.value
|
||||
if (chosenFileVal != null) {
|
||||
file = saveFileFromUri(context, chosenFileVal)
|
||||
if (file != null) {
|
||||
mc = MsgContent.MCFile(cs.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val quotedItemId: Long? = when (contextItem) {
|
||||
is ComposeContextItem.QuotedItem -> contextItem.chatItem.id
|
||||
@@ -236,8 +252,7 @@ fun ComposeView(
|
||||
}
|
||||
}
|
||||
}
|
||||
// hide "in progress"
|
||||
composeState.value = ComposeState()
|
||||
clearState()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,8 +278,13 @@ fun ComposeView(
|
||||
}
|
||||
|
||||
fun cancelImage() {
|
||||
chosenImage.value = null
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
|
||||
chosenImage.value = null
|
||||
}
|
||||
|
||||
fun cancelFile() {
|
||||
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
|
||||
chosenFile.value = null
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -277,6 +297,11 @@ fun ComposeView(
|
||||
::cancelImage,
|
||||
cancelEnabled = !composeState.value.editing
|
||||
)
|
||||
is ComposePreview.FilePreview -> ComposeFileView(
|
||||
preview.fileName,
|
||||
::cancelFile,
|
||||
cancelEnabled = !composeState.value.editing
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,14 +309,21 @@ fun ComposeView(
|
||||
fun contextItemView() {
|
||||
when (val contextItem = composeState.value.contextItem) {
|
||||
ComposeContextItem.NoContextItem -> {}
|
||||
is ComposeContextItem.QuotedItem -> ContextItemView(contextItem.chatItem) { composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem) }
|
||||
is ComposeContextItem.EditingItem -> ContextItemView(contextItem.chatItem) { composeState.value = ComposeState() }
|
||||
is ComposeContextItem.QuotedItem -> ContextItemView(contextItem.chatItem, Icons.Outlined.Reply) {
|
||||
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem)
|
||||
}
|
||||
is ComposeContextItem.EditingItem -> ContextItemView(contextItem.chatItem, Icons.Filled.Edit) {
|
||||
clearState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
contextItemView()
|
||||
previewView()
|
||||
when {
|
||||
composeState.value.editing && composeState.value.preview is ComposePreview.FilePreview -> {}
|
||||
else -> previewView()
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.padding(start = 4.dp, end = 8.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
|
||||
@@ -4,10 +4,12 @@ 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.Edit
|
||||
import androidx.compose.material.icons.outlined.Close
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.withStyle
|
||||
@@ -16,6 +18,7 @@ import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.CIDirection
|
||||
import chat.simplex.app.model.ChatItem
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.views.chat.item.*
|
||||
import kotlinx.datetime.Clock
|
||||
@@ -23,6 +26,7 @@ import kotlinx.datetime.Clock
|
||||
@Composable
|
||||
fun ContextItemView(
|
||||
contextItem: ChatItem,
|
||||
contextIcon: ImageVector,
|
||||
cancelContextItem: () -> Unit
|
||||
) {
|
||||
val sent = contextItem.chatDir.sent
|
||||
@@ -32,13 +36,22 @@ fun ContextItemView(
|
||||
.background(if (sent) SentColorLight else ReceivedColorLight),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
Row(
|
||||
Modifier
|
||||
.padding(start = 16.dp)
|
||||
.padding(vertical = 12.dp)
|
||||
.fillMaxWidth()
|
||||
.weight(1F)
|
||||
.weight(1F),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
contextIcon,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp)
|
||||
.height(20.dp)
|
||||
.width(20.dp),
|
||||
contentDescription = stringResource(R.string.icon_descr_context),
|
||||
tint = HighOrLowlight,
|
||||
)
|
||||
ContextItemText(contextItem)
|
||||
}
|
||||
IconButton(onClick = cancelContextItem) {
|
||||
@@ -56,11 +69,11 @@ fun ContextItemView(
|
||||
private fun ContextItemText(cxtItem: ChatItem) {
|
||||
val member = cxtItem.memberDisplayName
|
||||
if (member == null) {
|
||||
Text(cxtItem.content.text, maxLines = 3)
|
||||
Text(cxtItem.text, maxLines = 3)
|
||||
} else {
|
||||
val annotatedText = buildAnnotatedString {
|
||||
withStyle(boldFont) { append(member) }
|
||||
append(": ${cxtItem.content.text}")
|
||||
append(": ${cxtItem.text}")
|
||||
}
|
||||
Text(annotatedText, maxLines = 3)
|
||||
}
|
||||
@@ -72,6 +85,7 @@ fun PreviewContextItemView() {
|
||||
SimpleXTheme {
|
||||
ContextItemView(
|
||||
contextItem = ChatItem.getSampleData(1, CIDirection.DirectRcv(), Clock.System.now(), "hello"),
|
||||
contextIcon = Icons.Filled.Edit,
|
||||
cancelContextItem = {}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,8 +24,7 @@ import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||
import chat.simplex.app.ui.theme.*
|
||||
|
||||
@Composable
|
||||
fun SendMsgView(
|
||||
@@ -65,22 +64,34 @@ fun SendMsgView(
|
||||
innerTextField()
|
||||
}
|
||||
val icon = if (cs.editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward
|
||||
val color = if (cs.sendEnabled) MaterialTheme.colors.primary else Color.Gray
|
||||
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()
|
||||
val color = if (cs.sendEnabled()) MaterialTheme.colors.primary else HighOrLowlight
|
||||
if (cs.inProgress
|
||||
&& (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.FilePreview)
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
Modifier
|
||||
.size(36.dp)
|
||||
.padding(4.dp),
|
||||
color = HighOrLowlight,
|
||||
strokeWidth = 4.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()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,3 +138,24 @@ fun PreviewSendMsgViewEditing() {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Preview(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
name = "Dark Mode"
|
||||
)
|
||||
@Composable
|
||||
fun PreviewSendMsgViewInProgress() {
|
||||
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
|
||||
val textStyle = remember { mutableStateOf(smallFont) }
|
||||
val composeStateInProgress = ComposeState(inProgress = true)
|
||||
SimpleXTheme {
|
||||
SendMsgView(
|
||||
composeState = remember { mutableStateOf(composeStateInProgress) },
|
||||
sendMessage = {},
|
||||
onMessageChange = { _ -> },
|
||||
textStyle = textStyle
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.InsertDriveFile
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -20,11 +24,8 @@ import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.*
|
||||
import chat.simplex.app.ui.theme.*
|
||||
import chat.simplex.app.views.chat.item.FramedItemView
|
||||
import chat.simplex.app.views.helpers.*
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlin.math.log2
|
||||
import kotlin.math.pow
|
||||
|
||||
@Composable
|
||||
fun CIFileView(
|
||||
@@ -33,15 +34,13 @@ fun CIFileView(
|
||||
receiveFile: (Long) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val saveFileLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument(),
|
||||
onResult = { destination ->
|
||||
saveFile(context, file, destination)
|
||||
}
|
||||
)
|
||||
val saveFileLauncher = rememberSaveFileLauncher(cxt = context, ciFile = file)
|
||||
|
||||
@Composable
|
||||
fun fileIcon(innerIcon: ImageVector? = null, color: Color = HighOrLowlight) {
|
||||
fun fileIcon(
|
||||
innerIcon: ImageVector? = null,
|
||||
color: Color = if (isSystemInDarkTheme()) FileDark else FileLight
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
@@ -80,7 +79,7 @@ fun CIFileView(
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.large_file),
|
||||
String.format(generalGetString(R.string.contact_sent_large_file), MAX_FILE_SIZE)
|
||||
String.format(generalGetString(R.string.contact_sent_large_file), formatBytes(MAX_FILE_SIZE))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -102,14 +101,29 @@ fun CIFileView(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun progressIndicator() {
|
||||
CircularProgressIndicator(
|
||||
Modifier.size(32.dp),
|
||||
color = if (isSystemInDarkTheme()) FileDark else FileLight,
|
||||
strokeWidth = 4.dp
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun fileIndicator() {
|
||||
Box(
|
||||
Modifier.size(44.dp),
|
||||
Modifier
|
||||
.size(42.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.clickable(onClick = { fileAction() }),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (file != null) {
|
||||
when (file.fileStatus) {
|
||||
CIFileStatus.SndStored -> fileIcon()
|
||||
CIFileStatus.SndTransfer -> progressIndicator()
|
||||
CIFileStatus.SndComplete -> fileIcon(innerIcon = Icons.Filled.Check)
|
||||
CIFileStatus.SndCancelled -> fileIcon(innerIcon = Icons.Outlined.Close)
|
||||
CIFileStatus.RcvInvitation ->
|
||||
if (fileSizeValid())
|
||||
@@ -117,14 +131,9 @@ fun CIFileView(
|
||||
else
|
||||
fileIcon(innerIcon = Icons.Outlined.PriorityHigh, color = WarningOrange)
|
||||
CIFileStatus.RcvAccepted -> fileIcon(innerIcon = Icons.Outlined.MoreHoriz)
|
||||
CIFileStatus.RcvTransfer ->
|
||||
CircularProgressIndicator(
|
||||
Modifier.size(36.dp),
|
||||
color = HighOrLowlight,
|
||||
strokeWidth = 4.dp
|
||||
)
|
||||
CIFileStatus.RcvTransfer -> progressIndicator()
|
||||
CIFileStatus.RcvComplete -> fileIcon(innerIcon = Icons.Outlined.ArrowDownward)
|
||||
CIFileStatus.RcvCancelled -> fileIcon(innerIcon = Icons.Outlined.Close)
|
||||
else -> fileIcon()
|
||||
}
|
||||
} else {
|
||||
fileIcon()
|
||||
@@ -132,30 +141,10 @@ fun CIFileView(
|
||||
}
|
||||
}
|
||||
|
||||
fun formatBytes(bytes: Long): String {
|
||||
if (bytes == 0.toLong()) {
|
||||
return "0 bytes"
|
||||
}
|
||||
val bytesDouble = bytes.toDouble()
|
||||
val k = 1000.toDouble()
|
||||
val units = arrayOf("bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB")
|
||||
val i = kotlin.math.floor(log2(bytesDouble) / log2(k))
|
||||
val size = bytesDouble / k.pow(i)
|
||||
val unit = units[i.toInt()]
|
||||
|
||||
return if (i <= 1) {
|
||||
String.format("%.0f %s", size, unit)
|
||||
} else {
|
||||
String.format("%.2f %s", size, unit)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
Modifier
|
||||
.padding(top = 4.dp, bottom = 6.dp, start = 10.dp, end = 12.dp)
|
||||
.clickable(onClick = { fileAction() }),
|
||||
Modifier.padding(top = 4.dp, bottom = 6.dp, start = 6.dp, end = 12.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
fileIndicator()
|
||||
val metaReserve = if (edited)
|
||||
@@ -189,7 +178,7 @@ class ChatItemProvider: PreviewParameterProvider<ChatItem> {
|
||||
meta = CIMeta.getSample(1, Clock.System.now(), "", CIStatus.SndSent(), itemDeleted = false, itemEdited = true, editable = false),
|
||||
content = CIContent.SndMsgContent(msgContent = MsgContent.MCFile("")),
|
||||
quotedItem = null,
|
||||
file = CIFile.getSample(fileStatus = CIFileStatus.SndStored)
|
||||
file = CIFile.getSample(fileStatus = CIFileStatus.SndComplete)
|
||||
)
|
||||
private val fileChatItemWtFile = ChatItem(
|
||||
chatDir = CIDirection.DirectRcv(),
|
||||
@@ -205,7 +194,7 @@ class ChatItemProvider: PreviewParameterProvider<ChatItem> {
|
||||
ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvAccepted),
|
||||
ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvTransfer),
|
||||
ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvCancelled),
|
||||
ChatItem.getFileMsgContentSample(fileSize = 2000000, fileStatus = CIFileStatus.RcvInvitation),
|
||||
ChatItem.getFileMsgContentSample(fileSize = 1_000_000_000, fileStatus = CIFileStatus.RcvInvitation),
|
||||
ChatItem.getFileMsgContentSample(text = "Hello there", fileStatus = CIFileStatus.RcvInvitation),
|
||||
ChatItem.getFileMsgContentSample(text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus = CIFileStatus.RcvInvitation),
|
||||
fileChatItemWtFile
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.CircularProgressIndicator
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.outlined.MoreHoriz
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -34,6 +35,19 @@ fun CIImageView(
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
when (file.fileStatus) {
|
||||
CIFileStatus.SndTransfer ->
|
||||
CircularProgressIndicator(
|
||||
Modifier.size(16.dp),
|
||||
color = Color.White,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
CIFileStatus.SndComplete ->
|
||||
Icon(
|
||||
Icons.Filled.Check,
|
||||
stringResource(R.string.icon_descr_image_snd_complete),
|
||||
Modifier.fillMaxSize(),
|
||||
tint = Color.White
|
||||
)
|
||||
CIFileStatus.RcvAccepted ->
|
||||
Icon(
|
||||
Icons.Outlined.MoreHoriz,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
package chat.simplex.app.views.chat.item
|
||||
|
||||
import android.content.Context
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
@@ -12,6 +11,7 @@ import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -42,19 +42,18 @@ fun ChatItemView(
|
||||
val sent = cItem.chatDir.sent
|
||||
val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart
|
||||
val showMenu = remember { mutableStateOf(false) }
|
||||
val saveFileLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument(),
|
||||
onResult = { destination ->
|
||||
saveFile(context, cItem.file, destination)
|
||||
}
|
||||
)
|
||||
val saveFileLauncher = rememberSaveFileLauncher(cxt = context, ciFile = cItem.file)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(bottom = 4.dp)
|
||||
.fillMaxWidth(),
|
||||
contentAlignment = alignment,
|
||||
) {
|
||||
Column(Modifier.combinedClickable(onLongClick = { showMenu.value = true }, onClick = {})) {
|
||||
Column(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = {})
|
||||
) {
|
||||
if (cItem.isMsgContent) {
|
||||
if (cItem.file == null && cItem.quotedItem == null && isShortEmoji(cItem.content.text)) {
|
||||
EmojiItemView(cItem)
|
||||
@@ -71,7 +70,11 @@ fun ChatItemView(
|
||||
Modifier.width(220.dp)
|
||||
) {
|
||||
ItemAction(stringResource(R.string.reply_verb), Icons.Outlined.Reply, onClick = {
|
||||
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
|
||||
if (composeState.value.editing) {
|
||||
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem))
|
||||
} else {
|
||||
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
|
||||
}
|
||||
showMenu.value = false
|
||||
})
|
||||
ItemAction(stringResource(R.string.share_verb), Icons.Outlined.Share, onClick = {
|
||||
@@ -82,7 +85,7 @@ fun ChatItemView(
|
||||
copyText(cxt, cItem.content.text)
|
||||
showMenu.value = false
|
||||
})
|
||||
if (cItem.content.msgContent is MsgContent.MCImage) {
|
||||
if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCFile) {
|
||||
val filePath = getStoredFilePath(context, cItem.file)
|
||||
if (filePath != null) {
|
||||
ItemAction(stringResource(R.string.save_verb), Icons.Outlined.SaveAlt, onClick = {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -18,8 +18,7 @@ import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Collections
|
||||
import androidx.compose.material.icons.outlined.PhotoCamera
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
@@ -37,7 +36,6 @@ import kotlin.math.min
|
||||
import kotlin.math.sqrt
|
||||
|
||||
// Inspired by https://github.com/MakeItEasyDev/Jetpack-Compose-Capture-Image-Or-Choose-from-Gallery
|
||||
|
||||
fun cropToSquare(image: Bitmap): Bitmap {
|
||||
var xOffset = 0
|
||||
var yOffset = 0
|
||||
@@ -50,7 +48,7 @@ fun cropToSquare(image: Bitmap): Bitmap {
|
||||
return Bitmap.createBitmap(image, xOffset, yOffset, side, side)
|
||||
}
|
||||
|
||||
fun resizeImageToStrSize(image: Bitmap, maxDataSize: Int): String {
|
||||
fun resizeImageToStrSize(image: Bitmap, maxDataSize: Long): String {
|
||||
var img = image
|
||||
var str = compressImageStr(img)
|
||||
while (str.length > maxDataSize) {
|
||||
@@ -68,7 +66,7 @@ private fun compressImageStr(bitmap: Bitmap): String {
|
||||
return "data:image/jpg;base64," + Base64.encodeToString(compressImageData(bitmap).toByteArray(), Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
fun resizeImageToDataSize(image: Bitmap, maxDataSize: Int): ByteArrayOutputStream {
|
||||
fun resizeImageToDataSize(image: Bitmap, maxDataSize: Long): ByteArrayOutputStream {
|
||||
var img = image
|
||||
var stream = compressImageData(img)
|
||||
while (stream.size() > maxDataSize) {
|
||||
@@ -88,7 +86,7 @@ private fun compressImageData(bitmap: Bitmap): ByteArrayOutputStream {
|
||||
return stream
|
||||
}
|
||||
|
||||
fun base64ToBitmap(base64ImageString: String) : Bitmap {
|
||||
fun base64ToBitmap(base64ImageString: String): Bitmap {
|
||||
val imageString = base64ImageString
|
||||
.removePrefix("data:image/png;base64,")
|
||||
.removePrefix("data:image/jpg;base64,")
|
||||
@@ -96,7 +94,7 @@ fun base64ToBitmap(base64ImageString: String) : Bitmap {
|
||||
return BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
|
||||
}
|
||||
|
||||
class CustomTakePicturePreview : ActivityResultContract<Void?, Bitmap?>() {
|
||||
class CustomTakePicturePreview: ActivityResultContract<Void?, Bitmap?>() {
|
||||
private var uri: Uri? = null
|
||||
private var tmpFile: File? = null
|
||||
lateinit var externalContext: Context
|
||||
@@ -122,15 +120,24 @@ class CustomTakePicturePreview : ActivityResultContract<Void?, Bitmap?>() {
|
||||
tmpFile?.delete()
|
||||
bitmap
|
||||
} else {
|
||||
Log.e( TAG, "Getting image from camera cancelled or failed.")
|
||||
Log.e(TAG, "Getting image from camera cancelled or failed.")
|
||||
tmpFile?.delete()
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//class GetGalleryContent: ActivityResultContracts.GetContent() {
|
||||
// override fun createIntent(context: Context, input: String): Intent {
|
||||
// return super.createIntent(context, input).apply {
|
||||
// Log.e(TAG, "########################################################### in GetGalleryContent")
|
||||
// uri = DocumentsContract.buildDocumentUriUsingTree(uri, DocumentsContract.getTreeDocumentId(uri))
|
||||
// putExtra(DocumentsContract.EXTRA_INITIAL_URI, Environment.DIRECTORY_PICTURES)
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
@Composable
|
||||
fun rememberGalleryLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLauncher<String, Uri?> =
|
||||
fun rememberGetContentLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLauncher<String, Uri?> =
|
||||
// rememberLauncherForActivityResult(contract = GetGalleryContent(), cb)
|
||||
rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent(), cb)
|
||||
|
||||
@Composable
|
||||
@@ -145,12 +152,13 @@ fun rememberPermissionLauncher(cb: (Boolean) -> Unit): ManagedActivityResultLaun
|
||||
fun GetImageBottomSheet(
|
||||
imageBitmap: MutableState<Bitmap?>,
|
||||
onImageChange: (Bitmap) -> Unit,
|
||||
fileUri: MutableState<Uri?>? = null,
|
||||
onFileChange: ((Uri) -> Unit)? = null,
|
||||
hideBottomSheet: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val isCameraSelected = remember { mutableStateOf (false) }
|
||||
|
||||
val galleryLauncher = rememberGalleryLauncher { uri: Uri? ->
|
||||
val isCameraSelected = remember { mutableStateOf(false) }
|
||||
val galleryLauncher = rememberGetContentLauncher { uri: Uri? ->
|
||||
if (uri != null) {
|
||||
val source = ImageDecoder.createSource(context.contentResolver, uri)
|
||||
val bitmap = ImageDecoder.decodeBitmap(source)
|
||||
@@ -158,21 +166,41 @@ fun GetImageBottomSheet(
|
||||
onImageChange(bitmap)
|
||||
}
|
||||
}
|
||||
|
||||
val cameraLauncher = rememberCameraLauncher { bitmap: Bitmap? ->
|
||||
if (bitmap != null) {
|
||||
imageBitmap.value = bitmap
|
||||
onImageChange(bitmap)
|
||||
}
|
||||
}
|
||||
|
||||
val permissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
|
||||
if (isGranted) {
|
||||
if (isCameraSelected.value) cameraLauncher.launch(null)
|
||||
else galleryLauncher.launch("image/*")
|
||||
hideBottomSheet()
|
||||
} else {
|
||||
Toast.makeText(context, generalGetString(R.string.toast_camera_permission_denied), Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
val filesLauncher = rememberGetContentLauncher { uri: Uri? ->
|
||||
if (uri != null && fileUri != null && onFileChange != null) {
|
||||
val fileSize = getFileSize(context, uri)
|
||||
if (fileSize != null && fileSize <= MAX_FILE_SIZE) {
|
||||
fileUri.value = uri
|
||||
onFileChange(uri)
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(R.string.large_file),
|
||||
String.format(generalGetString(R.string.maximum_supported_file_size), formatBytes(MAX_FILE_SIZE))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val filesPermissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
|
||||
if (isGranted) {
|
||||
filesLauncher.launch("*/*")
|
||||
hideBottomSheet()
|
||||
} else {
|
||||
Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,6 +242,19 @@ fun GetImageBottomSheet(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (fileUri != null && onFileChange != null) {
|
||||
ActionButton(null, stringResource(R.string.choose_file), icon = Icons.Outlined.InsertDriveFile) {
|
||||
when (PackageManager.PERMISSION_GRANTED) {
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE) -> {
|
||||
filesLauncher.launch("*/*")
|
||||
hideBottomSheet()
|
||||
}
|
||||
else -> {
|
||||
filesPermissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@ package chat.simplex.app.views.helpers
|
||||
import android.content.*
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.ManagedActivityResultLauncher
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.core.content.ContextCompat
|
||||
import chat.simplex.app.R
|
||||
import chat.simplex.app.model.CIFile
|
||||
@@ -24,24 +28,29 @@ fun copyText(cxt: Context, text: String) {
|
||||
clipboard?.setPrimaryClip(ClipData.newPlainText("text", text))
|
||||
}
|
||||
|
||||
fun saveFile(cxt: Context, ciFile: CIFile?, destination: Uri?) {
|
||||
if (destination != null) {
|
||||
val filePath = getStoredFilePath(cxt, ciFile)
|
||||
if (filePath != null) {
|
||||
val contentResolver = cxt.contentResolver
|
||||
val file = File(filePath)
|
||||
try {
|
||||
val outputStream = contentResolver.openOutputStream(destination)
|
||||
if (outputStream != null) {
|
||||
outputStream.write(file.readBytes())
|
||||
outputStream.close()
|
||||
Toast.makeText(cxt, generalGetString(R.string.file_saved), Toast.LENGTH_SHORT).show()
|
||||
@Composable
|
||||
fun rememberSaveFileLauncher(cxt: Context, ciFile: CIFile?): ManagedActivityResultLauncher<String, Uri?> =
|
||||
rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument(),
|
||||
onResult = { destination ->
|
||||
if (destination != null) {
|
||||
val filePath = getStoredFilePath(cxt, ciFile)
|
||||
if (filePath != null) {
|
||||
val contentResolver = cxt.contentResolver
|
||||
val file = File(filePath)
|
||||
try {
|
||||
val outputStream = contentResolver.openOutputStream(destination)
|
||||
if (outputStream != null) {
|
||||
outputStream.write(file.readBytes())
|
||||
outputStream.close()
|
||||
Toast.makeText(cxt, generalGetString(R.string.file_saved), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Toast.makeText(cxt, generalGetString(R.string.error_saving_file), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
} else {
|
||||
Toast.makeText(cxt, generalGetString(R.string.file_not_found), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Toast.makeText(cxt, generalGetString(R.string.error_saving_file), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
} else {
|
||||
Toast.makeText(cxt, generalGetString(R.string.file_not_found), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -4,9 +4,13 @@ import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import android.graphics.*
|
||||
import android.graphics.Typeface
|
||||
import android.net.Uri
|
||||
import android.os.FileUtils
|
||||
import android.provider.OpenableColumns
|
||||
import android.text.Spanned
|
||||
import android.text.SpannedString
|
||||
import android.text.style.*
|
||||
import android.util.Log
|
||||
import android.view.ViewTreeObserver
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.runtime.*
|
||||
@@ -24,6 +28,9 @@ import chat.simplex.app.SimplexApp
|
||||
import chat.simplex.app.model.CIFile
|
||||
import kotlinx.coroutines.*
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import kotlin.math.log2
|
||||
import kotlin.math.pow
|
||||
|
||||
fun withApi(action: suspend CoroutineScope.() -> Unit): Job =
|
||||
GlobalScope.launch { withContext(Dispatchers.Main, action) }
|
||||
@@ -202,21 +209,24 @@ private fun spannableStringToAnnotatedString(
|
||||
}
|
||||
|
||||
// maximum image file size to be auto-accepted
|
||||
const val MAX_IMAGE_SIZE = 236700
|
||||
|
||||
const val MAX_FILE_SIZE = 1893600
|
||||
const val MAX_IMAGE_SIZE: Long = 236700
|
||||
const val MAX_FILE_SIZE: Long = 8000000
|
||||
|
||||
fun getFilesDirectory(context: Context): String {
|
||||
return context.filesDir.toString()
|
||||
}
|
||||
|
||||
fun getAppFilesDirectory(context: Context): String {
|
||||
return getFilesDirectory(context) + "/app_files"
|
||||
return "${getFilesDirectory(context)}/app_files"
|
||||
}
|
||||
|
||||
fun getAppFilePath(context: Context, fileName: String): String {
|
||||
return "${getAppFilesDirectory(context)}/$fileName"
|
||||
}
|
||||
|
||||
fun getStoredFilePath(context: Context, file: CIFile?): String? {
|
||||
return if (file?.filePath != null && file.stored) {
|
||||
val filePath = getAppFilesDirectory(context) + "/" + file.filePath
|
||||
val filePath = getAppFilePath(context, file.filePath)
|
||||
if (File(filePath).exists()) filePath else null
|
||||
} else {
|
||||
null
|
||||
@@ -241,3 +251,92 @@ fun getStoredImage(context: Context, file: CIFile?): Bitmap? {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun getFileName(context: Context, uri: Uri): String? {
|
||||
return context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
cursor.moveToFirst()
|
||||
cursor.getString(nameIndex)
|
||||
}
|
||||
}
|
||||
|
||||
fun getFileSize(context: Context, uri: Uri): Long? {
|
||||
return context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||
cursor.moveToFirst()
|
||||
cursor.getLong(sizeIndex)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveImage(context: Context, image: Bitmap): String? {
|
||||
return try {
|
||||
val dataResized = resizeImageToDataSize(image, maxDataSize = MAX_IMAGE_SIZE)
|
||||
val fileToSave = uniqueCombine(context, "image_${System.currentTimeMillis()}.jpg")
|
||||
val file = File(getAppFilePath(context, fileToSave))
|
||||
val output = FileOutputStream(file)
|
||||
dataResized.writeTo(output)
|
||||
output.flush()
|
||||
output.close()
|
||||
fileToSave
|
||||
} catch (e: Exception) {
|
||||
Log.e(chat.simplex.app.TAG, "Util.kt saveImage error: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun saveFileFromUri(context: Context, uri: Uri): String? {
|
||||
return try {
|
||||
val inputStream = context.contentResolver.openInputStream(uri)
|
||||
val fileToSave = getFileName(context, uri)
|
||||
if (inputStream != null && fileToSave != null) {
|
||||
val destFileName = uniqueCombine(context, fileToSave)
|
||||
val destFile = File(getAppFilePath(context, destFileName))
|
||||
FileUtils.copy(inputStream, FileOutputStream(destFile))
|
||||
destFileName
|
||||
} else {
|
||||
Log.e(chat.simplex.app.TAG, "Util.kt saveFileFromUri null inputStream")
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(chat.simplex.app.TAG, "Util.kt saveFileFromUri error: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun uniqueCombine(context: Context, fileName: String): String {
|
||||
fun tryCombine(fileName: String, n: Int): String {
|
||||
val name = File(fileName).nameWithoutExtension
|
||||
val ext = File(fileName).extension
|
||||
val suffix = if (n == 0) "" else "_$n"
|
||||
val f = "$name$suffix.$ext"
|
||||
return if (File(getAppFilePath(context, f)).exists()) tryCombine(fileName, n + 1) else f
|
||||
}
|
||||
return tryCombine(fileName, 0)
|
||||
}
|
||||
|
||||
fun formatBytes(bytes: Long): String {
|
||||
if (bytes == 0.toLong()) {
|
||||
return "0 bytes"
|
||||
}
|
||||
val bytesDouble = bytes.toDouble()
|
||||
val k = 1000.toDouble()
|
||||
val units = arrayOf("bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB")
|
||||
val i = kotlin.math.floor(log2(bytesDouble) / log2(k))
|
||||
val size = bytesDouble / k.pow(i)
|
||||
val unit = units[i.toInt()]
|
||||
|
||||
return if (i <= 1) {
|
||||
String.format("%.0f %s", size, unit)
|
||||
} else {
|
||||
String.format("%.2f %s", size, unit)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeFile(context: Context, fileName: String): Boolean {
|
||||
val file = File(getAppFilePath(context, fileName))
|
||||
val fileDeleted = file.delete()
|
||||
if (!fileDeleted) {
|
||||
Log.e(chat.simplex.app.TAG, "Util.kt removeFile error")
|
||||
}
|
||||
return fileDeleted
|
||||
}
|
||||
|
||||
@@ -87,20 +87,24 @@
|
||||
<string name="your_chats">Ваши чаты</string>
|
||||
<string name="contact_connection_pending">соединяется…</string>
|
||||
|
||||
<!-- ComposeView.kt -->
|
||||
<!-- ComposeView.kt, helpers -->
|
||||
<string name="attach">Прикрепить</string>
|
||||
|
||||
<!-- Images -->
|
||||
<string name="image_descr">Изображение</string>
|
||||
<string name="icon_descr_context">Значок контекста</string>
|
||||
<string name="icon_descr_cancel_image_preview">Удалить превью изображения</string>
|
||||
<string name="icon_descr_cancel_file_preview">Удалить превью файла</string>
|
||||
|
||||
<!-- Images - CIImageView.kt -->
|
||||
<string name="image_descr">Изображение</string>
|
||||
<string name="icon_descr_waiting_for_image">Ожидание изображения</string>
|
||||
<string name="icon_descr_image_snd_complete">Изображение отправлено</string>
|
||||
<string name="waiting_for_image">Ожидание изображения</string>
|
||||
<string name="image_will_be_received_when_contact_is_online">Изображение будет получено, когда ваш контакт будет в сети, пожалуйста, подождите или проверьте позже!</string>
|
||||
|
||||
<!-- Files - CIFileView.kt -->
|
||||
<string name="icon_descr_file">Файл</string>
|
||||
<string name="large_file">Большой файл!</string>
|
||||
<string name="contact_sent_large_file">Ваш контакт отправил файл, размер которого превышает поддерживаемый в настоящее время максимальный размер (<xliff:g id="maxFileSize">%1$s</xliff:g> байта).</string>
|
||||
<string name="contact_sent_large_file">Ваш контакт отправил файл, размер которого превышает поддерживаемый в настоящее время максимальный размер (<xliff:g id="maxFileSize">%1$s</xliff:g>).</string>
|
||||
<string name="maximum_supported_file_size">В настоящее время максимальный поддерживаемый размер файла составляет <xliff:g id="maxFileSize">%1$s</xliff:g>.</string>
|
||||
<string name="waiting_for_file">Ожидание файла</string>
|
||||
<string name="file_will_be_received_when_contact_is_online">Файл будет получен, когда ваш контакт будет в сети, пожалуйста, подождите или проверьте позже!</string>
|
||||
<string name="file_saved">Файл сохранен</string>
|
||||
@@ -136,9 +140,10 @@
|
||||
<string name="paste_received_link_from_clipboard">(вставить ссылку из буфера обмена)</string>
|
||||
|
||||
<!-- GetImageView -->
|
||||
<string name="toast_camera_permission_denied">Разрешение не получено!</string>
|
||||
<string name="toast_permission_denied">Разрешение не получено!</string>
|
||||
<string name="use_camera_button">Использовать камеру</string>
|
||||
<string name="from_gallery_button">Открыть галерею</string>
|
||||
<string name="choose_file">Выбрать файл</string>
|
||||
|
||||
<!-- help - ChatHelpView.kt -->
|
||||
<string name="thank_you_for_installing_simplex">Спасибо, что установили <xliff:g id="appNameFull">SimpleX Chat</xliff:g>!</string>
|
||||
|
||||
@@ -87,20 +87,24 @@
|
||||
<string name="your_chats">Your chats</string>
|
||||
<string name="contact_connection_pending">connecting…</string>
|
||||
|
||||
<!-- ComposeView.kt -->
|
||||
<!-- ComposeView.kt, helpers -->
|
||||
<string name="attach">Attach</string>
|
||||
|
||||
<!-- Images -->
|
||||
<string name="image_descr">Image</string>
|
||||
<string name="icon_descr_context">Context icon</string>
|
||||
<string name="icon_descr_cancel_image_preview">Cancel image preview</string>
|
||||
<string name="icon_descr_cancel_file_preview">Cancel file preview</string>
|
||||
|
||||
<!-- Images - CIImageView.kt -->
|
||||
<string name="image_descr">Image</string>
|
||||
<string name="icon_descr_waiting_for_image">Waiting for image</string>
|
||||
<string name="icon_descr_image_snd_complete">Image sent</string>
|
||||
<string name="waiting_for_image">Waiting for image</string>
|
||||
<string name="image_will_be_received_when_contact_is_online">Image will be received when your contact is online, please wait or check later!</string>
|
||||
|
||||
<!-- Files - CIFileView.kt -->
|
||||
<string name="icon_descr_file">File</string>
|
||||
<string name="large_file">Large file!</string>
|
||||
<string name="contact_sent_large_file">Your contact sent a file that is larger than currently supported maximum size (<xliff:g id="maxFileSize">%1$s</xliff:g> bytes).</string>
|
||||
<string name="contact_sent_large_file">Your contact sent a file that is larger than currently supported maximum size (<xliff:g id="maxFileSize">%1$s</xliff:g>).</string>
|
||||
<string name="maximum_supported_file_size">Currently maximum supported file size is <xliff:g id="maxFileSize">%1$s</xliff:g>.</string>
|
||||
<string name="waiting_for_file">Waiting for file</string>
|
||||
<string name="file_will_be_received_when_contact_is_online">File will be received when your contact is online, please wait or check later!</string>
|
||||
<string name="file_saved">File saved</string>
|
||||
@@ -137,9 +141,10 @@
|
||||
<string name="paste_received_link_from_clipboard">(paste link from clipboard)</string>
|
||||
|
||||
<!-- GetImageView -->
|
||||
<string name="toast_camera_permission_denied">Permission Denied!</string>
|
||||
<string name="toast_permission_denied">Permission Denied!</string>
|
||||
<string name="use_camera_button">Use Camera</string>
|
||||
<string name="from_gallery_button">From Gallery</string>
|
||||
<string name="choose_file">Choose file</string>
|
||||
|
||||
<!-- help - ChatHelpView.kt -->
|
||||
<string name="thank_you_for_installing_simplex">Thank you for installing <xliff:g id="appNameFull">SimpleX Chat</xliff:g>!</string>
|
||||
|
||||
@@ -10,23 +10,27 @@ import Foundation
|
||||
import SwiftUI
|
||||
|
||||
// maximum image file size to be auto-accepted
|
||||
let maxImageSize = 236700
|
||||
let maxImageSize: Int64 = 236700
|
||||
|
||||
let maxFileSize = 1893600
|
||||
let maxFileSize: Int64 = 8000000
|
||||
|
||||
func getDocumentsDirectory() -> URL {
|
||||
return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
|
||||
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
|
||||
}
|
||||
|
||||
func getAppFilesDirectory() -> URL {
|
||||
return getDocumentsDirectory().appendingPathComponent("app_files", isDirectory: true)
|
||||
getDocumentsDirectory().appendingPathComponent("app_files", isDirectory: true)
|
||||
}
|
||||
|
||||
func getAppFilePath(_ fileName: String) -> URL {
|
||||
getAppFilesDirectory().appendingPathComponent(fileName)
|
||||
}
|
||||
|
||||
func getStoredFilePath(_ file: CIFile?) -> String? {
|
||||
if let file = file,
|
||||
file.stored,
|
||||
let savedFile = file.filePath {
|
||||
return getAppFilesDirectory().appendingPathComponent(savedFile).path
|
||||
return getAppFilePath(savedFile).path
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -38,6 +42,76 @@ func getStoredImage(_ file: CIFile?) -> UIImage? {
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveFileFromURL(_ url: URL) -> String? {
|
||||
let savedFile: String?
|
||||
if url.startAccessingSecurityScopedResource() {
|
||||
do {
|
||||
let fileData = try Data(contentsOf: url)
|
||||
let fileName = uniqueCombine(url.lastPathComponent)
|
||||
savedFile = saveFile(fileData, fileName)
|
||||
} catch {
|
||||
logger.error("FileUtils.saveFileFromURL error: \(error.localizedDescription)")
|
||||
savedFile = nil
|
||||
}
|
||||
} else {
|
||||
logger.error("FileUtils.saveFileFromURL startAccessingSecurityScopedResource returned false")
|
||||
savedFile = nil
|
||||
}
|
||||
url.stopAccessingSecurityScopedResource()
|
||||
return savedFile
|
||||
}
|
||||
|
||||
func saveImage(_ uiImage: UIImage) -> String? {
|
||||
if let imageDataResized = resizeImageToDataSize(uiImage, maxDataSize: maxImageSize) {
|
||||
let millisecondsSince1970 = Int64((Date().timeIntervalSince1970 * 1000.0).rounded())
|
||||
let fileName = uniqueCombine("image_\(millisecondsSince1970).jpg")
|
||||
return saveFile(imageDataResized, fileName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func saveFile(_ data: Data, _ fileName: String) -> String? {
|
||||
let filePath = getAppFilePath(fileName)
|
||||
do {
|
||||
try data.write(to: filePath)
|
||||
return fileName
|
||||
} catch {
|
||||
logger.error("FileUtils.saveFile error: \(error.localizedDescription)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func uniqueCombine(_ fileName: String) -> String {
|
||||
func tryCombine(_ fileName: String, _ n: Int) -> String {
|
||||
let name = fileName.deletingPathExtension
|
||||
let ext = fileName.pathExtension
|
||||
let suffix = (n == 0) ? "" : "_\(n)"
|
||||
let f = "\(name)\(suffix).\(ext)"
|
||||
return (FileManager.default.fileExists(atPath: getAppFilePath(f).path)) ? tryCombine(fileName, n + 1) : f
|
||||
}
|
||||
return tryCombine(fileName, 0)
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var ns: NSString {
|
||||
return self as NSString
|
||||
}
|
||||
var pathExtension: String {
|
||||
return ns.pathExtension
|
||||
}
|
||||
var deletingPathExtension: String {
|
||||
return ns.deletingPathExtension
|
||||
}
|
||||
}
|
||||
|
||||
func removeFile(_ fileName: String) {
|
||||
do {
|
||||
try FileManager.default.removeItem(atPath: getAppFilePath(fileName).path)
|
||||
} catch {
|
||||
logger.error("FileUtils.removeFile error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
// image utils
|
||||
|
||||
func dropImagePrefix(_ s: String) -> String {
|
||||
@@ -61,7 +135,7 @@ func cropToSquare(_ image: UIImage) -> UIImage {
|
||||
return resizeImage(image, newBounds: CGRect(origin: .zero, size: newSize), drawIn: CGRect(origin: origin, size: size))
|
||||
}
|
||||
|
||||
func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int) -> Data? {
|
||||
func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int64) -> Data? {
|
||||
var img = image
|
||||
var data = img.jpegData(compressionQuality: 0.85)
|
||||
var dataSize = data?.count ?? 0
|
||||
@@ -76,7 +150,7 @@ func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int) -> Data? {
|
||||
return data
|
||||
}
|
||||
|
||||
func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int) -> String? {
|
||||
func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int64) -> String? {
|
||||
var img = image
|
||||
var str = compressImageStr(img)
|
||||
var dataSize = str?.count ?? 0
|
||||
|
||||
@@ -52,7 +52,7 @@ enum ChatCommand {
|
||||
case let .apiGetChat(type, id): return "/_get chat \(ref(type, id)) count=100"
|
||||
case let .apiSendMessage(type, id, file, quotedItemId, mc):
|
||||
let msg = encodeJSON(ComposedMessage(filePath: file, quotedItemId: quotedItemId, msgContent: mc))
|
||||
return "/_send_v2 \(ref(type, id)) \(msg)"
|
||||
return "/_send \(ref(type, id)) json \(msg)"
|
||||
case let .apiUpdateChatItem(type, id, itemId, mc): return "/_update item \(ref(type, id)) \(itemId) \(mc.cmdString)"
|
||||
case let .apiDeleteChatItem(type, id, itemId, mode): return "/_delete item \(ref(type, id)) \(itemId) \(mode.rawValue)"
|
||||
case let .apiRegisterToken(token): return "/_ntf register apns \(token)"
|
||||
|
||||
@@ -647,6 +647,8 @@ struct CIFile: Decodable {
|
||||
get {
|
||||
switch self.fileStatus {
|
||||
case .sndStored: return true
|
||||
case .sndTransfer: return true
|
||||
case .sndComplete: return true
|
||||
case .sndCancelled: return true
|
||||
case .rcvComplete: return true
|
||||
default: return false
|
||||
@@ -696,6 +698,13 @@ enum MsgContent {
|
||||
}
|
||||
}
|
||||
|
||||
func isFile() -> Bool {
|
||||
switch self {
|
||||
case .file: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case type
|
||||
case text
|
||||
|
||||
@@ -65,17 +65,21 @@ func createNotification(categoryIdentifier: String, title: String, subtitle: Str
|
||||
}
|
||||
|
||||
func hideSecrets(_ cItem: ChatItem) -> String {
|
||||
if let md = cItem.formattedText {
|
||||
var res = ""
|
||||
for ft in md {
|
||||
if case .secret = ft.format {
|
||||
res = res + "..."
|
||||
} else {
|
||||
res = res + ft.text
|
||||
if cItem.content.text != "" {
|
||||
if let md = cItem.formattedText {
|
||||
var res = ""
|
||||
for ft in md {
|
||||
if case .secret = ft.format {
|
||||
res = res + "..."
|
||||
} else {
|
||||
res = res + ft.text
|
||||
}
|
||||
}
|
||||
return res
|
||||
} else {
|
||||
return cItem.content.text
|
||||
}
|
||||
return res
|
||||
} else {
|
||||
return cItem.content.text
|
||||
return cItem.file?.fileName ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -519,6 +519,17 @@ func processReceivedMsg(_ res: ChatResponse) {
|
||||
chatItemSimpleUpdate(aChatItem)
|
||||
case let .rcvFileComplete(aChatItem):
|
||||
chatItemSimpleUpdate(aChatItem)
|
||||
case let .sndFileStart(aChatItem, _):
|
||||
chatItemSimpleUpdate(aChatItem)
|
||||
case let .sndFileComplete(aChatItem, _):
|
||||
chatItemSimpleUpdate(aChatItem)
|
||||
let cItem = aChatItem.chatItem
|
||||
if aChatItem.chatInfo.chatType == .direct,
|
||||
let mc = cItem.content.msgContent,
|
||||
mc.isFile(),
|
||||
let fileName = cItem.file?.filePath {
|
||||
removeFile(fileName)
|
||||
}
|
||||
default:
|
||||
logger.debug("unsupported event: \(res.responseType)")
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ struct FramedItemView: View {
|
||||
}
|
||||
case let .file(text):
|
||||
CIFileView(file: chatItem.file, edited: chatItem.meta.itemEdited)
|
||||
.overlay(DetermineWidth())
|
||||
if text != "" {
|
||||
ciMsgContentView (chatItem, showMember)
|
||||
}
|
||||
@@ -107,6 +108,16 @@ struct FramedItemView: View {
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 68, height: 68)
|
||||
.clipped()
|
||||
} else if case .file = qi.content {
|
||||
ciQuotedMsgView(qi)
|
||||
.padding(.trailing, 20).frame(minWidth: msgWidth, alignment: .leading)
|
||||
Image(systemName: "doc.fill")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 18, height: 18)
|
||||
.foregroundColor(Color(uiColor: .tertiaryLabel))
|
||||
.padding(.top, 6)
|
||||
.padding(.trailing, 4)
|
||||
} else {
|
||||
ciQuotedMsgView(qi)
|
||||
}
|
||||
|
||||
@@ -116,7 +116,11 @@ struct ChatView: View {
|
||||
if ci.isMsgContent() {
|
||||
Button {
|
||||
withAnimation {
|
||||
composeState = composeState.copy(contextItem: .quotedItem(chatItem: ci))
|
||||
if composeState.editing() {
|
||||
composeState = ComposeState(contextItem: .quotedItem(chatItem: ci))
|
||||
} else {
|
||||
composeState = composeState.copy(contextItem: .quotedItem(chatItem: ci))
|
||||
}
|
||||
}
|
||||
} label: { Label("Reply", systemImage: "arrowshape.turn.up.left") }
|
||||
Button {
|
||||
|
||||
@@ -12,6 +12,7 @@ enum ComposePreview {
|
||||
case noPreview
|
||||
case linkPreview(linkPreview: LinkPreview)
|
||||
case imagePreview(imagePreview: String)
|
||||
case filePreview(fileName: String)
|
||||
}
|
||||
|
||||
enum ComposeContextItem {
|
||||
@@ -65,6 +66,8 @@ struct ComposeState {
|
||||
switch preview {
|
||||
case .imagePreview:
|
||||
return true
|
||||
case .filePreview:
|
||||
return true
|
||||
default:
|
||||
return !message.isEmpty
|
||||
}
|
||||
@@ -74,6 +77,8 @@ struct ComposeState {
|
||||
switch preview {
|
||||
case .imagePreview:
|
||||
return false
|
||||
case .filePreview:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
@@ -92,10 +97,14 @@ struct ComposeState {
|
||||
func chatItemPreview(chatItem: ChatItem) -> ComposePreview {
|
||||
let chatItemPreview: ComposePreview
|
||||
switch chatItem.content.msgContent {
|
||||
case .text:
|
||||
chatItemPreview = .noPreview
|
||||
case let .link(_, preview: preview):
|
||||
chatItemPreview = .linkPreview(linkPreview: preview)
|
||||
case let .image(_, image: image):
|
||||
chatItemPreview = .imagePreview(imagePreview: image)
|
||||
case .file:
|
||||
chatItemPreview = .filePreview(fileName: chatItem.file?.fileName ?? "")
|
||||
default:
|
||||
chatItemPreview = .noPreview
|
||||
}
|
||||
@@ -116,12 +125,18 @@ struct ComposeView: View {
|
||||
@State private var showChooseSource = false
|
||||
@State private var showImagePicker = false
|
||||
@State private var imageSource: ImageSource = .imageLibrary
|
||||
@State var chosenImage: UIImage?
|
||||
@State var chosenImage: UIImage? = nil
|
||||
|
||||
@State private var showFileImporter = false
|
||||
@State var chosenFile: URL? = nil
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
contextItemView()
|
||||
previewView()
|
||||
switch (composeState.editing(), composeState.preview) {
|
||||
case (true, .filePreview): EmptyView()
|
||||
default: previewView()
|
||||
}
|
||||
HStack (alignment: .bottom) {
|
||||
Button {
|
||||
showChooseSource = true
|
||||
@@ -163,6 +178,9 @@ struct ComposeView: View {
|
||||
imageSource = .imageLibrary
|
||||
showImagePicker = true
|
||||
}
|
||||
Button("Choose file") {
|
||||
showFileImporter = true
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showImagePicker) {
|
||||
switch imageSource {
|
||||
@@ -182,6 +200,36 @@ struct ComposeView: View {
|
||||
composeState = composeState.copy(preview: .noPreview)
|
||||
}
|
||||
}
|
||||
.fileImporter(
|
||||
isPresented: $showFileImporter,
|
||||
allowedContentTypes: [.data],
|
||||
allowsMultipleSelection: false
|
||||
) { result in
|
||||
if case .success = result {
|
||||
do {
|
||||
let fileURL: URL = try result.get().first!
|
||||
var fileSize: Int? = nil
|
||||
if fileURL.startAccessingSecurityScopedResource() {
|
||||
let resourceValues = try fileURL.resourceValues(forKeys: [.fileSizeKey])
|
||||
fileSize = resourceValues.fileSize
|
||||
}
|
||||
fileURL.stopAccessingSecurityScopedResource()
|
||||
if let fileSize = fileSize,
|
||||
fileSize <= maxFileSize {
|
||||
chosenFile = fileURL
|
||||
composeState = composeState.copy(preview: .filePreview(fileName: fileURL.lastPathComponent))
|
||||
} else {
|
||||
let prettyMaxFileSize = ByteCountFormatter().string(fromByteCount: maxFileSize)
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Large file!",
|
||||
message: "Currently maximum supported file size is \(prettyMaxFileSize)."
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
logger.error("ComposeView fileImporter error \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func previewView() -> some View {
|
||||
@@ -193,7 +241,18 @@ struct ComposeView: View {
|
||||
case let .imagePreview(imagePreview: img):
|
||||
ComposeImageView(
|
||||
image: img,
|
||||
cancelImage: { composeState = composeState.copy(preview: .noPreview) },
|
||||
cancelImage: {
|
||||
composeState = composeState.copy(preview: .noPreview)
|
||||
chosenImage = nil
|
||||
},
|
||||
cancelEnabled: !composeState.editing())
|
||||
case let .filePreview(fileName: fileName):
|
||||
ComposeFileView(
|
||||
fileName: fileName,
|
||||
cancelFile: {
|
||||
composeState = composeState.copy(preview: .noPreview)
|
||||
chosenFile = nil
|
||||
},
|
||||
cancelEnabled: !composeState.editing())
|
||||
}
|
||||
}
|
||||
@@ -203,9 +262,17 @@ struct ComposeView: View {
|
||||
case .noContextItem:
|
||||
EmptyView()
|
||||
case let .quotedItem(chatItem: quotedItem):
|
||||
ContextItemView(contextItem: quotedItem, cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) })
|
||||
ContextItemView(
|
||||
contextItem: quotedItem,
|
||||
contextIcon: "arrowshape.turn.up.left.circle",
|
||||
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }
|
||||
)
|
||||
case let .editingItem(chatItem: editingItem):
|
||||
ContextItemView(contextItem: editingItem, cancelContextItem: { composeState = ComposeState()})
|
||||
ContextItemView(
|
||||
contextItem: editingItem,
|
||||
contextIcon: "pencil.circle",
|
||||
cancelContextItem: { clearState() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,6 +308,12 @@ struct ComposeView: View {
|
||||
mc = .image(text: composeState.message, image: image)
|
||||
file = savedFile
|
||||
}
|
||||
case .filePreview:
|
||||
if let fileURL = chosenFile,
|
||||
let savedFile = saveFileFromURL(fileURL) {
|
||||
mc = .file(composeState.message)
|
||||
file = savedFile
|
||||
}
|
||||
}
|
||||
|
||||
var quotedItemId: Int64? = nil
|
||||
@@ -261,13 +334,24 @@ struct ComposeView: View {
|
||||
chatModel.addChatItem(chat.chatInfo, chatItem)
|
||||
}
|
||||
}
|
||||
composeState = ComposeState()
|
||||
clearState()
|
||||
} catch {
|
||||
clearState()
|
||||
logger.error("ChatView.sendMessage error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func clearState() {
|
||||
composeState = ComposeState()
|
||||
linkUrl = nil
|
||||
prevLinkUrl = nil
|
||||
pendingLinkUrl = nil
|
||||
cancelledLinks = []
|
||||
chosenImage = nil
|
||||
chosenFile = nil
|
||||
}
|
||||
|
||||
private func updateMsgContent(_ msgContent: MsgContent) -> MsgContent {
|
||||
switch msgContent {
|
||||
case .text:
|
||||
@@ -283,22 +367,6 @@ struct ComposeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func saveImage(_ uiImage: UIImage) -> String? {
|
||||
if let imageDataResized = resizeImageToDataSize(uiImage, maxDataSize: maxImageSize) {
|
||||
let millisecondsSince1970 = Int64((Date().timeIntervalSince1970 * 1000.0).rounded())
|
||||
let fileToSave = "image_\(millisecondsSince1970).jpg"
|
||||
let filePath = getAppFilesDirectory().appendingPathComponent(fileToSave)
|
||||
do {
|
||||
try imageDataResized.write(to: filePath)
|
||||
return fileToSave
|
||||
} catch {
|
||||
logger.error("ChatView.saveImage error: \(error.localizedDescription)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func showLinkPreview(_ s: String) {
|
||||
prevLinkUrl = linkUrl
|
||||
linkUrl = parseMessage(s)
|
||||
|
||||
@@ -11,10 +11,16 @@ import SwiftUI
|
||||
struct ContextItemView: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
let contextItem: ChatItem
|
||||
let contextIcon: String
|
||||
let cancelContextItem: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Image(systemName: contextIcon)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 20, height: 20)
|
||||
.foregroundColor(.secondary)
|
||||
contextText(contextItem).lineLimit(3)
|
||||
Spacer()
|
||||
Button {
|
||||
@@ -26,6 +32,7 @@ struct ContextItemView: View {
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.frame(minHeight: 50)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(chatItemFrameColor(contextItem, colorScheme))
|
||||
.padding(.top, 8)
|
||||
@@ -33,9 +40,9 @@ struct ContextItemView: View {
|
||||
|
||||
func contextText(_ cxtItem: ChatItem) -> some View {
|
||||
if let s = cxtItem.memberDisplayName {
|
||||
return (Text(s).fontWeight(.medium) + Text(": \(cxtItem.content.text)"))
|
||||
return (Text(s).fontWeight(.medium) + Text(": \(cxtItem.text)"))
|
||||
} else {
|
||||
return Text(cxtItem.content.text)
|
||||
return Text(cxtItem.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,6 +50,6 @@ struct ContextItemView: View {
|
||||
struct ContextItemView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let contextItem: ChatItem = ChatItem.getSample(1, .directSnd, .now, "hello")
|
||||
return ContextItemView(contextItem: contextItem, cancelContextItem: {})
|
||||
return ContextItemView(contextItem: contextItem, contextIcon: "pencil.circle", cancelContextItem: {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,12 +23,13 @@ struct CIFileView: View {
|
||||
.padding(.top, 5)
|
||||
.padding(.bottom, 3)
|
||||
if let file = file {
|
||||
let prettyFileSize = ByteCountFormatter().string(fromByteCount: file.fileSize)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(file.fileName)
|
||||
.lineLimit(1)
|
||||
.multilineTextAlignment(.leading)
|
||||
.foregroundColor(.primary)
|
||||
Text(formatBytes(bytes: file.fileSize) + metaReserve)
|
||||
Text(prettyFileSize + metaReserve)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
.multilineTextAlignment(.leading)
|
||||
@@ -64,9 +65,10 @@ struct CIFileView: View {
|
||||
await receiveFile(fileId: file.fileId)
|
||||
}
|
||||
} else {
|
||||
let prettyMaxFileSize = ByteCountFormatter().string(fromByteCount: maxFileSize)
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Large file!",
|
||||
message: "Your contact sent a file that is larger than currently supported maximum size (\(maxFileSize) bytes)."
|
||||
message: "Your contact sent a file that is larger than currently supported maximum size (\(prettyMaxFileSize))."
|
||||
)
|
||||
}
|
||||
case .rcvAccepted:
|
||||
@@ -88,6 +90,9 @@ struct CIFileView: View {
|
||||
@ViewBuilder func fileIndicator() -> some View {
|
||||
if let file = file {
|
||||
switch file.fileStatus {
|
||||
case .sndStored: fileIcon("doc.fill")
|
||||
case .sndTransfer: ProgressView().frame(width: 30, height: 30)
|
||||
case .sndComplete: fileIcon("doc.fill", innerIcon: "checkmark", innerIconSize: 10)
|
||||
case .sndCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
||||
case .rcvInvitation:
|
||||
if fileSizeValid() {
|
||||
@@ -97,15 +102,15 @@ struct CIFileView: View {
|
||||
}
|
||||
case .rcvAccepted: fileIcon("doc.fill", innerIcon: "ellipsis", innerIconSize: 12)
|
||||
case .rcvTransfer: ProgressView().frame(width: 30, height: 30)
|
||||
case .rcvComplete: fileIcon("arrow.down.doc.fill", innerIconSize: 10)
|
||||
case .rcvCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
||||
default: fileIcon("doc.fill")
|
||||
}
|
||||
} else {
|
||||
fileIcon("doc.fill")
|
||||
}
|
||||
}
|
||||
|
||||
func fileIcon(_ icon: String, color: Color = .secondary, innerIcon: String? = nil, innerIconSize: CGFloat? = nil) -> some View {
|
||||
func fileIcon(_ icon: String, color: Color = Color(uiColor: .tertiaryLabel), innerIcon: String? = nil, innerIconSize: CGFloat? = nil) -> some View {
|
||||
ZStack(alignment: .center) {
|
||||
Image(systemName: icon)
|
||||
.resizable()
|
||||
@@ -124,24 +129,6 @@ struct CIFileView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func formatBytes(bytes: Int64) -> String {
|
||||
if (bytes == 0) { return "0 bytes" }
|
||||
|
||||
let bytesDouble = Double(bytes)
|
||||
let k: Double = 1000
|
||||
let units = ["bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]
|
||||
|
||||
let i = floor(log2(bytesDouble) / log2(k))
|
||||
let size = bytesDouble / pow(k, i)
|
||||
let unit = units[Int(i)]
|
||||
|
||||
if (i <= 1) {
|
||||
return String(format: "%.0f \(unit)", size)
|
||||
} else {
|
||||
return String(format: "%.2f \(unit)", size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CIFileView_Previews: PreviewProvider {
|
||||
@@ -151,7 +138,7 @@ struct CIFileView_Previews: PreviewProvider {
|
||||
meta: CIMeta.getSample(1, .now, "", .sndSent, false, true, false),
|
||||
content: .sndMsgContent(msgContent: .file("")),
|
||||
quotedItem: nil,
|
||||
file: CIFile.getSample(fileStatus: .sndStored)
|
||||
file: CIFile.getSample(fileStatus: .sndComplete)
|
||||
)
|
||||
let fileChatItemWtFile = ChatItem(
|
||||
chatDir: .directRcv,
|
||||
@@ -167,7 +154,7 @@ struct CIFileView_Previews: PreviewProvider {
|
||||
ChatItemView(chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted))
|
||||
ChatItemView(chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer))
|
||||
ChatItemView(chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled))
|
||||
ChatItemView(chatItem: ChatItem.getFileMsgContentSample(fileSize: 2000000, fileStatus: .rcvInvitation))
|
||||
ChatItemView(chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation))
|
||||
ChatItemView(chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation))
|
||||
ChatItemView(chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation))
|
||||
ChatItemView(chatItem: fileChatItemWtFile)
|
||||
|
||||
@@ -62,24 +62,38 @@ struct CIImageView: View {
|
||||
.scaledToFit()
|
||||
.frame(maxWidth: w)
|
||||
loadingIndicator()
|
||||
.padding(8)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func loadingIndicator() -> some View {
|
||||
if let file = file {
|
||||
switch file.fileStatus {
|
||||
case .sndTransfer:
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
.frame(width: 20, height: 20)
|
||||
.tint(.white)
|
||||
.padding(8)
|
||||
case .sndComplete:
|
||||
Image(systemName: "checkmark")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 10, height: 10)
|
||||
.foregroundColor(.white)
|
||||
.padding(13)
|
||||
case .rcvAccepted:
|
||||
Image(systemName: "ellipsis")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 20, height: 20)
|
||||
.frame(width: 14, height: 14)
|
||||
.foregroundColor(.white)
|
||||
.padding(11)
|
||||
case .rcvTransfer:
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
.frame(width: 20, height: 20)
|
||||
.tint(.white)
|
||||
.padding(8)
|
||||
default: EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
40
apps/ios/Shared/Views/Helpers/ComposeFileView.swift
Normal file
40
apps/ios/Shared/Views/Helpers/ComposeFileView.swift
Normal file
@@ -0,0 +1,40 @@
|
||||
//
|
||||
// ComposeFileView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by JRoberts on 04.05.2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ComposeFileView: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
let fileName: String
|
||||
let cancelFile: (() -> Void)
|
||||
let cancelEnabled: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
Image(systemName: "doc.fill")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 30, height: 30)
|
||||
.foregroundColor(Color(uiColor: .tertiaryLabel))
|
||||
.padding(.leading, 4)
|
||||
Text(fileName)
|
||||
Spacer()
|
||||
if cancelEnabled {
|
||||
Button { cancelFile() } label: {
|
||||
Image(systemName: "multiply")
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 1)
|
||||
.padding(.trailing, 12)
|
||||
.frame(height: 50)
|
||||
.background(colorScheme == .light ? sentColorLight : sentColorDark)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,7 @@
|
||||
5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCE227DE9246000BD591 /* ComposeView.swift */; };
|
||||
5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */; };
|
||||
640F50E327CF991C001E05C2 /* SMPServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640F50E227CF991C001E05C2 /* SMPServers.swift */; };
|
||||
6454036F2822A9750090DDFF /* ComposeFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6454036E2822A9750090DDFF /* ComposeFileView.swift */; };
|
||||
648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; };
|
||||
649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; };
|
||||
649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; };
|
||||
@@ -214,6 +215,7 @@
|
||||
5CEACCE227DE9246000BD591 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = "<group>"; };
|
||||
5CEACCEC27DEA495000BD591 /* MsgContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MsgContentView.swift; sourceTree = "<group>"; };
|
||||
640F50E227CF991C001E05C2 /* SMPServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMPServers.swift; sourceTree = "<group>"; };
|
||||
6454036E2822A9750090DDFF /* ComposeFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeFileView.swift; sourceTree = "<group>"; };
|
||||
648010AA281ADD15009009B9 /* CIFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIFileView.swift; sourceTree = "<group>"; };
|
||||
6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = "<group>"; };
|
||||
@@ -350,6 +352,7 @@
|
||||
649BCD9F280460FD00C3A862 /* ComposeImageView.swift */,
|
||||
649BCDA12805D6EF00C3A862 /* CIImageView.swift */,
|
||||
648010AA281ADD15009009B9 /* CIFileView.swift */,
|
||||
6454036E2822A9750090DDFF /* ComposeFileView.swift */,
|
||||
);
|
||||
path = Helpers;
|
||||
sourceTree = "<group>";
|
||||
@@ -676,6 +679,7 @@
|
||||
5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */,
|
||||
5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */,
|
||||
5C971E2127AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */,
|
||||
6454036F2822A9750090DDFF /* ComposeFileView.swift in Sources */,
|
||||
5C5F2B6D27EBC3FE006A9D5F /* ImagePicker.swift in Sources */,
|
||||
5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */,
|
||||
5CA059EB279559F40002BEB4 /* SimpleXApp.swift in Sources */,
|
||||
|
||||
Reference in New Issue
Block a user