From 1152b5d737a9d0f8784b34ea1dc6bfc65d8cd025 Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Tue, 19 Apr 2022 12:29:03 +0400 Subject: [PATCH] mobile: support images (#536) * ios api * ios wip * android wip * ios files folder * ios get address on start * android app files folder * ios more backend * android more backend * translation * ios image without text, remove preview * android image without text, remove preview * fix translation * file name in previews and w/t text * Revert "file name in previews and w/t text" This reverts commit 0110570e55d23ecc361613f41aeadcaff07ac903. * ios filename in preview * android filename in preview * android wider images * ios determine width on image for correct quote width * ios images in previews wip * ios square image in quote * ios: update image layout * android images in quotes * android remove redundant modifier * android clip to bounds * android - image in right side of quote * android refactor image view * android - refactor, align quote text top * android fix emoji view * fix image layout * full screen image view, fix quote layout * android various size * android fixed image width * android meta on image * ios: add drag gesture to hide full-screen image * android: make image-only meta white * refactor file.stored * android: meta icon color * android: open chat scrolled to last unread item * copy/share image messages * android: full screen image * check file is loaded * terminal: refactor view for messages with files * android: change to onClick, only show stored file * android: remove close sheet bar * android: close image view on click * translation * android: pass showMenu to CIImageView to show menu on long click * increase DropDown width Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../main/java/chat/simplex/app/SimplexApp.kt | 3 +- .../java/chat/simplex/app/model/ChatModel.kt | 83 ++++++++--- .../java/chat/simplex/app/model/SimpleXAPI.kt | 55 ++++++-- .../chat/simplex/app/views/TerminalView.kt | 21 +-- .../chat/simplex/app/views/chat/ChatView.kt | 99 +++++++++++-- .../app/views/chat/ComposeImageView.kt | 31 +++++ .../simplex/app/views/chat/ComposeView.kt | 51 ++++++- .../simplex/app/views/chat/SendMsgView.kt | 15 +- .../app/views/chat/item/CIImageView.kt | 44 ++++++ .../simplex/app/views/chat/item/CIMetaView.kt | 12 +- .../app/views/chat/item/ChatItemView.kt | 24 ++-- .../app/views/chat/item/FramedItemView.kt | 107 ++++++++++---- .../views/chat/item/ImageFullScreenView.kt | 29 ++++ .../app/views/chat/item/TextItemView.kt | 4 +- .../app/views/chatlist/ChatPreviewView.kt | 2 +- .../simplex/app/views/helpers/GetImageView.kt | 13 +- .../chat/simplex/app/views/helpers/Util.kt | 47 ++++++- .../app/views/usersettings/UserProfileView.kt | 11 +- .../app/src/main/res/values-ru/strings.xml | 8 +- .../app/src/main/res/values/strings.xml | 6 + apps/ios/LOCALIZATION.md | 2 +- apps/ios/Shared/ContentView.swift | 2 + apps/ios/Shared/FileUtils.swift | 34 +++++ apps/ios/Shared/Model/ChatModel.swift | 78 +++++++++-- apps/ios/Shared/Model/SimpleXAPI.swift | 75 +++++++--- .../Views/Chat/ChatItem/CIMetaView.swift | 7 +- .../Views/Chat/ChatItem/FramedItemView.swift | 131 +++++++++++++----- .../Views/Chat/ChatItem/MsgContentView.swift | 6 +- apps/ios/Shared/Views/Chat/ChatItemView.swift | 5 +- apps/ios/Shared/Views/Chat/ChatView.swift | 50 ++++++- .../Chat/ComposeMessage/ComposeView.swift | 105 ++++++++++++-- .../Chat/ComposeMessage/SendMessageView.swift | 11 +- .../Views/ChatList/ChatPreviewView.swift | 2 +- .../Shared/Views/Helpers/CIImageView.swift | 56 ++++++++ ...hatItemLinkView.swift => CILinkView.swift} | 4 +- .../Views/Helpers/ComposeImageView.swift | 37 +++++ apps/ios/Shared/Views/TerminalView.swift | 8 +- .../Views/UserSettings/SettingsButton.swift | 12 -- .../en.xcloc/Localized Contents/en.xliff | 15 +- .../en.lproj/Localizable.strings | Bin 1678 -> 1278 bytes .../ru.xcloc/Localized Contents/ru.xliff | 17 +-- .../en.lproj/Localizable.strings | Bin 1678 -> 1278 bytes apps/ios/SimpleX.xcodeproj/project.pbxproj | 20 ++- apps/ios/ru.lproj/Localizable.strings | 11 +- src/Simplex/Chat/View.hs | 26 ++-- 45 files changed, 1103 insertions(+), 276 deletions(-) create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeImageView.kt create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt create mode 100644 apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ImageFullScreenView.kt create mode 100644 apps/ios/Shared/FileUtils.swift create mode 100644 apps/ios/Shared/Views/Helpers/CIImageView.swift rename apps/ios/Shared/Views/Helpers/{ChatItemLinkView.swift => CILinkView.swift} (99%) create mode 100644 apps/ios/Shared/Views/Helpers/ComposeImageView.swift diff --git a/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt index 73397f1908..770cb0c643 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt @@ -5,6 +5,7 @@ import android.net.LocalServerSocket import android.util.Log import androidx.lifecycle.* import chat.simplex.app.model.* +import chat.simplex.app.views.helpers.getFilesDirectory import chat.simplex.app.views.helpers.withApi import java.io.BufferedReader import java.io.InputStreamReader @@ -27,7 +28,7 @@ external fun chatRecvMsg(ctrl: ChatCtrl) : String class SimplexApp: Application(), LifecycleEventObserver { val chatController: ChatController by lazy { - val ctrl = chatInit(applicationContext.filesDir.toString()) + val ctrl = chatInit(getFilesDirectory(applicationContext)) ChatController(ctrl, ntfManager, applicationContext) } 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 8a4dbe9235..eec3f1d9ff 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 @@ -523,10 +523,18 @@ data class ChatItem ( val meta: CIMeta, val content: CIContent, val formattedText: List? = null, - val quotedItem: CIQuote? = null + val quotedItem: CIQuote? = null, + val file: CIFile? = null ) { val id: Long get() = meta.itemId val timestampText: String get() = meta.timestampText + + val text: String get() = + when { + content.text == "" && file != null -> file.fileName + else -> content.text + } + val isRcvNew: Boolean get() = meta.itemStatus is CIStatus.RcvNew val memberDisplayName: String? get() = @@ -555,6 +563,7 @@ data class ChatItem ( text: String = "hello\nthere", status: CIStatus = CIStatus.SndNew(), quotedItem: CIQuote? = null, + file: CIFile? = null, itemDeleted: Boolean = false, itemEdited: Boolean = false, editable: Boolean = true @@ -563,7 +572,8 @@ data class ChatItem ( chatDir = dir, meta = CIMeta.getSample(id, ts, text, status, itemDeleted, itemEdited, editable), content = CIContent.SndMsgContent(msgContent = MsgContent.MCText(text)), - quotedItem = quotedItem + quotedItem = quotedItem, + file = file ) fun getDeletedContentSampleData( @@ -575,9 +585,10 @@ data class ChatItem ( ) = ChatItem( chatDir = dir, - meta = CIMeta.getSample(id, ts, text, status, false, false, false), + meta = CIMeta.getSample(id, ts, text, status, itemDeleted = false, itemEdited = false, editable = false), content = CIContent.RcvDeleted(deleteMode = CIDeleteMode.cidmBroadcast), - quotedItem = null + quotedItem = null, + file = null ) } } @@ -705,18 +716,6 @@ sealed class CIContent: ItemContent { override val text get() = generalGetString(R.string.deleted_description) override val msgContent get() = null } - - @Serializable @SerialName("sndFileInvitation") - class SndFileInvitation(val fileId: Long, val filePath: String): CIContent() { - override val text get() = generalGetString(R.string.sending_files_not_yet_supported) - override val msgContent get() = null - } - - @Serializable @SerialName("rcvFileInvitation") - class RcvFileInvitation(val rcvFileTransfer: RcvFileTransfer): CIContent() { - override val text get() = generalGetString(R.string.receiving_files_not_yet_supported) - override val msgContent get() = null - } } @Serializable @@ -744,6 +743,37 @@ class CIQuote ( } } +@Serializable +class CIFile( + val fileId: Long, + val fileName: String, + val fileSize: Long, + val filePath: String? = null, + val fileStatus: CIFileStatus +) { + val stored: Boolean = when (fileStatus) { + CIFileStatus.SndStored -> true + CIFileStatus.SndCancelled -> true + CIFileStatus.RcvComplete -> true + else -> false + } + + companion object { + fun getSample(fileId: Long, fileName: String, fileSize: Long, filePath: String?, fileStatus: CIFileStatus = CIFileStatus.SndStored): CIFile = + CIFile(fileId = fileId, fileName = fileName, fileSize = fileSize, filePath = filePath, fileStatus = fileStatus) + } +} + +@Serializable +enum class CIFileStatus { + @SerialName("snd_stored") SndStored, + @SerialName("snd_cancelled") SndCancelled, + @SerialName("rcv_invitation") RcvInvitation, + @SerialName("rcv_transfer") RcvTransfer, + @SerialName("rcv_complete") RcvComplete, + @SerialName("rcv_cancelled") RcvCancelled; +} + @Suppress("SERIALIZER_TYPE_INCOMPATIBLE") @Serializable(with = MsgContentSerializer::class) sealed class MsgContent { @@ -755,12 +785,16 @@ sealed class MsgContent { @Serializable(with = MsgContentSerializer::class) class MCLink(override val text: String, val preview: LinkPreview): MsgContent() + @Serializable(with = MsgContentSerializer::class) + class MCImage(override val text: String, val image: String): MsgContent() + @Serializable(with = MsgContentSerializer::class) class MCUnknown(val type: String? = null, override val text: String, val json: JsonElement): MsgContent() val cmdString: String get() = when (this) { is MCText -> "text $text" is MCLink -> "json ${json.encodeToString(this)}" + is MCImage -> "json ${json.encodeToString(this)}" is MCUnknown -> "json $json" } } @@ -775,6 +809,10 @@ object MsgContentSerializer : KSerializer { element("text") element("preview") }) + element("MCImage", buildClassSerialDescriptor("MCImage") { + element("text") + element("image") + }) element("MCUnknown", buildClassSerialDescriptor("MCUnknown")) } @@ -791,6 +829,10 @@ object MsgContentSerializer : KSerializer { val preview = Json.decodeFromString(json["preview"].toString()) MsgContent.MCLink(text, preview) } + "image" -> { + val image = json["image"]?.jsonPrimitive?.content ?: "unknown message format" + MsgContent.MCImage(text, image) + } else -> MsgContent.MCUnknown(t, text, json) } } else { @@ -815,6 +857,12 @@ object MsgContentSerializer : KSerializer { put("text", value.text) put("preview", json.encodeToJsonElement(value.preview)) } + is MsgContent.MCImage -> + buildJsonObject { + put("type", "image") + put("text", value.text) + put("image", value.image) + } is MsgContent.MCUnknown -> value.json } encoder.encodeJsonElement(json) @@ -882,6 +930,3 @@ enum class FormatColor(val color: String) { white -> MaterialTheme.colors.onBackground } } - -@Serializable -class RcvFileTransfer 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 0234dfce44..ba96f7be04 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 @@ -40,6 +40,7 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt Log.d(TAG, "user: $user") try { apiStartChat() + apiSetFilesFolder(getAppFilesDirectory(appContext)) chatModel.userAddress.value = apiGetUserAddress() chatModel.userSMPServers.value = getUserSMPServers() val chats = apiGetChats() @@ -133,6 +134,12 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt throw Error("failed starting chat: ${r.responseType} ${r.details}") } + suspend fun apiSetFilesFolder(filesFolder: String) { + val r = sendCmd(CC.SetFilesFolder(filesFolder)) + if (r is CR.CmdOk) return + throw Error("failed to set files folder: ${r.responseType} ${r.details}") + } + suspend fun apiGetChats(): List { val r = sendCmd(CC.ApiGetChats()) if (r is CR.ApiChats ) return r.chats @@ -146,9 +153,8 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt return null } - suspend fun apiSendMessage(type: ChatType, id: Long, quotedItemId: Long? = null, mc: MsgContent): AChatItem? { - val cmd = if (quotedItemId == null) CC.ApiSendMessage(type, id, mc) - else CC.ApiSendMessageQuote(type, id, quotedItemId, mc) + suspend fun apiSendMessage(type: ChatType, id: Long, file: String? = null, quotedItemId: Long? = null, mc: MsgContent): AChatItem? { + val cmd = CC.ApiSendMessage(type, id, file, quotedItemId, mc) val r = sendCmd(cmd) if (r is CR.NewChatItem ) return r.chatItem Log.e(TAG, "apiSendMessage bad response: ${r.responseType} ${r.details}") @@ -303,6 +309,13 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt return false } + suspend fun receiveFile(fileId: Long): Boolean { + val r = sendCmd(CC.ReceiveFile(fileId)) + if (r is CR.RcvFileAccepted) return true + Log.e(TAG, "receiveFile bad response: ${r.responseType} ${r.details}") + return false + } + fun apiErrorAlert(method: String, title: String, r: CR) { val errMsg = "${r.responseType}: ${r.details}" Log.e(TAG, "$method bad response: $errMsg") @@ -346,6 +359,10 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt val cInfo = r.chatItem.chatInfo val cItem = r.chatItem.chatItem chatModel.addChatItem(cInfo, cItem) + val file = cItem.file + if (file != null && file.fileSize <= 394500) { // 236700 + withApi {receiveFile(file.fileId)} + } if (!isAppOnForeground(appContext) || chatModel.chatId.value != cInfo.id) { ntfManager.notifyMessageReceived(cInfo, cItem) } @@ -378,6 +395,13 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt chatModel.upsertChatItem(cInfo, cItem) } } + is CR.RcvFileComplete -> { + val cInfo = r.chatItem.chatInfo + val cItem = r.chatItem.chatItem + if (chatModel.upsertChatItem(cInfo, cItem)) { + ntfManager.notifyMessageReceived(cInfo, cItem) + } + } else -> Log.d(TAG , "unsupported event: ${r.responseType}") } @@ -471,10 +495,10 @@ sealed class CC { class ShowActiveUser: CC() class CreateActiveUser(val profile: Profile): CC() class StartChat: CC() + class SetFilesFolder(val filesFolder: String): CC() class ApiGetChats: CC() class ApiGetChat(val type: ChatType, val id: Long): CC() - class ApiSendMessage(val type: ChatType, val id: Long, val mc: MsgContent): CC() - class ApiSendMessageQuote(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent): CC() + class ApiSendMessage(val type: ChatType, val id: Long, val file: String?, val quotedItemId: Long?, val mc: MsgContent): CC() class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent): CC() class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemId: Long, val mode: CIDeleteMode): CC() class GetUserSMPServers(): CC() @@ -490,16 +514,23 @@ sealed class CC { class ApiAcceptContact(val contactReqId: Long): CC() class ApiRejectContact(val contactReqId: Long): CC() class ApiChatRead(val type: ChatType, val id: Long, val range: ItemRange): CC() + class ReceiveFile(val fileId: Long): CC() val cmdString: String get() = when (this) { is Console -> cmd is ShowActiveUser -> "/u" is CreateActiveUser -> "/u ${profile.displayName} ${profile.fullName}" is StartChat -> "/_start" + is SetFilesFolder -> "/_files_folder $filesFolder" is ApiGetChats -> "/_get chats" is ApiGetChat -> "/_get chat ${chatRef(type, id)} count=100" - is ApiSendMessage -> "/_send ${chatRef(type, id)} ${mc.cmdString}" - is ApiSendMessageQuote -> "/_send_quote ${chatRef(type, id)} $itemId ${mc.cmdString}" + 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() + } is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId ${mc.cmdString}" is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} $itemId ${mode.deleteMode}" is GetUserSMPServers -> "/smp_servers" @@ -515,6 +546,7 @@ sealed class CC { is ApiAcceptContact -> "/_accept $contactReqId" is ApiRejectContact -> "/_reject $contactReqId" is ApiChatRead -> "/_read chat ${chatRef(type, id)} from=${range.from} to=${range.to}" + is ReceiveFile -> "/freceive $fileId" } val cmdType: String get() = when (this) { @@ -522,10 +554,10 @@ sealed class CC { is ShowActiveUser -> "showActiveUser" is CreateActiveUser -> "createActiveUser" is StartChat -> "startChat" + is SetFilesFolder -> "setFilesFolder" is ApiGetChats -> "apiGetChats" is ApiGetChat -> "apiGetChat" is ApiSendMessage -> "apiSendMessage" - is ApiSendMessageQuote -> "apiSendMessageQuote" is ApiUpdateChatItem -> "apiUpdateChatItem" is ApiDeleteChatItem -> "apiDeleteChatItem" is GetUserSMPServers -> "getUserSMPServers" @@ -541,6 +573,7 @@ sealed class CC { is ApiAcceptContact -> "apiAcceptContact" is ApiRejectContact -> "apiRejectContact" is ApiChatRead -> "apiChatRead" + is ReceiveFile -> "receiveFile" } class ItemRange(val from: Long, val to: Long) @@ -616,6 +649,8 @@ sealed class CR { @Serializable @SerialName("chatItemStatusUpdated") class ChatItemStatusUpdated(val chatItem: AChatItem): CR() @Serializable @SerialName("chatItemUpdated") class ChatItemUpdated(val chatItem: AChatItem): CR() @Serializable @SerialName("chatItemDeleted") class ChatItemDeleted(val deletedChatItem: AChatItem, val toChatItem: AChatItem): CR() + @Serializable @SerialName("rcvFileAccepted") class RcvFileAccepted: CR() + @Serializable @SerialName("rcvFileComplete") class RcvFileComplete(val chatItem: AChatItem): CR() @Serializable @SerialName("cmdOk") class CmdOk: CR() @Serializable @SerialName("chatCmdError") class ChatCmdError(val chatError: ChatError): CR() @Serializable @SerialName("chatError") class ChatRespError(val chatError: ChatError): CR() @@ -657,6 +692,8 @@ sealed class CR { is ChatItemStatusUpdated -> "chatItemStatusUpdated" is ChatItemUpdated -> "chatItemUpdated" is ChatItemDeleted -> "chatItemDeleted" + is RcvFileAccepted -> "rcvFileAccepted" + is RcvFileComplete -> "rcvFileComplete" is CmdOk -> "cmdOk" is ChatCmdError -> "chatCmdError" is ChatRespError -> "chatError" @@ -699,6 +736,8 @@ sealed class CR { is ChatItemStatusUpdated -> json.encodeToString(chatItem) is ChatItemUpdated -> json.encodeToString(chatItem) is ChatItemDeleted -> "deletedChatItem:\n${json.encodeToString(deletedChatItem)}\ntoChatItem:\n${json.encodeToString(toChatItem)}" + is RcvFileAccepted -> noDetails() + is RcvFileComplete -> json.encodeToString(chatItem) is CmdOk -> noDetails() is ChatCmdError -> chatError.string is ChatRespError -> chatError.string diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt index 2042ef469e..116aa4d299 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt @@ -3,8 +3,7 @@ package chat.simplex.app.views import android.content.res.Configuration import androidx.activity.compose.BackHandler import androidx.compose.foundation.* -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.* @@ -38,17 +37,21 @@ fun TerminalView(chatModel: ChatModel, close: () -> Unit) { @Composable fun TerminalLayout(terminalItems: List, close: () -> Unit, sendCommand: (String) -> Unit) { + var msg = remember { mutableStateOf("") } ProvideWindowInsets(windowInsetsAnimationsEnabled = true) { Scaffold( topBar = { CloseSheetBar(close) }, bottomBar = { - SendMsgView( - msg = remember { mutableStateOf("") }, - linkPreview = remember { mutableStateOf(null) }, - cancelledLinks = remember { mutableSetOf() }, - parseMarkdown = { null }, - sendMessage = sendCommand - ) + Box(Modifier.padding(horizontal = 8.dp)) { + SendMsgView( + msg = msg, + linkPreview = remember { mutableStateOf(null) }, + cancelledLinks = remember { mutableSetOf() }, + parseMarkdown = { null }, + sendMessage = sendCommand, + sendEnabled = msg.value.isNotEmpty() + ) + } }, modifier = Modifier.navigationBarsWithImePadding() ) { contentPadding -> 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 3ffab8066e..37716c8979 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 @@ -1,12 +1,15 @@ package chat.simplex.app.views.chat +import android.content.Context import android.content.res.Configuration +import android.graphics.Bitmap import android.util.Log import androidx.activity.compose.BackHandler import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ArrowBackIos @@ -16,6 +19,7 @@ import androidx.compose.runtime.saveable.rememberSaveable 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.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.font.FontWeight @@ -33,6 +37,8 @@ import com.google.accompanist.insets.ProvideWindowInsets import com.google.accompanist.insets.navigationBarsWithImePadding import kotlinx.coroutines.* import kotlinx.datetime.Clock +import java.io.File +import java.io.FileOutputStream @Composable fun ChatView(chatModel: ChatModel) { @@ -41,9 +47,12 @@ fun ChatView(chatModel: ChatModel) { if (chat == null || user == null) { chatModel.chatId.value = null } else { + val context = LocalContext.current val quotedItem = remember { mutableStateOf(null) } val editingItem = remember { mutableStateOf(null) } val linkPreview = remember { mutableStateOf(null) } + val chosenImage = remember { mutableStateOf(null) } + val imagePreview = remember { mutableStateOf(null) } var msg = remember { mutableStateOf("") } BackHandler { chatModel.chatId.value = null } @@ -63,7 +72,7 @@ fun ChatView(chatModel: ChatModel) { } } } - ChatLayout(user, chat, chatModel.chatItems, msg, quotedItem, editingItem, linkPreview, + ChatLayout(user, chat, chatModel.chatItems, msg, quotedItem, editingItem, linkPreview, chosenImage, imagePreview, back = { chatModel.chatId.value = null }, info = { ModalManager.shared.showCustomModal { close -> ChatInfoView(chatModel, close) } }, openDirectChat = { contactId -> @@ -86,12 +95,28 @@ fun ChatView(chatModel: ChatModel) { ) if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem) } else { + var file: String? = null + val imagePreviewData = imagePreview.value + val chosenImageData = chosenImage.value val linkPreviewData = linkPreview.value + val mc = when { + imagePreviewData != null && chosenImageData != null -> { + file = saveImage(context, chosenImageData) + MsgContent.MCImage(msg, imagePreviewData) + } + linkPreviewData != null -> { + MsgContent.MCLink(msg, linkPreviewData) + } + else -> { + MsgContent.MCText(msg) + } + } val newItem = chatModel.controller.apiSendMessage( type = cInfo.chatType, id = cInfo.apiId, + file = file, quotedItemId = quotedItem.value?.meta?.itemId, - mc = if (linkPreviewData != null) MsgContent.MCLink(msg, linkPreviewData) else MsgContent.MCText(msg) + mc = mc ) if (newItem != null) chatModel.addChatItem(cInfo, newItem.chatItem) } @@ -99,6 +124,8 @@ fun ChatView(chatModel: ChatModel) { editingItem.value = null quotedItem.value = null linkPreview.value = null + chosenImage.value = null + imagePreview.value = null } }, resetMessage = { msg.value = "" }, @@ -114,11 +141,23 @@ fun ChatView(chatModel: ChatModel) { if (toItem != null) chatModel.removeChatItem(cInfo, toItem.chatItem) } }, - parseMarkdown = { text -> runBlocking { chatModel.controller.apiParseMarkdown(text) } } + parseMarkdown = { text -> runBlocking { chatModel.controller.apiParseMarkdown(text) } }, + onImageChange = { bitmap -> imagePreview.value = resizeImageToDataSize(bitmap, maxDataSize = 12500) } ) } } +fun saveImage(context: Context, image: Bitmap): String { + val imageResized = base64ToBitmap(resizeImageToDataSize(image, 160000)) + val fileToSave = "image_${System.currentTimeMillis()}.jpg" + val file = File(getAppFilesDirectory(context) + "/" + fileToSave) + val output = FileOutputStream(file) + imageResized.compress(Bitmap.CompressFormat.JPEG, 100, output) + output.flush() + output.close() + return fileToSave +} + @Composable fun ChatLayout( user: User, @@ -128,27 +167,53 @@ fun ChatLayout( quotedItem: MutableState, editingItem: MutableState, linkPreview: MutableState, + chosenImage: MutableState, + imagePreview: MutableState, back: () -> Unit, info: () -> Unit, openDirectChat: (Long) -> Unit, sendMessage: (String) -> Unit, resetMessage: () -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, - parseMarkdown: (String) -> List? + parseMarkdown: (String) -> List?, + onImageChange: (Bitmap) -> Unit ) { + val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) + val scope = rememberCoroutineScope() + Surface( Modifier .fillMaxWidth() .background(MaterialTheme.colors.background) ) { ProvideWindowInsets(windowInsetsAnimationsEnabled = true) { - Scaffold( - topBar = { ChatInfoToolbar(chat, back, info) }, - bottomBar = { ComposeView(msg, quotedItem, editingItem, linkPreview, sendMessage, resetMessage, parseMarkdown) }, - modifier = Modifier.navigationBarsWithImePadding() - ) { contentPadding -> - Box(Modifier.padding(contentPadding)) { - ChatItemsList(user, chat, chatItems, msg, quotedItem, editingItem, openDirectChat, deleteMessage) + ModalBottomSheetLayout( + scrimColor = Color.Black.copy(alpha = 0.12F), + modifier = Modifier.navigationBarsWithImePadding(), + sheetContent = { + GetImageBottomSheet( + chosenImage, + onImageChange = onImageChange, + hideBottomSheet = { + scope.launch { bottomSheetModalState.hide() } + }) + }, + sheetState = bottomSheetModalState, + sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp) + ) { + Scaffold( + topBar = { ChatInfoToolbar(chat, back, info) }, + bottomBar = { + ComposeView( + msg, quotedItem, editingItem, linkPreview, imagePreview, sendMessage, resetMessage, parseMarkdown, + showBottomSheet = { scope.launch { bottomSheetModalState.show() } } + ) + }, + modifier = Modifier.navigationBarsWithImePadding() + ) { contentPadding -> + Box(Modifier.padding(contentPadding)) { + ChatItemsList(user, chat, chatItems, msg, quotedItem, editingItem, openDirectChat, deleteMessage) + } } } } @@ -228,7 +293,7 @@ fun ChatItemsList( openDirectChat: (Long) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit ) { - val listState = rememberLazyListState() + val listState = rememberLazyListState(initialFirstVisibleItemIndex = chatItems.size - chatItems.count { it.isRcvNew }) val keyboardState by getKeyboardState() val ciListState = rememberSaveable(stateSaver = CIListStateSaver) { mutableStateOf(CIListState(false, chatItems.count(), keyboardState)) @@ -342,13 +407,16 @@ fun PreviewChatLayout() { quotedItem = remember { mutableStateOf(null) }, editingItem = remember { mutableStateOf(null) }, linkPreview = remember { mutableStateOf(null) }, + chosenImage = remember { mutableStateOf(null) }, + imagePreview = remember { mutableStateOf(null) }, back = {}, info = {}, openDirectChat = {}, sendMessage = {}, resetMessage = {}, deleteMessage = { _, _ -> }, - parseMarkdown = { null } + parseMarkdown = { null }, + onImageChange = {} ) } } @@ -387,13 +455,16 @@ fun PreviewGroupChatLayout() { quotedItem = remember { mutableStateOf(null) }, editingItem = remember { mutableStateOf(null) }, linkPreview = remember { mutableStateOf(null) }, + chosenImage = remember { mutableStateOf(null) }, + imagePreview = remember { mutableStateOf(null) }, back = {}, info = {}, openDirectChat = {}, sendMessage = {}, resetMessage = {}, deleteMessage = { _, _ -> }, - parseMarkdown = { null } + parseMarkdown = { null }, + onImageChange = {} ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeImageView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeImageView.kt new file mode 100644 index 0000000000..0e2b438de8 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeImageView.kt @@ -0,0 +1,31 @@ +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.unit.dp +import chat.simplex.app.views.chat.item.SentColorLight +import chat.simplex.app.views.helpers.base64ToBitmap + +@Composable +fun ComposeImageView(image: String) { + Row( + Modifier + .fillMaxWidth() + .padding(top = 8.dp) + .background(SentColorLight), + verticalAlignment = Alignment.CenterVertically + ) { + val imageBitmap = base64ToBitmap(image).asImageBitmap() + Image( + imageBitmap, + "preview image", + modifier = Modifier + .width(80.dp) + .height(60.dp) + .padding(end = 8.dp) + ) + } +} 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 79e4b71ad4..6573ee6449 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,9 +1,24 @@ package chat.simplex.app.views.chat +import ComposeImageView +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AddCircleOutline import androidx.compose.foundation.layout.Column 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.unit.dp +import chat.simplex.app.R import chat.simplex.app.model.* import chat.simplex.app.views.helpers.ComposeLinkView +import chat.simplex.app.views.helpers.generalGetString // TODO ComposeState @@ -13,9 +28,11 @@ fun ComposeView( quotedItem: MutableState, editingItem: MutableState, linkPreview: MutableState, + imagePreview: MutableState, sendMessage: (String) -> Unit, resetMessage: () -> Unit, - parseMarkdown: (String) -> List? + parseMarkdown: (String) -> List?, + showBottomSheet: () -> Unit ) { val cancelledLinks = remember { mutableSetOf() } @@ -28,8 +45,13 @@ fun ComposeView( } Column { - val lp = linkPreview.value - if (lp != null) ComposeLinkView(lp, ::cancelPreview) + val ip = imagePreview.value + if (ip != null) { + ComposeImageView(ip) + } else { + val lp = linkPreview.value + if (lp != null) ComposeLinkView(lp, ::cancelPreview) + } when { quotedItem.value != null -> { ContextItemView(quotedItem) @@ -39,6 +61,27 @@ fun ComposeView( } else -> {} } - SendMsgView(msg, linkPreview, cancelledLinks, parseMarkdown, sendMessage, editing = editingItem.value != null) + Row( + modifier = Modifier.padding(start = 2.dp, end = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + Icon( + Icons.Outlined.AddCircleOutline, + contentDescription = generalGetString(R.string.attach), + tint = if (editingItem.value == null) MaterialTheme.colors.primary else Color.Gray, + modifier = Modifier + .size(40.dp) + .padding(vertical = 4.dp) + .clip(CircleShape) + .clickable { + if (editingItem.value == null) { + showBottomSheet() + } + } + ) + SendMsgView(msg, linkPreview, cancelledLinks, parseMarkdown, sendMessage, + editing = editingItem.value != null, sendEnabled = msg.value.isNotEmpty() || imagePreview.value != null) + } } } 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 7fcda1bd03..17065d0834 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 @@ -35,7 +35,8 @@ fun SendMsgView( cancelledLinks: MutableSet, parseMarkdown: (String) -> List?, sendMessage: (String) -> Unit, - editing: Boolean = false + editing: Boolean = false, + sendEnabled: Boolean = false ) { val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) var textStyle by remember { mutableStateOf(smallFont) } @@ -104,7 +105,7 @@ fun SendMsgView( capitalization = KeyboardCapitalization.Sentences, autoCorrect = true ), - modifier = Modifier.padding(8.dp), + modifier = Modifier.padding(vertical = 8.dp), cursorBrush = SolidColor(HighOrLowlight), decorationBox = { innerTextField -> Surface( @@ -124,7 +125,7 @@ fun SendMsgView( ) { innerTextField() } - val color = if (msg.value.isNotEmpty()) MaterialTheme.colors.primary else Color.Gray + val color = if (sendEnabled) MaterialTheme.colors.primary else Color.Gray Icon( if (editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward, generalGetString(R.string.icon_descr_send_message), @@ -135,7 +136,7 @@ fun SendMsgView( .clip(CircleShape) .background(color) .clickable { - if (msg.value.isNotEmpty()) { + if (sendEnabled) { sendMessage(msg.value) msg.value = "" textStyle = smallFont @@ -162,7 +163,8 @@ fun PreviewSendMsgView() { linkPreview = remember {mutableStateOf(null) }, cancelledLinks = mutableSetOf(), parseMarkdown = { null }, - sendMessage = { msg -> println(msg) } + sendMessage = { msg -> println(msg) }, + sendEnabled = true ) } } @@ -182,7 +184,8 @@ fun PreviewSendMsgViewEditing() { cancelledLinks = mutableSetOf(), sendMessage = { msg -> println(msg) }, parseMarkdown = { null }, - editing = true + editing = true, + sendEnabled = true ) } } 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 new file mode 100644 index 0000000000..d0196336c7 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIImageView.kt @@ -0,0 +1,44 @@ +import android.graphics.Bitmap +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import chat.simplex.app.model.CIFile +import chat.simplex.app.views.helpers.* +import chat.simplex.app.R + +@Composable +fun CIImageView( + image: String, + file: CIFile?, + showMenu: MutableState +) { + Column { + val context = LocalContext.current + var imageBitmap: Bitmap? = getStoredImage(context, file) + if (imageBitmap == null) { + imageBitmap = base64ToBitmap(image) + } + Image( + imageBitmap.asImageBitmap(), + contentDescription = generalGetString(R.string.image_descr), + // .width(1000.dp) is a hack for image to increase IntrinsicSize of FramedItemView + // if text is short and take all available width if text is long + modifier = Modifier + .width(1000.dp) + .combinedClickable( + onLongClick = { showMenu.value = true }, + onClick = { + if (getStoredFilePath(context, file) != null) { + ModalManager.shared.showCustomModal { close -> ImageFullScreenView(imageBitmap, close) } + } + } + ), + contentScale = ContentScale.FillWidth, + ) + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt index ca6e5a71c0..7e67d33664 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt @@ -20,7 +20,7 @@ import chat.simplex.app.views.helpers.generalGetString import kotlinx.datetime.Clock @Composable -fun CIMetaView(chatItem: ChatItem) { +fun CIMetaView(chatItem: ChatItem, metaColor: Color = HighOrLowlight) { Row(verticalAlignment = Alignment.CenterVertically) { if (!chatItem.isDeletedContent) { if (chatItem.meta.itemEdited) { @@ -28,14 +28,14 @@ fun CIMetaView(chatItem: ChatItem) { Icons.Filled.Edit, modifier = Modifier.height(12.dp).padding(end = 1.dp), contentDescription = generalGetString(R.string.icon_descr_edited), - tint = HighOrLowlight, + tint = metaColor, ) } - CIStatusView(chatItem.meta.itemStatus) + CIStatusView(chatItem.meta.itemStatus, metaColor) } Text( chatItem.timestampText, - color = HighOrLowlight, + color = metaColor, fontSize = 14.sp, modifier = Modifier.padding(start = 3.dp) ) @@ -44,10 +44,10 @@ fun CIMetaView(chatItem: ChatItem) { @Composable -fun CIStatusView(status: CIStatus) { +fun CIStatusView(status: CIStatus, metaColor: Color = HighOrLowlight) { when (status) { is CIStatus.SndSent -> { - Icon(Icons.Filled.Check, generalGetString(R.string.icon_descr_sent_msg_status_sent), Modifier.height(12.dp), tint = HighOrLowlight) + Icon(Icons.Filled.Check, generalGetString(R.string.icon_descr_sent_msg_status_sent), Modifier.height(12.dp), tint = metaColor) } is CIStatus.SndErrorAuth -> { Icon(Icons.Filled.Close, generalGetString(R.string.icon_descr_sent_msg_status_unauthorized_send), Modifier.height(12.dp), tint = Color.Red) 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 f067e6f8df..4f02622c58 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 @@ -36,51 +36,55 @@ fun ChatItemView( ) { val sent = cItem.chatDir.sent val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart - var showMenu by remember { mutableStateOf(false) } + val showMenu = remember { mutableStateOf(false) } Box( modifier = Modifier .padding(bottom = 4.dp) .fillMaxWidth(), contentAlignment = alignment, ) { - Column(Modifier.combinedClickable(onLongClick = { showMenu = true }, onClick = {})) { + Column(Modifier.combinedClickable(onLongClick = { showMenu.value = true }, onClick = {})) { if (cItem.isMsgContent) { - if (cItem.quotedItem == null && isShortEmoji(cItem.content.text)) { + if (cItem.file == null && cItem.quotedItem == null && isShortEmoji(cItem.content.text)) { EmojiItemView(cItem) } else { - FramedItemView(user, cItem, uriHandler, showMember = showMember) + FramedItemView(user, cItem, uriHandler, showMember = showMember, showMenu) } } else if (cItem.isDeletedContent) { DeletedItemView(cItem, showMember = showMember) } if (cItem.isMsgContent) { - DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) { + DropdownMenu( + expanded = showMenu.value, + onDismissRequest = { showMenu.value = false }, + Modifier.width(150.dp) + ) { ItemAction(generalGetString(R.string.reply_verb), Icons.Outlined.Reply, onClick = { editingItem.value = null quotedItem.value = cItem - showMenu = false + showMenu.value = false }) ItemAction(generalGetString(R.string.share_verb), Icons.Outlined.Share, onClick = { shareText(cxt, cItem.content.text) - showMenu = false + showMenu.value = false }) ItemAction(generalGetString(R.string.copy_verb), Icons.Outlined.ContentCopy, onClick = { copyText(cxt, cItem.content.text) - showMenu = false + showMenu.value = false }) if (cItem.chatDir.sent && cItem.meta.editable) { ItemAction(generalGetString(R.string.edit_verb), Icons.Filled.Edit, onClick = { quotedItem.value = null editingItem.value = cItem msg.value = cItem.content.text - showMenu = false + showMenu.value = false }) } ItemAction( generalGetString(R.string.delete_verb), Icons.Outlined.Delete, onClick = { - showMenu = false + showMenu.value = false deleteMessageAlertDialog(cItem, deleteMessage = deleteMessage) }, color = Color.Red 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 54860a84f8..a75194f97d 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,21 +1,28 @@ package chat.simplex.app.views.chat.item +import CIImageView +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.UriHandler 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 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.views.helpers.ChatItemLinkView +import chat.simplex.app.views.helpers.* import kotlinx.datetime.Clock val SentColorLight = Color(0x1E45B8FF) @@ -24,29 +31,52 @@ val SentQuoteColorLight = Color(0x2545B8FF) val ReceivedQuoteColorLight = Color(0x25B1B0B5) @Composable -fun FramedItemView(user: User, ci: ChatItem, uriHandler: UriHandler? = null, showMember: Boolean = false) { +fun FramedItemView( + user: User, + ci: ChatItem, + uriHandler: UriHandler? = null, + showMember: Boolean = false, + showMenu: MutableState +) { val sent = ci.chatDir.sent Surface( shape = RoundedCornerShape(18.dp), color = if (sent) SentColorLight else ReceivedColorLight ) { + var metaColor = HighOrLowlight Box(contentAlignment = Alignment.BottomEnd) { Column(Modifier.width(IntrinsicSize.Max)) { val qi = ci.quotedItem if (qi != null) { - Box( + Row( Modifier .background(if (sent) SentQuoteColorLight else ReceivedQuoteColorLight) - .padding(vertical = 6.dp, horizontal = 12.dp) .fillMaxWidth() ) { - MarkdownText( - qi, sender = qi.sender(user), senderBold = true, maxLines = 3, - style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface) - ) + 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 = generalGetString(R.string.image_descr), + contentScale = ContentScale.Crop, + modifier = Modifier + .size(60.dp) + .clipToBounds() + ) + } } } - if (ci.formattedText == null && isShortEmoji(ci.content.text)) { + if (ci.file == null && ci.formattedText == null && isShortEmoji(ci.content.text)) { Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) { Column( Modifier @@ -60,26 +90,41 @@ fun FramedItemView(user: User, ci: ChatItem, uriHandler: UriHandler? = null, sho } } else { Column(Modifier.fillMaxWidth()) { - val mc = ci.content.msgContent - if (mc is MsgContent.MCLink) { - ChatItemLinkView(mc.preview) - } - Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) { - MarkdownText( - ci.content, ci.formattedText, if (showMember) ci.memberDisplayName else null, - metaText = ci.timestampText, edited = ci.meta.itemEdited, uriHandler = uriHandler, senderBold = true - ) + when (val mc = ci.content.msgContent) { + is MsgContent.MCImage -> { + CIImageView(image = mc.image, file = ci.file, showMenu) + if (mc.text == "") { + metaColor = Color.White + } else { + CIMarkdownText(ci, showMember, uriHandler) + } + } + is MsgContent.MCLink -> { + ChatItemLinkView(mc.preview) + CIMarkdownText(ci, showMember, uriHandler) + } + else -> CIMarkdownText(ci, showMember, uriHandler) } } } } Box(Modifier.padding(bottom = 6.dp, end = 12.dp)) { - CIMetaView(ci) + CIMetaView(ci, metaColor) } } } } +@Composable +fun CIMarkdownText(ci: ChatItem, showMember: Boolean, uriHandler: UriHandler?) { + Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) { + MarkdownText( + ci.content.text, ci.formattedText, if (showMember) ci.memberDisplayName else null, + metaText = ci.timestampText, edited = ci.meta.itemEdited, uriHandler = uriHandler, senderBold = true + ) + } +} + class EditedProvider: PreviewParameterProvider { override val values = listOf(false, true).asSequence() } @@ -87,12 +132,14 @@ class EditedProvider: PreviewParameterProvider { @Preview @Composable fun PreviewTextItemViewSnd(@PreviewParameter(EditedProvider::class) edited: Boolean) { + val showMenu = remember { mutableStateOf(false) } SimpleXTheme { FramedItemView( User.sampleData, ChatItem.getSampleData( - 1, CIDirection.DirectSnd(), Clock.System.now(), "hello", itemEdited = edited - ) + 1, CIDirection.DirectSnd(), Clock.System.now(), "hello", itemEdited = edited, + ), + showMenu = showMenu ) } } @@ -100,12 +147,14 @@ fun PreviewTextItemViewSnd(@PreviewParameter(EditedProvider::class) edited: Bool @Preview @Composable fun PreviewTextItemViewRcv(@PreviewParameter(EditedProvider::class) edited: Boolean) { + val showMenu = remember { mutableStateOf(false) } SimpleXTheme { FramedItemView( User.sampleData, ChatItem.getSampleData( 1, CIDirection.DirectRcv(), Clock.System.now(), "hello", itemEdited = edited - ) + ), + showMenu = showMenu ) } } @@ -113,6 +162,7 @@ fun PreviewTextItemViewRcv(@PreviewParameter(EditedProvider::class) edited: Bool @Preview @Composable fun PreviewTextItemViewLong(@PreviewParameter(EditedProvider::class) edited: Boolean) { + val showMenu = remember { mutableStateOf(false) } SimpleXTheme { FramedItemView( User.sampleData, @@ -122,7 +172,8 @@ fun PreviewTextItemViewLong(@PreviewParameter(EditedProvider::class) edited: Boo Clock.System.now(), "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.", itemEdited = edited - ) + ), + showMenu = showMenu ) } } @@ -130,6 +181,7 @@ fun PreviewTextItemViewLong(@PreviewParameter(EditedProvider::class) edited: Boo @Preview @Composable fun PreviewTextItemViewQuote(@PreviewParameter(EditedProvider::class) edited: Boolean) { + val showMenu = remember { mutableStateOf(false) } SimpleXTheme { FramedItemView( User.sampleData, @@ -140,7 +192,8 @@ fun PreviewTextItemViewQuote(@PreviewParameter(EditedProvider::class) edited: Bo CIStatus.SndSent(), quotedItem = CIQuote.getSample(1, Clock.System.now(), "hi", chatDir = CIDirection.DirectRcv()), itemEdited = edited - ) + ), + showMenu = showMenu ) } } @@ -148,6 +201,7 @@ fun PreviewTextItemViewQuote(@PreviewParameter(EditedProvider::class) edited: Bo @Preview @Composable fun PreviewTextItemViewEmoji(@PreviewParameter(EditedProvider::class) edited: Boolean) { + val showMenu = remember { mutableStateOf(false) } SimpleXTheme { FramedItemView( User.sampleData, @@ -158,7 +212,8 @@ fun PreviewTextItemViewEmoji(@PreviewParameter(EditedProvider::class) edited: Bo CIStatus.SndSent(), quotedItem = CIQuote.getSample(1, Clock.System.now(), "Lorem ipsum dolor sit amet", chatDir = CIDirection.DirectRcv()), itemEdited = edited - ) + ), + showMenu = showMenu ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ImageFullScreenView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ImageFullScreenView.kt new file mode 100644 index 0000000000..bf74ab05ce --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ImageFullScreenView.kt @@ -0,0 +1,29 @@ +import android.graphics.Bitmap +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import chat.simplex.app.R +import chat.simplex.app.views.helpers.generalGetString + +@Composable +fun ImageFullScreenView(imageBitmap: Bitmap, close: () -> Unit) { + BackHandler(onBack = close) + Column( + Modifier + .fillMaxSize() + .background(Color.Black) + .clickable(onClick = close) + ) { + Image( + imageBitmap.asImageBitmap(), + contentDescription = generalGetString(R.string.image_descr), + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + ) + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt index 6f971eaaa9..6d503d1b46 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt @@ -35,7 +35,7 @@ fun appendSender(b: AnnotatedString.Builder, sender: String?, senderBold: Boolea @Composable fun MarkdownText ( - content: ItemContent, + text: String, formattedText: List? = null, sender: String? = null, metaText: String? = null, @@ -51,7 +51,7 @@ fun MarkdownText ( if (formattedText == null) { val annotatedText = buildAnnotatedString { appendSender(this, sender, senderBold) - append(content.text) + append(text) if (metaText != null) withStyle(reserveTimestampStyle) { append(reserve + metaText) } } Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt index 81b10548be..3c9a4158b2 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt @@ -42,7 +42,7 @@ fun ChatPreviewView(chat: Chat) { val ci = chat.chatItems.lastOrNull() if (ci != null) { MarkdownText( - ci.content, ci.formattedText, ci.memberDisplayName, + ci.text, ci.formattedText, ci.memberDisplayName, metaText = ci.timestampText, maxLines = 2, overflow = TextOverflow.Ellipsis 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 21b9166dbe..a5a0ef27e3 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 @@ -37,7 +37,7 @@ import kotlin.math.sqrt // Inspired by https://github.com/MakeItEasyDev/Jetpack-Compose-Capture-Image-Or-Choose-from-Gallery -private fun cropToSquare(image: Bitmap): Bitmap { +fun cropToSquare(image: Bitmap): Bitmap { var xOffset = 0 var yOffset = 0 val side = min(image.height, image.width) @@ -124,7 +124,8 @@ fun rememberPermissionLauncher(cb: (Boolean) -> Unit): ManagedActivityResultLaun @Composable fun GetImageBottomSheet( - profileImageStr: MutableState, + imageBitmap: MutableState, + onImageChange: (Bitmap) -> Unit, hideBottomSheet: () -> Unit ) { val context = LocalContext.current @@ -134,12 +135,16 @@ fun GetImageBottomSheet( if (uri != null) { val source = ImageDecoder.createSource(context.contentResolver, uri) val bitmap = ImageDecoder.decodeBitmap(source) - profileImageStr.value = resizeImageToDataSize(cropToSquare(bitmap), maxDataSize = 12500) + imageBitmap.value = bitmap + onImageChange(bitmap) } } val cameraLauncher = rememberCameraLauncher { bitmap: Bitmap? -> - if (bitmap != null) profileImageStr.value = resizeImageToDataSize(cropToSquare(bitmap), maxDataSize = 12500) + if (bitmap != null) { + imageBitmap.value = bitmap + onImageChange(bitmap) + } } val permissionLauncher = rememberPermissionLauncher { isGranted: Boolean -> 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 c1838b1256..be4e5d2d7c 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 @@ -1,7 +1,8 @@ package chat.simplex.app.views.helpers +import android.content.Context import android.content.res.Resources -import android.graphics.Rect +import android.graphics.* import android.graphics.Typeface import android.text.Spanned import android.text.SpannedString @@ -16,9 +17,13 @@ import androidx.compose.ui.text.font.* import androidx.compose.ui.text.style.BaselineShift import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.* +import androidx.core.content.FileProvider import androidx.core.text.HtmlCompat +import chat.simplex.app.BuildConfig import chat.simplex.app.SimplexApp +import chat.simplex.app.model.CIFile import kotlinx.coroutines.* +import java.io.File fun withApi(action: suspend CoroutineScope.() -> Unit): Job = GlobalScope.launch { withContext(Dispatchers.Main, action) } @@ -52,11 +57,9 @@ fun getKeyboardState(): State { return keyboardState } - // Resource to annotated string from // https://stackoverflow.com/questions/68549248/android-jetpack-compose-how-to-show-styled-text-from-string-resources - -fun generalGetString(id: Int) : String { +fun generalGetString(id: Int): String { return SimplexApp.context.getString(id) } @@ -195,3 +198,39 @@ private fun spannableStringToAnnotatedString( AnnotatedString(text.toString()) } } + +fun getFilesDirectory(context: Context): String { + return context.filesDir.toString() +} + +fun getAppFilesDirectory(context: Context): String { + return getFilesDirectory(context) + "/app_files" +} + +fun getStoredFilePath(context: Context, file: CIFile?): String? { + return if (file?.filePath != null && file.stored) { + val filePath = getAppFilesDirectory(context) + "/" + file.filePath + if (File(filePath).exists()) filePath else null + } else { + null + } +} + +// https://developer.android.com/training/data-storage/shared/documents-files#bitmap +fun getStoredImage(context: Context, file: CIFile?): Bitmap? { + val filePath = getStoredFilePath(context, file) + return if (filePath != null) { + try { + val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath)) + val parcelFileDescriptor = context.contentResolver.openFileDescriptor(uri, "r") + val fileDescriptor = parcelFileDescriptor?.fileDescriptor + val image = BitmapFactory.decodeFileDescriptor(fileDescriptor) + parcelFileDescriptor?.close() + image + } catch (e: Exception) { + null + } + } else { + null + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt index 39af8a3c21..4d96085b63 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt @@ -1,6 +1,7 @@ package chat.simplex.app.views.usersettings import android.content.res.Configuration +import android.graphics.Bitmap import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape @@ -64,6 +65,7 @@ fun UserProfileLayout( val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) val displayName = remember { mutableStateOf(profile.displayName) } val fullName = remember { mutableStateOf(profile.fullName) } + val chosenImage = remember { mutableStateOf(null) } val profileImage = remember { mutableStateOf(profile.image) } val scope = rememberCoroutineScope() val scrollState = rememberScrollState() @@ -75,9 +77,12 @@ fun UserProfileLayout( scrimColor = Color.Black.copy(alpha = 0.12F), modifier = Modifier.navigationBarsWithImePadding(), sheetContent = { - GetImageBottomSheet(profileImage, hideBottomSheet = { - scope.launch { bottomSheetModalState.hide() } - }) + GetImageBottomSheet( + chosenImage, + onImageChange = { bitmap -> profileImage.value = resizeImageToDataSize(cropToSquare(bitmap), maxDataSize = 12500) }, + hideBottomSheet = { + scope.launch { bottomSheetModalState.hide() } + }) }, sheetState = bottomSheetModalState, sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp) 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 1d54970f46..92700b3bb2 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -49,7 +49,7 @@ Ответить Поделиться - Скопировать + Копировать Редактировать Удалить Удалить сообщение? @@ -70,6 +70,12 @@ Этот текст можно найти в Настройках Ваши чаты + + Прикрепить + + + Изображение + Удалить контакт? Контакт и все сообщения будут удалены - это действие нельзя отменить! diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index dda6fd3439..696d9380b0 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -70,6 +70,12 @@ This text is available in settings Your chats + + Attach + + + Image + Delete contact? Contact and all messages will be deleted - this cannot be undone! diff --git a/apps/ios/LOCALIZATION.md b/apps/ios/LOCALIZATION.md index 40fe37c215..f58b8918b4 100644 --- a/apps/ios/LOCALIZATION.md +++ b/apps/ios/LOCALIZATION.md @@ -18,7 +18,7 @@ String.localizedStringWithFormat(NSLocalizedString("You can now send messages to 1. Choose `Product -> Export Localizations...` in the menu, choose `ios` folder as the destination and `SimpleX Localizations` as the folder name, confirm to overwrite it (make sure not to save to subfolder). 2. Add `target` keys to the localizations that were added or changed. -3. Choose `Product -> Import Localizations...` for any non-Enlish folders - that would update Localizable files. +3. Choose `Product -> Import Localizations...` for any non-English folders - that would update Localizable files. Localizable files values can be edited directly, the changes will be included in the next export. Following the process above though guarantees that all strings are localized. diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 7d911d4c82..b5c5b8392a 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -18,6 +18,8 @@ struct ContentView: View { .onAppear { do { try apiStartChat() + try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path) + chatModel.userAddress = try apiGetUserAddress() chatModel.userSMPServers = try getUserSMPServers() chatModel.chats = try apiGetChats() } catch { diff --git a/apps/ios/Shared/FileUtils.swift b/apps/ios/Shared/FileUtils.swift new file mode 100644 index 0000000000..2c36e33a2f --- /dev/null +++ b/apps/ios/Shared/FileUtils.swift @@ -0,0 +1,34 @@ +// +// FileUtils.swift +// SimpleX (iOS) +// +// Created by JRoberts on 15.04.2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import Foundation +import SwiftUI + +func getDocumentsDirectory() -> URL { + return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! +} + +func getAppFilesDirectory() -> URL { + return getDocumentsDirectory().appendingPathComponent("app_files", isDirectory: true) +} + +func getStoredFilePath(_ file: CIFile?) -> String? { + if let file = file, + file.stored, + let savedFile = file.filePath { + return getAppFilesDirectory().path + "/" + savedFile + } + return nil +} + +func getStoredImage(_ file: CIFile?) -> UIImage? { + if let filePath = getStoredFilePath(file) { + return UIImage(contentsOfFile: filePath) + } + return nil +} diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index e05806458e..4d63b485a6 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -579,11 +579,21 @@ struct ChatItem: Identifiable, Decodable { var content: CIContent var formattedText: [FormattedText]? var quotedItem: CIQuote? + var file: CIFile? var id: Int64 { get { meta.itemId } } var timestampText: Text { get { meta.timestampText } } + var text: String { + get { + switch (content.text, file) { + case let ("", .some(file)): return file.fileName + default: return content.text + } + } + } + func isRcvNew() -> Bool { if case .rcvNew = meta.itemStatus { return true } return false @@ -615,12 +625,13 @@ struct ChatItem: Identifiable, Decodable { } } - static func getSample (_ id: Int64, _ dir: CIDirection, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, quotedItem: CIQuote? = nil, _ itemDeleted: Bool = false, _ itemEdited: Bool = false, _ editable: Bool = true) -> ChatItem { + static func getSample (_ id: Int64, _ dir: CIDirection, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, quotedItem: CIQuote? = nil, file: CIFile? = nil, _ itemDeleted: Bool = false, _ itemEdited: Bool = false, _ editable: Bool = true) -> ChatItem { ChatItem( chatDir: dir, meta: CIMeta.getSample(id, ts, text, status, itemDeleted, itemEdited, editable), content: .sndMsgContent(msgContent: .text(text)), - quotedItem: quotedItem + quotedItem: quotedItem, + file: file ) } @@ -629,7 +640,8 @@ struct ChatItem: Identifiable, Decodable { chatDir: dir, meta: CIMeta.getSample(id, ts, text, status, false, false, false), content: .rcvDeleted(deleteMode: .cidmBroadcast), - quotedItem: nil + quotedItem: nil, + file: nil ) } } @@ -711,8 +723,6 @@ enum CIContent: Decodable, ItemContent { case rcvMsgContent(msgContent: MsgContent) case sndDeleted(deleteMode: CIDeleteMode) case rcvDeleted(deleteMode: CIDeleteMode) - case sndFileInvitation(fileId: Int64, filePath: String) - case rcvFileInvitation(rcvFileTransfer: RcvFileTransfer) var text: String { get { @@ -721,11 +731,10 @@ enum CIContent: Decodable, ItemContent { case let .rcvMsgContent(mc): return mc.text case .sndDeleted: return NSLocalizedString("deleted", comment: "deleted chat item") case .rcvDeleted: return NSLocalizedString("deleted", comment: "deleted chat item") - case .sndFileInvitation: return NSLocalizedString("sending files is not supported yet", comment: "to be removed") - case .rcvFileInvitation: return NSLocalizedString("receiving files is not supported yet", comment: "to be removed") } } } + var msgContent: MsgContent? { get { switch self { @@ -737,10 +746,6 @@ enum CIContent: Decodable, ItemContent { } } -struct RcvFileTransfer: Decodable { - -} - struct CIQuote: Decodable, ItemContent { var chatDir: CIDirection? var itemId: Int64? @@ -763,14 +768,53 @@ struct CIQuote: Decodable, ItemContent { } } - static func getSample(_ itemId: Int64?, _ sentAt: Date, _ text: String, chatDir: CIDirection?) -> CIQuote { - CIQuote(chatDir: chatDir, itemId: itemId, sentAt: sentAt, content: .text(text)) + static func getSample(_ itemId: Int64?, _ sentAt: Date, _ text: String, chatDir: CIDirection?, image: String? = nil) -> CIQuote { + let mc: MsgContent + if let image = image { + mc = .image(text: text, image: image) + } else { + mc = .text(text) + } + return CIQuote(chatDir: chatDir, itemId: itemId, sentAt: sentAt, content: mc) } } +struct CIFile: Decodable { + var fileId: Int64 + var fileName: String + var fileSize: Int64 + var filePath: String? + var fileStatus: CIFileStatus + + static func getSample(_ fileId: Int64, _ fileName: String, _ fileSize: Int64, filePath: String?, fileStatus: CIFileStatus = .sndStored) -> CIFile { + CIFile(fileId: fileId, fileName: fileName, fileSize: fileSize, filePath: filePath, fileStatus: fileStatus) + } + + var stored: Bool { + get { + switch self.fileStatus { + case .sndStored: return true + case .sndCancelled: return true + case .rcvComplete: return true + default: return false + } + } + } +} + +enum CIFileStatus: String, Decodable { + case sndStored = "snd_stored" + case sndCancelled = "snd_cancelled" + case rcvInvitation = "rcv_invitation" + case rcvTransfer = "rcv_transfer" + case rcvComplete = "rcv_complete" + case rcvCancelled = "rcv_cancelled" +} + enum MsgContent { case text(String) case link(text: String, preview: LinkPreview) + case image(text: String, image: String) // TODO include original JSON, possibly using https://github.com/zoul/generic-json-swift case unknown(type: String, text: String) @@ -779,6 +823,7 @@ enum MsgContent { switch self { case let .text(text): return text case let .link(text, _): return text + case let .image(text, _): return text case let .unknown(_, text): return text } } @@ -790,6 +835,8 @@ enum MsgContent { case let .text(text): return "text \(text)" case let .link(text: text, preview: preview): return "json {\"type\":\"link\",\"text\":\(encodeJSON(text)),\"preview\":\(encodeJSON(preview))}" + case let .image(text: text, image: image): + return "json {\"type\":\"image\",\"text\":\(encodeJSON(text)),\"image\":\(encodeJSON(image))}" default: return "" } } @@ -799,6 +846,7 @@ enum MsgContent { case type case text case preview + case image } } @@ -816,6 +864,10 @@ extension MsgContent: Decodable { let text = try container.decode(String.self, forKey: CodingKeys.text) let preview = try container.decode(LinkPreview.self, forKey: CodingKeys.preview) self = .link(text: text, preview: preview) + case "image": + let text = try container.decode(String.self, forKey: CodingKeys.text) + let image = try container.decode(String.self, forKey: CodingKeys.image) + self = .image(text: text, image: image) default: let text = try? container.decode(String.self, forKey: CodingKeys.text) self = .unknown(type: type, text: text ?? "unknown message format") diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 7622ac881d..06582e075b 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -19,10 +19,10 @@ enum ChatCommand { case showActiveUser case createActiveUser(profile: Profile) case startChat + case setFilesFolder(filesFolder: String) case apiGetChats case apiGetChat(type: ChatType, id: Int64) - case apiSendMessage(type: ChatType, id: Int64, msg: MsgContent) - case apiSendMessageQuote(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent) + case apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent) case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent) case apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) case getUserSMPServers @@ -38,6 +38,7 @@ enum ChatCommand { case apiAcceptContact(contactReqId: Int64) case apiRejectContact(contactReqId: Int64) case apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) + case receiveFile(fileId: Int64) case string(String) var cmdString: String { @@ -46,10 +47,16 @@ enum ChatCommand { case .showActiveUser: return "/u" case let .createActiveUser(profile): return "/u \(profile.displayName) \(profile.fullName)" case .startChat: return "/_start" + case let .setFilesFolder(filesFolder): return "/_files_folder \(filesFolder)" case .apiGetChats: return "/_get chats" case let .apiGetChat(type, id): return "/_get chat \(ref(type, id)) count=100" - case let .apiSendMessage(type, id, mc): return "/_send \(ref(type, id)) \(mc.cmdString)" - case let .apiSendMessageQuote(type, id, itemId, mc): return "/_send_quote \(ref(type, id)) \(itemId) \(mc.cmdString)" + case let .apiSendMessage(type, id, file, quotedItemId, mc): + switch (file, quotedItemId) { + case (nil, nil): return "/_send \(ref(type, id)) \(mc.cmdString)" + case let (.some(file), nil): return "/_send \(ref(type, id)) file \(file) \(mc.cmdString)" + case let (nil, .some(quotedItemId)): return "/_send \(ref(type, id)) quoted \(quotedItemId) \(mc.cmdString)" + case let (.some(file), .some(quotedItemId)): return "/_send \(ref(type, id)) file \(file) quoted \(quotedItemId) \(mc.cmdString)" + } 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 .getUserSMPServers: return "/smp_servers" @@ -65,6 +72,7 @@ enum ChatCommand { case let .apiAcceptContact(contactReqId): return "/_accept \(contactReqId)" case let .apiRejectContact(contactReqId): return "/_reject \(contactReqId)" case let .apiChatRead(type, id, itemRange: (from, to)): return "/_read chat \(ref(type, id)) from=\(from) to=\(to)" + case let .receiveFile(fileId): return "/freceive \(fileId)" case let .string(str): return str } } @@ -76,10 +84,10 @@ enum ChatCommand { case .showActiveUser: return "showActiveUser" case .createActiveUser: return "createActiveUser" case .startChat: return "startChat" + case .setFilesFolder: return "setFilesFolder" case .apiGetChats: return "apiGetChats" case .apiGetChat: return "apiGetChat" case .apiSendMessage: return "apiSendMessage" - case .apiSendMessageQuote: return "apiSendMessageQuote" case .apiUpdateChatItem: return "apiUpdateChatItem" case .apiDeleteChatItem: return "apiDeleteChatItem" case .getUserSMPServers: return "getUserSMPServers" @@ -95,6 +103,7 @@ enum ChatCommand { case .apiAcceptContact: return "apiAcceptContact" case .apiRejectContact: return "apiRejectContact" case .apiChatRead: return "apiChatRead" + case .receiveFile: return "receiveFile" case .string: return "console command" } } @@ -103,7 +112,7 @@ enum ChatCommand { func ref(_ type: ChatType, _ id: Int64) -> String { "\(type.rawValue)\(id)" } - + func smpServersStr(smpServers: [String]) -> String { smpServers.isEmpty ? "default" : smpServers.joined(separator: ",") } @@ -149,6 +158,8 @@ enum ChatResponse: Decodable, Error { case chatItemStatusUpdated(chatItem: AChatItem) case chatItemUpdated(chatItem: AChatItem) case chatItemDeleted(deletedChatItem: AChatItem, toChatItem: AChatItem) + case rcvFileAccepted + case rcvFileComplete(chatItem: AChatItem) case cmdOk case chatCmdError(chatError: ChatError) case chatError(chatError: ChatError) @@ -191,13 +202,15 @@ enum ChatResponse: Decodable, Error { case .chatItemStatusUpdated: return "chatItemStatusUpdated" case .chatItemUpdated: return "chatItemUpdated" case .chatItemDeleted: return "chatItemDeleted" + case .rcvFileAccepted: return "rcvFileAccepted" + case .rcvFileComplete: return "rcvFileComplete" case .cmdOk: return "cmdOk" case .chatCmdError: return "chatCmdError" case .chatError: return "chatError" } } } - + var details: String { get { switch self { @@ -236,6 +249,8 @@ enum ChatResponse: Decodable, Error { case let .chatItemStatusUpdated(chatItem): return String(describing: chatItem) case let .chatItemUpdated(chatItem): return String(describing: chatItem) case let .chatItemDeleted(deletedChatItem, toChatItem): return "deletedChatItem:\n\(String(describing: deletedChatItem))\ntoChatItem:\n\(String(describing: toChatItem))" + case .rcvFileAccepted: return noDetails + case let .rcvFileComplete(chatItem): return String(describing: chatItem) case .cmdOk: return noDetails case let .chatCmdError(chatError): return String(describing: chatError) case let .chatError(chatError): return String(describing: chatError) @@ -376,6 +391,12 @@ func apiStartChat() throws { throw r } +func apiSetFilesFolder(filesFolder: String) throws { + let r = chatSendCmdSync(.setFilesFolder(filesFolder: filesFolder)) + if case .cmdOk = r { return } + throw r +} + func apiGetChats() throws -> [Chat] { let r = chatSendCmdSync(.apiGetChats) if case let .apiChats(chats) = r { return chats.map { Chat.init($0) } } @@ -388,14 +409,9 @@ func apiGetChat(type: ChatType, id: Int64) throws -> Chat { throw r } -func apiSendMessage(type: ChatType, id: Int64, quotedItemId: Int64?, msg: MsgContent) async throws -> ChatItem { +func apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent) async throws -> ChatItem { let chatModel = ChatModel.shared - let cmd: ChatCommand - if let itemId = quotedItemId { - cmd = .apiSendMessageQuote(type: type, id: id, itemId: itemId, msg: msg) - } else { - cmd = .apiSendMessage(type: type, id: id, msg: msg) - } + let cmd: ChatCommand = .apiSendMessage(type: type, id: id, file: file, quotedItemId: quotedItemId, msg: msg) let r: ChatResponse if type == .direct { var cItem: ChatItem! @@ -513,8 +529,8 @@ func apiDeleteUserAddress() async throws { throw r } -func apiGetUserAddress() async throws -> String? { - let r = await chatSendCmd(.showMyAddress) +func apiGetUserAddress() throws -> String? { + let r = chatSendCmdSync(.showMyAddress) switch r { case let .userContactLink(connReq): return connReq @@ -542,6 +558,12 @@ func apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) async thr throw r } +func receiveFile(fileId: Int64) async throws { + let r = await chatSendCmd(.receiveFile(fileId: fileId)) + if case .rcvFileAccepted = r { return } + throw r +} + func acceptContactRequest(_ contactRequest: UserContactRequest) async { do { let contact = try await apiAcceptContactRequest(contactReqId: contactRequest.apiId) @@ -666,6 +688,17 @@ func processReceivedMsg(_ res: ChatResponse) { let cInfo = aChatItem.chatInfo let cItem = aChatItem.chatItem chatModel.addChatItem(cInfo, cItem) + if let file = cItem.file, + file.fileSize <= 394500 { + // file.fileSize <= 236700 { + Task { + do { + try await receiveFile(fileId: file.fileId) + } catch { + logger.error("receiveFile error: \(error.localizedDescription)") + } + } + } NtfManager.shared.notifyMessageReceived(cInfo, cItem) case let .chatItemStatusUpdated(aChatItem): let cInfo = aChatItem.chatInfo @@ -699,6 +732,12 @@ func processReceivedMsg(_ res: ChatResponse) { // currently only broadcast deletion of rcv message can be received, and only this case should happen _ = chatModel.upsertChatItem(cInfo, cItem) } + case let .rcvFileComplete(aChatItem): + let cInfo = aChatItem.chatInfo + let cItem = aChatItem.chatItem + if chatModel.upsertChatItem(cInfo, cItem) { + NtfManager.shared.notifyMessageReceived(cInfo, cItem) + } default: logger.debug("unsupported event: \(res.responseType)") } @@ -743,7 +782,7 @@ private func chatResponse(_ cjson: UnsafeMutablePointer) -> ChatResponse } catch { logger.error("chatResponse jsonDecoder.decode error: \(error.localizedDescription)") } - + var type: String? var json: String? if let j = try? JSONSerialization.jsonObject(with: d) as? NSDictionary { @@ -765,7 +804,7 @@ func prettyJSON(_ obj: NSDictionary) -> String? { private func getChatCtrl() -> chat_ctrl { if let controller = chatController { return controller } - let dataDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.path + "/mobile_v1" + let dataDir = getDocumentsDirectory().path + "/mobile_v1" var cstr = dataDir.cString(using: .utf8)! logger.debug("getChatCtrl: chat_init") ChatModel.shared.terminalItems.append(.cmd(.now, .string("chat_init"))) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift index 738e94ad1b..390281c34a 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift @@ -10,17 +10,18 @@ import SwiftUI struct CIMetaView: View { var chatItem: ChatItem + var metaColor = Color.secondary var body: some View { HStack(alignment: .center, spacing: 4) { if !chatItem.isDeletedContent() { if chatItem.meta.itemEdited { - statusImage("pencil", .secondary, 9) + statusImage("pencil", metaColor, 9) } switch chatItem.meta.itemStatus { case .sndSent: - statusImage("checkmark", .secondary) + statusImage("checkmark", metaColor) case .sndErrorAuth: statusImage("multiply", .red) case .sndError: @@ -33,7 +34,7 @@ struct CIMetaView: View { chatItem.timestampText .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(metaColor) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index 0567598c03..21670879dc 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -17,30 +17,25 @@ struct FramedItemView: View { @Environment(\.colorScheme) var colorScheme var chatItem: ChatItem var showMember = false + var maxWidth: CGFloat = .infinity @State var msgWidth: CGFloat = 0 + @State var imgWidth: CGFloat? = nil + @State var metaColor = Color.secondary + @State var showFullScreenImage = false var body: some View { let v = ZStack(alignment: .bottomTrailing) { VStack(alignment: .leading, spacing: 0) { if let qi = chatItem.quotedItem { - MsgContentView( - content: qi, - sender: qi.sender - ) - .lineLimit(3) - .font(.subheadline) - .padding(.vertical, 6) - .padding(.horizontal, 12) - .frame(minWidth: msgWidth, alignment: .leading) - .background( - chatItem.chatDir.sent - ? (colorScheme == .light ? sentQuoteColorLight : sentQuoteColorDark) - : Color(uiColor: .quaternarySystemFill) - ) - .overlay(DetermineWidth()) + if let imgWidth = imgWidth, imgWidth < maxWidth { + ciQuoteView(qi) + .frame(maxWidth: imgWidth, alignment: .leading) + } else { + ciQuoteView(qi) + } } - if chatItem.formattedText == nil && isShortEmoji(chatItem.content.text) { + if chatItem.formattedText == nil && chatItem.file == nil && isShortEmoji(chatItem.content.text) { VStack { emojiText(chatItem.content.text) Text("") @@ -51,25 +46,36 @@ struct FramedItemView: View { .frame(minWidth: msgWidth, alignment: .center) .padding(.bottom, 2) } else { - if case let .link(_, preview) = chatItem.content.msgContent { - ChatItemLinkView(linkPreview: preview) + switch (chatItem.content.msgContent) { + case let .image(text, image): + CIImageView(image: image, file: chatItem.file, maxWidth: maxWidth, imgWidth: $imgWidth) + .overlay(DetermineWidth()) + if text == "" { + Color.clear + .frame(width: 0, height: 0) + .preference( + key: MetaColorPreferenceKey.self, + value: .white + ) + } else { + let v = ciMsgContentView (chatItem, showMember) + if let imgWidth = imgWidth, imgWidth < maxWidth { + v.frame(maxWidth: imgWidth, alignment: .leading) + } else { + v + } + } + case let .link(_, preview): + CILinkView(linkPreview: preview) + ciMsgContentView (chatItem, showMember) + default: + ciMsgContentView (chatItem, showMember) } - MsgContentView( - content: chatItem.content, - formattedText: chatItem.formattedText, - sender: showMember ? chatItem.memberDisplayName : nil, - metaText: chatItem.timestampText, - edited: chatItem.meta.itemEdited - ) - .padding(.vertical, 6) - .padding(.horizontal, 12) - .overlay(DetermineWidth()) - .frame(minWidth: 0, alignment: .leading) - .textSelection(.enabled) } } - - CIMetaView(chatItem: chatItem) + .onPreferenceChange(MetaColorPreferenceKey.self) { metaColor = $0 } + + CIMetaView(chatItem: chatItem, metaColor: metaColor) .padding(.horizontal, 12) .padding(.bottom, 6) .overlay(DetermineWidth()) @@ -93,6 +99,64 @@ struct FramedItemView: View { message: err ) } + + private func ciQuoteView(_ qi: CIQuote) -> some View { + ZStack(alignment: .topTrailing) { + if case let .image(_, image) = qi.content, + let data = Data(base64Encoded: dropImagePrefix(image)), + let uiImage = UIImage(data: data) { + ciQuotedMsgView(qi) + .padding(.trailing, 70).frame(minWidth: msgWidth, alignment: .leading) + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 68, height: 68) + .clipped() + } else { + ciQuotedMsgView(qi) + } + } + .overlay(DetermineWidth()) + .frame(minWidth: msgWidth, alignment: .leading) + .background( + chatItem.chatDir.sent + ? (colorScheme == .light ? sentQuoteColorLight : sentQuoteColorDark) + : Color(uiColor: .quaternarySystemFill) + ) + } +} + +private struct MetaColorPreferenceKey: PreferenceKey { + static var defaultValue = Color.secondary + static func reduce(value: inout Color, nextValue: () -> Color) { + value = nextValue() + } +} + +private func ciQuotedMsgView(_ qi: CIQuote) -> some View { + MsgContentView( + content: qi, + sender: qi.sender + ) + .lineLimit(3) + .font(.subheadline) + .padding(.vertical, 6) + .padding(.horizontal, 12) +} + +private func ciMsgContentView(_ ci: ChatItem, _ showMember: Bool = false) -> some View { + MsgContentView( + content: ci.content, + formattedText: ci.formattedText, + sender: showMember ? ci.memberDisplayName : nil, + metaText: ci.timestampText, + edited: ci.meta.itemEdited + ) + .padding(.vertical, 6) + .padding(.horizontal, 12) + .overlay(DetermineWidth()) + .frame(minWidth: 0, alignment: .leading) + .textSelection(.enabled) } func chatItemFrameColor(_ ci: ChatItem, _ colorScheme: ColorScheme) -> Color { @@ -128,7 +192,8 @@ struct FramedItemViewEdited_Previews: PreviewProvider { FramedItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, false, true)) FramedItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, false, true)) FramedItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, false, true)) - } + FramedItemView(chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,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"), false, true)) + FramedItemView(chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello there this is a long text", quotedItem: CIQuote.getSample(1, .now, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,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"), false, true)) } .previewLayout(.fixed(width: 360, height: 200)) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index 73a5b9f855..52b4d806f8 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -19,7 +19,7 @@ struct MsgContentView: View { var edited = false var body: some View { - let v = messageText(content, formattedText, sender) + let v = messageText(content.text, formattedText, sender) if let mt = metaText { return v + reserveSpaceForMeta(mt, edited) } else { @@ -35,8 +35,8 @@ struct MsgContentView: View { } } -func messageText(_ content: ItemContent, _ formattedText: [FormattedText]?, _ sender: String?, preview: Bool = false) -> Text { - let s = content.text +func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, preview: Bool = false) -> Text { + let s = text var res: Text if let ft = formattedText, ft.count > 0 { res = formattText(ft[0], preview) diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index 275350a852..5cb4aad03e 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -11,13 +11,14 @@ import SwiftUI struct ChatItemView: View { var chatItem: ChatItem var showMember = false + var maxWidth: CGFloat = .infinity var body: some View { if chatItem.isMsgContent() { - if (chatItem.quotedItem == nil && isShortEmoji(chatItem.content.text)) { + if (chatItem.quotedItem == nil && chatItem.file == nil && isShortEmoji(chatItem.content.text)) { EmojiItemView(chatItem: chatItem) } else { - FramedItemView(chatItem: chatItem, showMember: showMember) + FramedItemView(chatItem: chatItem, showMember: showMember, maxWidth: maxWidth) } } else if chatItem.isDeletedContent() { DeletedItemView(chatItem: chatItem, showMember: showMember) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 04cb218ede..411290a8c2 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -24,6 +24,9 @@ struct ChatView: View { @State private var showChatInfo = false @State private var showDeleteMessage = false + @State private var chosenImage: UIImage? = nil + @State private var imagePreview: String? = nil + var body: some View { let cInfo = chat.chatInfo @@ -90,7 +93,9 @@ struct ChatView: View { sendMessage: sendMessage, resetMessage: { message = "" }, inProgress: inProgress, - keyboardVisible: $keyboardVisible + keyboardVisible: $keyboardVisible, + chosenImage: $chosenImage, + imagePreview: $imagePreview ) } .navigationTitle(cInfo.chatViewName) @@ -120,7 +125,7 @@ struct ChatView: View { private func chatItemWithMenu(_ ci: ChatItem, _ maxWidth: CGFloat, showMember: Bool = false) -> some View { let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading - return ChatItemView(chatItem: ci, showMember: showMember) + return ChatItemView(chatItem: ci, showMember: showMember, maxWidth: maxWidth) .contextMenu { if ci.isMsgContent() { Button { @@ -130,10 +135,18 @@ struct ChatView: View { } } label: { Label("Reply", systemImage: "arrowshape.turn.up.left") } Button { - showShareSheet(items: [ci.content.text]) + var shareItems: [Any] = [ci.content.text] + if case .image = ci.content.msgContent, let image = getStoredImage(ci.file) { + shareItems.append(image) + } + showShareSheet(items: shareItems) } label: { Label("Share", systemImage: "square.and.arrow.up") } Button { - UIPasteboard.general.string = ci.content.text + if case .image = ci.content.msgContent, let image = getStoredImage(ci.file) { + UIPasteboard.general.image = image + } else { + UIPasteboard.general.string = ci.content.text + } } label: { Label("Copy", systemImage: "doc.on.doc") } if ci.meta.editable { Button { @@ -221,7 +234,13 @@ struct ChatView: View { } } else { let mc: MsgContent - if let preview = linkPreview { + var file: String? = nil + if let preview = imagePreview, + let uiImage = chosenImage, + let savedFile = saveImage(uiImage) { + mc = .image(text: text, image: preview) + file = savedFile + } else if let preview = linkPreview { mc = .link(text: text, preview: preview) } else { mc = .text(text) @@ -229,12 +248,15 @@ struct ChatView: View { let chatItem = try await apiSendMessage( type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, + file: file, quotedItemId: quotedItem?.meta.itemId, msg: mc ) DispatchQueue.main.async { quotedItem = nil linkPreview = nil + chosenImage = nil + imagePreview = nil chatModel.addChatItem(chat.chatInfo, chatItem) } } @@ -243,6 +265,24 @@ struct ChatView: View { } } } + + func saveImage(_ uiImage: UIImage) -> String? { + if let imageResized = resizeImageToDataSize(uiImage, maxDataSize: 160000), + let dataResized = Data(base64Encoded: dropImagePrefix(imageResized)), + let jpegData = UIImage(data: dataResized)?.jpegData(compressionQuality: 1) { + let millisecondsSince1970 = Int64((Date().timeIntervalSince1970 * 1000.0).rounded()) + let fileToSave = "image_\(millisecondsSince1970).jpg" + let filePath = getAppFilesDirectory().appendingPathComponent(fileToSave) + do { + try jpegData.write(to: filePath) + return fileToSave + } catch { + logger.error("ChatView.saveImage error: \(error.localizedDescription)") + return nil + } + } + return nil + } func deleteMessage(_ mode: CIDeleteMode) { logger.debug("ChatView deleteMessage") diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 72c5e66449..766103ae8a 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -15,6 +15,24 @@ import SwiftUI // case editing(editingItem: ChatItem) //} +//enum ReferencedItem { +// case none +// case quoted(quotedItem: ChatItem) +// case editing(editingItem: ChatItem) +//} +// +//enum Preview { +// case none +// case link(linkPreview: LinkPreview) +// case image(image: UIImage) +//} +// +//struct ComposeState { +// var quotedItem: ChatItem? = nil +// var editingItem: ChatItem? = nil +// var linkPreview: LinkPreview? = nil +//} + struct ComposeView: View { @Binding var message: String @Binding var quotedItem: ChatItem? @@ -26,15 +44,23 @@ struct ComposeView: View { var inProgress: Bool = false @FocusState.Binding var keyboardVisible: Bool @State var editing: Bool = false + @State var sendEnabled: Bool = false @State var linkUrl: URL? = nil @State var prevLinkUrl: URL? = nil @State var pendingLinkUrl: URL? = nil @State var cancelledLinks: Set = [] + @State private var showChooseSource = false + @State private var showImagePicker = false + @State private var imageSource: ImageSource = .imageLibrary + @Binding var chosenImage: UIImage? + @Binding var imagePreview: String? var body: some View { VStack(spacing: 0) { - if let metadata = linkPreview { + if let metadata = imagePreview { + ComposeImageView(image: metadata, cancelImage: nil) + } else if let metadata = linkPreview { ComposeLinkView(linkPreview: metadata, cancelPreview: cancelPreview) } if (quotedItem != nil) { @@ -42,17 +68,31 @@ struct ComposeView: View { } else if (editingItem != nil) { ContextItemView(contextItem: $editingItem, editing: $editing, resetMessage: resetMessage) } - SendMessageView( - sendMessage: { text in - sendMessage(text) - resetLinkPreview() - }, - inProgress: inProgress, - message: $message, - keyboardVisible: $keyboardVisible, - editing: $editing - ) - .background(.background) + HStack{ + Button { + showChooseSource = true + } label: { + Image(systemName: "paperclip") + .resizable() + } + .disabled(editingItem != nil) + .frame(width: 25, height: 25) + .padding(.vertical, 4) + .padding(.leading, 12) + SendMessageView( + sendMessage: { text in + sendMessage(text) + resetLinkPreview() + }, + inProgress: inProgress, + message: $message, + keyboardVisible: $keyboardVisible, + editing: $editing, + sendEnabled: $sendEnabled + ) + .padding(.trailing, 12) + .background(.background) + } } .onChange(of: message) { _ in if message.count > 0 { @@ -60,10 +100,41 @@ struct ComposeView: View { } else { resetLinkPreview() } + sendEnabled = (imagePreview != nil || !message.isEmpty) } .onChange(of: editingItem == nil) { _ in editing = (editingItem != nil) } + .confirmationDialog("Attach", isPresented: $showChooseSource, titleVisibility: .visible) { + Button("Take picture") { + imageSource = .camera + showImagePicker = true + } + Button("Choose from library") { + imageSource = .imageLibrary + showImagePicker = true + } + } + .sheet(isPresented: $showImagePicker) { + switch imageSource { + case .imageLibrary: + LibraryImagePicker(image: $chosenImage) { + didSelectItem in showImagePicker = false + } + case .camera: + CameraImagePicker(image: $chosenImage) + } + } + .onChange(of: chosenImage) { image in + if let image = image { + imagePreview = resizeImageToDataSize(image, maxDataSize: 12500) + } else { + imagePreview = nil + } + } + .onChange(of: imagePreview) { _ in + sendEnabled = (imagePreview != nil || !message.isEmpty) + } } private func showLinkPreview(_ s: String) { @@ -136,6 +207,8 @@ struct ComposeView_Previews: PreviewProvider { @State var item: ChatItem? = ChatItem.getSample(1, .directSnd, .now, "hello") @State var nilItem: ChatItem? = nil @State var linkPreview: LinkPreview? = nil + @State var chosenImage: UIImage? = nil + @State var imagePreview: String? = nil return Group { ComposeView( @@ -145,7 +218,9 @@ struct ComposeView_Previews: PreviewProvider { linkPreview: $linkPreview, sendMessage: { print ($0) }, resetMessage: {}, - keyboardVisible: $keyboardVisible + keyboardVisible: $keyboardVisible, + chosenImage: $chosenImage, + imagePreview: $imagePreview ) ComposeView( message: $message, @@ -154,7 +229,9 @@ struct ComposeView_Previews: PreviewProvider { linkPreview: $linkPreview, sendMessage: { print ($0) }, resetMessage: {}, - keyboardVisible: $keyboardVisible + keyboardVisible: $keyboardVisible, + chosenImage: $chosenImage, + imagePreview: $imagePreview ) } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index afaa4788b3..bede2e2c03 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -15,6 +15,7 @@ struct SendMessageView: View { @Namespace var namespace @FocusState.Binding var keyboardVisible: Bool @Binding var editing: Bool + @Binding var sendEnabled: Bool @State private var teHeight: CGFloat = 42 @State private var teFont: Font = .body var maxHeight: CGFloat = 360 @@ -52,7 +53,7 @@ struct SendMessageView: View { .resizable() .foregroundColor(.accentColor) } - .disabled(message.isEmpty) + .disabled(!sendEnabled) .frame(width: 29, height: 29) .padding([.bottom, .trailing], 4) } @@ -62,7 +63,6 @@ struct SendMessageView: View { .strokeBorder(.secondary, lineWidth: 0.3, antialiased: true) .frame(height: teHeight) } - .padding(.horizontal, 12) .padding(.vertical, 8) } @@ -90,6 +90,7 @@ struct SendMessageView_Previews: PreviewProvider { @FocusState var keyboardVisible: Bool @State var editingOff: Bool = false @State var editingOn: Bool = true + @State var sendEnabled: Bool = true @State var item: ChatItem? = ChatItem.getSample(1, .directSnd, .now, "hello") return Group { @@ -100,7 +101,8 @@ struct SendMessageView_Previews: PreviewProvider { sendMessage: { print ($0) }, message: $message, keyboardVisible: $keyboardVisible, - editing: $editingOff + editing: $editingOff, + sendEnabled: $sendEnabled ) } VStack { @@ -110,7 +112,8 @@ struct SendMessageView_Previews: PreviewProvider { sendMessage: { print ($0) }, message: $message, keyboardVisible: $keyboardVisible, - editing: $editingOn + editing: $editingOn, + sendEnabled: $sendEnabled ) } } diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 89694d1529..5088d63557 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -51,7 +51,7 @@ struct ChatPreviewView: View { if let cItem = cItem { ZStack(alignment: .topTrailing) { - (itemStatusMark(cItem) + messageText(cItem.content, cItem.formattedText, cItem.memberDisplayName, preview: true)) + (itemStatusMark(cItem) + messageText(cItem.text, cItem.formattedText, cItem.memberDisplayName, preview: true)) .frame(minWidth: 0, maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading) .padding(.leading, 8) .padding(.trailing, 36) diff --git a/apps/ios/Shared/Views/Helpers/CIImageView.swift b/apps/ios/Shared/Views/Helpers/CIImageView.swift new file mode 100644 index 0000000000..13d3f6e1a9 --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/CIImageView.swift @@ -0,0 +1,56 @@ +// +// CIImageView.swift +// SimpleX +// +// Created by JRoberts on 12/04/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct CIImageView: View { + @Environment(\.colorScheme) var colorScheme + let image: String + let file: CIFile? + let maxWidth: CGFloat + @Binding var imgWidth: CGFloat? + @State var showFullScreenImage = false + + var body: some View { + VStack(alignment: .center, spacing: 6) { + if let uiImage = getStoredImage(file) { + imageView(uiImage) + .fullScreenCover(isPresented: $showFullScreenImage) { + ZStack { + Color.black.edgesIgnoringSafeArea(.all) + Image(uiImage: uiImage) + .resizable() + .scaledToFit() + } + .onTapGesture { showFullScreenImage = false } + .gesture( + DragGesture(minimumDistance: 80).onChanged { gesture in + let t = gesture.translation + if t.height > 60 && t.height > abs(t.width) { + showFullScreenImage = false + } + } + ) + } + .onTapGesture { showFullScreenImage = true } + } else if let data = Data(base64Encoded: dropImagePrefix(image)), + let uiImage = UIImage(data: data) { + imageView(uiImage) + } + } + } + + private func imageView(_ img: UIImage) -> some View { + let w = img.size.width > img.size.height ? .infinity : maxWidth * 0.75 + DispatchQueue.main.async { imgWidth = w } + return Image(uiImage: img) + .resizable() + .scaledToFit() + .frame(maxWidth: w) + } +} diff --git a/apps/ios/Shared/Views/Helpers/ChatItemLinkView.swift b/apps/ios/Shared/Views/Helpers/CILinkView.swift similarity index 99% rename from apps/ios/Shared/Views/Helpers/ChatItemLinkView.swift rename to apps/ios/Shared/Views/Helpers/CILinkView.swift index 09db349295..a2a4ecedeb 100644 --- a/apps/ios/Shared/Views/Helpers/ChatItemLinkView.swift +++ b/apps/ios/Shared/Views/Helpers/CILinkView.swift @@ -8,7 +8,7 @@ import SwiftUI -struct ChatItemLinkView: View { +struct CILinkView: View { @Environment(\.colorScheme) var colorScheme let linkPreview: LinkPreview @@ -47,7 +47,7 @@ struct LargeLinkPreview_Previews: PreviewProvider { description: "", image: "data:image/jpg;base64,/9j/4AAQSkZJRgABAQAASABIAAD/4QBYRXhpZgAATU0AKgAAAAgAAgESAAMAAAABAAEAAIdpAAQAAAABAAAAJgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAuKADAAQAAAABAAAAYAAAAAD/7QA4UGhvdG9zaG9wIDMuMAA4QklNBAQAAAAAAAA4QklNBCUAAAAAABDUHYzZjwCyBOmACZjs+EJ+/8AAEQgAYAC4AwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/bAEMAAQEBAQEBAgEBAgMCAgIDBAMDAwMEBgQEBAQEBgcGBgYGBgYHBwcHBwcHBwgICAgICAkJCQkJCwsLCwsLCwsLC//bAEMBAgICAwMDBQMDBQsIBggLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLC//dAAQADP/aAAwDAQACEQMRAD8A/v4ooooAKKKKACiiigAooooAKK+CP2vP+ChXwZ/ZPibw7dMfEHi2VAYdGs3G9N33TO/IiU9hgu3ZSOa/NzXNL/4KJ/td6JJ49+NXiq2+Cvw7kG/ZNKbDMLcjKblmfI/57SRqewrwMdxBRo1HQoRdWqt1HaP+KT0j838j7XKOCMXiqEcbjKkcPh5bSne8/wDr3BXlN+is+5+43jb45/Bf4bs0fj/xZpGjSL1jvL2KF/8AvlmDfpXjH/DfH7GQuPsv/CydD35x/wAfIx+fT9a/AO58D/8ABJj4UzvF4v8AFfif4l6mp/evpkfkWzP3w2Isg+omb61X/wCF0/8ABJr/AI9f+FQeJPL6ed9vbzPrj7ZivnavFuIT+KhHyc5Sf3wjY+7w/hlgZQv7PF1P70aUKa+SqTUvwP6afBXx2+CnxIZYvAHi3R9ZkfpHZ3sUz/8AfKsW/SvVq/lItvBf/BJX4rTLF4V8UeJ/hpqTH91JqUfn2yv2y2JcD3MqfUV9OaFon/BRH9krQ4vH3wI8XW3xq+HkY3+XDKb/ABCvJxHuaZMDr5Ergd1ruwvFNVrmq0VOK3lSkp29Y6SS+R5GY+HGGi1DD4qVKo9oYmm6XN5RqK9Nvsro/obor4A/ZC/4KH/Bv9qxV8MLnw54vjU+bo9443SFPvG3k4EoHdcB17rjmvv+vqcHjaGKpKth5qUX1X9aPyZ+b5rlOMy3ESwmOpOFRdH+aezT6NXTCiiiuo84KKKKACiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/Q/v4ooooAKKKKACiiigAr8tf+ChP7cWs/BEWfwD+A8R1P4k+JQkUCQr5rWUc52o+zndNIf9Up4H324wD9x/tDfGjw/wDs9fBnX/i/4jAeHRrZpI4c4M87YWKIe7yFV9gc9q/n6+B3iOb4GfCLxL/wU1+Oypq3jzxndT2nhK2uBwZptyvcBeoQBSq4xthjwPvivluIs0lSthKM+WUk5Sl/JBbtebekfM/R+BOHaeIcszxVL2kISUKdP/n7WlrGL/uxXvT8u6uizc6b8I/+CbmmRePPi9HD8Q/j7rifbktLmTz7bSGm582ZzktITyX++5+5tX5z5L8LPgv+0X/wVH12+8ZfEbxneW/2SRxB9o02eTSosdY4XRlgjYZGV++e5Jr8xvF3i7xN4+8UX/jXxney6jquqTNcXVzMcvJI5ySfQdgBwBgDgV+sP/BPX9jj9oL9oXw9H4tuvG2s+DfAVlM8VsthcyJLdSBsyCBNwREDZ3SEHLcBTgkfmuX4j+0MXHB06LdBXagna/8AenK6u+7el9Ej9+zvA/2Jls81r4uMcY7J1px5lHf93ShaVo9FFJNq8pMyPil/wRs/aj8D6dLq3gq70vxdHECxgtZGtrogf3UmAQn2EmT2r8rPEPh3xB4R1u58M+KrGfTdRsnMdxa3MbRTROOzKwBBr+674VfCnTfhNoI0DTtX1jWFAGZtYvpL2U4934X/AICAK8V/aW/Yf/Z9/areHUvibpkkerWsRhg1KxkMFyqHkBiMrIAeQJFYDJxjJr6bNPD+nOkqmAfLP+WTuvk7XX4/I/PeHvG6tSxDo5zH2lLpUhHll6uN7NelmvPY/iir2T4KftA/GD9njxMvir4Q65caTPkGWFTutrgD+GaE/I4+oyOxB5r2n9tb9jTxj+x18RYvD+pTtqmgaqrS6VqezZ5qpjfHIBwsseRuA4IIYdcD4yr80q0sRgcQ4SvCpB+jT8mvzP6Bw2JwOcYGNany1aFRdVdNdmn22aauno9T9tLO0+D/APwUr02Txd8NI4Ph38ftGT7b5NtIYLXWGh58yJwQVkBGd/8ArEP3i6fMP0R/4J7ftw6/8YZ7z9nb9oGJtN+JPhoPFIJ18p75IPlclegnj/5aKOGHzrxnH8rPhXxT4j8D+JbHxj4QvZdO1TTJkuLW5hba8UqHIIP8x0I4PFfsZ8bPEdx+0N8FvDv/AAUl+CgXSfiJ4EuYLXxZBbDALw4CXO0clMEZznMLlSf3Zr7PJM+nzyxUF+9ir1IrRVILeVtlOO+lrr5n5RxfwbRdKGXVXfDzfLRm9ZUKr+GDlq3RqP3UnfllZfy2/ptorw/9m/43aF+0X8FNA+L+gARpq1uGnhByYLlCUmiP+44IHqMHvXuFfsNGtCrTjVpu8ZJNPyZ/LWKwtXDVp4evG04Nxa7NOzX3hRRRWhzhRRRQBBdf8e0n+6f5Vx1djdf8e0n+6f5Vx1AH/9H+/iiiigAooooAKKKKAPw9/wCCvXiPWviH4q+F/wCyN4XlKT+K9TS6uQvoXFvAT7AvI3/AQe1fnF/wVO+IOnXfxx034AeDj5Xhv4ZaXb6TawKfkE7Ro0rY6bgvlofdT61+h3xNj/4Tv/gtd4Q0W/8Anh8P6THLGp6Ax21xOD/324Nfg3+0T4kufGH7QHjjxRdtukvte1GXJ9PPcKPwAAr8a4pxUpLEz6zq8n/btOK0+cpX9Uf1d4c5bCDy+lbSlh3W/wC38RNq/qoQcV5M8fjiaeRYEOGchR9TxX9svw9+GHijSvgB4I+Gnwr1ceGbGztYY728gijluhbohLLAJVeJZJJCN0jo+0Zwu4gj+JgO8REsf3l+YfUV/bf8DNVm+Mv7KtkNF1CTTZ9Z0d4Ir2D/AFls9zF8sidPmj3hhz1Fel4YyhGtiHpzWjur6e9f9Dw/H9VXQwFvgvUv62hb8Oa3zPoDwfp6aPoiaONXuNaa1Zo3ubp43nLDqrmJEXI/3QfWukmjMsTRBihYEbl6jPcZ7ivxk/4JMf8ABOv9ob9hBvFdr8ZvGOma9Yak22wttLiYGV2kMkl1dzSIkkkzcKisX8tSwDYNfs/X7Bj6NOlXlCjUU4/zJWv8j+ZsNUnOmpThyvtufj/+1Z8Hf2bPi58PviF8Avh/4wl1j4iaBZjXG0m71qfU7i3u4FMqt5VxLL5LzR70Kx7AVfJXAXH8sysGUMOh5r+vzwl+wD+y78KP2wPEX7bGn6xqFv4g8QmWa70+fUFGlrdTRmGS4EGATIY2dRvdlXe+0DPH83Nh+x58bPFev3kljpSaVYPcymGS+kEX7oudp2DL/dx/DX4Z4xZxkmCxGHxdTGRTlG0ueUU7q3S93a7S69Oh/SngTnNSjgcZhMc1CnCSlC70966dr/4U7Lq79T5Kr9MP+CWfxHsNH+P138EPF2JvDfxL0640a9gc/I0vls0Rx6kb4x/v1x3iz9hmHwV4KuPFHiLxlaWkltGzt5sBSAsBkIHL7iT0GFJJ7V8qfAnxLc+D/jd4N8V2bFJdP1vT5wR/szoT+YyK/NeD+Lcvx+Ijisuq88ackpPlklruveSvdX2ufsmavC5zlWKw9CV7xaTs1aSV4tXS1Ukmrdj9/P8Agkfrus/DD4ifFP8AY/8AEkrPJ4Z1F7y1DeiSG3mI9m2wv/wI1+5Ffhd4Ki/4Qf8A4Lb+INM0/wCSHxDpDySqOhL2cMx/8fizX7o1/RnC7ccLPDP/AJdTnBeid1+DP5M8RkqmZUselZ4ijSqv1lG0vvcWwooor6Q+BCiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/S/v4ooooAKKKKACiiigD8LfiNIfBP/BbLwpq9/wDJDr2kJHGTwCZLS4gH/j0eK/Bj9oPw7c+Evj3428M3ilZLHXtRiIPoJ3x+Ywa/fL/grnoWsfDPx98K/wBrzw5EzyeGNSS0uSvokguYQfZtsy/8CFfnB/wVP+HNho/7QFp8bvCeJvDnxK0231mznQfI0vlqsoz6kbJD/v1+M8U4WUViYW1hV5/+3akVr/4FG3qz+r/DnMYTeX1b6VcP7L/t/Dzenq4Tcl5I/M2v6yP+CR3j4eLP2XbLRZZN0uku9sRnp5bMB/45sr+Tev3u/wCCJXj7yNW8T/DyZ+C6XUak9pUw36xD865uAcV7LNFTf24tfd736Hd405d9Y4cddLWlOMvk7wf/AKUvuP6Kq/P/APaa+InjJfF8vge3lez06KONgIyVM+8ZJYjkgHIx045r9AK/Gr/gsB8UPHXwg8N+AvFfgV4oWmv7u3uTJEsiyL5SsiNkZxkMeCDmvU8bsgzPN+Fa+FyrEujUUot6tKcdnBtapO6fny2ejZ/OnAOFWJzqjheVOU+ZK+yaTlfr2t8z85td/b18H6D4n1DQLrw5fSLY3Elv5okRWcxsVJKMAVyR0yTivEPHf7f3jjVFe18BaXb6PGeBPcH7RN9QMBAfqGrFP7UPwj8c3f2/4y/DuzvbxgA93ZNtd8dyGwT+Lmuvh/aP/ZT8IxC58EfD0y3Y5UzwxKAf99mlP5Cv49wvCeBwUoc3D9Sday3qRlTb73c7Wf8Aej8j+rKWVUKLV8vlKf8AiTj/AOlW+9Hw74w8ceNvHl8NX8bajc6jK2SjTsSo/wBxeFUf7orovgf4dufF3xp8H+F7NS0uoa3p8Cgf7c6A/pW98avjx4q+NmoW0mswW9jY2G/7LaWy4WPfjJLHlicD0HoBX13/AMEtPhrZeI/2jH+L3inEPh34cWE+t31w/wBxJFRliBPqPmkH/XOv3fhXCVa/1ahUoRoybV4RacYq/dKK0jq7Ky1s3uezm+PeByeviqkFBxhK0U767RirJattLTqz9H/CMg8af8Futd1DT/ni8P6OySsOxSyiiP8A49Niv3Qr8NP+CS+j6t8V/iv8V/2wdfiZD4i1B7K0LDtLJ9olUf7imFfwr9y6/oLhe88LUxPSrUnNejdl+CP5G8RWqeY0cAnd4ejSpP8AxRjd/c5NBRRRX0h8CFFFFAEF1/x7Sf7p/lXHV2N1/wAe0n+6f5Vx1AH/0/7+KKKKACiiigAooooA8M/aT+B+iftGfBLxB8INcIjGrWxFvORnyLmMh4ZB/uSAE46jI71+AfwU8N3H7SXwL8Qf8E5fjFt0r4kfD65nuvCstycbmhz5ltuPVcE4x1idWHEdf031+UX/AAUL/Yj8T/FG/sv2mP2c5H074keGtkoFufLe+jg5Taennx9Ezw6/Ie2PleI8slUtjKUOZpOM4/zwe6X96L1j5/cfpPAXEMKF8rxNX2cZSU6VR7Uq0dE3/cmvcn5dldn8r/iXw3r/AIN8Q3vhPxXZy6fqemzPb3VtMNskUsZwysPY/n1HFfe3/BL3x/8A8IP+1bptvK+2HVbeSBvdoyso/RWH419SX8fwg/4Kc6QmleIpLfwB8f8ASI/ssiXCGC11kwfLtZSNwkGMbceZH0w6Dj88tM+HvxW/ZK/aO8OQ/FvR7nQ7uw1OElpV/czQs+x2ilGUkUqTypPvivy3DYWWX46hjaT56HOrSXa+ql/LK26fy0P6LzDMYZ3lGMynEx9ni/ZyvTfV2bjKD+3BtJqS9HZn9gnxB/aM+Cvwp8XWXgj4ja/Bo+o6hB9ogW5DrG0ZYoCZNvlr8wI+Zh0r48/4KkfDey+NP7GOqeIPDUsV7L4elh1u0khYOskcOVl2MCQcwu5GDyRXwx/wVBnbVPH3gjxGeVvPDwUt2LxzOW/9Cr87tO8PfFXVdPisbDS9avNImbzLNILa4mtXfo5j2KULZwDjmvqs+4srKvi8rqYfnjays2nqlq9JX3v0P4FwfiDisjzqNanQU3RnGUbNq9rOz0ej207nxZovhrV9enMNhHwpwztwq/U+vt1qrrWlT6JqUumXBDNHj5l6EEZr7U+IHhHxF8JvEUHhL4j2Umiald2sV/Hb3Q8t2hnztbB75BDKfmVgQQCK8e0f4N/E349/FRvBvwh0a41y+YRq/kD91ECPvSyHCRqPVmFfl8aNZ1vYcj59rWd79rbn9T+HPjFnnEPE1WhmmEWEwKw8qkVJNbSppTdSSimmpO1ko2a3aueH+H/D+ueLNds/DHhi0lv9R1CZLe2toV3SSyyHCqoHUk1+yfxl8N3X7Ln7P+h/8E9/hOF1X4nfEm4gufFDWp3FBMR5dqGHRTgLzx5au5wJKtaZZ/B7/gmFpBhsJLbx78fdVi+zwQWyma00UzjbgAfMZDnGMCSToAiElvv/AP4J7fsS+LPh5q15+1H+0q76h8R/Em+ZUuSHksI5/vFj0E8g4YDiNPkH8VfeZJkVTnlhYfxpK02tqUHur7c8trdFfzt9dxdxjQ9lDMKi/wBlpvmpRejxFVfDK26o03713bmla2yv90/sw/ArRv2bvgboHwh0crK2mQZup1GPPu5Tvmk9fmcnGei4HavfKKK/YaFGFGnGlTVoxSSXkj+WMXi6uKr1MTXlec25N923dsKKKK1OcKKKKAILr/j2k/3T/KuOrsbr/j2k/wB0/wAq46gD/9T+/iiiigAooooAKKKKACiiigD87P2wf+Ccnwm/ahmbxvosh8K+NY8NHq1onyzOn3ftEYK7yMcSKVkX1IAFfnT4m8f/ALdv7L+gyfDn9rjwFb/GLwFD8q3ssf2srGOjfaAjspA6GeMMOzV/RTRXz+N4eo1akq+Hm6VR7uNrS/xRekvzPuMo45xOGoQweOpRxFCPwqd1KH/XuorSh8m0uiPwz0L/AIKEf8E3vi6miH4saHd6Xc6B5gs4tWs3vYIPNILAGFpA65UcSLxjgCvtS1/4KT/sLWVlHFZePrCGCJAqRJa3K7VHQBRFxj0xXv8A48/Zc/Zx+J0z3Xj3wPoupzyHLTS2cfnE+8iqH/WvGP8Ah23+w953n/8ACu9PznOPMn2/98+bj9K5oYTOqMpSpyoyb3k4yjJ2015Xqac/BNSbrPD4mlKW6hKlJf8AgUkpP5n5zfta/tof8Ex/jPq+k+IPHelan491HQlljtI7KGWyikWUqSkryNCzJlcgc4JPHNcZ4V+Iv7c37TGgJ8N/2Ovh7bfB7wHN8pvoo/shMZ4LfaSiMxx1MERf/ar9sPAn7LH7N3wxmS68B+BtF02eM5WaOzjMwI9JGBf9a98AAGBWSyDF16kquKrqPN8Xso8rfrN3lY9SXG+WYPDww2W4SdRQ+B4io5xjre6pRtTvfW+up+cv7H//AATg+FX7MdynjzxHMfFnjeTLvqt2vyQO/wB77OjFtpOeZGLSH1AOK/Rqiivo8FgaGEpKjh4KMV/V33fmz4LNs5xuZ4h4rHVXOb6vouyWyS6JJIKKKK6zzAooooAKKKKAILr/AI9pP90/yrjq7G6/49pP90/yrjqAP//Z" ) - ChatItemLinkView(linkPreview: preview) + CILinkView(linkPreview: preview) .previewLayout(.fixed(width: 360, height: 200)) } } diff --git a/apps/ios/Shared/Views/Helpers/ComposeImageView.swift b/apps/ios/Shared/Views/Helpers/ComposeImageView.swift new file mode 100644 index 0000000000..2d083c9721 --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/ComposeImageView.swift @@ -0,0 +1,37 @@ +// +// ComposeImageView.swift +// SimpleX +// +// Created by JRoberts on 11/04/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct ComposeImageView: View { + @Environment(\.colorScheme) var colorScheme + let image: String + var cancelImage: (() -> Void)? = nil + + var body: some View { + HStack(alignment: .center, spacing: 8) { + if let data = Data(base64Encoded: dropImagePrefix(image)), + let uiImage = UIImage(data: data) { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 80, maxHeight: 60) + } + if let cancelImage = cancelImage { + Button { cancelImage() } label: { + Image(systemName: "multiply") + } + } + } + .padding(.vertical, 1) + .padding(.trailing, 12) + .background(colorScheme == .light ? sentColorLight : sentColorDark) + .frame(maxWidth: .infinity) + .padding(.top, 8) + } +} diff --git a/apps/ios/Shared/Views/TerminalView.swift b/apps/ios/Shared/Views/TerminalView.swift index 9e92eebb4d..98d7bbd39d 100644 --- a/apps/ios/Shared/Views/TerminalView.swift +++ b/apps/ios/Shared/Views/TerminalView.swift @@ -18,6 +18,7 @@ struct TerminalView: View { @State var message: String = "" @FocusState private var keyboardVisible: Bool @State var editing: Bool = false + @State var sendEnabled: Bool = false var body: some View { VStack { @@ -67,12 +68,17 @@ struct TerminalView: View { inProgress: inProgress, message: $message, keyboardVisible: $keyboardVisible, - editing: $editing + editing: $editing, + sendEnabled: $sendEnabled ) + .padding(.horizontal, 12) } } .navigationViewStyle(.stack) .navigationTitle("Chat console") + .onChange(of: message) { _ in + sendEnabled = !message.isEmpty + } } func scrollToBottom(_ proxy: ScrollViewProxy, animation: Animation = .default) { diff --git a/apps/ios/Shared/Views/UserSettings/SettingsButton.swift b/apps/ios/Shared/Views/UserSettings/SettingsButton.swift index 4d9818278d..7292fd4373 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsButton.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsButton.swift @@ -18,18 +18,6 @@ struct SettingsButton: View { } .sheet(isPresented: $showSettings, content: { SettingsView(showSettings: $showSettings) - .onAppear { - Task { - do { - let userAddress = try await apiGetUserAddress() - DispatchQueue.main.async { - chatModel.userAddress = userAddress - } - } catch { - logger.error("SettingsButton apiGetUserAddress error: \(error.localizedDescription)") - } - } - } }) } } diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff index 6cea986b34..f41e3bd49e 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -120,6 +120,11 @@ All your contacts will remain connected No comment provided by engineer. + + Attach + Attach + No comment provided by engineer. + Cancel Cancel @@ -734,21 +739,11 @@ SimpleX servers cannot see your profile. italic No comment provided by engineer. - - receiving files is not supported yet - receiving files is not supported yet - to be removed - secret secret No comment provided by engineer. - - sending files is not supported yet - sending files is not supported yet - to be removed - strike strike diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/en.lproj/Localizable.strings index 8a3092dda65330a4b5cb82313b5aa45120322b7e..bfd0429fb5fe372380e127a26cfc81f4608a22d2 100644 GIT binary patch delta 14 VcmeC<{l~e%g=J!&)Z}-p3jiy(1^NI0 literal 1678 zcmb`HO-{ow5QS&mQy3&R-Shwm0b8uFM%T;q2pXRQ8&{X6F^UQ|+`ubI6M@)xqX1SgKaWBt_Cl8^0Fh52a3 zYlKWPvZWPm>s9@F?P^|YT4fpu$Ep9;TOP_o{OL?N)Mu=muwuK%d)%)kZ!PwH3=8d) zA&&w+HD4k-c4NE4`_PW`Все контакты, которые соединились через этот адрес, сохранятся. No comment provided by engineer. + + Attach + Прикрепить + No comment provided by engineer. + Cancel Отменить @@ -603,7 +608,7 @@ to scan from the app You are connected to the server used to receive messages from this contact. - Установлено соединение с сервером, через который вы получается сообщения от этого контакта. + Установлено соединение с сервером, через который вы получаете сообщения от этого контакта. No comment provided by engineer. @@ -733,21 +738,11 @@ SimpleX серверы не могут получить доступ к ваше курсив No comment provided by engineer. - - receiving files is not supported yet - получение файлов не поддерживается - to be removed - secret секрет No comment provided by engineer. - - sending files is not supported yet - отправка файлов не поддерживается - to be removed - strike зачеркнуть diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/en.lproj/Localizable.strings index 8a3092dda65330a4b5cb82313b5aa45120322b7e..bfd0429fb5fe372380e127a26cfc81f4608a22d2 100644 GIT binary patch delta 14 VcmeC<{l~e%g=J!&)Z}-p3jiy(1^NI0 literal 1678 zcmb`HO-{ow5QS&mQy3&R-Shwm0b8uFM%T;q2pXRQ8&{X6F^UQ|+`ubI6M@)xqX1SgKaWBt_Cl8^0Fh52a3 zYlKWPvZWPm>s9@F?P^|YT4fpu$Ep9;TOP_o{OL?N)Mu=muwuK%d)%)kZ!PwH3=8d) zA&&w+HD4k-c4NE4`_PW` [] where - sndMsg to quote mc = case (msgContentText mc, file) of + withSndFile = withFile viewSentFileInvitation + withRcvFile = withFile viewReceivedFileInvitation + withFile view dir l = maybe l (\f -> l <> view dir f meta) file + sndMsg = msg viewSentMessage + rcvMsg = msg viewReceivedMessage + msg view dir quote mc = case (msgContentText mc, file) of ("", Just _) -> [] - _ -> viewSentMessage to quote mc meta - withSndFile to l = case file of - -- TODO pass CIFile - Just CIFile {fileId, filePath = Just fPath} -> l <> viewSentFileInvitation to fileId fPath meta - _ -> l - rcvMsg from quote mc = case (msgContentText mc, file) of - ("", Just _) -> [] - _ -> viewReceivedMessage from quote mc meta - withRcvFile from l = case file of - Just f -> l <> viewReceivedFileInvitation from f meta - _ -> l + -- (_, Just _) -> prependFirst " " $ ttyMsgContent mc + _ -> view dir quote mc meta viewItemUpdate :: MsgDirectionI d => ChatInfo c -> ChatItem c d -> [StyledString] viewItemUpdate chat ChatItem {chatDir, meta, content, quotedItem} = case chat of @@ -476,8 +472,10 @@ viewSentMessage to quote mc = sentWithTime_ (prependFirst to $ quote <> prependF viewSentBroadcast :: MsgContent -> Int -> ZonedTime -> [StyledString] viewSentBroadcast mc n ts = prependFirst (highlight' "/feed" <> " (" <> sShow n <> ") " <> ttyMsgTime ts <> " ") (ttyMsgContent mc) -viewSentFileInvitation :: StyledString -> FileTransferId -> FilePath -> CIMeta d -> [StyledString] -viewSentFileInvitation to fId fPath = sentWithTime_ $ ttySentFile to fId fPath +viewSentFileInvitation :: StyledString -> CIFile d -> CIMeta d -> [StyledString] +viewSentFileInvitation to CIFile {fileId, filePath} = case filePath of + Just fPath -> sentWithTime_ $ ttySentFile to fileId fPath + _ -> const [] sentWithTime_ :: [StyledString] -> CIMeta d -> [StyledString] sentWithTime_ styledMsg CIMeta {localItemTs} =