Merge pull request #543 from simplex-chat/master (version 1.6)

This commit is contained in:
JRoberts
2022-04-20 22:17:45 +04:00
committed by GitHub
52 changed files with 1220 additions and 342 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId "chat.simplex.app"
minSdk 29
targetSdk 32
versionCode 23
versionName "1.5.1"
versionCode 26
versionName "1.6"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
ndk {
@@ -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 <= 236700) { // 394500
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 ->
@@ -17,13 +17,18 @@ import chat.simplex.app.R
import chat.simplex.app.SimplexService
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.Profile
import chat.simplex.app.views.helpers.generalGetString
import chat.simplex.app.views.helpers.withApi
import chat.simplex.app.views.helpers.*
import com.google.accompanist.insets.ProvideWindowInsets
import com.google.accompanist.insets.navigationBarsWithImePadding
import kotlinx.coroutines.launch
@Composable
fun WelcomeView(chatModel: ChatModel) {
val scope = rememberCoroutineScope()
val scrollState = rememberScrollState()
val keyboardState by getKeyboardState()
var savedKeyboardState by remember { mutableStateOf(keyboardState) }
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
Box(
modifier = Modifier
@@ -33,7 +38,7 @@ fun WelcomeView(chatModel: ChatModel) {
Column(
verticalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.verticalScroll(rememberScrollState())
.verticalScroll(scrollState)
.fillMaxSize()
.background(color = MaterialTheme.colors.background)
.padding(12.dp)
@@ -62,6 +67,14 @@ fun WelcomeView(chatModel: ChatModel) {
Spacer(Modifier.height(24.dp))
CreateProfilePanel(chatModel)
}
if (savedKeyboardState != keyboardState) {
LaunchedEffect(keyboardState) {
scope.launch {
savedKeyboardState = keyboardState
scrollState.animateScrollTo(scrollState.maxValue)
}
}
}
}
}
}
@@ -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,29 @@ fun ComposeView(
}
else -> {}
}
SendMsgView(msg, linkPreview, cancelledLinks, parseMarkdown, sendMessage, editing = editingItem.value != null)
Row(
modifier = Modifier.padding(horizontal = 8.dp),
// // use this padding when attach button is uncommented
// 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,10 +136,11 @@ fun SendMsgView(
.clip(CircleShape)
.background(color)
.clickable {
if (msg.value.isNotEmpty()) {
if (sendEnabled) {
sendMessage(msg.value)
msg.value = ""
textStyle = smallFont
cancelledLinks.clear()
}
}
)
@@ -162,7 +164,8 @@ fun PreviewSendMsgView() {
linkPreview = remember {mutableStateOf<LinkPreview?>(null) },
cancelledLinks = mutableSetOf(),
parseMarkdown = { null },
sendMessage = { msg -> println(msg) }
sendMessage = { msg -> println(msg) },
sendEnabled = true
)
}
}
@@ -182,7 +185,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(220.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
@@ -122,13 +126,13 @@ fun deleteMessageAlertDialog(chatItem: ChatItem, deleteMessage: (Long, CIDeleteM
deleteMessage(chatItem.id, CIDeleteMode.cidmInternal)
AlertManager.shared.hideAlert()
}) { Text(generalGetString(R.string.for_me_only)) }
// if (chatItem.meta.editable) {
// Spacer(Modifier.padding(horizontal = 4.dp))
// Button(onClick = {
// deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast)
// AlertManager.shared.hideAlert()
// }) { Text(generalGetString(R.string.for_everybody)) }
// }
if (chatItem.meta.editable) {
Spacer(Modifier.padding(horizontal = 4.dp))
Button(onClick = {
deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast)
AlertManager.shared.hideAlert()
}) { Text(generalGetString(R.string.for_everybody)) }
}
}
}
)
@@ -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)
@@ -1,7 +1,7 @@
package chat.simplex.app.views.chatlist
import android.content.res.Configuration
import androidx.compose.foundation.clickable
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
@@ -108,6 +108,7 @@ fun ChatListView(chatModel: ChatModel) {
fun Help(scaffoldCtrl: ScaffoldController, displayName: String?) {
Column(
Modifier
.verticalScroll(rememberScrollState())
.fillMaxWidth()
.padding(16.dp)
) {
@@ -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
}
}
@@ -29,7 +29,9 @@ fun HelpView(chatModel: ChatModel) {
@Composable
fun HelpLayout(displayName: String) {
Column(
Modifier.verticalScroll(rememberScrollState()),
Modifier
.verticalScroll(rememberScrollState())
.padding(bottom = 16.dp),
horizontalAlignment = Alignment.Start
){
Text(
@@ -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
@@ -33,7 +34,7 @@ import kotlinx.coroutines.launch
fun UserProfileView(chatModel: ChatModel, close: () -> Unit) {
val user = chatModel.currentUser.value
if (user != null) {
var editProfile = remember { mutableStateOf(false) }
val editProfile = remember { mutableStateOf(false) }
var profile by remember { mutableStateOf(user.profile) }
UserProfileLayout(
close = close,
@@ -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)
@@ -273,7 +278,7 @@ fun PreviewUserProfileLayoutEditOn() {
close = {},
profile = Profile.sampleData,
editProfile = remember { mutableStateOf(true) },
saveProfile = {_, _, _ ->}
saveProfile = { _, _, _ -> }
)
}
}
@@ -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>
@@ -93,7 +99,7 @@
<!-- NewChatSheet -->
<string name="create_QR_code_or_link__bracketed__multiline">(создать QR код или ссылку)</string>
<string name="in_person_or_in_video_call__bracketed">(при встрече или через видео звонок)</string>
<string name="in_person_or_in_video_call__bracketed">(при встрече или через видеозвонок)</string>
<string name="create_group">Создать группу</string>
<string name="coming_soon__bracketed">(скоро!)</string>
@@ -103,7 +109,7 @@
<string name="from_gallery_button">Открыть галерею</string>
<!-- help - ChatHelpView.kt -->
<string name="thank_you_for_installing_simplex">Спасибо что установили <xliff:g id="appNameFull">SimpleX Chat</xliff:g>!</string>
<string name="thank_you_for_installing_simplex">Спасибо, что установили <xliff:g id="appNameFull">SimpleX Chat</xliff:g>!</string>
<string name="you_can_connect_to_simplex_chat_founder">Вы можете <font color="#0088ff">соединиться с разработчиками</font>, чтобы задать любые вопросы или получать уведомления о новых версиях.</string>
<string name="to_start_a_new_chat_help_header">Чтобы начать новый чат</string>
<string name="chat_help_tap_button">Нажмите кнопку</string>
@@ -149,9 +155,9 @@
<string name="you_will_be_connected_when_your_connection_request_is_accepted">Соединение будет установлено когда ваш запрос будет принят. Пожалуйста, подождите или проверьте позже!</string>
<string name="you_will_be_connected_when_your_contacts_device_is_online">Соединение будет установлено когда ваш контакт будет онлайн. Пожалуйста, подождите или проверьте позже!</string>
<string name="show_QR_code_for_your_contact_to_scan_from_the_app__multiline">Покажите QR код вашему контакту, чтобы сосканировать его из приложения</string>
<string name="if_you_cannot_meet_in_person_show_QR_in_video_call_or_via_another_channel">Если вы не можете встретиться лично, вы можете <b>показать QR код во время видео звонка</b> или отправить ссылку через любой другой канал связи.</string>
<string name="if_you_cannot_meet_in_person_show_QR_in_video_call_or_via_another_channel">Если вы не можете встретиться лично, вы можете <b>показать QR код во время видеозвонка</b> или отправить ссылку через любой другой канал связи.</string>
<string name="your_chat_profile_will_be_sent_to_your_contact">Ваш профиль будет отправлен\nвашему контакту</string>
<string name="if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link">Если вы не можете встретиться лично, вы можете <b>сосканировать QR код во время видео звонка</b>, или ваш контакт может отправить вам ссылку.</string>
<string name="if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link">Если вы не можете встретиться лично, вы можете <b>сосканировать QR код во время видеозвонка</b>, или ваш контакт может отправить вам ссылку.</string>
<string name="share_invitation_link">Поделиться ссылкой</string>
<!-- settings - SettingsView.kt -->
@@ -172,7 +178,7 @@
<string name="configure_SMP_servers">Настройка SMP серверов</string>
<string name="using_simplex_chat_servers">Используются серверы предоставленные <xliff:g id="appNameFull">SimpleX Chat</xliff:g>.</string>
<string name="enter_one_SMP_server_per_line">Введите SMP серверы, каждый сервер в отдельной строке:</string>
<string name="how_to">Информация</string>
<string name="how_to">Инфо</string>
<string name="save_servers_button">Сохранить</string>
<!-- Address Items - UserAddressView.kt -->
@@ -55,7 +55,7 @@
<string name="delete_message__question">Delete message?</string>
<string name="delete_message_cannot_be_undone_warning">Message will be deleted - this cannot be undone!</string>
<string name="for_me_only">For me only</string>
<string name="for_everybody">For everybody</string>
<string name="for_everybody">For everyone</string>
<!-- CIMetaView.kt -->
<string name="icon_descr_edited">edited</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>
+1 -1
View File
@@ -18,7 +18,7 @@ String.localizedStringWithFormat(NSLocalizedString("You can now send messages to
1. Choose `Product -> Export Localizations...` in the menu, choose `ios` folder as the destination and `SimpleX Localizations` as the folder name, confirm to overwrite it (make sure not to save to subfolder).
2. Add `target` keys to the localizations that were added or changed.
3. Choose `Product -> Import Localizations...` for any non-Enlish folders - that would update Localizable files.
3. Choose `Product -> Import Localizations...` for any non-English folders - that would update Localizable files.
Localizable files values can be edited directly, the changes will be included in the next export. Following the process above though guarantees that all strings are localized.
+2
View File
@@ -18,6 +18,8 @@ struct ContentView: View {
.onAppear {
do {
try apiStartChat()
try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path)
chatModel.userAddress = try apiGetUserAddress()
chatModel.userSMPServers = try getUserSMPServers()
chatModel.chats = try apiGetChats()
} catch {
+34
View File
@@ -0,0 +1,34 @@
//
// FileUtils.swift
// SimpleX (iOS)
//
// Created by JRoberts on 15.04.2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import Foundation
import SwiftUI
func getDocumentsDirectory() -> URL {
return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
}
func getAppFilesDirectory() -> URL {
return getDocumentsDirectory().appendingPathComponent("app_files", isDirectory: true)
}
func getStoredFilePath(_ file: CIFile?) -> String? {
if let file = file,
file.stored,
let savedFile = file.filePath {
return getAppFilesDirectory().path + "/" + savedFile
}
return nil
}
func getStoredImage(_ file: CIFile?) -> UIImage? {
if let filePath = getStoredFilePath(file) {
return UIImage(contentsOfFile: filePath)
}
return nil
}
+65 -13
View File
@@ -579,11 +579,21 @@ struct ChatItem: Identifiable, Decodable {
var content: CIContent
var formattedText: [FormattedText]?
var quotedItem: CIQuote?
var file: CIFile?
var id: Int64 { get { meta.itemId } }
var timestampText: Text { get { meta.timestampText } }
var text: String {
get {
switch (content.text, file) {
case let ("", .some(file)): return file.fileName
default: return content.text
}
}
}
func isRcvNew() -> Bool {
if case .rcvNew = meta.itemStatus { return true }
return false
@@ -615,12 +625,13 @@ struct ChatItem: Identifiable, Decodable {
}
}
static func getSample (_ id: Int64, _ dir: CIDirection, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, quotedItem: CIQuote? = nil, _ itemDeleted: Bool = false, _ itemEdited: Bool = false, _ editable: Bool = true) -> ChatItem {
static func getSample (_ id: Int64, _ dir: CIDirection, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, quotedItem: CIQuote? = nil, file: CIFile? = nil, _ itemDeleted: Bool = false, _ itemEdited: Bool = false, _ editable: Bool = true) -> ChatItem {
ChatItem(
chatDir: dir,
meta: CIMeta.getSample(id, ts, text, status, itemDeleted, itemEdited, editable),
content: .sndMsgContent(msgContent: .text(text)),
quotedItem: quotedItem
quotedItem: quotedItem,
file: file
)
}
@@ -629,7 +640,8 @@ struct ChatItem: Identifiable, Decodable {
chatDir: dir,
meta: CIMeta.getSample(id, ts, text, status, false, false, false),
content: .rcvDeleted(deleteMode: .cidmBroadcast),
quotedItem: nil
quotedItem: nil,
file: nil
)
}
}
@@ -711,8 +723,6 @@ enum CIContent: Decodable, ItemContent {
case rcvMsgContent(msgContent: MsgContent)
case sndDeleted(deleteMode: CIDeleteMode)
case rcvDeleted(deleteMode: CIDeleteMode)
case sndFileInvitation(fileId: Int64, filePath: String)
case rcvFileInvitation(rcvFileTransfer: RcvFileTransfer)
var text: String {
get {
@@ -721,11 +731,10 @@ enum CIContent: Decodable, ItemContent {
case let .rcvMsgContent(mc): return mc.text
case .sndDeleted: return NSLocalizedString("deleted", comment: "deleted chat item")
case .rcvDeleted: return NSLocalizedString("deleted", comment: "deleted chat item")
case .sndFileInvitation: return NSLocalizedString("sending files is not supported yet", comment: "to be removed")
case .rcvFileInvitation: return NSLocalizedString("receiving files is not supported yet", comment: "to be removed")
}
}
}
var msgContent: MsgContent? {
get {
switch self {
@@ -737,10 +746,6 @@ enum CIContent: Decodable, ItemContent {
}
}
struct RcvFileTransfer: Decodable {
}
struct CIQuote: Decodable, ItemContent {
var chatDir: CIDirection?
var itemId: Int64?
@@ -763,14 +768,53 @@ struct CIQuote: Decodable, ItemContent {
}
}
static func getSample(_ itemId: Int64?, _ sentAt: Date, _ text: String, chatDir: CIDirection?) -> CIQuote {
CIQuote(chatDir: chatDir, itemId: itemId, sentAt: sentAt, content: .text(text))
static func getSample(_ itemId: Int64?, _ sentAt: Date, _ text: String, chatDir: CIDirection?, image: String? = nil) -> CIQuote {
let mc: MsgContent
if let image = image {
mc = .image(text: text, image: image)
} else {
mc = .text(text)
}
return CIQuote(chatDir: chatDir, itemId: itemId, sentAt: sentAt, content: mc)
}
}
struct CIFile: Decodable {
var fileId: Int64
var fileName: String
var fileSize: Int64
var filePath: String?
var fileStatus: CIFileStatus
static func getSample(_ fileId: Int64, _ fileName: String, _ fileSize: Int64, filePath: String?, fileStatus: CIFileStatus = .sndStored) -> CIFile {
CIFile(fileId: fileId, fileName: fileName, fileSize: fileSize, filePath: filePath, fileStatus: fileStatus)
}
var stored: Bool {
get {
switch self.fileStatus {
case .sndStored: return true
case .sndCancelled: return true
case .rcvComplete: return true
default: return false
}
}
}
}
enum CIFileStatus: String, Decodable {
case sndStored = "snd_stored"
case sndCancelled = "snd_cancelled"
case rcvInvitation = "rcv_invitation"
case rcvTransfer = "rcv_transfer"
case rcvComplete = "rcv_complete"
case rcvCancelled = "rcv_cancelled"
}
enum MsgContent {
case text(String)
case link(text: String, preview: LinkPreview)
case image(text: String, image: String)
// TODO include original JSON, possibly using https://github.com/zoul/generic-json-swift
case unknown(type: String, text: String)
@@ -779,6 +823,7 @@ enum MsgContent {
switch self {
case let .text(text): return text
case let .link(text, _): return text
case let .image(text, _): return text
case let .unknown(_, text): return text
}
}
@@ -790,6 +835,8 @@ enum MsgContent {
case let .text(text): return "text \(text)"
case let .link(text: text, preview: preview):
return "json {\"type\":\"link\",\"text\":\(encodeJSON(text)),\"preview\":\(encodeJSON(preview))}"
case let .image(text: text, image: image):
return "json {\"type\":\"image\",\"text\":\(encodeJSON(text)),\"image\":\(encodeJSON(image))}"
default: return ""
}
}
@@ -799,6 +846,7 @@ enum MsgContent {
case type
case text
case preview
case image
}
}
@@ -816,6 +864,10 @@ extension MsgContent: Decodable {
let text = try container.decode(String.self, forKey: CodingKeys.text)
let preview = try container.decode(LinkPreview.self, forKey: CodingKeys.preview)
self = .link(text: text, preview: preview)
case "image":
let text = try container.decode(String.self, forKey: CodingKeys.text)
let image = try container.decode(String.self, forKey: CodingKeys.image)
self = .image(text: text, image: image)
default:
let text = try? container.decode(String.self, forKey: CodingKeys.text)
self = .unknown(type: type, text: text ?? "unknown message format")
+57 -18
View File
@@ -19,10 +19,10 @@ enum ChatCommand {
case showActiveUser
case createActiveUser(profile: Profile)
case startChat
case setFilesFolder(filesFolder: String)
case apiGetChats
case apiGetChat(type: ChatType, id: Int64)
case apiSendMessage(type: ChatType, id: Int64, msg: MsgContent)
case apiSendMessageQuote(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent)
case apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent)
case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent)
case apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode)
case getUserSMPServers
@@ -38,6 +38,7 @@ enum ChatCommand {
case apiAcceptContact(contactReqId: Int64)
case apiRejectContact(contactReqId: Int64)
case apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64))
case receiveFile(fileId: Int64)
case string(String)
var cmdString: String {
@@ -46,10 +47,16 @@ enum ChatCommand {
case .showActiveUser: return "/u"
case let .createActiveUser(profile): return "/u \(profile.displayName) \(profile.fullName)"
case .startChat: return "/_start"
case let .setFilesFolder(filesFolder): return "/_files_folder \(filesFolder)"
case .apiGetChats: return "/_get chats"
case let .apiGetChat(type, id): return "/_get chat \(ref(type, id)) count=100"
case let .apiSendMessage(type, id, mc): return "/_send \(ref(type, id)) \(mc.cmdString)"
case let .apiSendMessageQuote(type, id, itemId, mc): return "/_send_quote \(ref(type, id)) \(itemId) \(mc.cmdString)"
case let .apiSendMessage(type, id, file, quotedItemId, mc):
switch (file, quotedItemId) {
case (nil, nil): return "/_send \(ref(type, id)) \(mc.cmdString)"
case let (.some(file), nil): return "/_send \(ref(type, id)) file \(file) \(mc.cmdString)"
case let (nil, .some(quotedItemId)): return "/_send \(ref(type, id)) quoted \(quotedItemId) \(mc.cmdString)"
case let (.some(file), .some(quotedItemId)): return "/_send \(ref(type, id)) file \(file) quoted \(quotedItemId) \(mc.cmdString)"
}
case let .apiUpdateChatItem(type, id, itemId, mc): return "/_update item \(ref(type, id)) \(itemId) \(mc.cmdString)"
case let .apiDeleteChatItem(type, id, itemId, mode): return "/_delete item \(ref(type, id)) \(itemId) \(mode.rawValue)"
case .getUserSMPServers: return "/smp_servers"
@@ -65,6 +72,7 @@ enum ChatCommand {
case let .apiAcceptContact(contactReqId): return "/_accept \(contactReqId)"
case let .apiRejectContact(contactReqId): return "/_reject \(contactReqId)"
case let .apiChatRead(type, id, itemRange: (from, to)): return "/_read chat \(ref(type, id)) from=\(from) to=\(to)"
case let .receiveFile(fileId): return "/freceive \(fileId)"
case let .string(str): return str
}
}
@@ -76,10 +84,10 @@ enum ChatCommand {
case .showActiveUser: return "showActiveUser"
case .createActiveUser: return "createActiveUser"
case .startChat: return "startChat"
case .setFilesFolder: return "setFilesFolder"
case .apiGetChats: return "apiGetChats"
case .apiGetChat: return "apiGetChat"
case .apiSendMessage: return "apiSendMessage"
case .apiSendMessageQuote: return "apiSendMessageQuote"
case .apiUpdateChatItem: return "apiUpdateChatItem"
case .apiDeleteChatItem: return "apiDeleteChatItem"
case .getUserSMPServers: return "getUserSMPServers"
@@ -95,6 +103,7 @@ enum ChatCommand {
case .apiAcceptContact: return "apiAcceptContact"
case .apiRejectContact: return "apiRejectContact"
case .apiChatRead: return "apiChatRead"
case .receiveFile: return "receiveFile"
case .string: return "console command"
}
}
@@ -103,7 +112,7 @@ enum ChatCommand {
func ref(_ type: ChatType, _ id: Int64) -> String {
"\(type.rawValue)\(id)"
}
func smpServersStr(smpServers: [String]) -> String {
smpServers.isEmpty ? "default" : smpServers.joined(separator: ",")
}
@@ -149,6 +158,8 @@ enum ChatResponse: Decodable, Error {
case chatItemStatusUpdated(chatItem: AChatItem)
case chatItemUpdated(chatItem: AChatItem)
case chatItemDeleted(deletedChatItem: AChatItem, toChatItem: AChatItem)
case rcvFileAccepted
case rcvFileComplete(chatItem: AChatItem)
case cmdOk
case chatCmdError(chatError: ChatError)
case chatError(chatError: ChatError)
@@ -191,13 +202,15 @@ enum ChatResponse: Decodable, Error {
case .chatItemStatusUpdated: return "chatItemStatusUpdated"
case .chatItemUpdated: return "chatItemUpdated"
case .chatItemDeleted: return "chatItemDeleted"
case .rcvFileAccepted: return "rcvFileAccepted"
case .rcvFileComplete: return "rcvFileComplete"
case .cmdOk: return "cmdOk"
case .chatCmdError: return "chatCmdError"
case .chatError: return "chatError"
}
}
}
var details: String {
get {
switch self {
@@ -236,6 +249,8 @@ enum ChatResponse: Decodable, Error {
case let .chatItemStatusUpdated(chatItem): return String(describing: chatItem)
case let .chatItemUpdated(chatItem): return String(describing: chatItem)
case let .chatItemDeleted(deletedChatItem, toChatItem): return "deletedChatItem:\n\(String(describing: deletedChatItem))\ntoChatItem:\n\(String(describing: toChatItem))"
case .rcvFileAccepted: return noDetails
case let .rcvFileComplete(chatItem): return String(describing: chatItem)
case .cmdOk: return noDetails
case let .chatCmdError(chatError): return String(describing: chatError)
case let .chatError(chatError): return String(describing: chatError)
@@ -376,6 +391,12 @@ func apiStartChat() throws {
throw r
}
func apiSetFilesFolder(filesFolder: String) throws {
let r = chatSendCmdSync(.setFilesFolder(filesFolder: filesFolder))
if case .cmdOk = r { return }
throw r
}
func apiGetChats() throws -> [Chat] {
let r = chatSendCmdSync(.apiGetChats)
if case let .apiChats(chats) = r { return chats.map { Chat.init($0) } }
@@ -388,14 +409,9 @@ func apiGetChat(type: ChatType, id: Int64) throws -> Chat {
throw r
}
func apiSendMessage(type: ChatType, id: Int64, quotedItemId: Int64?, msg: MsgContent) async throws -> ChatItem {
func apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent) async throws -> ChatItem {
let chatModel = ChatModel.shared
let cmd: ChatCommand
if let itemId = quotedItemId {
cmd = .apiSendMessageQuote(type: type, id: id, itemId: itemId, msg: msg)
} else {
cmd = .apiSendMessage(type: type, id: id, msg: msg)
}
let cmd: ChatCommand = .apiSendMessage(type: type, id: id, file: file, quotedItemId: quotedItemId, msg: msg)
let r: ChatResponse
if type == .direct {
var cItem: ChatItem!
@@ -513,8 +529,8 @@ func apiDeleteUserAddress() async throws {
throw r
}
func apiGetUserAddress() async throws -> String? {
let r = await chatSendCmd(.showMyAddress)
func apiGetUserAddress() throws -> String? {
let r = chatSendCmdSync(.showMyAddress)
switch r {
case let .userContactLink(connReq):
return connReq
@@ -542,6 +558,12 @@ func apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) async thr
throw r
}
func receiveFile(fileId: Int64) async throws {
let r = await chatSendCmd(.receiveFile(fileId: fileId))
if case .rcvFileAccepted = r { return }
throw r
}
func acceptContactRequest(_ contactRequest: UserContactRequest) async {
do {
let contact = try await apiAcceptContactRequest(contactReqId: contactRequest.apiId)
@@ -666,6 +688,17 @@ func processReceivedMsg(_ res: ChatResponse) {
let cInfo = aChatItem.chatInfo
let cItem = aChatItem.chatItem
chatModel.addChatItem(cInfo, cItem)
if let file = cItem.file,
file.fileSize <= 236700 {
// file.fileSize <= 394500 {
Task {
do {
try await receiveFile(fileId: file.fileId)
} catch {
logger.error("receiveFile error: \(error.localizedDescription)")
}
}
}
NtfManager.shared.notifyMessageReceived(cInfo, cItem)
case let .chatItemStatusUpdated(aChatItem):
let cInfo = aChatItem.chatInfo
@@ -699,6 +732,12 @@ func processReceivedMsg(_ res: ChatResponse) {
// currently only broadcast deletion of rcv message can be received, and only this case should happen
_ = chatModel.upsertChatItem(cInfo, cItem)
}
case let .rcvFileComplete(aChatItem):
let cInfo = aChatItem.chatInfo
let cItem = aChatItem.chatItem
if chatModel.upsertChatItem(cInfo, cItem) {
NtfManager.shared.notifyMessageReceived(cInfo, cItem)
}
default:
logger.debug("unsupported event: \(res.responseType)")
}
@@ -743,7 +782,7 @@ private func chatResponse(_ cjson: UnsafeMutablePointer<CChar>) -> ChatResponse
} catch {
logger.error("chatResponse jsonDecoder.decode error: \(error.localizedDescription)")
}
var type: String?
var json: String?
if let j = try? JSONSerialization.jsonObject(with: d) as? NSDictionary {
@@ -765,7 +804,7 @@ func prettyJSON(_ obj: NSDictionary) -> String? {
private func getChatCtrl() -> chat_ctrl {
if let controller = chatController { return controller }
let dataDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.path + "/mobile_v1"
let dataDir = getDocumentsDirectory().path + "/mobile_v1"
var cstr = dataDir.cString(using: .utf8)!
logger.debug("getChatCtrl: chat_init")
ChatModel.shared.terminalItems.append(.cmd(.now, .string("chat_init")))
@@ -10,17 +10,18 @@ import SwiftUI
struct CIMetaView: View {
var chatItem: ChatItem
var metaColor = Color.secondary
var body: some View {
HStack(alignment: .center, spacing: 4) {
if !chatItem.isDeletedContent() {
if chatItem.meta.itemEdited {
statusImage("pencil", .secondary, 9)
statusImage("pencil", metaColor, 9)
}
switch chatItem.meta.itemStatus {
case .sndSent:
statusImage("checkmark", .secondary)
statusImage("checkmark", metaColor)
case .sndErrorAuth:
statusImage("multiply", .red)
case .sndError:
@@ -33,7 +34,7 @@ struct CIMetaView: View {
chatItem.timestampText
.font(.caption)
.foregroundColor(.secondary)
.foregroundColor(metaColor)
}
}
File diff suppressed because one or more lines are too long
@@ -19,7 +19,7 @@ struct MsgContentView: View {
var edited = false
var body: some View {
let v = messageText(content, formattedText, sender)
let v = messageText(content.text, formattedText, sender)
if let mt = metaText {
return v + reserveSpaceForMeta(mt, edited)
} else {
@@ -35,8 +35,8 @@ struct MsgContentView: View {
}
}
func messageText(_ content: ItemContent, _ formattedText: [FormattedText]?, _ sender: String?, preview: Bool = false) -> Text {
let s = content.text
func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, preview: Bool = false) -> Text {
let s = text
var res: Text
if let ft = formattedText, ft.count > 0 {
res = formattText(ft[0], preview)
@@ -11,13 +11,14 @@ import SwiftUI
struct ChatItemView: View {
var chatItem: ChatItem
var showMember = false
var maxWidth: CGFloat = .infinity
var body: some View {
if chatItem.isMsgContent() {
if (chatItem.quotedItem == nil && isShortEmoji(chatItem.content.text)) {
if (chatItem.quotedItem == nil && chatItem.file == nil && isShortEmoji(chatItem.content.text)) {
EmojiItemView(chatItem: chatItem)
} else {
FramedItemView(chatItem: chatItem, showMember: showMember)
FramedItemView(chatItem: chatItem, showMember: showMember, maxWidth: maxWidth)
}
} else if chatItem.isDeletedContent() {
DeletedItemView(chatItem: chatItem, showMember: showMember)
+51 -11
View File
@@ -24,6 +24,9 @@ struct ChatView: View {
@State private var showChatInfo = false
@State private var showDeleteMessage = false
@State private var chosenImage: UIImage? = nil
@State private var imagePreview: String? = nil
var body: some View {
let cInfo = chat.chatInfo
@@ -90,7 +93,9 @@ struct ChatView: View {
sendMessage: sendMessage,
resetMessage: { message = "" },
inProgress: inProgress,
keyboardVisible: $keyboardVisible
keyboardVisible: $keyboardVisible,
chosenImage: $chosenImage,
imagePreview: $imagePreview
)
}
.navigationTitle(cInfo.chatViewName)
@@ -120,7 +125,7 @@ struct ChatView: View {
private func chatItemWithMenu(_ ci: ChatItem, _ maxWidth: CGFloat, showMember: Bool = false) -> some View {
let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading
return ChatItemView(chatItem: ci, showMember: showMember)
return ChatItemView(chatItem: ci, showMember: showMember, maxWidth: maxWidth)
.contextMenu {
if ci.isMsgContent() {
Button {
@@ -130,10 +135,18 @@ struct ChatView: View {
}
} label: { Label("Reply", systemImage: "arrowshape.turn.up.left") }
Button {
showShareSheet(items: [ci.content.text])
var shareItems: [Any] = [ci.content.text]
if case .image = ci.content.msgContent, let image = getStoredImage(ci.file) {
shareItems.append(image)
}
showShareSheet(items: shareItems)
} label: { Label("Share", systemImage: "square.and.arrow.up") }
Button {
UIPasteboard.general.string = ci.content.text
if case .image = ci.content.msgContent, let image = getStoredImage(ci.file) {
UIPasteboard.general.image = image
} else {
UIPasteboard.general.string = ci.content.text
}
} label: { Label("Copy", systemImage: "doc.on.doc") }
if ci.meta.editable {
Button {
@@ -156,12 +169,12 @@ struct ChatView: View {
Button("Delete for me", role: .destructive) {
deleteMessage(.cidmInternal)
}
// if let di = deletingItem {
// if di.meta.editable {
// Button("Delete for everyone",role: .destructive) { deleteMessage(.cidmBroadcast)
// }
// }
// }
if let di = deletingItem {
if di.meta.editable {
Button("Delete for everyone",role: .destructive) { deleteMessage(.cidmBroadcast)
}
}
}
}
.frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment)
.frame(minWidth: 0, maxWidth: .infinity, alignment: alignment)
@@ -221,7 +234,13 @@ struct ChatView: View {
}
} else {
let mc: MsgContent
if let preview = linkPreview {
var file: String? = nil
if let preview = imagePreview,
let uiImage = chosenImage,
let savedFile = saveImage(uiImage) {
mc = .image(text: text, image: preview)
file = savedFile
} else if let preview = linkPreview {
mc = .link(text: text, preview: preview)
} else {
mc = .text(text)
@@ -229,12 +248,15 @@ struct ChatView: View {
let chatItem = try await apiSendMessage(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
file: file,
quotedItemId: quotedItem?.meta.itemId,
msg: mc
)
DispatchQueue.main.async {
quotedItem = nil
linkPreview = nil
chosenImage = nil
imagePreview = nil
chatModel.addChatItem(chat.chatInfo, chatItem)
}
}
@@ -243,6 +265,24 @@ struct ChatView: View {
}
}
}
func saveImage(_ uiImage: UIImage) -> String? {
if let imageResized = resizeImageToDataSize(uiImage, maxDataSize: 160000),
let dataResized = Data(base64Encoded: dropImagePrefix(imageResized)),
let jpegData = UIImage(data: dataResized)?.jpegData(compressionQuality: 1) {
let millisecondsSince1970 = Int64((Date().timeIntervalSince1970 * 1000.0).rounded())
let fileToSave = "image_\(millisecondsSince1970).jpg"
let filePath = getAppFilesDirectory().appendingPathComponent(fileToSave)
do {
try jpegData.write(to: filePath)
return fileToSave
} catch {
logger.error("ChatView.saveImage error: \(error.localizedDescription)")
return nil
}
}
return nil
}
func deleteMessage(_ mode: CIDeleteMode) {
logger.debug("ChatView deleteMessage")
@@ -15,6 +15,24 @@ import SwiftUI
// case editing(editingItem: ChatItem)
//}
//enum ReferencedItem {
// case none
// case quoted(quotedItem: ChatItem)
// case editing(editingItem: ChatItem)
//}
//
//enum Preview {
// case none
// case link(linkPreview: LinkPreview)
// case image(image: UIImage)
//}
//
//struct ComposeState {
// var quotedItem: ChatItem? = nil
// var editingItem: ChatItem? = nil
// var linkPreview: LinkPreview? = nil
//}
struct ComposeView: View {
@Binding var message: String
@Binding var quotedItem: ChatItem?
@@ -26,15 +44,23 @@ struct ComposeView: View {
var inProgress: Bool = false
@FocusState.Binding var keyboardVisible: Bool
@State var editing: Bool = false
@State var sendEnabled: Bool = false
@State var linkUrl: URL? = nil
@State var prevLinkUrl: URL? = nil
@State var pendingLinkUrl: URL? = nil
@State var cancelledLinks: Set<String> = []
@State private var showChooseSource = false
@State private var showImagePicker = false
@State private var imageSource: ImageSource = .imageLibrary
@Binding var chosenImage: UIImage?
@Binding var imagePreview: String?
var body: some View {
VStack(spacing: 0) {
if let metadata = linkPreview {
if let metadata = imagePreview {
ComposeImageView(image: metadata, cancelImage: nil)
} else if let metadata = linkPreview {
ComposeLinkView(linkPreview: metadata, cancelPreview: cancelPreview)
}
if (quotedItem != nil) {
@@ -42,17 +68,33 @@ struct ComposeView: View {
} else if (editingItem != nil) {
ContextItemView(contextItem: $editingItem, editing: $editing, resetMessage: resetMessage)
}
SendMessageView(
sendMessage: { text in
sendMessage(text)
resetLinkPreview()
},
inProgress: inProgress,
message: $message,
keyboardVisible: $keyboardVisible,
editing: $editing
)
.background(.background)
HStack{
// Button {
// showChooseSource = true
// } label: {
// Image(systemName: "paperclip")
// .resizable()
// }
// .disabled(editingItem != nil)
// .frame(width: 25, height: 25)
// .padding(.vertical, 4)
// .padding(.leading, 12)
SendMessageView(
sendMessage: { text in
sendMessage(text)
resetLinkPreview()
},
inProgress: inProgress,
message: $message,
keyboardVisible: $keyboardVisible,
editing: $editing,
sendEnabled: $sendEnabled
)
.padding(.horizontal, 12)
// // use this padding when attach button is uncommented
// .padding(.trailing, 12)
.background(.background)
}
}
.onChange(of: message) { _ in
if message.count > 0 {
@@ -60,10 +102,41 @@ struct ComposeView: View {
} else {
resetLinkPreview()
}
sendEnabled = (imagePreview != nil || !message.isEmpty)
}
.onChange(of: editingItem == nil) { _ in
editing = (editingItem != nil)
}
.confirmationDialog("Attach", isPresented: $showChooseSource, titleVisibility: .visible) {
Button("Take picture") {
imageSource = .camera
showImagePicker = true
}
Button("Choose from library") {
imageSource = .imageLibrary
showImagePicker = true
}
}
.sheet(isPresented: $showImagePicker) {
switch imageSource {
case .imageLibrary:
LibraryImagePicker(image: $chosenImage) {
didSelectItem in showImagePicker = false
}
case .camera:
CameraImagePicker(image: $chosenImage)
}
}
.onChange(of: chosenImage) { image in
if let image = image {
imagePreview = resizeImageToDataSize(image, maxDataSize: 12500)
} else {
imagePreview = nil
}
}
.onChange(of: imagePreview) { _ in
sendEnabled = (imagePreview != nil || !message.isEmpty)
}
}
private func showLinkPreview(_ s: String) {
@@ -136,6 +209,8 @@ struct ComposeView_Previews: PreviewProvider {
@State var item: ChatItem? = ChatItem.getSample(1, .directSnd, .now, "hello")
@State var nilItem: ChatItem? = nil
@State var linkPreview: LinkPreview? = nil
@State var chosenImage: UIImage? = nil
@State var imagePreview: String? = nil
return Group {
ComposeView(
@@ -145,7 +220,9 @@ struct ComposeView_Previews: PreviewProvider {
linkPreview: $linkPreview,
sendMessage: { print ($0) },
resetMessage: {},
keyboardVisible: $keyboardVisible
keyboardVisible: $keyboardVisible,
chosenImage: $chosenImage,
imagePreview: $imagePreview
)
ComposeView(
message: $message,
@@ -154,7 +231,9 @@ struct ComposeView_Previews: PreviewProvider {
linkPreview: $linkPreview,
sendMessage: { print ($0) },
resetMessage: {},
keyboardVisible: $keyboardVisible
keyboardVisible: $keyboardVisible,
chosenImage: $chosenImage,
imagePreview: $imagePreview
)
}
}
@@ -15,6 +15,7 @@ struct SendMessageView: View {
@Namespace var namespace
@FocusState.Binding var keyboardVisible: Bool
@Binding var editing: Bool
@Binding var sendEnabled: Bool
@State private var teHeight: CGFloat = 42
@State private var teFont: Font = .body
var maxHeight: CGFloat = 360
@@ -52,7 +53,7 @@ struct SendMessageView: View {
.resizable()
.foregroundColor(.accentColor)
}
.disabled(message.isEmpty)
.disabled(!sendEnabled)
.frame(width: 29, height: 29)
.padding([.bottom, .trailing], 4)
}
@@ -62,7 +63,6 @@ struct SendMessageView: View {
.strokeBorder(.secondary, lineWidth: 0.3, antialiased: true)
.frame(height: teHeight)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
@@ -90,6 +90,7 @@ struct SendMessageView_Previews: PreviewProvider {
@FocusState var keyboardVisible: Bool
@State var editingOff: Bool = false
@State var editingOn: Bool = true
@State var sendEnabled: Bool = true
@State var item: ChatItem? = ChatItem.getSample(1, .directSnd, .now, "hello")
return Group {
@@ -100,7 +101,8 @@ struct SendMessageView_Previews: PreviewProvider {
sendMessage: { print ($0) },
message: $message,
keyboardVisible: $keyboardVisible,
editing: $editingOff
editing: $editingOff,
sendEnabled: $sendEnabled
)
}
VStack {
@@ -110,7 +112,8 @@ struct SendMessageView_Previews: PreviewProvider {
sendMessage: { print ($0) },
message: $message,
keyboardVisible: $keyboardVisible,
editing: $editingOn
editing: $editingOn,
sendEnabled: $sendEnabled
)
}
}
@@ -24,6 +24,7 @@ struct ChatHelp: View {
UIApplication.shared.open(simplexTeamURL)
}
}
.padding(.top, 2)
}
VStack(alignment: .leading, spacing: 10) {
@@ -51,7 +51,7 @@ struct ChatPreviewView: View {
if let cItem = cItem {
ZStack(alignment: .topTrailing) {
(itemStatusMark(cItem) + messageText(cItem.content, cItem.formattedText, cItem.memberDisplayName, preview: true))
(itemStatusMark(cItem) + messageText(cItem.text, cItem.formattedText, cItem.memberDisplayName, preview: true))
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading)
.padding(.leading, 8)
.padding(.trailing, 36)
@@ -0,0 +1,56 @@
//
// CIImageView.swift
// SimpleX
//
// Created by JRoberts on 12/04/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
struct CIImageView: View {
@Environment(\.colorScheme) var colorScheme
let image: String
let file: CIFile?
let maxWidth: CGFloat
@Binding var imgWidth: CGFloat?
@State var showFullScreenImage = false
var body: some View {
VStack(alignment: .center, spacing: 6) {
if let uiImage = getStoredImage(file) {
imageView(uiImage)
.fullScreenCover(isPresented: $showFullScreenImage) {
ZStack {
Color.black.edgesIgnoringSafeArea(.all)
Image(uiImage: uiImage)
.resizable()
.scaledToFit()
}
.onTapGesture { showFullScreenImage = false }
.gesture(
DragGesture(minimumDistance: 80).onChanged { gesture in
let t = gesture.translation
if t.height > 60 && t.height > abs(t.width) {
showFullScreenImage = false
}
}
)
}
.onTapGesture { showFullScreenImage = true }
} else if let data = Data(base64Encoded: dropImagePrefix(image)),
let uiImage = UIImage(data: data) {
imageView(uiImage)
}
}
}
private func imageView(_ img: UIImage) -> some View {
let w = img.size.width > img.size.height ? .infinity : maxWidth * 0.75
DispatchQueue.main.async { imgWidth = w }
return Image(uiImage: img)
.resizable()
.scaledToFit()
.frame(maxWidth: w)
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,37 @@
//
// ComposeImageView.swift
// SimpleX
//
// Created by JRoberts on 11/04/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
struct ComposeImageView: View {
@Environment(\.colorScheme) var colorScheme
let image: String
var cancelImage: (() -> Void)? = nil
var body: some View {
HStack(alignment: .center, spacing: 8) {
if let data = Data(base64Encoded: dropImagePrefix(image)),
let uiImage = UIImage(data: data) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: 80, maxHeight: 60)
}
if let cancelImage = cancelImage {
Button { cancelImage() } label: {
Image(systemName: "multiply")
}
}
}
.padding(.vertical, 1)
.padding(.trailing, 12)
.background(colorScheme == .light ? sentColorLight : sentColorDark)
.frame(maxWidth: .infinity)
.padding(.top, 8)
}
}
+7 -1
View File
@@ -18,6 +18,7 @@ struct TerminalView: View {
@State var message: String = ""
@FocusState private var keyboardVisible: Bool
@State var editing: Bool = false
@State var sendEnabled: Bool = false
var body: some View {
VStack {
@@ -67,12 +68,17 @@ struct TerminalView: View {
inProgress: inProgress,
message: $message,
keyboardVisible: $keyboardVisible,
editing: $editing
editing: $editing,
sendEnabled: $sendEnabled
)
.padding(.horizontal, 12)
}
}
.navigationViewStyle(.stack)
.navigationTitle("Chat console")
.onChange(of: message) { _ in
sendEnabled = !message.isEmpty
}
}
func scrollToBottom(_ proxy: ScrollViewProxy, animation: Animation = .default) {
@@ -18,18 +18,6 @@ struct SettingsButton: View {
}
.sheet(isPresented: $showSettings, content: {
SettingsView(showSettings: $showSettings)
.onAppear {
Task {
do {
let userAddress = try await apiGetUserAddress()
DispatchQueue.main.async {
chatModel.userAddress = userAddress
}
} catch {
logger.error("SettingsButton apiGetUserAddress error: \(error.localizedDescription)")
}
}
}
})
}
}
@@ -120,6 +120,11 @@
<target>All your contacts will remain connected</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Attach" xml:space="preserve">
<source>Attach</source>
<target>Attach</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cancel" xml:space="preserve">
<source>Cancel</source>
<target>Cancel</target>
@@ -171,13 +176,13 @@
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Connecting server…" xml:space="preserve">
<source>Connecting server…</source>
<target>Connecting server…</target>
<source>Connecting to server…</source>
<target>Connecting to server…</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Connecting server… (error: %@)" xml:space="preserve">
<source>Connecting server… (error: %@)</source>
<target>Connecting server… (error: %@)</target>
<source>Connecting to server… (error: %@)</source>
<target>Connecting to server… (error: %@)</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Connecting..." xml:space="preserve">
@@ -270,6 +275,11 @@
<target>Delete contact?</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Delete for everyone" xml:space="preserve">
<source>Delete for everyone</source>
<target>Delete for everyone</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Delete for me" xml:space="preserve">
<source>Delete for me</source>
<target>Delete for me</target>
@@ -734,21 +744,11 @@ SimpleX servers cannot see your profile.</target>
<target>italic</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="receiving files is not supported yet" xml:space="preserve">
<source>receiving files is not supported yet</source>
<target>receiving files is not supported yet</target>
<note>to be removed</note>
</trans-unit>
<trans-unit id="secret" xml:space="preserve">
<source>secret</source>
<target>secret</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="sending files is not supported yet" xml:space="preserve">
<source>sending files is not supported yet</source>
<target>sending files is not supported yet</target>
<note>to be removed</note>
</trans-unit>
<trans-unit id="strike" xml:space="preserve">
<source>strike</source>
<target>strike</target>
@@ -120,6 +120,11 @@
<target>Все контакты, которые соединились через этот адрес, сохранятся.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Attach" xml:space="preserve">
<source>Attach</source>
<target>Прикрепить</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cancel" xml:space="preserve">
<source>Cancel</source>
<target>Отменить</target>
@@ -171,12 +176,12 @@
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Connecting server…" xml:space="preserve">
<source>Connecting server…</source>
<source>Connecting to server…</source>
<target>Устанавливается соединение с сервером…</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Connecting server… (error: %@)" xml:space="preserve">
<source>Connecting server… (error: %@)</source>
<source>Connecting to server… (error: %@)</source>
<target>Устанавливается соединение с сервером… (ошибка: %@)</target>
<note>No comment provided by engineer.</note>
</trans-unit>
@@ -270,6 +275,11 @@
<target>Удалить контакт?</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Delete for everyone" xml:space="preserve">
<source>Delete for everyone</source>
<target>Удалить для всех</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Delete for me" xml:space="preserve">
<source>Delete for me</source>
<target>Удалить для меня</target>
@@ -337,7 +347,7 @@
</trans-unit>
<trans-unit id="How to" xml:space="preserve">
<source>How to</source>
<target>Информация</target>
<target>Инфо</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="How to use SimpleX Chat" xml:space="preserve">
@@ -528,7 +538,7 @@ to scan from the app</source>
</trans-unit>
<trans-unit id="Thank you for installing SimpleX Chat!" xml:space="preserve">
<source>Thank you for installing SimpleX Chat!</source>
<target>Спасибо, что Вы установили SimpleX Chat!</target>
<target>Спасибо, что установили SimpleX Chat!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The app can notify you when you receive messages or contact requests - please open settings to enable." xml:space="preserve">
@@ -548,7 +558,7 @@ to scan from the app</source>
</trans-unit>
<trans-unit id="To ask any questions and to receive updates:" xml:space="preserve">
<source>To ask any questions and to receive updates:</source>
<target>Задать вопросы и получать уведомления о новых версиях:</target>
<target>Чтобы задать вопросы и получать уведомления о новых версиях,</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="To connect via link" xml:space="preserve">
@@ -603,7 +613,7 @@ to scan from the app</source>
</trans-unit>
<trans-unit id="You are connected to the server used to receive messages from this contact." xml:space="preserve">
<source>You are connected to the server used to receive messages from this contact.</source>
<target>Установлено соединение с сервером, через который вы получается сообщения от этого контакта.</target>
<target>Установлено соединение с сервером, через который вы получаете сообщения от этого контакта.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="You can now send messages to %@" xml:space="preserve">
@@ -720,7 +730,7 @@ SimpleX серверы не могут получить доступ к ваше
</trans-unit>
<trans-unit id="connect to SimpleX Chat developers." xml:space="preserve">
<source>connect to SimpleX Chat developers.</source>
<target>соединиться с разработчиками.</target>
<target>соединитесь с разработчиками.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="deleted" xml:space="preserve">
@@ -733,21 +743,11 @@ SimpleX серверы не могут получить доступ к ваше
<target>курсив</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="receiving files is not supported yet" xml:space="preserve">
<source>receiving files is not supported yet</source>
<target>получение файлов не поддерживается</target>
<note>to be removed</note>
</trans-unit>
<trans-unit id="secret" xml:space="preserve">
<source>secret</source>
<target>секрет</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="sending files is not supported yet" xml:space="preserve">
<source>sending files is not supported yet</source>
<target>отправка файлов не поддерживается</target>
<note>to be removed</note>
</trans-unit>
<trans-unit id="strike" xml:space="preserve">
<source>strike</source>
<target>зачеркнуть</target>
+50 -28
View File
@@ -8,7 +8,7 @@
/* Begin PBXBuildFile section */
3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */; };
3CDBCF4827FF621E00354CDD /* ChatItemLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4727FF621E00354CDD /* ChatItemLinkView.swift */; };
3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4727FF621E00354CDD /* CILinkView.swift */; };
5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */; };
5C116CDC27AABE0400E66D01 /* ContactRequestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */; };
5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */; };
@@ -20,12 +20,12 @@
5C35CFCB27B2E91D00FB6C6D /* NtfManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */; };
5C3A88CE27DF50170060F1C2 /* DetermineWidth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88CD27DF50170060F1C2 /* DetermineWidth.swift */; };
5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88D027DF57800060F1C2 /* FramedItemView.swift */; };
5C411598280048E90054D6CB /* libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C411593280048E90054D6CB /* libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx.a */; };
5C41159A280048E90054D6CB /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C411594280048E90054D6CB /* libffi.a */; };
5C41159C280048E90054D6CB /* libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C411595280048E90054D6CB /* libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx-ghc8.10.7.a */; };
5C41159E280048E90054D6CB /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C411596280048E90054D6CB /* libgmpxx.a */; };
5C4115A0280048E90054D6CB /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C411597280048E90054D6CB /* libgmp.a */; };
5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; };
5C545C81280D7A7E007A6B96 /* libHSsimplex-chat-1.6.0-JmSjOVFru1I9XqltphBD8q.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C545C7C280D7A7E007A6B96 /* libHSsimplex-chat-1.6.0-JmSjOVFru1I9XqltphBD8q.a */; };
5C545C82280D7A7E007A6B96 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C545C7D280D7A7E007A6B96 /* libffi.a */; };
5C545C83280D7A7E007A6B96 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C545C7E280D7A7E007A6B96 /* libgmp.a */; };
5C545C84280D7A7E007A6B96 /* libHSsimplex-chat-1.6.0-JmSjOVFru1I9XqltphBD8q-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C545C7F280D7A7E007A6B96 /* libHSsimplex-chat-1.6.0-JmSjOVFru1I9XqltphBD8q-ghc8.10.7.a */; };
5C545C85280D7A7E007A6B96 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C545C80280D7A7E007A6B96 /* libgmpxx.a */; };
5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */; };
5C5F2B6D27EBC3FE006A9D5F /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5F2B6C27EBC3FE006A9D5F /* ImagePicker.swift */; };
5C5F2B7027EBC704006A9D5F /* ProfileImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5F2B6F27EBC704006A9D5F /* ProfileImage.swift */; };
@@ -65,8 +65,11 @@
5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCE227DE9246000BD591 /* ComposeView.swift */; };
5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */; };
640F50E327CF991C001E05C2 /* SMPServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640F50E227CF991C001E05C2 /* SMPServers.swift */; };
649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; };
649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; };
64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; };
64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; };
64DAE1512809D9F5000DA960 /* FileUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64DAE1502809D9F5000DA960 /* FileUtils.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -81,7 +84,7 @@
/* Begin PBXFileReference section */
3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeLinkView.swift; sourceTree = "<group>"; };
3CDBCF4727FF621E00354CDD /* ChatItemLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemLinkView.swift; sourceTree = "<group>"; };
3CDBCF4727FF621E00354CDD /* CILinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CILinkView.swift; sourceTree = "<group>"; };
5C063D2627A4564100AEC577 /* ChatPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatPreviewView.swift; sourceTree = "<group>"; };
5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactRequestView.swift; sourceTree = "<group>"; };
5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemView.swift; sourceTree = "<group>"; };
@@ -93,13 +96,13 @@
5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NtfManager.swift; sourceTree = "<group>"; };
5C3A88CD27DF50170060F1C2 /* DetermineWidth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetermineWidth.swift; sourceTree = "<group>"; };
5C3A88D027DF57800060F1C2 /* FramedItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FramedItemView.swift; sourceTree = "<group>"; };
5C411593280048E90054D6CB /* libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx.a"; sourceTree = "<group>"; };
5C411594280048E90054D6CB /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
5C411595280048E90054D6CB /* libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx-ghc8.10.7.a"; sourceTree = "<group>"; };
5C411596280048E90054D6CB /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
5C411597280048E90054D6CB /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX (iOS).entitlements"; sourceTree = "<group>"; };
5C5346A727B59A6A004DF848 /* ChatHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatHelp.swift; sourceTree = "<group>"; };
5C545C7C280D7A7E007A6B96 /* libHSsimplex-chat-1.6.0-JmSjOVFru1I9XqltphBD8q.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.6.0-JmSjOVFru1I9XqltphBD8q.a"; sourceTree = "<group>"; };
5C545C7D280D7A7E007A6B96 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
5C545C7E280D7A7E007A6B96 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5C545C7F280D7A7E007A6B96 /* libHSsimplex-chat-1.6.0-JmSjOVFru1I9XqltphBD8q-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.6.0-JmSjOVFru1I9XqltphBD8q-ghc8.10.7.a"; sourceTree = "<group>"; };
5C545C80280D7A7E007A6B96 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
5C577F7C27C83AA10006112D /* MarkdownHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownHelp.swift; sourceTree = "<group>"; };
5C5F2B6C27EBC3FE006A9D5F /* ImagePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePicker.swift; sourceTree = "<group>"; };
5C5F2B6F27EBC704006A9D5F /* ProfileImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileImage.swift; sourceTree = "<group>"; };
@@ -141,8 +144,12 @@
5CEACCE227DE9246000BD591 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = "<group>"; };
5CEACCEC27DEA495000BD591 /* MsgContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MsgContentView.swift; sourceTree = "<group>"; };
640F50E227CF991C001E05C2 /* SMPServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMPServers.swift; sourceTree = "<group>"; };
6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = "<group>"; };
649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = "<group>"; };
64AA1C6827EE10C800AC7277 /* ContextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextItemView.swift; sourceTree = "<group>"; };
64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = "<group>"; };
64DAE1502809D9F5000DA960 /* FileUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUtils.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -151,13 +158,13 @@
buildActionMask = 2147483647;
files = (
5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */,
5C411598280048E90054D6CB /* libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx.a in Frameworks */,
5C4115A0280048E90054D6CB /* libgmp.a in Frameworks */,
5C41159C280048E90054D6CB /* libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx-ghc8.10.7.a in Frameworks */,
5C764E83279C748B000C6508 /* libz.tbd in Frameworks */,
5C41159A280048E90054D6CB /* libffi.a in Frameworks */,
5C545C81280D7A7E007A6B96 /* libHSsimplex-chat-1.6.0-JmSjOVFru1I9XqltphBD8q.a in Frameworks */,
5C545C82280D7A7E007A6B96 /* libffi.a in Frameworks */,
5C545C85280D7A7E007A6B96 /* libgmpxx.a in Frameworks */,
5C545C84280D7A7E007A6B96 /* libHSsimplex-chat-1.6.0-JmSjOVFru1I9XqltphBD8q-ghc8.10.7.a in Frameworks */,
5C545C83280D7A7E007A6B96 /* libgmp.a in Frameworks */,
5C764E82279C748B000C6508 /* libiconv.tbd in Frameworks */,
5C41159E280048E90054D6CB /* libgmpxx.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -202,11 +209,11 @@
5C764E5C279C70B7000C6508 /* Libraries */ = {
isa = PBXGroup;
children = (
5C411594280048E90054D6CB /* libffi.a */,
5C411597280048E90054D6CB /* libgmp.a */,
5C411596280048E90054D6CB /* libgmpxx.a */,
5C411595280048E90054D6CB /* libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx-ghc8.10.7.a */,
5C411593280048E90054D6CB /* libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx.a */,
5C545C7D280D7A7E007A6B96 /* libffi.a */,
5C545C7E280D7A7E007A6B96 /* libgmp.a */,
5C545C80280D7A7E007A6B96 /* libgmpxx.a */,
5C545C7F280D7A7E007A6B96 /* libHSsimplex-chat-1.6.0-JmSjOVFru1I9XqltphBD8q-ghc8.10.7.a */,
5C545C7C280D7A7E007A6B96 /* libHSsimplex-chat-1.6.0-JmSjOVFru1I9XqltphBD8q.a */,
);
path = Libraries;
sourceTree = "<group>";
@@ -242,7 +249,9 @@
5C5F2B6C27EBC3FE006A9D5F /* ImagePicker.swift */,
5C5F2B6F27EBC704006A9D5F /* ProfileImage.swift */,
3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */,
3CDBCF4727FF621E00354CDD /* ChatItemLinkView.swift */,
3CDBCF4727FF621E00354CDD /* CILinkView.swift */,
649BCD9F280460FD00C3A862 /* ComposeImageView.swift */,
649BCDA12805D6EF00C3A862 /* CIImageView.swift */,
);
path = Helpers;
sourceTree = "<group>";
@@ -266,6 +275,7 @@
children = (
5CA059C3279559F40002BEB4 /* SimpleXApp.swift */,
5CA059C4279559F40002BEB4 /* ContentView.swift */,
64DAE1502809D9F5000DA960 /* FileUtils.swift */,
5C764E87279CBC8E000C6508 /* Model */,
5C2E260D27A30E2400F70299 /* Views */,
5CA059C5279559F40002BEB4 /* Assets.xcassets */,
@@ -469,7 +479,7 @@
5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */,
5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */,
3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */,
3CDBCF4827FF621E00354CDD /* ChatItemLinkView.swift in Sources */,
3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */,
5C764E80279C7276000C6508 /* dummy.m in Sources */,
5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */,
5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */,
@@ -483,6 +493,7 @@
5CC1C99227A6C7F5000D9FF6 /* QRCode.swift in Sources */,
5C116CDC27AABE0400E66D01 /* ContactRequestView.swift in Sources */,
5CB9250D27A9432000ACCCDD /* ChatListNavLink.swift in Sources */,
649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */,
5CA059ED279559F40002BEB4 /* ContentView.swift in Sources */,
5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */,
5C3A88CE27DF50170060F1C2 /* DetermineWidth.swift in Sources */,
@@ -497,8 +508,10 @@
5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */,
5CA059EB279559F40002BEB4 /* SimpleXApp.swift in Sources */,
5CCD403727A5F9A200368C90 /* ConnectContactView.swift in Sources */,
649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */,
5CCD403A27A5F9BE00368C90 /* CreateGroupView.swift in Sources */,
5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */,
64DAE1512809D9F5000DA960 /* FileUtils.swift in Sources */,
5C764E89279CBCB3000C6508 /* ChatModel.swift in Sources */,
5C971E1D27AEBEF600C8A3CE /* ChatInfoView.swift in Sources */,
5CC1C99527A6CF7F000D9FF6 /* ShareSheet.swift in Sources */,
@@ -536,6 +549,7 @@
isa = PBXVariantGroup;
children = (
5CC2C0FB2809BF11000C35E3 /* ru */,
6493D667280ED77F007A76FB /* en */,
);
name = Localizable.strings;
sourceTree = "<group>";
@@ -672,7 +686,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 35;
CURRENT_PROJECT_VERSION = 39;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@@ -690,9 +704,13 @@
"$(inherited)",
"@executable_path/Frameworks",
);
LIBRARY_SEARCH_PATHS = (
"$(inherited)",
"$(PROJECT_DIR)/Libraries",
);
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios";
"LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim";
MARKETING_VERSION = 1.5;
MARKETING_VERSION = 1.6;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos;
@@ -712,7 +730,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 35;
CURRENT_PROJECT_VERSION = 39;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@@ -730,9 +748,13 @@
"$(inherited)",
"@executable_path/Frameworks",
);
LIBRARY_SEARCH_PATHS = (
"$(inherited)",
"$(PROJECT_DIR)/Libraries",
);
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios";
"LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim";
MARKETING_VERSION = 1.5;
MARKETING_VERSION = 1.6;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos;
+6
View File
@@ -0,0 +1,6 @@
/* No comment provided by engineer. */
"Connecting server…" = "Connecting to server…";
/* No comment provided by engineer. */
"Connecting server… (error: %@)" = "Connecting to server… (error: %@)";
+11 -11
View File
@@ -88,6 +88,9 @@
/* No comment provided by engineer. */
"All your contacts will remain connected" = "Все контакты, которые соединились через этот адрес, сохранятся.";
/* No comment provided by engineer. */
"Attach" = "Прикрепить";
/* No comment provided by engineer. */
"bold" = "жирный";
@@ -119,7 +122,7 @@
"Connect" = "Соединиться";
/* No comment provided by engineer. */
"connect to SimpleX Chat developers." = "соединиться с разработчиками.";
"connect to SimpleX Chat developers." = "соединитесь с разработчиками.";
/* No comment provided by engineer. */
"Connect via contact link?" = "Соединиться через ссылку-контакт?";
@@ -187,6 +190,9 @@
/* No comment provided by engineer. */
"Delete contact?" = "Удалить контакт?";
/* No comment provided by engineer. */
"Delete for everyone" = "Удалить для всех";
/* No comment provided by engineer. */
"Delete for me" = "Удалить для меня";
@@ -230,7 +236,7 @@
"Help" = "Помощь";
/* No comment provided by engineer. */
"How to" = "Информация";
"How to" = "Инфо";
/* No comment provided by engineer. */
"How to use markdown" = "Как форматировать";
@@ -292,9 +298,6 @@
/* No comment provided by engineer. */
"Read" = "Прочитано";
/* to be removed */
"receiving files is not supported yet" = "получение файлов не поддерживается";
/* No comment provided by engineer. */
"Reject" = "Отклонить";
@@ -322,9 +325,6 @@
/* No comment provided by engineer. */
"secret" = "секрет";
/* to be removed */
"sending files is not supported yet" = "отправка файлов не поддерживается";
/* No comment provided by engineer. */
"Server connected" = "Установлено соединение с сервером";
@@ -359,7 +359,7 @@
"Tap button " = "Нажмите кнопку";
/* No comment provided by engineer. */
"Thank you for installing SimpleX Chat!" = "Спасибо, что Вы установили SimpleX Chat!";
"Thank you for installing SimpleX Chat!" = "Спасибо, что установили SimpleX Chat!";
/* No comment provided by engineer. */
"The app can notify you when you receive messages or contact requests - please open settings to enable." = "Приложение может посылать вам уведомления о сообщениях и запросах на соединение - уведомления можно включить в Настройках.";
@@ -371,7 +371,7 @@
"The sender will NOT be notified" = "Отправитель не будет уведомлён";
/* No comment provided by engineer. */
"To ask any questions and to receive updates:" = "Задать вопросы и получать уведомления о новых версиях:";
"To ask any questions and to receive updates:" = "Чтобы задать вопросы и получать уведомления о новых версиях,";
/* No comment provided by engineer. */
"To connect via link" = "Соединиться через ссылку";
@@ -410,7 +410,7 @@
"You are already connected to %@ via this link." = "Вы уже соединены с %@ через эту ссылку.";
/* No comment provided by engineer. */
"You are connected to the server used to receive messages from this contact." = "Установлено соединение с сервером, через который вы получается сообщения от этого контакта.";
"You are connected to the server used to receive messages from this contact." = "Установлено соединение с сервером, через который вы получаете сообщения от этого контакта.";
/* notification body */
"You can now send messages to %@" = "Вы теперь можете отправлять сообщения %@";
+12 -14
View File
@@ -200,19 +200,15 @@ viewChatItem chat ChatItem {chatDir, meta, content, quotedItem, file} = case cha
quote = maybe [] (groupQuote g) quotedItem
_ -> []
where
sndMsg to quote mc = case (msgContentText mc, file) of
withSndFile = withFile viewSentFileInvitation
withRcvFile = withFile viewReceivedFileInvitation
withFile view dir l = maybe l (\f -> l <> view dir f meta) file
sndMsg = msg viewSentMessage
rcvMsg = msg viewReceivedMessage
msg view dir quote mc = case (msgContentText mc, file) of
("", Just _) -> []
_ -> viewSentMessage to quote mc meta
withSndFile to l = case file of
-- TODO pass CIFile
Just CIFile {fileId, filePath = Just fPath} -> l <> viewSentFileInvitation to fileId fPath meta
_ -> l
rcvMsg from quote mc = case (msgContentText mc, file) of
("", Just _) -> []
_ -> viewReceivedMessage from quote mc meta
withRcvFile from l = case file of
Just f -> l <> viewReceivedFileInvitation from f meta
_ -> l
-- (_, Just _) -> prependFirst " " $ ttyMsgContent mc
_ -> view dir quote mc meta
viewItemUpdate :: MsgDirectionI d => ChatInfo c -> ChatItem c d -> [StyledString]
viewItemUpdate chat ChatItem {chatDir, meta, content, quotedItem} = case chat of
@@ -476,8 +472,10 @@ viewSentMessage to quote mc = sentWithTime_ (prependFirst to $ quote <> prependF
viewSentBroadcast :: MsgContent -> Int -> ZonedTime -> [StyledString]
viewSentBroadcast mc n ts = prependFirst (highlight' "/feed" <> " (" <> sShow n <> ") " <> ttyMsgTime ts <> " ") (ttyMsgContent mc)
viewSentFileInvitation :: StyledString -> FileTransferId -> FilePath -> CIMeta d -> [StyledString]
viewSentFileInvitation to fId fPath = sentWithTime_ $ ttySentFile to fId fPath
viewSentFileInvitation :: StyledString -> CIFile d -> CIMeta d -> [StyledString]
viewSentFileInvitation to CIFile {fileId, filePath} = case filePath of
Just fPath -> sentWithTime_ $ ttySentFile to fileId fPath
_ -> const []
sentWithTime_ :: [StyledString] -> CIMeta d -> [StyledString]
sentWithTime_ styledMsg CIMeta {localItemTs} =