From d69e222b7b27351ad5f5f1d6355507c6b642996b Mon Sep 17 00:00:00 2001 From: Diogo Date: Thu, 19 Sep 2024 15:39:14 +0100 Subject: [PATCH] android, desktop: bulk forward (#4868) --------- Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Co-authored-by: Evgeny Poberezkin --- .../chat/simplex/common/model/ChatModel.kt | 8 + .../chat/simplex/common/model/SimpleXAPI.kt | 202 +++++++++++++----- .../simplex/common/views/chat/ChatView.kt | 166 +++++++++++++- .../simplex/common/views/chat/ComposeView.kt | 70 +++--- .../common/views/chat/ContextItemView.kt | 66 +++--- .../views/chat/SelectableChatItemToolbars.kt | 29 ++- .../common/views/chatlist/ShareListView.kt | 16 +- .../simplex/common/views/helpers/Enums.kt | 5 +- .../commonMain/resources/MR/base/strings.xml | 16 ++ 9 files changed, 450 insertions(+), 128 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index ac3397ce97..4fcda25855 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -1404,6 +1404,14 @@ class Group ( var members: List ) +@Serializable +sealed class ForwardConfirmation { + @Serializable @SerialName("filesNotAccepted") data class FilesNotAccepted(val fileIds: List) : ForwardConfirmation() + @Serializable @SerialName("filesInProgress") data class FilesInProgress(val filesCount: Int) : ForwardConfirmation() + @Serializable @SerialName("filesMissing") data class FilesMissing(val filesCount: Int) : ForwardConfirmation() + @Serializable @SerialName("filesFailed") data class FilesFailed(val filesCount: Int) : ForwardConfirmation() +} + @Serializable data class GroupInfo ( val groupId: Long, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 906ca826ca..7bf97a6e37 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -1,18 +1,19 @@ package chat.simplex.common.model import SectionItemView -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text +import androidx.compose.foundation.layout.* +import androidx.compose.material.* import chat.simplex.common.views.helpers.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp import chat.simplex.common.model.ChatController.getNetCfg import chat.simplex.common.model.ChatController.setNetCfg import chat.simplex.common.model.ChatModel.changingActiveUserMutex @@ -905,7 +906,15 @@ object ChatController { return processSendMessageCmd(rh, cmd)?.map { it.chatItem } } - + suspend fun apiPlanForwardChatItems(rh: Long?, fromChatType: ChatType, fromChatId: Long, chatItemIds: List): CR.ForwardPlan? { + return when (val r = sendCmd(rh, CC.ApiPlanForwardChatItems(fromChatType, fromChatId, chatItemIds))) { + is CR.ForwardPlan -> r + else -> { + apiErrorAlert("apiPlanForwardChatItems", generalGetString(MR.strings.error_forwarding_messages), r) + null + } + } + } suspend fun apiUpdateChatItem(rh: Long?, type: ChatType, id: Long, itemId: Long, mc: MsgContent, live: Boolean = false): AChatItem? { val r = sendCmd(rh, CC.ApiUpdateChatItem(type, id, itemId, mc, live)) @@ -1541,50 +1550,132 @@ object ChatController { } } - suspend fun apiReceiveFile(rh: Long?, fileId: Long, userApprovedRelays: Boolean, encrypted: Boolean, inline: Boolean? = null, auto: Boolean = false): AChatItem? { - // -1 here is to override default behavior of providing current remote host id because file can be asked by local device while remote is connected - val r = sendCmd(rh, CC.ReceiveFile(fileId, userApprovedRelays = userApprovedRelays, encrypt = encrypted, inline = inline)) - return when (r) { - is CR.RcvFileAccepted -> r.chatItem - is CR.RcvFileAcceptedSndCancelled -> { - Log.d(TAG, "apiReceiveFile error: sender cancelled file transfer") - if (!auto) { - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.cannot_receive_file), - generalGetString(MR.strings.sender_cancelled_file_transfer) - ) - } - null - } + suspend fun receiveFiles(rhId: Long?, user: UserLike, fileIds: List, userApprovedRelays: Boolean = false, auto: Boolean = false) { + val fileIdsToApprove = mutableListOf() + val srvsToApprove = mutableSetOf() + val otherFileErrs = mutableListOf() - else -> { - if (!(networkErrorAlert(r))) { - val maybeChatError = chatError(r) - if (maybeChatError is ChatErrorType.FileCancelled || maybeChatError is ChatErrorType.FileAlreadyReceiving) { - Log.d(TAG, "apiReceiveFile ignoring FileCancelled or FileAlreadyReceiving error") - } else if (maybeChatError is ChatErrorType.FileNotApproved) { - Log.d(TAG, "apiReceiveFile FileNotApproved error") - if (!auto) { - val srvs = maybeChatError.unknownServers.map{ serverHostname(it) } - AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.file_not_approved_title), - text = generalGetString(MR.strings.file_not_approved_descr).format(srvs.sorted().joinToString(separator = ", ")), - confirmText = generalGetString(MR.strings.download_file), - onConfirm = { - val user = chatModel.currentUser.value - if (user != null) { - withBGApi { chatModel.controller.receiveFile(rh, user, fileId, userApprovedRelays = true) } - } - }, - ) - } - } else if (!auto) { - apiErrorAlert("apiReceiveFile", generalGetString(MR.strings.error_receiving_file), r) - } + for (fileId in fileIds) { + val r = sendCmd( + rhId, CC.ReceiveFile( + fileId, + userApprovedRelays = userApprovedRelays || !appPrefs.privacyAskToApproveRelays.get(), + encrypt = appPrefs.privacyEncryptLocalFiles.get(), + inline = null + ) + ) + if (r is CR.RcvFileAccepted) { + chatItemSimpleUpdate(rhId, user, r.chatItem) + } else { + val maybeChatError = chatError(r) + if (maybeChatError is ChatErrorType.FileNotApproved) { + fileIdsToApprove.add(maybeChatError.fileId) + srvsToApprove.addAll(maybeChatError.unknownServers.map { serverHostname(it) }) + } else { + otherFileErrs.add(r) } - null } } + + if (!auto) { + // If there are not approved files, alert is shown the same way both in case of singular and plural files reception + if (fileIdsToApprove.isNotEmpty()) { + showFilesToApproveAlert( + srvsToApprove = srvsToApprove, + otherFileErrs = otherFileErrs, + approveFiles = { + withBGApi { + receiveFiles( + rhId = rhId, + user = user, + fileIds = fileIdsToApprove, + userApprovedRelays = true + ) + } + } + ) + } else if (otherFileErrs.size == 1) { // If there is a single other error, we differentiate on it + when (val errCR = otherFileErrs.first()) { + is CR.RcvFileAcceptedSndCancelled -> { + Log.d(TAG, "receiveFiles error: sender cancelled file transfer") + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.cannot_receive_file), + generalGetString(MR.strings.sender_cancelled_file_transfer) + ) + } + else -> { + val maybeChatError = chatError(errCR) + if (maybeChatError is ChatErrorType.FileCancelled || maybeChatError is ChatErrorType.FileAlreadyReceiving) { + Log.d(TAG, "receiveFiles ignoring FileCancelled or FileAlreadyReceiving error") + } else { + apiErrorAlert("receiveFiles", generalGetString(MR.strings.error_receiving_file), errCR) + } + } + } + } else if (otherFileErrs.size > 1) { // If there are multiple other errors, we show general alert + val errsStr = otherFileErrs.map { json.encodeToString(it) }.joinToString(separator = "\n") + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.error_receiving_file), + text = String.format(generalGetString(MR.strings.n_file_errors), otherFileErrs.size, errsStr), + shareText = true + ) + } + } + } + + private fun showFilesToApproveAlert( + srvsToApprove: Set, + otherFileErrs: List, + approveFiles: (() -> Unit) + ) { + val srvsToApproveStr = srvsToApprove.sorted().joinToString(separator = ", ") + val alertText = + generalGetString(MR.strings.file_not_approved_descr).format(srvsToApproveStr) + + (if (otherFileErrs.isNotEmpty()) "\n" + generalGetString(MR.strings.n_other_file_errors).format(otherFileErrs.size) else "") + + AlertManager.shared.showAlertDialogButtonsColumn(generalGetString(MR.strings.file_not_approved_title), alertText, belowTextContent = { + if (otherFileErrs.isNotEmpty()) { + val clipboard = LocalClipboardManager.current + SimpleButtonFrame(click = { + clipboard.setText(AnnotatedString(otherFileErrs.map { json.encodeToString(it) }.joinToString(separator = "\n"))) + }) { + Icon( + painterResource(MR.images.ic_content_copy), + contentDescription = null, + tint = MaterialTheme.colors.primary, + modifier = Modifier.padding(end = 8.dp) + ) + Text(generalGetString(MR.strings.copy_error), color = MaterialTheme.colors.primary) + } + } + }) { + Row( + Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING), + horizontalArrangement = Arrangement.SpaceBetween + ) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + // Wait before focusing to prevent auto-confirming if a user used Enter key on hardware keyboard + delay(200) + focusRequester.requestFocus() + } + TextButton(onClick = AlertManager.shared::hideAlert) { Text(generalGetString(MR.strings.cancel_verb)) } + TextButton(onClick = { + approveFiles.invoke() + AlertManager.shared.hideAlert() + }, Modifier.focusRequester(focusRequester)) { Text(generalGetString(MR.strings.download_file)) } + } + } + } + + suspend fun receiveFile(rhId: Long?, user: UserLike, fileId: Long, userApprovedRelays: Boolean = false, auto: Boolean = false) { + receiveFiles( + rhId = rhId, + user = user, + fileIds = listOf(fileId), + userApprovedRelays = userApprovedRelays, + auto = auto + ) } suspend fun cancelFile(rh: Long?, user: User, fileId: Long) { @@ -2689,19 +2780,6 @@ object ChatController { } } - suspend fun receiveFile(rhId: Long?, user: UserLike, fileId: Long, userApprovedRelays: Boolean = false, auto: Boolean = false) { - val chatItem = apiReceiveFile( - rhId, - fileId, - userApprovedRelays = userApprovedRelays || !appPrefs.privacyAskToApproveRelays.get(), - encrypted = appPrefs.privacyEncryptLocalFiles.get(), - auto = auto - ) - if (chatItem != null) { - chatItemSimpleUpdate(rhId, user, chatItem) - } - } - suspend fun leaveGroup(rh: Long?, groupId: Long) { val groupInfo = apiLeaveGroup(rh, groupId) if (groupInfo != null) { @@ -2914,6 +2992,7 @@ sealed class CC { class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemIds: List, val mode: CIDeleteMode): CC() class ApiDeleteMemberChatItem(val groupId: Long, val itemIds: List): CC() class ApiChatItemReaction(val type: ChatType, val id: Long, val itemId: Long, val add: Boolean, val reaction: MsgReaction): CC() + class ApiPlanForwardChatItems(val fromChatType: ChatType, val fromChatId: Long, val chatItemIds: List): CC() class ApiForwardChatItems(val toChatType: ChatType, val toChatId: Long, val fromChatType: ChatType, val fromChatId: Long, val itemIds: List, val ttl: Int?): CC() class ApiNewGroup(val userId: Long, val incognito: Boolean, val groupProfile: GroupProfile): CC() class ApiAddMember(val groupId: Long, val contactId: Long, val memberRole: GroupMemberRole): CC() @@ -3072,6 +3151,9 @@ sealed class CC { val ttlStr = if (ttl != null) "$ttl" else "default" "/_forward ${chatRef(toChatType, toChatId)} ${chatRef(fromChatType, fromChatId)} ${itemIds.joinToString(",")} ttl=${ttlStr}" } + is ApiPlanForwardChatItems -> { + "/_forward plan ${chatRef(fromChatType, fromChatId)} ${chatItemIds.joinToString(",")}" + } is ApiNewGroup -> "/_group $userId incognito=${onOff(incognito)} ${json.encodeToString(groupProfile)}" is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}" is ApiJoinGroup -> "/_join #$groupId" @@ -3216,6 +3298,7 @@ sealed class CC { is ApiDeleteMemberChatItem -> "apiDeleteMemberChatItem" is ApiChatItemReaction -> "apiChatItemReaction" is ApiForwardChatItems -> "apiForwardChatItems" + is ApiPlanForwardChatItems -> "apiPlanForwardChatItems" is ApiNewGroup -> "apiNewGroup" is ApiAddMember -> "apiAddMember" is ApiJoinGroup -> "apiJoinGroup" @@ -4878,6 +4961,7 @@ sealed class CR { @Serializable @SerialName("chatItemNotChanged") class ChatItemNotChanged(val user: UserRef, val chatItem: AChatItem): CR() @Serializable @SerialName("chatItemReaction") class ChatItemReaction(val user: UserRef, val added: Boolean, val reaction: ACIReaction): CR() @Serializable @SerialName("chatItemsDeleted") class ChatItemsDeleted(val user: UserRef, val chatItemDeletions: List, val byUser: Boolean): CR() + @Serializable @SerialName("forwardPlan") class ForwardPlan(val user: UserRef, val itemsCount: Int, val chatItemIds: List, val forwardConfirmation: ForwardConfirmation? = null): CR() // group events @Serializable @SerialName("groupCreated") class GroupCreated(val user: UserRef, val groupInfo: GroupInfo): CR() @Serializable @SerialName("sentGroupInvitation") class SentGroupInvitation(val user: UserRef, val groupInfo: GroupInfo, val contact: Contact, val member: GroupMember): CR() @@ -5055,6 +5139,7 @@ sealed class CR { is ChatItemNotChanged -> "chatItemNotChanged" is ChatItemReaction -> "chatItemReaction" is ChatItemsDeleted -> "chatItemsDeleted" + is ForwardPlan -> "forwardPlan" is GroupCreated -> "groupCreated" is SentGroupInvitation -> "sentGroupInvitation" is UserAcceptedGroupSent -> "userAcceptedGroupSent" @@ -5224,6 +5309,7 @@ sealed class CR { is ChatItemNotChanged -> withUser(user, json.encodeToString(chatItem)) is ChatItemReaction -> withUser(user, "added: $added\n${json.encodeToString(reaction)}") is ChatItemsDeleted -> withUser(user, "${chatItemDeletions.map { (deletedChatItem, toChatItem) -> "deletedChatItem: ${json.encodeToString(deletedChatItem)}\ntoChatItem: ${json.encodeToString(toChatItem)}" }} \nbyUser: $byUser") + is ForwardPlan -> withUser(user, "itemsCount: $itemsCount\nchatItemIds: ${json.encodeToString(chatItemIds)}\nforwardConfirmation: ${json.encodeToString(forwardConfirmation)}") is GroupCreated -> withUser(user, json.encodeToString(groupInfo)) is SentGroupInvitation -> withUser(user, "groupInfo: $groupInfo\ncontact: $contact\nmember: $member") is UserAcceptedGroupSent -> json.encodeToString(groupInfo) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 2bef0bdca7..835f884f98 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.* +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.* import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs @@ -172,7 +173,30 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - }) } } - } + }, + forwardItems = { + val itemIds = selectedChatItems.value + + if (itemIds != null) { + withBGApi { + val chatItemIds = itemIds.toList() + val forwardPlan = controller.apiPlanForwardChatItems( + rh = chatRh, + fromChatType = chatInfo.chatType, + fromChatId = chatInfo.apiId, + chatItemIds = chatItemIds + ) + + if (forwardPlan != null) { + if (forwardPlan.chatItemIds.count() < chatItemIds.count() || forwardPlan.forwardConfirmation != null) { + handleForwardConfirmation(chatRh, forwardPlan, chatInfo) + } else { + forwardContent(forwardPlan.chatItemIds, chatInfo) + } + } + } + } + }, ) } }, @@ -347,9 +371,9 @@ fun ChatView(staleChatId: State, onComposed: suspend (chatId: String) - openDirectChat(chatRh, contactId, chatModel) } }, - forwardItem = { cItem, cInfo -> + forwardItem = { cInfo, cItem -> chatModel.chatId.value = null - chatModel.sharedContent.value = SharedContent.Forward(cInfo, cItem) + chatModel.sharedContent.value = SharedContent.Forward(listOf(cItem), cInfo) }, updateContactStats = { contact -> withBGApi { @@ -1416,6 +1440,65 @@ private fun TopEndFloatingButton( } } +@Composable +private fun DownloadFilesButton( + forwardConfirmation: ForwardConfirmation.FilesNotAccepted, + rhId: Long?, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = ButtonDefaults.TextButtonContentPadding +) { + val user = chatModel.currentUser.value + + if (user != null) { + TextButton( + contentPadding = contentPadding, + modifier = modifier, + onClick = { + AlertManager.shared.hideAlert() + + withBGApi { + controller.receiveFiles( + rhId = rhId, + fileIds = forwardConfirmation.fileIds, + user = user + ) + } + } + ) { + Text(stringResource(MR.strings.forward_files_not_accepted_receive_files), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } +} + +@Composable +private fun ForwardButton( + forwardPlan: CR.ForwardPlan, + chatInfo: ChatInfo, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = ButtonDefaults.TextButtonContentPadding +) { + TextButton( + onClick = { + forwardContent(forwardPlan.chatItemIds, chatInfo) + AlertManager.shared.hideAlert() + }, + modifier = modifier, + contentPadding = contentPadding + ) { + Text(stringResource(MR.strings.forward_chat_item), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } +} + +@Composable +private fun ButtonRow(horizontalArrangement: Arrangement.Horizontal, content: @Composable() (RowScope.() -> Unit)) { + Row( + Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING), + horizontalArrangement = horizontalArrangement + ) { + content() + } +} + val chatViewScrollState = MutableStateFlow(false) fun addGroupMembers(groupInfo: GroupInfo, rhId: Long?, view: Any? = null, close: (() -> Unit)? = null) { @@ -1712,6 +1795,83 @@ private fun ViewConfiguration.bigTouchSlop(slop: Float = 50f) = object: ViewConf override val touchSlop: Float get() = slop } +private fun forwardContent(chatItemsIds: List, chatInfo: ChatInfo) { + chatModel.chatId.value = null + chatModel.sharedContent.value = SharedContent.Forward( + chatModel.chatItems.value.filter { chatItemsIds.contains(it.id) }, + chatInfo + ) +} + +private fun forwardConfirmationAlertDescription(forwardConfirmation: ForwardConfirmation): String { + return when (forwardConfirmation) { + is ForwardConfirmation.FilesNotAccepted -> String.format(generalGetString(MR.strings.forward_files_not_accepted_desc), forwardConfirmation.fileIds.count()) + is ForwardConfirmation.FilesInProgress -> String.format(generalGetString(MR.strings.forward_files_in_progress_desc), forwardConfirmation.filesCount) + is ForwardConfirmation.FilesFailed -> String.format(generalGetString(MR.strings.forward_files_failed_to_receive_desc), forwardConfirmation.filesCount) + is ForwardConfirmation.FilesMissing -> String.format(generalGetString(MR.strings.forward_files_missing_desc), forwardConfirmation.filesCount) + } +} + +private fun handleForwardConfirmation( + rhId: Long?, + forwardPlan: CR.ForwardPlan, + chatInfo: ChatInfo +) { + var alertDescription = if (forwardPlan.forwardConfirmation != null) forwardConfirmationAlertDescription(forwardPlan.forwardConfirmation) else "" + + if (forwardPlan.chatItemIds.isNotEmpty()) { + alertDescription += "\n${generalGetString(MR.strings.forward_alert_forward_messages_without_files)}" + } + + AlertManager.shared.showAlertDialogButtonsColumn( + title = if (forwardPlan.chatItemIds.isNotEmpty()) + String.format(generalGetString(MR.strings.forward_alert_title_messages_to_forward), forwardPlan.chatItemIds.count()) else + generalGetString(MR.strings.forward_alert_title_nothing_to_forward), + text = alertDescription, + buttons = { + if (forwardPlan.chatItemIds.isNotEmpty()) { + when (val confirmation = forwardPlan.forwardConfirmation) { + is ForwardConfirmation.FilesNotAccepted -> { + val fillMaxWidthModifier = Modifier.fillMaxWidth() + val contentPadding = PaddingValues(vertical = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL) + Column { + ForwardButton(forwardPlan, chatInfo, fillMaxWidthModifier, contentPadding) + DownloadFilesButton(confirmation, rhId, fillMaxWidthModifier, contentPadding) + TextButton(onClick = { AlertManager.shared.hideAlert() }, modifier = fillMaxWidthModifier, contentPadding = contentPadding) { + Text(stringResource(MR.strings.cancel_verb), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + } + else -> { + ButtonRow(Arrangement.SpaceBetween) { + TextButton(onClick = { AlertManager.shared.hideAlert() }) { + Text(stringResource(MR.strings.cancel_verb), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + ForwardButton(forwardPlan, chatInfo) + } + } + } + } else { + when (val confirmation = forwardPlan.forwardConfirmation) { + is ForwardConfirmation.FilesNotAccepted -> { + ButtonRow(Arrangement.SpaceBetween) { + TextButton(onClick = { AlertManager.shared.hideAlert() }) { + Text(stringResource(MR.strings.cancel_verb), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + DownloadFilesButton(confirmation, rhId) + } + } + else -> ButtonRow(Arrangement.Center) { + TextButton(onClick = { AlertManager.shared.hideAlert() }) { + Text(stringResource(MR.strings.ok), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + } + } + } + ) +} + @Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index 821a449509..cad18af9bb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -13,7 +13,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontStyle import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @@ -49,7 +48,7 @@ sealed class ComposeContextItem { @Serializable object NoContextItem: ComposeContextItem() @Serializable class QuotedItem(val chatItem: ChatItem): ComposeContextItem() @Serializable class EditingItem(val chatItem: ChatItem): ComposeContextItem() - @Serializable class ForwardingItem(val chatItem: ChatItem, val fromChatInfo: ChatInfo): ComposeContextItem() + @Serializable class ForwardingItems(val chatItems: List, val fromChatInfo: ChatInfo): ComposeContextItem() } @Serializable @@ -85,7 +84,7 @@ data class ComposeState( } val forwarding: Boolean get() = when (contextItem) { - is ComposeContextItem.ForwardingItem -> true + is ComposeContextItem.ForwardingItems -> true else -> false } val sendEnabled: () -> Boolean @@ -407,33 +406,41 @@ fun ComposeView( return null } - suspend fun sendMessageAsync(text: String?, live: Boolean, ttl: Int?): ChatItem? { + suspend fun sendMessageAsync(text: String?, live: Boolean, ttl: Int?): List? { val cInfo = chat.chatInfo val cs = composeState.value - var sent: ChatItem? + var sent: List? val msgText = text ?: cs.message fun sending() { composeState.value = composeState.value.copy(inProgress = true) } - suspend fun forwardItem(rhId: Long?, forwardedItem: ChatItem, fromChatInfo: ChatInfo, ttl: Int?): ChatItem? { + suspend fun forwardItem(rhId: Long?, forwardedItem: List, fromChatInfo: ChatInfo, ttl: Int?): List? { val chatItems = controller.apiForwardChatItems( rh = rhId, toChatType = chat.chatInfo.chatType, toChatId = chat.chatInfo.apiId, fromChatType = fromChatInfo.chatType, fromChatId = fromChatInfo.apiId, - itemIds = listOf(forwardedItem.id), + itemIds = forwardedItem.map { it.id }, ttl = ttl ) + chatItems?.forEach { chatItem -> withChats { addChatItem(rhId, chat.chatInfo, chatItem) } } - // TODO batch send: forward multiple messages - return chatItems?.firstOrNull() + + if (chatItems != null && chatItems.count() < forwardedItem.count()) { + AlertManager.shared.showAlertMsg( + title = String.format(generalGetString(MR.strings.forward_files_messages_deleted_after_selection_title), forwardedItem.count() - chatItems.count()), + text = generalGetString(MR.strings.forward_files_messages_deleted_after_selection_desc) + ) + } + + return chatItems } fun checkLinkPreview(): MsgContent { @@ -506,16 +513,25 @@ fun ComposeView( if (chat.nextSendGrpInv) { sendMemberContactInvitation() sent = null - } else if (cs.contextItem is ComposeContextItem.ForwardingItem) { - sent = forwardItem(chat.remoteHostId, cs.contextItem.chatItem, cs.contextItem.fromChatInfo, ttl = ttl) + } else if (cs.contextItem is ComposeContextItem.ForwardingItems) { + sent = forwardItem(chat.remoteHostId, cs.contextItem.chatItems, cs.contextItem.fromChatInfo, ttl = ttl) if (cs.message.isNotEmpty()) { - sent = send(chat, checkLinkPreview(), quoted = sent?.id, live = false, ttl = ttl) + sent?.mapIndexed { index, message -> + if (index == sent!!.lastIndex) { + send(chat, checkLinkPreview(), quoted = message.id, live = false, ttl = ttl) + } else { + message + } + } } - } else if (cs.contextItem is ComposeContextItem.EditingItem) { + } + else if (cs.contextItem is ComposeContextItem.EditingItem) { val ei = cs.contextItem.chatItem - sent = updateMessage(ei, chat, live) + val updatedMessage = updateMessage(ei, chat, live) + sent = if (updatedMessage != null) listOf(updatedMessage) else null } else if (liveMessage != null && liveMessage.sent) { - sent = updateMessage(liveMessage.chatItem, chat, live) + val updatedMessage = updateMessage(liveMessage.chatItem, chat, live) + sent = if (updatedMessage != null) listOf(updatedMessage) else null } else { val msgs: ArrayList = ArrayList() val files: ArrayList = ArrayList() @@ -608,21 +624,23 @@ fun ComposeView( localPath = file.filePath ) } - sent = send(chat, content, if (index == 0) quotedItemId else null, file, + val sendResult = send(chat, content, if (index == 0) quotedItemId else null, file, live = if (content !is MsgContent.MCVoice && index == msgs.lastIndex) live else false, ttl = ttl ) + sent = if (sendResult != null) listOf(sendResult) else null } if (sent == null && (cs.preview is ComposePreview.MediaPreview || cs.preview is ComposePreview.FilePreview || cs.preview is ComposePreview.VoicePreview) ) { - sent = send(chat, MsgContent.MCText(msgText), quotedItemId, null, live, ttl) + val sendResult = send(chat, MsgContent.MCText(msgText), quotedItemId, null, live, ttl) + sent = if (sendResult != null) listOf(sendResult) else null } } val wasForwarding = cs.forwarding - val forwardingFromChatId = (cs.contextItem as? ComposeContextItem.ForwardingItem)?.fromChatInfo?.id + val forwardingFromChatId = (cs.contextItem as? ComposeContextItem.ForwardingItems)?.fromChatInfo?.id clearState(live) val draft = chatModel.draft.value if (wasForwarding && chatModel.draftChatId.value == chat.chatInfo.id && forwardingFromChatId != chat.chatInfo.id && draft != null) { @@ -724,8 +742,8 @@ fun ComposeView( val typedMsg = cs.message if ((cs.sendEnabled() || cs.contextItem is ComposeContextItem.QuotedItem) && (cs.liveMessage == null || !cs.liveMessage.sent)) { val ci = sendMessageAsync(typedMsg, live = true, ttl = null) - if (ci != null) { - composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = typedMsg, sent = true)) + if (!ci.isNullOrEmpty()) { + composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci.last(), typedMsg = typedMsg, sentMsg = typedMsg, sent = true)) } } else if (cs.liveMessage == null) { val cItem = chatModel.addLiveDummy(chat.chatInfo) @@ -745,8 +763,8 @@ fun ComposeView( val sentMsg = liveMessageToSend(liveMessage, typedMsg) if (sentMsg != null) { val ci = sendMessageAsync(sentMsg, live = true, ttl = null) - if (ci != null) { - composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = sentMsg, sent = true)) + if (!ci.isNullOrEmpty()) { + composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci.last(), typedMsg = typedMsg, sentMsg = sentMsg, sent = true)) } } else if (liveMessage.typedMsg != typedMsg) { composeState.value = composeState.value.copy(liveMessage = liveMessage.copy(typedMsg = typedMsg)) @@ -805,13 +823,13 @@ fun ComposeView( fun contextItemView() { when (val contextItem = composeState.value.contextItem) { ComposeContextItem.NoContextItem -> {} - is ComposeContextItem.QuotedItem -> ContextItemView(contextItem.chatItem, painterResource(MR.images.ic_reply)) { + is ComposeContextItem.QuotedItem -> ContextItemView(listOf(contextItem.chatItem), painterResource(MR.images.ic_reply), chatType = chat.chatInfo.chatType) { composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem) } - is ComposeContextItem.EditingItem -> ContextItemView(contextItem.chatItem, painterResource(MR.images.ic_edit_filled)) { + is ComposeContextItem.EditingItem -> ContextItemView(listOf(contextItem.chatItem), painterResource(MR.images.ic_edit_filled), chatType = chat.chatInfo.chatType) { clearState() } - is ComposeContextItem.ForwardingItem -> ContextItemView(contextItem.chatItem, painterResource(MR.images.ic_forward), showSender = false) { + is ComposeContextItem.ForwardingItems -> ContextItemView(contextItem.chatItems, painterResource(MR.images.ic_forward), showSender = false, chatType = chat.chatInfo.chatType) { composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem) } } @@ -834,7 +852,7 @@ fun ComposeView( is SharedContent.Media -> composeState.processPickedMedia(shared.uris, shared.text) is SharedContent.File -> composeState.processPickedFile(shared.uri, shared.text) is SharedContent.Forward -> composeState.value = composeState.value.copy( - contextItem = ComposeContextItem.ForwardingItem(shared.chatItem, shared.fromChatInfo), + contextItem = ComposeContextItem.ForwardingItems(shared.chatItems, shared.fromChatInfo), preview = if (composeState.value.preview is ComposePreview.CLinkPreview) composeState.value.preview else ComposePreview.NoPreview ) null -> {} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt index 0c4efa7d0d..5850f0b7ec 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt @@ -13,28 +13,31 @@ import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.appendInlineContent import androidx.compose.runtime.* import androidx.compose.ui.text.* +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.item.* import chat.simplex.common.model.* +import chat.simplex.common.platform.getLoadedFilePath +import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource import kotlinx.datetime.Clock @Composable fun ContextItemView( - contextItem: ChatItem, + contextItems: List, contextIcon: Painter, showSender: Boolean = true, - cancelContextItem: () -> Unit + chatType: ChatType, + cancelContextItem: () -> Unit, ) { - val sent = contextItem.chatDir.sent val sentColor = MaterialTheme.appColors.sentMessage val receivedColor = MaterialTheme.appColors.receivedMessage @Composable - fun MessageText(attachment: ImageResource?, lines: Int) { + fun MessageText(contextItem: ChatItem, attachment: ImageResource?, lines: Int) { val inlineContent: Pair Unit, Map>? = if (attachment != null) { remember(contextItem.id) { val inlineContentBuilder: AnnotatedString.Builder.() -> Unit = { @@ -62,19 +65,24 @@ fun ContextItemView( ) } - fun attachment(): ImageResource? = - when (contextItem.content.msgContent) { - is MsgContent.MCFile -> MR.images.ic_draft_filled + fun attachment(contextItem: ChatItem): ImageResource? { + val fileIsLoaded = getLoadedFilePath(contextItem.file) != null + + return when (contextItem.content.msgContent) { + is MsgContent.MCFile -> if (fileIsLoaded) MR.images.ic_draft_filled else null is MsgContent.MCImage -> MR.images.ic_image - is MsgContent.MCVoice -> MR.images.ic_play_arrow_filled + is MsgContent.MCVoice -> if (fileIsLoaded) MR.images.ic_play_arrow_filled else null else -> null } + } @Composable - fun ContextMsgPreview(lines: Int) { - MessageText(remember(contextItem.id) { attachment() }, lines) + fun ContextMsgPreview(contextItem: ChatItem, lines: Int) { + MessageText(contextItem, remember(contextItem.id) { attachment(contextItem) }, lines) } + val sent = contextItems[0].chatDir.sent + Row( Modifier .padding(top = 8.dp) @@ -97,20 +105,27 @@ fun ContextItemView( contentDescription = stringResource(MR.strings.icon_descr_context), tint = MaterialTheme.colors.secondary, ) - val sender = contextItem.memberDisplayName - if (showSender && sender != null) { - Column( - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text( - sender, - style = TextStyle(fontSize = 13.5.sp, color = CurrentColors.value.colors.secondary) - ) - ContextMsgPreview(lines = 2) + + if (contextItems.count() == 1) { + val contextItem = contextItems[0] + val sender = contextItem.memberDisplayName + + if (showSender && sender != null) { + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + sender, + style = TextStyle(fontSize = 13.5.sp, color = CurrentColors.value.colors.secondary) + ) + ContextMsgPreview(contextItem, lines = 2) + } + } else { + ContextMsgPreview(contextItem, lines = 3) } - } else { - ContextMsgPreview(lines = 3) + } else if (contextItems.isNotEmpty()) { + Text(String.format(generalGetString(if (chatType == ChatType.Local) MR.strings.compose_save_messages_n else MR.strings.compose_forward_messages_n), contextItems.count()), fontStyle = FontStyle.Italic) } } IconButton(onClick = cancelContextItem) { @@ -129,8 +144,9 @@ fun ContextItemView( fun PreviewContextItemView() { SimpleXTheme { ContextItemView( - contextItem = ChatItem.getSampleData(1, CIDirection.DirectRcv(), Clock.System.now(), "hello"), - contextIcon = painterResource(MR.images.ic_edit_filled) + contextItems = listOf(ChatItem.getSampleData(1, CIDirection.DirectRcv(), Clock.System.now(), "hello")), + contextIcon = painterResource(MR.images.ic_edit_filled), + chatType = ChatType.Direct ) {} } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt index 2aebe07306..d12e7ac090 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SelectableChatItemToolbars.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -51,17 +52,29 @@ fun SelectedItemsBottomToolbar( selectedChatItems: MutableState?>, deleteItems: (Boolean) -> Unit, // Boolean - delete for everyone is possible moderateItems: () -> Unit, -// shareItems: () -> Unit, + forwardItems: () -> Unit, ) { val deleteEnabled = remember { mutableStateOf(false) } val deleteForEveryoneEnabled = remember { mutableStateOf(false) } val canModerate = remember { mutableStateOf(false) } val moderateEnabled = remember { mutableStateOf(false) } + val forwardEnabled = remember { mutableStateOf(false) } val allButtonsDisabled = remember { mutableStateOf(false) } Box { // It's hard to measure exact height of ComposeView with different fontSizes. Better to depend on actual ComposeView, even empty ComposeView(chatModel = chatModel, Chat.sampleData, remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }, remember { mutableStateOf(null) }, {}) - Row(Modifier.matchParentSize().background(MaterialTheme.colors.background), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Row( + Modifier + .matchParentSize() + .background(MaterialTheme.colors.background) + .pointerInput(Unit) { + detectGesture { + true + } + }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { IconButton({ deleteItems(deleteForEveryoneEnabled.value) }, enabled = deleteEnabled.value && !allButtonsDisabled.value) { Icon( painterResource(MR.images.ic_delete), @@ -80,18 +93,18 @@ fun SelectedItemsBottomToolbar( ) } - IconButton({ /*shareItems()*/ }, Modifier.alpha(0f), enabled = false/*!allButtonsDisabled.value*/) { + IconButton({ forwardItems() }, enabled = forwardEnabled.value && !allButtonsDisabled.value) { Icon( - painterResource(MR.images.ic_share), + painterResource(MR.images.ic_forward), null, Modifier.size(22.dp), - tint = if (allButtonsDisabled.value) MaterialTheme.colors.secondary else MaterialTheme.colors.primary + tint = if (!forwardEnabled.value || allButtonsDisabled.value) MaterialTheme.colors.secondary else MaterialTheme.colors.primary ) } } } LaunchedEffect(chatInfo, chatItems, selectedChatItems.value) { - recheckItems(chatInfo, chatItems, selectedChatItems, deleteEnabled, deleteForEveryoneEnabled, canModerate, moderateEnabled, allButtonsDisabled) + recheckItems(chatInfo, chatItems, selectedChatItems, deleteEnabled, deleteForEveryoneEnabled, canModerate, moderateEnabled, forwardEnabled, allButtonsDisabled) } } @@ -102,6 +115,7 @@ private fun recheckItems(chatInfo: ChatInfo, deleteForEveryoneEnabled: MutableState, canModerate: MutableState, moderateEnabled: MutableState, + forwardEnabled: MutableState, allButtonsDisabled: MutableState ) { val count = selectedChatItems.value?.size ?: 0 @@ -112,6 +126,7 @@ private fun recheckItems(chatInfo: ChatInfo, var rDeleteForEveryoneEnabled = true var rModerateEnabled = true var rOnlyOwnGroupItems = true + var rForwardEnabled = true val rSelectedChatItems = mutableSetOf() for (ci in chatItems) { if (selected.contains(ci.id)) { @@ -119,6 +134,7 @@ private fun recheckItems(chatInfo: ChatInfo, rDeleteForEveryoneEnabled = rDeleteForEveryoneEnabled && ci.meta.deletable && !ci.localNote rOnlyOwnGroupItems = rOnlyOwnGroupItems && ci.chatDir is CIDirection.GroupSnd rModerateEnabled = rModerateEnabled && ci.content.msgContent != null && ci.memberToModerate(chatInfo) != null + rForwardEnabled = rForwardEnabled && ci.content.msgContent != null && ci.meta.itemDeleted == null && !ci.isLiveDummy rSelectedChatItems.add(ci.id) // we are collecting new selected items here to account for any changes in chat items list } } @@ -126,6 +142,7 @@ private fun recheckItems(chatInfo: ChatInfo, deleteEnabled.value = rDeleteEnabled deleteForEveryoneEnabled.value = rDeleteForEveryoneEnabled moderateEnabled.value = rModerateEnabled + forwardEnabled.value = rForwardEnabled selectedChatItems.value = rSelectedChatItems } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt index b4d0b05584..769a0b83f6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt @@ -58,11 +58,13 @@ fun ShareListView(chatModel: ChatModel, stopped: Boolean) { hasSimplexLink = hasSimplexLink(sharedContent.text) } is SharedContent.Forward -> { - val mc = sharedContent.chatItem.content.msgContent - if (mc != null) { - isMediaOrFileAttachment = mc.isMediaOrFileAttachment - isVoice = mc.isVoice - hasSimplexLink = hasSimplexLink(mc.text) + sharedContent.chatItems.forEach { ci -> + val mc = ci.content.msgContent + if (mc != null) { + isMediaOrFileAttachment = isMediaOrFileAttachment || mc.isMediaOrFileAttachment + isVoice = isVoice || mc.isVoice + hasSimplexLink = hasSimplexLink || hasSimplexLink(mc.text) + } } } null -> {} @@ -175,11 +177,11 @@ private fun ShareListToolbar(chatModel: ChatModel, stopped: Boolean, onSearchVal title = { Row(verticalAlignment = Alignment.CenterVertically) { Text( - when (chatModel.sharedContent.value) { + when (val v = chatModel.sharedContent.value) { is SharedContent.Text -> stringResource(MR.strings.share_message) is SharedContent.Media -> stringResource(MR.strings.share_image) is SharedContent.File -> stringResource(MR.strings.share_file) - is SharedContent.Forward -> stringResource(MR.strings.forward_message) + is SharedContent.Forward -> if (v.chatItems.size > 1) stringResource(MR.strings.forward_multiple) else stringResource(MR.strings.forward_message) null -> stringResource(MR.strings.share_message) }, color = MaterialTheme.colors.onBackground, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Enums.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Enums.kt index ee4638445b..30811d5c94 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Enums.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Enums.kt @@ -2,8 +2,7 @@ package chat.simplex.common.views.helpers import androidx.compose.runtime.saveable.Saver -import chat.simplex.common.model.ChatInfo -import chat.simplex.common.model.ChatItem +import chat.simplex.common.model.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.serialization.* import kotlinx.serialization.descriptors.* @@ -15,7 +14,7 @@ sealed class SharedContent { data class Text(val text: String): SharedContent() data class Media(val text: String, val uris: List): SharedContent() data class File(val text: String, val uri: URI): SharedContent() - data class Forward(val chatItem: ChatItem, val fromChatInfo: ChatInfo): SharedContent() + data class Forward(val chatItems: List, val fromChatInfo: ChatInfo): SharedContent() } enum class AnimatedViewState { diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 55cd86e035..08a16075a4 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -125,6 +125,7 @@ Destination server version of %1$s is incompatible with forwarding server %2$s. Please try later. Error sending message + Error forwarding messages Error creating message Error loading details Error adding member(s) @@ -133,7 +134,9 @@ Sender cancelled file transfer. Unknown servers! Without Tor or VPN, your IP address will be visible to these XFTP relays:\n%1$s. + %1$d other file error(s). Error receiving file + %1$d file error(s):\n%2$s Error creating address Contact already exists You are already connected to %1$s. @@ -378,12 +381,23 @@ No selected chat Nothing selected Selected %d + Forward %1$s message(s)? + Nothing to forward! + Forward messages without files? + Messages were deleted after you selected them. + %1$d file(s) were not downloaded. + %1$d file(s) are still being downloaded. + %1$d file(s) failed to download. + %1$d file(s) were deleted. + Download + %1$s messages not forwarded Share message… Share media… Share file… Forward message… + Forward messages… Cannot send message Selected chat preferences prohibit this message. @@ -405,6 +419,8 @@ Files and media prohibited! Only group owners can enable files and media. Send direct message to connect + Forwarding %1$s messages + Saving %1$s messages SimpleX links not allowed Files and media not allowed Voice messages not allowed