mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-08-29 03:18:37 +00:00
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:
co-authored by
Evgeny Poberezkin
parent
de81afc727
commit
1152b5d737
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+29
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+8
-3
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user