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 0110570e55.

* 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>
This commit is contained in:
JRoberts
2022-04-19 12:29:03 +04:00
committed by GitHub
co-authored by Evgeny Poberezkin
parent de81afc727
commit 1152b5d737
45 changed files with 1103 additions and 276 deletions
@@ -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)
}
@@ -523,10 +523,18 @@ data class ChatItem (
val meta: CIMeta,
val content: CIContent,
val formattedText: List<FormattedText>? = 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<MsgContent> {
element<String>("text")
element<String>("preview")
})
element("MCImage", buildClassSerialDescriptor("MCImage") {
element<String>("text")
element<String>("image")
})
element("MCUnknown", buildClassSerialDescriptor("MCUnknown"))
}
@@ -791,6 +829,10 @@ object MsgContentSerializer : KSerializer<MsgContent> {
val preview = Json.decodeFromString<LinkPreview>(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<MsgContent> {
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
@@ -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<Chat> {
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
@@ -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<TerminalItem>, 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 ->
@@ -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<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 }
@@ -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<ChatItem?>,
editingItem: MutableState<ChatItem?>,
linkPreview: MutableState<LinkPreview?>,
chosenImage: MutableState<Bitmap?>,
imagePreview: MutableState<String?>,
back: () -> Unit,
info: () -> Unit,
openDirectChat: (Long) -> Unit,
sendMessage: (String) -> Unit,
resetMessage: () -> Unit,
deleteMessage: (Long, CIDeleteMode) -> Unit,
parseMarkdown: (String) -> List<FormattedText>?
parseMarkdown: (String) -> List<FormattedText>?,
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 = {}
)
}
}
@@ -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)
)
}
}
@@ -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<ChatItem?>,
editingItem: MutableState<ChatItem?>,
linkPreview: MutableState<LinkPreview?>,
imagePreview: MutableState<String?>,
sendMessage: (String) -> Unit,
resetMessage: () -> Unit,
parseMarkdown: (String) -> List<FormattedText>?
parseMarkdown: (String) -> List<FormattedText>?,
showBottomSheet: () -> Unit
) {
val cancelledLinks = remember { mutableSetOf<String>() }
@@ -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)
}
}
}
@@ -35,7 +35,8 @@ fun SendMsgView(
cancelledLinks: MutableSet<String>,
parseMarkdown: (String) -> List<FormattedText>?,
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<LinkPreview?>(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
)
}
}
@@ -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<Boolean>
) {
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,
)
}
}
@@ -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)
@@ -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
@@ -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<Boolean>
) {
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<Boolean> {
override val values = listOf(false, true).asSequence()
}
@@ -87,12 +132,14 @@ class EditedProvider: PreviewParameterProvider<Boolean> {
@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
)
}
}
@@ -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,
)
}
}
@@ -35,7 +35,7 @@ fun appendSender(b: AnnotatedString.Builder, sender: String?, senderBold: Boolea
@Composable
fun MarkdownText (
content: ItemContent,
text: String,
formattedText: List<FormattedText>? = 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)
@@ -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
@@ -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<String?>,
imageBitmap: MutableState<Bitmap?>,
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 ->
@@ -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<KeyboardState> {
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
}
}
@@ -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<Bitmap?>(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)
@@ -49,7 +49,7 @@
<!-- Chat Actions - ChatItemView.kt (and general) -->
<string name="reply_verb">Ответить</string>
<string name="share_verb">Поделиться</string>
<string name="copy_verb">Скопировать</string>
<string name="copy_verb">Копировать</string>
<string name="edit_verb">Редактировать</string>
<string name="delete_verb">Удалить</string>
<string name="delete_message__question">Удалить сообщение?</string>
@@ -70,6 +70,12 @@
<string name="this_text_is_available_in_settings">Этот текст можно найти в Настройках</string>
<string name="your_chats">Ваши чаты</string>
<!-- ComposeView.kt -->
<string name="attach">Прикрепить</string>
<!-- Images -->
<string name="image_descr">Изображение</string>
<!-- Chat Info Actions - ChatInfoView.kt -->
<string name="delete_contact__question">Удалить контакт?</string>
<string name="delete_contact_all_messages_deleted_cannot_undo_warning">Контакт и все сообщения будут удалены - это действие нельзя отменить!</string>
@@ -70,6 +70,12 @@
<string name="this_text_is_available_in_settings">This text is available in settings</string>
<string name="your_chats">Your chats</string>
<!-- ComposeView.kt -->
<string name="attach">Attach</string>
<!-- Images -->
<string name="image_descr">Image</string>
<!-- Chat Info Actions - ChatInfoView.kt -->
<string name="delete_contact__question">Delete contact?</string>
<string name="delete_contact_all_messages_deleted_cannot_undo_warning">Contact and all messages will be deleted - this cannot be undone!</string>