android: refactor compose (#579)

This commit is contained in:
JRoberts
2022-04-27 20:54:21 +04:00
committed by GitHub
parent 645587431d
commit a7554771a0
11 changed files with 431 additions and 357 deletions

View File

@@ -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

View File

@@ -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<TerminalItem>, close: () -> Unit, sendCommand: (String) -> Unit) {
var msg = remember { mutableStateOf("") }
fun TerminalLayout(
terminalItems: List<TerminalItem>,
composeState: MutableState<ComposeState>,
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 = {}
)
}
}

View File

@@ -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<Bitmap?>(null) }
if (chat == null || user == null) {
chatModel.chatId.value = null
} else {
val context = LocalContext.current
val quotedItem = remember { mutableStateOf<ChatItem?>(null) }
val editingItem = remember { mutableStateOf<ChatItem?>(null) }
val linkPreview = remember { mutableStateOf<LinkPreview?>(null) }
val chosenImage = remember { mutableStateOf<Bitmap?>(null) }
val imagePreview = remember { mutableStateOf<String?>(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<ChatItem>,
msg: MutableState<String>,
quotedItem: MutableState<ChatItem?>,
editingItem: MutableState<ChatItem?>,
linkPreview: MutableState<LinkPreview?>,
composeState: MutableState<ComposeState>,
composeView: (@Composable () -> Unit),
chosenImage: MutableState<Bitmap?>,
imagePreview: MutableState<String?>,
scope: CoroutineScope,
attachmentBottomSheetState: ModalBottomSheetState,
chatItems: List<ChatItem>,
back: () -> Unit,
info: () -> Unit,
openDirectChat: (Long) -> Unit,
sendMessage: (String) -> Unit,
resetMessage: () -> Unit,
deleteMessage: (Long, CIDeleteMode) -> Unit,
parseMarkdown: (String) -> List<FormattedText>?,
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<ComposeState>,
chatItems: List<ChatItem>,
msg: MutableState<String>,
quotedItem: MutableState<ChatItem?>,
editingItem: MutableState<ChatItem?>,
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 = { _, _ -> }
)
}
}

View File

@@ -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)
)
}
}
}
}

View File

@@ -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<String>,
quotedItem: MutableState<ChatItem?>,
editingItem: MutableState<ChatItem?>,
linkPreview: MutableState<LinkPreview?>,
chatModel: ChatModel,
chat: Chat,
composeState: MutableState<ComposeState>,
chosenImage: MutableState<Bitmap?>,
imagePreview: MutableState<String?>,
sendMessage: (String) -> Unit,
resetMessage: () -> Unit,
parseMarkdown: (String) -> List<FormattedText>?,
showBottomSheet: () -> Unit
showAttachmentBottomSheet: () -> Unit
) {
val context = LocalContext.current
val linkUrl = remember { mutableStateOf<String?>(null) }
val prevLinkUrl = remember { mutableStateOf<String?>(null) }
val pendingLinkUrl = remember { mutableStateOf<String?>(null) }
val cancelledLinks = remember { mutableSetOf<String>() }
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
)
}
}

View File

@@ -22,41 +22,32 @@ import kotlinx.datetime.Clock
@Composable
fun ContextItemView(
contextItem: MutableState<ChatItem?>,
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 = {}
)
}
}

View File

@@ -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<String>,
linkPreview: MutableState<LinkPreview?>,
cancelledLinks: MutableSet<String>,
parseMarkdown: (String) -> List<FormattedText>?,
sendMessage: (String) -> Unit,
editing: Boolean = false,
sendEnabled: Boolean = false
composeState: MutableState<ComposeState>,
sendMessage: () -> Unit,
onMessageChange: (String) -> Unit,
textStyle: MutableState<TextStyle>
) {
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
var textStyle by remember { mutableStateOf(smallFont) }
val linkUrl = remember { mutableStateOf<String?>(null) }
val prevLinkUrl = remember { mutableStateOf<String?>(null) }
val pendingLinkUrl = remember { mutableStateOf<String?>(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<LinkPreview?>(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<LinkPreview?>(null) },
cancelledLinks = mutableSetOf(),
sendMessage = { msg -> println(msg) },
parseMarkdown = { null },
editing = true,
sendEnabled = true
composeState = remember { mutableStateOf(composeStateEditing) },
sendMessage = {},
onMessageChange = { _ -> },
textStyle = textStyle
)
}
}

View File

@@ -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<String>,
quotedItem: MutableState<ChatItem?>,
editingItem: MutableState<ChatItem?>,
composeState: MutableState<ComposeState>,
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 = { _, _ -> }
)

View File

@@ -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")

View File

@@ -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
)
}

View File

@@ -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