diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index cf2931a0dd..a3fd1ffc45 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -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 diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt b/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt index 2d83c79d50..3e1fee40f9 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/NtfManager.kt @@ -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) { diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index 6f5148106c..2451a976fd 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -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" diff --git a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt index 2e8d94c47f..05e66ffb87 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt @@ -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) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt index 5aa7a98237..9c091ebdfa 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt @@ -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(null) } + val chosenFile = remember { mutableStateOf(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, composeView: (@Composable () -> Unit), chosenImage: MutableState, + chosenFile: MutableState, scope: CoroutineScope, attachmentBottomSheetState: ModalBottomSheetState, chatItems: List, @@ -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, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeFileView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeFileView.kt new file mode 100644 index 0000000000..32eeba9e32 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeFileView.kt @@ -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 + ) + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt index 05ab4d284b..8402d4e7fa 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt @@ -1,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, chosenImage: MutableState, + chosenFile: MutableState, 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, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt index 23227ebbc4..496c8323b7 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt @@ -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 = {} ) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt index 03c21786e3..74fee47400 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt @@ -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 + ) + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt index eaf02c10a5..35a0c22a75 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIFileView.kt @@ -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 { 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.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 diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt index 8df4976e8b..918c429ea9 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt @@ -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, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt index 5e87e6b2f7..3e484d74eb 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt @@ -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 = { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt index 4a184cc37d..19eabed563 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt @@ -1,12 +1,12 @@ package chat.simplex.app.views.chat.item -import CIFileView import CIImageView -import androidx.compose.foundation.Image -import androidx.compose.foundation.background +import androidx.compose.foundation.* 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.InsertDriveFile import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -18,12 +18,10 @@ import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.* -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.* 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.* import chat.simplex.app.views.helpers.ChatItemLinkView import chat.simplex.app.views.helpers.base64ToBitmap import kotlinx.datetime.Clock @@ -43,6 +41,63 @@ fun FramedItemView( receiveFile: (Long) -> Unit ) { val sent = ci.chatDir.sent + + @Composable + fun ciQuotedMsgView(qi: CIQuote, paddingEnd: Dp = 0.dp) { + Box( + Modifier + .padding(vertical = 6.dp, horizontal = 12.dp) + .padding(end = paddingEnd), + contentAlignment = Alignment.TopStart + ) { + MarkdownText( + qi.text, sender = qi.sender(user), senderBold = true, maxLines = 3, + style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface) + ) + } + } + + @Composable + fun ciQuoteView(qi: CIQuote) { + Row( + Modifier + .background(if (sent) SentQuoteColorLight else ReceivedQuoteColorLight) + .fillMaxWidth() + ) { + when (qi.content) { + is MsgContent.MCImage -> { + ciQuotedMsgView(qi) + // TODO fix quotes containing long text and image / file + // ciQuotedMsgView(qi, paddingEnd = 60.dp) + Spacer(Modifier.weight(1f)) + val imageBitmap = base64ToBitmap(qi.content.image).asImageBitmap() + Image( + imageBitmap, + contentDescription = stringResource(R.string.image_descr), + contentScale = ContentScale.Crop, + modifier = Modifier + .size(60.dp) + .clipToBounds() + ) + } + is MsgContent.MCFile -> { + ciQuotedMsgView(qi) + // ciQuotedMsgView(qi, paddingEnd = 26.dp) + Spacer(Modifier.weight(1f)) + Icon( + Icons.Filled.InsertDriveFile, + stringResource(R.string.icon_descr_file), + Modifier + .padding(top = 6.dp, end = 4.dp) + .size(22.dp), + tint = if (isSystemInDarkTheme()) FileDark else FileLight + ) + } + else -> ciQuotedMsgView(qi, 22.dp) + } + } + } + Surface( shape = RoundedCornerShape(18.dp), color = if (sent) SentColorLight else ReceivedColorLight @@ -52,33 +107,7 @@ fun FramedItemView( Column(Modifier.width(IntrinsicSize.Max)) { val qi = ci.quotedItem if (qi != null) { - Row( - Modifier - .background(if (sent) SentQuoteColorLight else ReceivedQuoteColorLight) - .fillMaxWidth() - ) { - Box( - Modifier.padding(vertical = 6.dp, horizontal = 12.dp), - contentAlignment = Alignment.TopStart - ) { - MarkdownText( - qi.text, sender = qi.sender(user), senderBold = true, maxLines = 3, - style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface) - ) - } - Spacer(Modifier.weight(1f)) - if (qi.content is MsgContent.MCImage) { - val imageBitmap = base64ToBitmap(qi.content.image).asImageBitmap() - Image( - imageBitmap, - contentDescription = stringResource(R.string.image_descr), - contentScale = ContentScale.Crop, - modifier = Modifier - .size(60.dp) - .clipToBounds() - ) - } - } + ciQuoteView(qi) } if (ci.file == null && ci.formattedText == null && isShortEmoji(ci.content.text)) { Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) { @@ -232,3 +261,59 @@ fun PreviewTextItemViewEmoji(@PreviewParameter(EditedProvider::class) edited: Bo ) } } + +@Preview +@Composable +fun PreviewQuoteWithTextAndImage(@PreviewParameter(EditedProvider::class) edited: Boolean) { + val ciQuote = CIQuote( + chatDir = CIDirection.DirectRcv(), itemId = 1, sentAt = Clock.System.now(), + content = MsgContent.MCImage( + text = "Hello there", + image = "data:image/jpg;base64,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" + ) + ) + val showMenu = remember { mutableStateOf(false) } + SimpleXTheme { + FramedItemView( + User.sampleData, + ChatItem.getSampleData( + 1, CIDirection.DirectSnd(), + Clock.System.now(), + "Hello there", + CIStatus.SndSent(), + quotedItem = ciQuote, + itemEdited = edited + ), + showMenu = showMenu, + receiveFile = {} + ) + } +} + +@Preview +@Composable +fun PreviewQuoteWithLongTextAndImage(@PreviewParameter(EditedProvider::class) edited: Boolean) { + val ciQuote = CIQuote( + chatDir = CIDirection.DirectRcv(), itemId = 1, sentAt = Clock.System.now(), + content = MsgContent.MCImage( + 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.", + image = "data:image/jpg;base64,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" + ) + ) + val showMenu = remember { mutableStateOf(false) } + SimpleXTheme { + FramedItemView( + User.sampleData, + ChatItem.getSampleData( + 1, CIDirection.DirectSnd(), + Clock.System.now(), + "Hello there", + CIStatus.SndSent(), + quotedItem = ciQuote, + itemEdited = edited + ), + showMenu = showMenu, + receiveFile = {} + ) + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt index 301a56aac3..095c3ef2e4 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt @@ -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() { +class CustomTakePicturePreview: ActivityResultContract() { private var uri: Uri? = null private var tmpFile: File? = null lateinit var externalContext: Context @@ -122,15 +120,24 @@ class CustomTakePicturePreview : ActivityResultContract() { 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 = +fun rememberGetContentLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLauncher = +// rememberLauncherForActivityResult(contract = GetGalleryContent(), cb) rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent(), cb) @Composable @@ -145,12 +152,13 @@ fun rememberPermissionLauncher(cb: (Boolean) -> Unit): ManagedActivityResultLaun fun GetImageBottomSheet( imageBitmap: MutableState, onImageChange: (Bitmap) -> Unit, + fileUri: MutableState? = 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) + } + } + } + } } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Share.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Share.kt index ad68516caa..e05633a1ac 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Share.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Share.kt @@ -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 = + 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() } - } -} + ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt index b4970f12e1..17c307f7d2 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt @@ -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 +} diff --git a/apps/android/app/src/main/res/values-ru/strings.xml b/apps/android/app/src/main/res/values-ru/strings.xml index b601587f99..6cc2985897 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -87,20 +87,24 @@ Ваши чаты соединяется… - + Прикрепить - - - Изображение + Значок контекста Удалить превью изображения + Удалить превью файла + + + Изображение Ожидание изображения + Изображение отправлено Ожидание изображения Изображение будет получено, когда ваш контакт будет в сети, пожалуйста, подождите или проверьте позже! Файл Большой файл! - Ваш контакт отправил файл, размер которого превышает поддерживаемый в настоящее время максимальный размер (%1$s байта). + Ваш контакт отправил файл, размер которого превышает поддерживаемый в настоящее время максимальный размер (%1$s). + В настоящее время максимальный поддерживаемый размер файла составляет %1$s. Ожидание файла Файл будет получен, когда ваш контакт будет в сети, пожалуйста, подождите или проверьте позже! Файл сохранен @@ -136,9 +140,10 @@ (вставить ссылку из буфера обмена) - Разрешение не получено! + Разрешение не получено! Использовать камеру Открыть галерею + Выбрать файл Спасибо, что установили SimpleX Chat! diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index c2a15bee79..50d46ea542 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -87,20 +87,24 @@ Your chats connecting… - + Attach - - - Image + Context icon Cancel image preview + Cancel file preview + + + Image Waiting for image + Image sent Waiting for image Image will be received when your contact is online, please wait or check later! File Large file! - Your contact sent a file that is larger than currently supported maximum size (%1$s bytes). + Your contact sent a file that is larger than currently supported maximum size (%1$s). + Currently maximum supported file size is %1$s. Waiting for file File will be received when your contact is online, please wait or check later! File saved @@ -137,9 +141,10 @@ (paste link from clipboard) - Permission Denied! + Permission Denied! Use Camera From Gallery + Choose file Thank you for installing SimpleX Chat! diff --git a/apps/ios/Shared/FileUtils.swift b/apps/ios/Shared/FileUtils.swift index b3346293a8..8978ad7ccf 100644 --- a/apps/ios/Shared/FileUtils.swift +++ b/apps/ios/Shared/FileUtils.swift @@ -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 diff --git a/apps/ios/Shared/Model/Shared/APITypes.swift b/apps/ios/Shared/Model/Shared/APITypes.swift index 2c019a834b..6edae1e60d 100644 --- a/apps/ios/Shared/Model/Shared/APITypes.swift +++ b/apps/ios/Shared/Model/Shared/APITypes.swift @@ -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)" diff --git a/apps/ios/Shared/Model/Shared/ChatTypes.swift b/apps/ios/Shared/Model/Shared/ChatTypes.swift index 6e34a62dd9..4005fdde4d 100644 --- a/apps/ios/Shared/Model/Shared/ChatTypes.swift +++ b/apps/ios/Shared/Model/Shared/ChatTypes.swift @@ -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 diff --git a/apps/ios/Shared/Model/Shared/Notifications.swift b/apps/ios/Shared/Model/Shared/Notifications.swift index 5da0fe08ad..7e98333f78 100644 --- a/apps/ios/Shared/Model/Shared/Notifications.swift +++ b/apps/ios/Shared/Model/Shared/Notifications.swift @@ -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 ?? "" } } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index d51f666b67..c298e611aa 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -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)") } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index ecca2a577d..80aa7dd830 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -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) } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 7daa6d6c6f..372df7434d 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -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 { diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 60fe848723..dcd57ce578 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -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) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift index f7ebb1e53f..0f22862d05 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift @@ -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: {}) } } diff --git a/apps/ios/Shared/Views/Helpers/CIFileView.swift b/apps/ios/Shared/Views/Helpers/CIFileView.swift index c5c864ada8..cebdfa64dd 100644 --- a/apps/ios/Shared/Views/Helpers/CIFileView.swift +++ b/apps/ios/Shared/Views/Helpers/CIFileView.swift @@ -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) diff --git a/apps/ios/Shared/Views/Helpers/CIImageView.swift b/apps/ios/Shared/Views/Helpers/CIImageView.swift index 61518f0438..ec15883cbf 100644 --- a/apps/ios/Shared/Views/Helpers/CIImageView.swift +++ b/apps/ios/Shared/Views/Helpers/CIImageView.swift @@ -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() } } diff --git a/apps/ios/Shared/Views/Helpers/ComposeFileView.swift b/apps/ios/Shared/Views/Helpers/ComposeFileView.swift new file mode 100644 index 0000000000..bc6a96aa86 --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/ComposeFileView.swift @@ -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) + } +} diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index db4127fcf2..04848db8f0 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -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 = ""; }; 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MsgContentView.swift; sourceTree = ""; }; 640F50E227CF991C001E05C2 /* SMPServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMPServers.swift; sourceTree = ""; }; + 6454036E2822A9750090DDFF /* ComposeFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeFileView.swift; sourceTree = ""; }; 648010AA281ADD15009009B9 /* CIFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIFileView.swift; sourceTree = ""; }; 6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = ""; }; @@ -350,6 +352,7 @@ 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */, 649BCDA12805D6EF00C3A862 /* CIImageView.swift */, 648010AA281ADD15009009B9 /* CIFileView.swift */, + 6454036E2822A9750090DDFF /* ComposeFileView.swift */, ); path = Helpers; sourceTree = ""; @@ -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 */,