From a7554771a0175cfad331ee2e7659723d3c88cacc Mon Sep 17 00:00:00 2001 From: JRoberts <8711996+jr-simplex@users.noreply.github.com> Date: Wed, 27 Apr 2022 20:54:21 +0400 Subject: [PATCH] android: refactor compose (#579) --- .../java/chat/simplex/app/model/ChatModel.kt | 1 - .../chat/simplex/app/views/TerminalView.kt | 50 +-- .../chat/simplex/app/views/chat/ChatView.kt | 190 +++-------- .../app/views/chat/ComposeImageView.kt | 18 +- .../simplex/app/views/chat/ComposeView.kt | 307 +++++++++++++++--- .../simplex/app/views/chat/ContextItemView.kt | 64 ++-- .../simplex/app/views/chat/SendMsgView.kt | 119 ++----- .../app/views/chat/item/ChatItemView.kt | 21 +- .../Chat/ComposeMessage/ComposeView.swift | 6 +- .../Chat/ComposeMessage/SendMessageView.swift | 8 +- apps/ios/Shared/Views/TerminalView.swift | 4 +- 11 files changed, 431 insertions(+), 357 deletions(-) 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 346f8ce8a..b2d03c0c0 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 @@ -4,7 +4,6 @@ import android.net.Uri import androidx.compose.material.MaterialTheme import androidx.compose.runtime.* import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.* import androidx.compose.ui.text.style.TextDecoration 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 116aa4d29..93ed18039 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 @@ -17,6 +17,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.app.model.* import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.views.chat.ComposeState import chat.simplex.app.views.chat.SendMsgView import chat.simplex.app.views.helpers.* import com.google.accompanist.insets.ProvideWindowInsets @@ -25,32 +26,42 @@ import kotlinx.coroutines.launch @Composable fun TerminalView(chatModel: ChatModel, close: () -> Unit) { + val composeState = remember { mutableStateOf(ComposeState()) } BackHandler(onBack = close) - TerminalLayout(chatModel.terminalItems, close) { cmd -> - withApi { - // show "in progress" - chatModel.controller.sendCmd(CC.Console(cmd)) - // hide "in progress" - } - } + TerminalLayout( + chatModel.terminalItems, + composeState, + sendCommand = { + withApi { + // show "in progress" + chatModel.controller.sendCmd(CC.Console(composeState.value.message)) + // hide "in progress" + } + }, + close + ) } @Composable -fun TerminalLayout(terminalItems: List, close: () -> Unit, sendCommand: (String) -> Unit) { - var msg = remember { mutableStateOf("") } +fun TerminalLayout( + terminalItems: List, + composeState: MutableState, + sendCommand: () -> Unit, + close: () -> Unit +) { + val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) + val textStyle = remember { mutableStateOf(smallFont) } + + fun onMessageChange(s: String) { + composeState.value = composeState.value.copy(message = s) + } + ProvideWindowInsets(windowInsetsAnimationsEnabled = true) { Scaffold( topBar = { CloseSheetBar(close) }, bottomBar = { Box(Modifier.padding(horizontal = 8.dp)) { - SendMsgView( - msg = msg, - linkPreview = remember { mutableStateOf(null) }, - cancelledLinks = remember { mutableSetOf() }, - parseMarkdown = { null }, - sendMessage = sendCommand, - sendEnabled = msg.value.isNotEmpty() - ) + SendMsgView(composeState, sendCommand, ::onMessageChange, textStyle) } }, modifier = Modifier.navigationBarsWithImePadding() @@ -108,8 +119,9 @@ fun PreviewTerminalLayout() { SimpleXTheme { TerminalLayout( terminalItems = TerminalItem.sampleData, - close = {}, - sendCommand = {} + composeState = remember { mutableStateOf(ComposeState()) }, + sendCommand = {}, + close = {} ) } } 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 73be70f10..880c423ef 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,6 +1,5 @@ package chat.simplex.app.views.chat -import android.content.Context import android.content.res.Configuration import android.graphics.Bitmap import android.util.Log @@ -38,24 +37,19 @@ 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) { val chat: Chat? = chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value } val user = chatModel.currentUser.value + val composeState = remember { mutableStateOf(ComposeState()) } + val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) + val scope = rememberCoroutineScope() + val chosenImage = remember { mutableStateOf(null) } + 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 } // TODO a more advanced version would mark as read only if in view LaunchedEffect(chat.chatItems) { @@ -73,7 +67,22 @@ fun ChatView(chatModel: ChatModel) { } } } - ChatLayout(user, chat, chatModel.chatItems, msg, quotedItem, editingItem, linkPreview, chosenImage, imagePreview, + ChatLayout( + user, + chat, + composeState, + composeView = { + ComposeView( + chatModel, + chat, + composeState, + chosenImage, + showAttachmentBottomSheet = { scope.launch { attachmentBottomSheetState.show() } }) + }, + chosenImage, + scope, + attachmentBottomSheetState, + chatModel.chatItems, back = { chatModel.chatId.value = null }, info = { ModalManager.shared.showCustomModal { close -> ChatInfoView(chatModel, close) } }, openDirectChat = { contactId -> @@ -82,57 +91,6 @@ fun ChatView(chatModel: ChatModel) { } if (c != null) withApi { openChat(chatModel, c.chatInfo) } }, - sendMessage = { msg -> - withApi { - // show "in progress" - val cInfo = chat.chatInfo - val ei = editingItem.value - if (ei != null) { - val oldMsgContent = ei.content.msgContent - if (oldMsgContent != null) { - val updatedItem = chatModel.controller.apiUpdateChatItem( - type = cInfo.chatType, - id = cInfo.apiId, - itemId = ei.meta.itemId, - mc = updateMsgContent(oldMsgContent, msg) - ) - 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 = mc - ) - if (newItem != null) chatModel.addChatItem(cInfo, newItem.chatItem) - } - // hide "in progress" - editingItem.value = null - quotedItem.value = null - linkPreview.value = null - chosenImage.value = null - imagePreview.value = null - } - }, - resetMessage = { msg.value = "" }, deleteMessage = { itemId, mode -> withApi { val cInfo = chat.chatInfo @@ -144,55 +102,30 @@ fun ChatView(chatModel: ChatModel) { ) if (toItem != null) chatModel.removeChatItem(cInfo, toItem.chatItem) } - }, - parseMarkdown = { text -> runBlocking { chatModel.controller.apiParseMarkdown(text) } }, - onImageChange = { bitmap -> imagePreview.value = resizeImageToStrSize(bitmap, maxDataSize = 14000) } + } ) } } -fun updateMsgContent(msgContent: MsgContent, text: String): MsgContent { - return when (msgContent) { - is MsgContent.MCText -> MsgContent.MCText(text) - is MsgContent.MCLink -> MsgContent.MCLink(text, preview = msgContent.preview) - is MsgContent.MCImage -> MsgContent.MCImage(text, image = msgContent.image) - is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = text, json = msgContent.json) - } -} - -fun saveImage(context: Context, image: Bitmap): String { - val dataResized = resizeImageToDataSize(image, maxDataSize = MAX_IMAGE_SIZE) - val fileToSave = "image_${System.currentTimeMillis()}.jpg" - val file = File(getAppFilesDirectory(context) + "/" + fileToSave) - val output = FileOutputStream(file) - dataResized.writeTo(output) - output.flush() - output.close() - return fileToSave -} - @Composable fun ChatLayout( user: User, chat: Chat, - chatItems: List, - msg: MutableState, - quotedItem: MutableState, - editingItem: MutableState, - linkPreview: MutableState, + composeState: MutableState, + composeView: (@Composable () -> Unit), chosenImage: MutableState, - imagePreview: MutableState, + scope: CoroutineScope, + attachmentBottomSheetState: ModalBottomSheetState, + chatItems: List, back: () -> Unit, info: () -> Unit, openDirectChat: (Long) -> Unit, - sendMessage: (String) -> Unit, - resetMessage: () -> Unit, - deleteMessage: (Long, CIDeleteMode) -> Unit, - parseMarkdown: (String) -> List?, - onImageChange: (Bitmap) -> Unit + deleteMessage: (Long, CIDeleteMode) -> Unit ) { - val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) - val scope = rememberCoroutineScope() + fun onImageChange(bitmap: Bitmap) { + val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000) + composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(imagePreview)) + } Surface( Modifier @@ -206,26 +139,21 @@ fun ChatLayout( sheetContent = { GetImageBottomSheet( chosenImage, - onImageChange = onImageChange, + ::onImageChange, hideBottomSheet = { - scope.launch { bottomSheetModalState.hide() } + scope.launch { attachmentBottomSheetState.hide() } }) }, - sheetState = bottomSheetModalState, + sheetState = attachmentBottomSheetState, sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp) ) { Scaffold( topBar = { ChatInfoToolbar(chat, back, info) }, - bottomBar = { - ComposeView( - msg, quotedItem, editingItem, linkPreview, chosenImage, imagePreview, sendMessage, resetMessage, parseMarkdown, - showBottomSheet = { scope.launch { bottomSheetModalState.show() } } - ) - }, + bottomBar = composeView, modifier = Modifier.navigationBarsWithImePadding() ) { contentPadding -> Box(Modifier.padding(contentPadding)) { - ChatItemsList(user, chat, chatItems, msg, quotedItem, editingItem, openDirectChat, deleteMessage) + ChatItemsList(user, chat, composeState, chatItems, openDirectChat, deleteMessage) } } } @@ -299,10 +227,8 @@ val CIListStateSaver = run { fun ChatItemsList( user: User, chat: Chat, + composeState: MutableState, chatItems: List, - msg: MutableState, - quotedItem: MutableState, - editingItem: MutableState, openDirectChat: (Long) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit ) { @@ -342,11 +268,11 @@ fun ChatItemsList( } else { Spacer(Modifier.size(42.dp)) } - ChatItemView(user, cItem, msg, quotedItem, editingItem, cxt, uriHandler, showMember = showMember, deleteMessage = deleteMessage) + ChatItemView(user, cItem, composeState, cxt, uriHandler, showMember = showMember, deleteMessage = deleteMessage) } } else { Box(Modifier.padding(start = 86.dp, end = 12.dp)) { - ChatItemView(user, cItem, msg, quotedItem, editingItem, cxt, uriHandler, deleteMessage = deleteMessage) + ChatItemView(user, cItem, composeState, cxt, uriHandler, deleteMessage = deleteMessage) } } } else { // direct message @@ -357,7 +283,7 @@ fun ChatItemsList( end = if (sent) 12.dp else 76.dp, ) ) { - ChatItemView(user, cItem, msg, quotedItem, editingItem, cxt, uriHandler, deleteMessage = deleteMessage) + ChatItemView(user, cItem, composeState, cxt, uriHandler, deleteMessage = deleteMessage) } } } @@ -415,21 +341,16 @@ fun PreviewChatLayout() { chatItems = chatItems, chatStats = Chat.ChatStats() ), - chatItems = chatItems, - msg = remember { mutableStateOf("") }, - quotedItem = remember { mutableStateOf(null) }, - editingItem = remember { mutableStateOf(null) }, - linkPreview = remember { mutableStateOf(null) }, + composeState = remember { mutableStateOf(ComposeState()) }, + composeView = {}, chosenImage = remember { mutableStateOf(null) }, - imagePreview = remember { mutableStateOf(null) }, + scope = rememberCoroutineScope(), + attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden), + chatItems = chatItems, back = {}, info = {}, openDirectChat = {}, - sendMessage = {}, - resetMessage = {}, - deleteMessage = { _, _ -> }, - parseMarkdown = { null }, - onImageChange = {} + deleteMessage = { _, _ -> } ) } } @@ -463,21 +384,16 @@ fun PreviewGroupChatLayout() { chatItems = chatItems, chatStats = Chat.ChatStats() ), - chatItems = chatItems, - msg = remember { mutableStateOf("") }, - quotedItem = remember { mutableStateOf(null) }, - editingItem = remember { mutableStateOf(null) }, - linkPreview = remember { mutableStateOf(null) }, + composeState = remember { mutableStateOf(ComposeState()) }, + composeView = {}, chosenImage = remember { mutableStateOf(null) }, - imagePreview = remember { mutableStateOf(null) }, + scope = rememberCoroutineScope(), + attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden), + chatItems = chatItems, back = {}, info = {}, openDirectChat = {}, - sendMessage = {}, - resetMessage = {}, - deleteMessage = { _, _ -> }, - parseMarkdown = { null }, - onImageChange = {} + deleteMessage = { _, _ -> } ) } } 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 index 01c60ecc8..d139ba90b 100644 --- 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 @@ -15,7 +15,7 @@ import chat.simplex.app.views.chat.item.SentColorLight import chat.simplex.app.views.helpers.base64ToBitmap @Composable -fun ComposeImageView(image: String, cancelImage: () -> Unit) { +fun ComposeImageView(image: String, cancelImage: () -> Unit, cancelEnabled: Boolean) { Row( Modifier .fillMaxWidth() @@ -33,13 +33,15 @@ fun ComposeImageView(image: String, cancelImage: () -> Unit) { .padding(end = 8.dp) ) Spacer(Modifier.weight(1f)) - IconButton(onClick = cancelImage, modifier = Modifier.padding(0.dp)) { - Icon( - Icons.Outlined.Close, - contentDescription = stringResource(R.string.icon_descr_cancel_image_preview), - tint = MaterialTheme.colors.primary, - modifier = Modifier.padding(10.dp) - ) + if (cancelEnabled) { + IconButton(onClick = cancelImage, modifier = Modifier.padding(0.dp)) { + Icon( + Icons.Outlined.Close, + contentDescription = stringResource(R.string.icon_descr_cancel_image_preview), + tint = MaterialTheme.colors.primary, + modifier = Modifier.padding(10.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 d0dea0aad..aae695d4b 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,12 +1,12 @@ package chat.simplex.app.views.chat import ComposeImageView +import android.content.Context import android.graphics.Bitmap 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.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AttachFile import androidx.compose.runtime.* @@ -14,81 +14,312 @@ 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.res.stringResource 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.chat.item.* +import chat.simplex.app.views.helpers.* +import kotlinx.coroutines.* +import java.io.File +import java.io.FileOutputStream + +sealed class ComposePreview { + object NoPreview: ComposePreview() + class CLinkPreview(val linkPreview: LinkPreview): ComposePreview() + class ImagePreview(val image: String): ComposePreview() +} + +sealed class ComposeContextItem { + object NoContextItem: ComposeContextItem() + class QuotedItem(val chatItem: ChatItem): ComposeContextItem() + class EditingItem(val chatItem: ChatItem): ComposeContextItem() +} + +data class ComposeState( + val message: String = "", + val preview: ComposePreview = ComposePreview.NoPreview, + val contextItem: ComposeContextItem = ComposeContextItem.NoContextItem, + val inProgress: Boolean = false +) { + constructor(editingItem: ChatItem): this( + editingItem.content.text, + chatItemPreview(editingItem), + ComposeContextItem.EditingItem(editingItem) + ) + + val editing: Boolean + get() = + when (contextItem) { + is ComposeContextItem.EditingItem -> true + else -> false + } + val sendEnabled: Boolean + get() = + when (preview) { + is ComposePreview.ImagePreview -> true + else -> message.isNotEmpty() + } + val linkPreviewAllowed: Boolean + get() = + when (preview) { + is ComposePreview.ImagePreview -> false + else -> true + } + val linkPreview: LinkPreview? + get() = + when (preview) { + is ComposePreview.CLinkPreview -> preview.linkPreview + else -> null + } +} + +fun chatItemPreview(chatItem: ChatItem): ComposePreview { + return when (val mc = chatItem.content.msgContent) { + is MsgContent.MCLink -> ComposePreview.CLinkPreview(linkPreview = mc.preview) + is MsgContent.MCImage -> ComposePreview.ImagePreview(image = mc.image) + else -> ComposePreview.NoPreview + } +} -// TODO ComposeState @Composable fun ComposeView( - msg: MutableState, - quotedItem: MutableState, - editingItem: MutableState, - linkPreview: MutableState, + chatModel: ChatModel, + chat: Chat, + composeState: MutableState, chosenImage: MutableState, - imagePreview: MutableState, - sendMessage: (String) -> Unit, - resetMessage: () -> Unit, - parseMarkdown: (String) -> List?, - showBottomSheet: () -> Unit + showAttachmentBottomSheet: () -> Unit ) { + val context = LocalContext.current + val linkUrl = remember { mutableStateOf(null) } + val prevLinkUrl = remember { mutableStateOf(null) } + val pendingLinkUrl = remember { mutableStateOf(null) } val cancelledLinks = remember { mutableSetOf() } + val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) + val textStyle = remember { mutableStateOf(smallFont) } - fun cancelPreview() { - val uri = linkPreview.value?.uri + fun isSimplexLink(link: String): Boolean = + link.startsWith("https://simplex.chat", true) || link.startsWith("http://simplex.chat", true) + + fun parseMessage(msg: String): String? { + val parsedMsg = runBlocking { chatModel.controller.apiParseMarkdown(msg) } + val link = parsedMsg?.firstOrNull { ft -> ft.format is Format.Uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) } + return link?.text + } + + fun loadLinkPreview(url: String, wait: Long? = null) { + if (pendingLinkUrl.value == url) { + withApi { + if (wait != null) delay(wait) + val lp = getLinkPreview(url) + if (lp != null && pendingLinkUrl.value == url) { + composeState.value = composeState.value.copy(preview = ComposePreview.CLinkPreview(lp)) + pendingLinkUrl.value = null + } + } + } + } + + fun showLinkPreview(s: String) { + prevLinkUrl.value = linkUrl.value + linkUrl.value = parseMessage(s) + val url = linkUrl.value + if (url != null) { + if (url != composeState.value.linkPreview?.uri && url != pendingLinkUrl.value) { + pendingLinkUrl.value = url + loadLinkPreview(url, wait = if (prevLinkUrl.value == url) null else 1500L) + } + } else { + composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview) + } + } + + fun resetLinkPreview() { + linkUrl.value = null + prevLinkUrl.value = null + pendingLinkUrl.value = null + cancelledLinks.clear() + } + + fun checkLinkPreview(): MsgContent { + val cs = composeState.value + return when (val composePreview = cs.preview) { + is ComposePreview.CLinkPreview -> { + val url = parseMessage(cs.message) + val lp = composePreview.linkPreview + if (url == lp.uri) { + MsgContent.MCLink(cs.message, preview = lp) + } else { + MsgContent.MCText(cs.message) + } + } + else -> MsgContent.MCText(cs.message) + } + } + + fun updateMsgContent(msgContent: MsgContent): MsgContent { + val cs = composeState.value + return when (msgContent) { + is MsgContent.MCText -> checkLinkPreview() + is MsgContent.MCLink -> checkLinkPreview() + is MsgContent.MCImage -> MsgContent.MCImage(cs.message, image = msgContent.image) + is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = cs.message, json = msgContent.json) + } + } + + fun saveImage(context: Context, image: Bitmap): String? { + return try { + val dataResized = resizeImageToDataSize(image, maxDataSize = MAX_IMAGE_SIZE) + val fileToSave = "image_${System.currentTimeMillis()}.jpg" + val file = File(getAppFilesDirectory(context) + "/" + fileToSave) + val output = FileOutputStream(file) + dataResized.writeTo(output) + output.flush() + output.close() + fileToSave + } catch (e: Exception) { + null + } + } + + fun sendMessage() { + withApi { + // show "in progress" + val cInfo = chat.chatInfo + val cs = composeState.value + when (val contextItem = cs.contextItem) { + is ComposeContextItem.EditingItem -> { + val ei = contextItem.chatItem + val oldMsgContent = ei.content.msgContent + if (oldMsgContent != null) { + val updatedItem = chatModel.controller.apiUpdateChatItem( + type = cInfo.chatType, + id = cInfo.apiId, + itemId = ei.meta.itemId, + mc = updateMsgContent(oldMsgContent) + ) + if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem) + } + } + else -> { + var mc: MsgContent? = null + var file: String? = null + when (val preview = cs.preview) { + ComposePreview.NoPreview -> mc = MsgContent.MCText(cs.message) + is ComposePreview.CLinkPreview -> mc = checkLinkPreview() + is ComposePreview.ImagePreview -> { + val chosenImageVal = chosenImage.value + if (chosenImageVal != null) { + file = saveImage(context, chosenImageVal) + if (file != null) { + mc = MsgContent.MCImage(cs.message, preview.image) + } + } + } + } + val quotedItemId: Long? = when (contextItem) { + is ComposeContextItem.QuotedItem -> contextItem.chatItem.id + else -> null + } + + if (mc != null) { + val aChatItem = chatModel.controller.apiSendMessage( + type = cInfo.chatType, + id = cInfo.apiId, + file = file, + quotedItemId = quotedItemId, + mc = mc + ) + if (aChatItem != null) chatModel.addChatItem(cInfo, aChatItem.chatItem) + } + } + } + // hide "in progress" + composeState.value = ComposeState() + } + } + + fun onMessageChange(s: String) { + composeState.value = composeState.value.copy(message = s) + if (isShortEmoji(s)) { + textStyle.value = if (s.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont + } else { + textStyle.value = smallFont + if (composeState.value.linkPreviewAllowed) { + if (s.isNotEmpty()) showLinkPreview(s) + else resetLinkPreview() + } + } + } + + fun cancelLinkPreview() { + val uri = composeState.value.linkPreview?.uri if (uri != null) { cancelledLinks.add(uri) } - linkPreview.value = null + composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview) } fun cancelImage() { chosenImage.value = null - imagePreview.value = null + composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview) + } + + @Composable + fun previewView() { + when (val preview = composeState.value.preview) { + ComposePreview.NoPreview -> {} + is ComposePreview.CLinkPreview -> ComposeLinkView(preview.linkPreview, ::cancelLinkPreview) + is ComposePreview.ImagePreview -> ComposeImageView( + preview.image, + ::cancelImage, + cancelEnabled = !composeState.value.editing + ) + } + } + + @Composable + fun contextItemView() { + when (val contextItem = composeState.value.contextItem) { + ComposeContextItem.NoContextItem -> {} + is ComposeContextItem.QuotedItem -> ContextItemView(contextItem.chatItem) { composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem) } + is ComposeContextItem.EditingItem -> ContextItemView(contextItem.chatItem) { composeState.value = ComposeState() } + } } Column { - val ip = imagePreview.value - if (ip != null) { - ComposeImageView(ip, ::cancelImage) - } else { - val lp = linkPreview.value - if (lp != null) ComposeLinkView(lp, ::cancelPreview) - } - when { - quotedItem.value != null -> { - ContextItemView(quotedItem) - } - editingItem.value != null -> { - ContextItemView(editingItem, editing = editingItem.value != null, resetMessage) - } - else -> {} - } + contextItemView() + previewView() Row( modifier = Modifier.padding(start = 4.dp, end = 8.dp), verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(2.dp) ) { + val attachEnabled = !composeState.value.editing Box(Modifier.padding(bottom = 12.dp)) { Icon( Icons.Filled.AttachFile, contentDescription = stringResource(R.string.attach), - tint = if (editingItem.value == null) MaterialTheme.colors.primary else Color.Gray, + tint = if (attachEnabled) MaterialTheme.colors.primary else Color.Gray, modifier = Modifier .size(28.dp) .clip(CircleShape) .clickable { - if (editingItem.value == null) { - showBottomSheet() + if (attachEnabled) { + showAttachmentBottomSheet() } } ) } SendMsgView( - msg, linkPreview, cancelledLinks, parseMarkdown, sendMessage, - editing = editingItem.value != null, sendEnabled = msg.value.isNotEmpty() || imagePreview.value != null + composeState, + sendMessage = { + sendMessage() + resetLinkPreview() + }, + ::onMessageChange, + textStyle ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt index 67581d81b..23227ebbc 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt @@ -22,41 +22,32 @@ import kotlinx.datetime.Clock @Composable fun ContextItemView( - contextItem: MutableState, - editing: Boolean = false, - resetMessage: () -> Unit = {} + contextItem: ChatItem, + cancelContextItem: () -> Unit ) { - val cxtItem = contextItem.value - if (cxtItem != null) { - val sent = cxtItem.chatDir.sent - Row( + val sent = contextItem.chatDir.sent + Row( + Modifier + .padding(top = 8.dp) + .background(if (sent) SentColorLight else ReceivedColorLight), + verticalAlignment = Alignment.CenterVertically + ) { + Box( Modifier - .padding(top = 8.dp) - .background(if (sent) SentColorLight else ReceivedColorLight), - verticalAlignment = Alignment.CenterVertically + .padding(start = 16.dp) + .padding(vertical = 12.dp) + .fillMaxWidth() + .weight(1F) ) { - Box( - Modifier - .padding(start = 16.dp) - .padding(vertical = 12.dp) - .fillMaxWidth() - .weight(1F) - ) { - ContextItemText(cxtItem) - } - IconButton(onClick = { - contextItem.value = null - if (editing) { - resetMessage() - } - }) { - Icon( - Icons.Outlined.Close, - contentDescription = stringResource(R.string.cancel_verb), - tint = MaterialTheme.colors.primary, - modifier = Modifier.padding(10.dp) - ) - } + ContextItemText(contextItem) + } + IconButton(onClick = cancelContextItem) { + Icon( + Icons.Outlined.Close, + contentDescription = stringResource(R.string.cancel_verb), + tint = MaterialTheme.colors.primary, + modifier = Modifier.padding(10.dp) + ) } } } @@ -80,13 +71,8 @@ private fun ContextItemText(cxtItem: ChatItem) { fun PreviewContextItemView() { SimpleXTheme { ContextItemView( - contextItem = remember { - mutableStateOf( - ChatItem.getSampleData( - 1, CIDirection.DirectRcv(), Clock.System.now(), "hello" - ) - ) - } + contextItem = ChatItem.getSampleData(1, CIDirection.DirectRcv(), Clock.System.now(), "hello"), + cancelContextItem = {} ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt index 32e8aa8b4..03c21786e 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 @@ -18,6 +18,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -25,83 +26,19 @@ 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.chat.item.* -import chat.simplex.app.views.helpers.getLinkPreview -import chat.simplex.app.views.helpers.withApi -import kotlinx.coroutines.delay @Composable fun SendMsgView( - msg: MutableState, - linkPreview: MutableState, - cancelledLinks: MutableSet, - parseMarkdown: (String) -> List?, - sendMessage: (String) -> Unit, - editing: Boolean = false, - sendEnabled: Boolean = false + composeState: MutableState, + sendMessage: () -> Unit, + onMessageChange: (String) -> Unit, + textStyle: MutableState ) { - val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) - var textStyle by remember { mutableStateOf(smallFont) } - val linkUrl = remember { mutableStateOf(null) } - val prevLinkUrl = remember { mutableStateOf(null) } - val pendingLinkUrl = remember { mutableStateOf(null) } - - fun isSimplexLink(link: String): Boolean = - link.startsWith("https://simplex.chat",true) || link.startsWith("http://simplex.chat", true) - - fun parseMessage(msg: String): String? { - val parsedMsg = parseMarkdown(msg) - val link = parsedMsg?.firstOrNull { ft -> ft.format is Format.Uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) } - return link?.text - } - - fun loadLinkPreview(url: String, wait: Long? = null) { - if (pendingLinkUrl.value == url) { - withApi { - if (wait != null) delay(wait) - val lp = getLinkPreview(url) - if (pendingLinkUrl.value == url) { - linkPreview.value = lp - pendingLinkUrl.value = null - } - } - } - } - - fun showLinkPreview(s: String) { - prevLinkUrl.value = linkUrl.value - linkUrl.value = parseMessage(s) - val url = linkUrl.value - if (url != null) { - if (url != linkPreview.value?.uri && url != pendingLinkUrl.value) { - pendingLinkUrl.value = url - loadLinkPreview(url, wait = if (prevLinkUrl.value == url) null else 1500L) - } - } else { - linkPreview.value = null - } - } - - fun resetLinkPreview() { - linkUrl.value = null - prevLinkUrl.value = null - pendingLinkUrl.value = null - cancelledLinks.clear() - } - + val cs = composeState.value BasicTextField( - value = msg.value, - onValueChange = { s -> - msg.value = s - if (isShortEmoji(s)) { - textStyle = if (s.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont - } else { - textStyle = smallFont - if (s.isNotEmpty()) showLinkPreview(s) - else resetLinkPreview() - } - }, - textStyle = textStyle, + value = cs.message, + onValueChange = onMessageChange, + textStyle = textStyle.value, maxLines = 16, keyboardOptions = KeyboardOptions.Default.copy( capitalization = KeyboardCapitalization.Sentences, @@ -127,9 +64,10 @@ fun SendMsgView( ) { innerTextField() } - val color = if (sendEnabled) MaterialTheme.colors.primary else Color.Gray + val icon = if (cs.editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward + val color = if (cs.sendEnabled) MaterialTheme.colors.primary else Color.Gray Icon( - if (editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward, + icon, stringResource(R.string.icon_descr_send_message), tint = Color.White, modifier = Modifier @@ -138,11 +76,8 @@ fun SendMsgView( .clip(CircleShape) .background(color) .clickable { - if (sendEnabled) { - sendMessage(msg.value) - msg.value = "" - textStyle = smallFont - cancelledLinks.clear() + if (cs.sendEnabled) { + sendMessage() } } ) @@ -160,14 +95,14 @@ fun SendMsgView( ) @Composable fun PreviewSendMsgView() { + val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) + val textStyle = remember { mutableStateOf(smallFont) } SimpleXTheme { SendMsgView( - msg = remember { mutableStateOf("") }, - linkPreview = remember {mutableStateOf(null) }, - cancelledLinks = mutableSetOf(), - parseMarkdown = { null }, - sendMessage = { msg -> println(msg) }, - sendEnabled = true + composeState = remember { mutableStateOf(ComposeState()) }, + sendMessage = {}, + onMessageChange = { _ -> }, + textStyle = textStyle ) } } @@ -180,15 +115,15 @@ fun PreviewSendMsgView() { ) @Composable fun PreviewSendMsgViewEditing() { + val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) + val textStyle = remember { mutableStateOf(smallFont) } + val composeStateEditing = ComposeState(editingItem = ChatItem.getSampleData()) SimpleXTheme { SendMsgView( - msg = remember { mutableStateOf("") }, - linkPreview = remember {mutableStateOf(null) }, - cancelledLinks = mutableSetOf(), - sendMessage = { msg -> println(msg) }, - parseMarkdown = { null }, - editing = true, - sendEnabled = true + composeState = remember { mutableStateOf(composeStateEditing) }, + sendMessage = {}, + onMessageChange = { _ -> }, + textStyle = textStyle ) } } 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 79c487c87..d005e1ea3 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 @@ -20,6 +20,8 @@ import androidx.compose.ui.unit.dp import chat.simplex.app.R import chat.simplex.app.model.* import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.views.chat.ComposeContextItem +import chat.simplex.app.views.chat.ComposeState import chat.simplex.app.views.helpers.* import kotlinx.datetime.Clock @@ -27,9 +29,7 @@ import kotlinx.datetime.Clock fun ChatItemView( user: User, cItem: ChatItem, - msg: MutableState, - quotedItem: MutableState, - editingItem: MutableState, + composeState: MutableState, cxt: Context, uriHandler: UriHandler? = null, showMember: Boolean = false, @@ -61,8 +61,7 @@ fun ChatItemView( Modifier.width(220.dp) ) { ItemAction(stringResource(R.string.reply_verb), Icons.Outlined.Reply, onClick = { - editingItem.value = null - quotedItem.value = cItem + composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem)) showMenu.value = false }) ItemAction(stringResource(R.string.share_verb), Icons.Outlined.Share, onClick = { @@ -75,9 +74,7 @@ fun ChatItemView( }) if (cItem.chatDir.sent && cItem.meta.editable) { ItemAction(stringResource(R.string.edit_verb), Icons.Filled.Edit, onClick = { - quotedItem.value = null - editingItem.value = cItem - msg.value = cItem.content.text + composeState.value = ComposeState(editingItem = cItem) showMenu.value = false }) } @@ -148,9 +145,7 @@ fun PreviewChatItemView() { ChatItem.getSampleData( 1, CIDirection.DirectSnd(), Clock.System.now(), "hello" ), - msg = remember { mutableStateOf("") }, - quotedItem = remember { mutableStateOf(null) }, - editingItem = remember { mutableStateOf(null) }, + composeState = remember { mutableStateOf(ComposeState()) }, cxt = LocalContext.current, deleteMessage = { _, _ -> } ) @@ -164,9 +159,7 @@ fun PreviewChatItemViewDeletedContent() { ChatItemView( User.sampleData, ChatItem.getDeletedContentSampleData(), - msg = remember { mutableStateOf("") }, - quotedItem = remember { mutableStateOf(null) }, - editingItem = remember { mutableStateOf(null) }, + composeState = remember { mutableStateOf(ComposeState()) }, cxt = LocalContext.current, deleteMessage = { _, _ -> } ) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index a73ed2314..43ca22a3d 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -135,8 +135,8 @@ struct ComposeView: View { .padding(.leading, 12) SendMessageView( composeState: $composeState, - sendMessage: { text in - sendMessage(text) + sendMessage: { + sendMessage() resetLinkPreview() }, keyboardVisible: $keyboardVisible @@ -209,7 +209,7 @@ struct ComposeView: View { } } - private func sendMessage(_ text: String) { + private func sendMessage() { logger.debug("ChatView sendMessage") Task { logger.debug("ChatView sendMessage: in Task") diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index 3c4663f50..22510808c 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -10,7 +10,7 @@ import SwiftUI struct SendMessageView: View { @Binding var composeState: ComposeState - var sendMessage: (String) -> Void + var sendMessage: () -> Void @Namespace var namespace @FocusState.Binding var keyboardVisible: Bool @State private var teHeight: CGFloat = 42 @@ -45,7 +45,7 @@ struct SendMessageView: View { .frame(width: 31, height: 31, alignment: .center) .padding([.bottom, .trailing], 3) } else { - Button(action: { sendMessage(composeState.message) }) { + Button(action: { sendMessage() }) { Image(systemName: composeState.editing() ? "checkmark.circle.fill" : "arrow.up.circle.fill") .resizable() .foregroundColor(.accentColor) @@ -90,7 +90,7 @@ struct SendMessageView_Previews: PreviewProvider { Spacer(minLength: 0) SendMessageView( composeState: $composeStateNew, - sendMessage: { print ($0) }, + sendMessage: {}, keyboardVisible: $keyboardVisible ) } @@ -99,7 +99,7 @@ struct SendMessageView_Previews: PreviewProvider { Spacer(minLength: 0) SendMessageView( composeState: $composeStateEditing, - sendMessage: { print ($0) }, + sendMessage: {}, keyboardVisible: $keyboardVisible ) } diff --git a/apps/ios/Shared/Views/TerminalView.swift b/apps/ios/Shared/Views/TerminalView.swift index 56cc0bb8c..75198c097 100644 --- a/apps/ios/Shared/Views/TerminalView.swift +++ b/apps/ios/Shared/Views/TerminalView.swift @@ -80,8 +80,8 @@ struct TerminalView: View { } } - func sendMessage(_ cmdStr: String) { - let cmd = ChatCommand.string(cmdStr) + func sendMessage() { + let cmd = ChatCommand.string(composeState.message) DispatchQueue.global().async { Task { composeState.inProgress = true