mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-04-25 07:42:15 +00:00
Merge branch 'master' into master-android
This commit is contained in:
@@ -82,7 +82,7 @@ object ChatModel {
|
||||
val desktopOnboardingRandomPassword = mutableStateOf(false)
|
||||
|
||||
// set when app is opened via contact or invitation URI (rhId, uri)
|
||||
val appOpenUrl = mutableStateOf<Pair<Long?, URI>?>(null)
|
||||
val appOpenUrl = mutableStateOf<Pair<Long?, String>?>(null)
|
||||
|
||||
// Needed to check for bottom nav bar and to apply or not navigation bar color on Android
|
||||
val newChatSheetVisible = mutableStateOf(false)
|
||||
@@ -1404,6 +1404,14 @@ class Group (
|
||||
var members: List<GroupMember>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
sealed class ForwardConfirmation {
|
||||
@Serializable @SerialName("filesNotAccepted") data class FilesNotAccepted(val fileIds: List<Long>) : 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,
|
||||
|
||||
@@ -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<Long>): 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<Long>, userApprovedRelays: Boolean = false, auto: Boolean = false) {
|
||||
val fileIdsToApprove = mutableListOf<Long>()
|
||||
val srvsToApprove = mutableSetOf<String>()
|
||||
val otherFileErrs = mutableListOf<CR>()
|
||||
|
||||
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<String>,
|
||||
otherFileErrs: List<CR>,
|
||||
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<Long>, val mode: CIDeleteMode): CC()
|
||||
class ApiDeleteMemberChatItem(val groupId: Long, val itemIds: List<Long>): 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<Long>): CC()
|
||||
class ApiForwardChatItems(val toChatType: ChatType, val toChatId: Long, val fromChatType: ChatType, val fromChatId: Long, val itemIds: List<Long>, 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<ChatItemDeletion>, val byUser: Boolean): CR()
|
||||
@Serializable @SerialName("forwardPlan") class ForwardPlan(val user: UserRef, val itemsCount: Int, val chatItemIds: List<Long>, 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)
|
||||
|
||||
@@ -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<String?>, 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<String?>, 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<Long>, 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,
|
||||
|
||||
@@ -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<ChatItem>, 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<ChatItem>? {
|
||||
val cInfo = chat.chatInfo
|
||||
val cs = composeState.value
|
||||
var sent: ChatItem?
|
||||
var sent: List<ChatItem>?
|
||||
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<ChatItem>, fromChatInfo: ChatInfo, ttl: Int?): List<ChatItem>? {
|
||||
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<MsgContent> = ArrayList()
|
||||
val files: ArrayList<CryptoFile> = 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 -> {}
|
||||
|
||||
@@ -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<ChatItem>,
|
||||
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<AnnotatedString.Builder.() -> Unit, Map<String, InlineTextContent>>? = 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
|
||||
) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Set<Long>?>,
|
||||
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<Boolean>,
|
||||
canModerate: MutableState<Boolean>,
|
||||
moderateEnabled: MutableState<Boolean>,
|
||||
forwardEnabled: MutableState<Boolean>,
|
||||
allButtonsDisabled: MutableState<Boolean>
|
||||
) {
|
||||
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<Long>()
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -665,9 +665,8 @@ private fun updateMemberRoleDialog(
|
||||
|
||||
fun connectViaMemberAddressAlert(rhId: Long?, connReqUri: String) {
|
||||
try {
|
||||
val uri = URI(connReqUri)
|
||||
withBGApi {
|
||||
planAndConnect(rhId, uri, incognito = null, close = { ModalManager.closeAllModalsEverywhere() })
|
||||
planAndConnect(rhId, connReqUri, incognito = null, close = { ModalManager.closeAllModalsEverywhere() })
|
||||
}
|
||||
} catch (e: RuntimeException) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
|
||||
@@ -478,7 +478,7 @@ private fun ToggleFilterEnabledButton() {
|
||||
@Composable
|
||||
expect fun ActiveCallInteractiveArea(call: Call)
|
||||
|
||||
fun connectIfOpenedViaUri(rhId: Long?, uri: URI, chatModel: ChatModel) {
|
||||
fun connectIfOpenedViaUri(rhId: Long?, uri: String, chatModel: ChatModel) {
|
||||
Log.d(TAG, "connectIfOpenedViaUri: opened via link")
|
||||
if (chatModel.currentUser.value == null) {
|
||||
chatModel.appOpenUrl.value = rhId to uri
|
||||
@@ -566,7 +566,7 @@ private fun connect(link: String, searchChatFilteredBySimplexLink: MutableState<
|
||||
withBGApi {
|
||||
planAndConnect(
|
||||
chatModel.remoteHostId(),
|
||||
URI.create(link),
|
||||
link,
|
||||
incognito = null,
|
||||
filterKnownContact = { searchChatFilteredBySimplexLink.value = it.id },
|
||||
filterKnownGroup = { searchChatFilteredBySimplexLink.value = it.id },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<URI>): 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<ChatItem>, val fromChatInfo: ChatInfo): SharedContent()
|
||||
}
|
||||
|
||||
enum class AnimatedViewState {
|
||||
|
||||
@@ -480,12 +480,11 @@ inline fun <reified T> serializableSaver(): Saver<T, *> = Saver(
|
||||
)
|
||||
|
||||
fun UriHandler.openVerifiedSimplexUri(uri: String) {
|
||||
val URI = try { URI.create(uri) } catch (e: Exception) { null }
|
||||
if (URI != null) {
|
||||
connectIfOpenedViaUri(chatModel.remoteHostId(), URI, ChatModel)
|
||||
}
|
||||
connectIfOpenedViaUri(chatModel.remoteHostId(), uri, ChatModel)
|
||||
}
|
||||
|
||||
fun uriCreateOrNull(uri: String) = try { URI.create(uri) } catch (e: Exception) { null }
|
||||
|
||||
fun UriHandler.openUriCatching(uri: String) {
|
||||
try {
|
||||
openUri(uri)
|
||||
|
||||
@@ -20,7 +20,7 @@ enum class ConnectionLinkType {
|
||||
|
||||
suspend fun planAndConnect(
|
||||
rhId: Long?,
|
||||
uri: URI,
|
||||
uri: String,
|
||||
incognito: Boolean?,
|
||||
close: (() -> Unit)?,
|
||||
cleanup: (() -> Unit)? = null,
|
||||
@@ -29,7 +29,7 @@ suspend fun planAndConnect(
|
||||
) {
|
||||
val connectionPlan = chatModel.controller.apiConnectPlan(rhId, uri.toString())
|
||||
if (connectionPlan != null) {
|
||||
val link = strHasSingleSimplexLink(uri.toString().trim())
|
||||
val link = strHasSingleSimplexLink(uri.trim())
|
||||
val linkText = if (link?.format is Format.SimplexLink)
|
||||
"<br><br><u>${link.simplexLinkText(link.format.linkType, link.format.smpHosts)}</u>"
|
||||
else
|
||||
@@ -323,13 +323,13 @@ suspend fun planAndConnect(
|
||||
suspend fun connectViaUri(
|
||||
chatModel: ChatModel,
|
||||
rhId: Long?,
|
||||
uri: URI,
|
||||
uri: String,
|
||||
incognito: Boolean,
|
||||
connectionPlan: ConnectionPlan?,
|
||||
close: (() -> Unit)?,
|
||||
cleanup: (() -> Unit)?,
|
||||
) {
|
||||
val pcc = chatModel.controller.apiConnect(rhId, incognito, uri.toString())
|
||||
val pcc = chatModel.controller.apiConnect(rhId, incognito, uri)
|
||||
val connLinkType = if (connectionPlan != null) planToConnectionLinkType(connectionPlan) else ConnectionLinkType.INVITATION
|
||||
if (pcc != null) {
|
||||
withChats {
|
||||
@@ -361,7 +361,7 @@ fun planToConnectionLinkType(connectionPlan: ConnectionPlan): ConnectionLinkType
|
||||
fun askCurrentOrIncognitoProfileAlert(
|
||||
chatModel: ChatModel,
|
||||
rhId: Long?,
|
||||
uri: URI,
|
||||
uri: String,
|
||||
connectionPlan: ConnectionPlan?,
|
||||
close: (() -> Unit)?,
|
||||
title: String,
|
||||
@@ -417,7 +417,7 @@ fun openKnownContact(chatModel: ChatModel, rhId: Long?, close: (() -> Unit)?, co
|
||||
fun ownGroupLinkConfirmConnect(
|
||||
chatModel: ChatModel,
|
||||
rhId: Long?,
|
||||
uri: URI,
|
||||
uri: String,
|
||||
linkText: String,
|
||||
incognito: Boolean?,
|
||||
connectionPlan: ConnectionPlan?,
|
||||
|
||||
@@ -482,7 +482,7 @@ private fun connect(link: String, searchChatFilteredBySimplexLink: MutableState<
|
||||
withBGApi {
|
||||
planAndConnect(
|
||||
chatModel.remoteHostId(),
|
||||
URI.create(link),
|
||||
link,
|
||||
incognito = null,
|
||||
filterKnownContact = { searchChatFilteredBySimplexLink.value = it.id },
|
||||
close = close,
|
||||
|
||||
@@ -68,7 +68,7 @@ fun ModalData.NewChatView(rh: RemoteHostInfo?, selection: NewChatOption, showQRC
|
||||
* Otherwise, it will be called here AFTER [AddContactLearnMore] is launched and will clear the value too soon.
|
||||
* It will be dropped automatically when connection established or when user goes away from this screen.
|
||||
**/
|
||||
if (chatModel.showingInvitation.value != null && ModalManager.start.openModalCount() == 1) {
|
||||
if (chatModel.showingInvitation.value != null && ModalManager.start.openModalCount() <= 1) {
|
||||
val conn = contactConnection.value
|
||||
if (chatModel.showingInvitation.value?.connChatUsed == false && conn != null) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
@@ -308,6 +308,7 @@ fun ActiveProfilePicker(
|
||||
switchingProfile.value = true
|
||||
withApi {
|
||||
try {
|
||||
appPreferences.incognito.set(false)
|
||||
var updatedConn: PendingContactConnection? = null;
|
||||
|
||||
if (contactConnection != null) {
|
||||
@@ -361,6 +362,7 @@ fun ActiveProfilePicker(
|
||||
switchingProfile.value = true
|
||||
withApi {
|
||||
try {
|
||||
appPreferences.incognito.set(true)
|
||||
val conn = controller.apiSetConnectionIncognito(rhId, contactConnection.pccConnId, true)
|
||||
if (conn != null) {
|
||||
withChats {
|
||||
@@ -653,7 +655,7 @@ private suspend fun verify(rhId: Long?, text: String?, close: () -> Unit): Boole
|
||||
private suspend fun connect(rhId: Long?, link: String, close: () -> Unit, cleanup: (() -> Unit)? = null) {
|
||||
planAndConnect(
|
||||
rhId,
|
||||
URI.create(link),
|
||||
link,
|
||||
close = close,
|
||||
cleanup = cleanup,
|
||||
incognito = null
|
||||
|
||||
@@ -125,6 +125,7 @@
|
||||
<string name="proxy_destination_error_broker_version">Destination server version of %1$s is incompatible with forwarding server %2$s.</string>
|
||||
<string name="please_try_later">Please try later.</string>
|
||||
<string name="error_sending_message">Error sending message</string>
|
||||
<string name="error_forwarding_messages">Error forwarding messages</string>
|
||||
<string name="error_creating_message">Error creating message</string>
|
||||
<string name="error_loading_details">Error loading details</string>
|
||||
<string name="error_adding_members">Error adding member(s)</string>
|
||||
@@ -133,7 +134,9 @@
|
||||
<string name="sender_cancelled_file_transfer">Sender cancelled file transfer.</string>
|
||||
<string name="file_not_approved_title">Unknown servers!</string>
|
||||
<string name="file_not_approved_descr">Without Tor or VPN, your IP address will be visible to these XFTP relays:\n%1$s.</string>
|
||||
<string name="n_other_file_errors">%1$d other file error(s).</string>
|
||||
<string name="error_receiving_file">Error receiving file</string>
|
||||
<string name="n_file_errors">%1$d file error(s):\n%2$s</string>
|
||||
<string name="error_creating_address">Error creating address</string>
|
||||
<string name="contact_already_exists">Contact already exists</string>
|
||||
<string name="you_are_already_connected_to_vName_via_this_link">You are already connected to %1$s.</string>
|
||||
@@ -378,12 +381,23 @@
|
||||
<string name="no_selected_chat">No selected chat</string>
|
||||
<string name="selected_chat_items_nothing_selected">Nothing selected</string>
|
||||
<string name="selected_chat_items_selected_n">Selected %d</string>
|
||||
<string name="forward_alert_title_messages_to_forward">Forward %1$s message(s)?</string>
|
||||
<string name="forward_alert_title_nothing_to_forward">Nothing to forward!</string>
|
||||
<string name="forward_alert_forward_messages_without_files">Forward messages without files?</string>
|
||||
<string name="forward_files_messages_deleted_after_selection_desc">Messages were deleted after you selected them.</string>
|
||||
<string name="forward_files_not_accepted_desc">%1$d file(s) were not downloaded.</string>
|
||||
<string name="forward_files_in_progress_desc">%1$d file(s) are still being downloaded.</string>
|
||||
<string name="forward_files_failed_to_receive_desc">%1$d file(s) failed to download.</string>
|
||||
<string name="forward_files_missing_desc">%1$d file(s) were deleted.</string>
|
||||
<string name="forward_files_not_accepted_receive_files">Download</string>
|
||||
<string name="forward_files_messages_deleted_after_selection_title">%1$s messages not forwarded</string>
|
||||
|
||||
<!-- ShareListView.kt -->
|
||||
<string name="share_message">Share message…</string>
|
||||
<string name="share_image">Share media…</string>
|
||||
<string name="share_file">Share file…</string>
|
||||
<string name="forward_message">Forward message…</string>
|
||||
<string name="forward_multiple">Forward messages…</string>
|
||||
<string name="cannot_share_message_alert_title">Cannot send message</string>
|
||||
<string name="cannot_share_message_alert_text">Selected chat preferences prohibit this message.</string>
|
||||
|
||||
@@ -405,6 +419,8 @@
|
||||
<string name="files_and_media_prohibited">Files and media prohibited!</string>
|
||||
<string name="only_owners_can_enable_files_and_media">Only group owners can enable files and media.</string>
|
||||
<string name="compose_send_direct_message_to_connect">Send direct message to connect</string>
|
||||
<string name="compose_forward_messages_n">Forwarding %1$s messages</string>
|
||||
<string name="compose_save_messages_n">Saving %1$s messages</string>
|
||||
<string name="simplex_links_not_allowed">SimpleX links not allowed</string>
|
||||
<string name="files_and_media_not_allowed">Files and media not allowed</string>
|
||||
<string name="voice_messages_not_allowed">Voice messages not allowed</string>
|
||||
|
||||
@@ -34,6 +34,9 @@ var useWorker = false;
|
||||
var isDesktop = false;
|
||||
var localizedState = "";
|
||||
var localizedDescription = "";
|
||||
// When one side of a call sends candidates tot fast (until local & remote descriptions are set), that candidates
|
||||
// will be stored here and then set when the call will be ready to process them
|
||||
let afterCallInitializedCandidates = [];
|
||||
const processCommand = (function () {
|
||||
const defaultIceServers = [
|
||||
{ urls: ["stuns:stun.simplex.im:443"] },
|
||||
@@ -234,6 +237,8 @@ const processCommand = (function () {
|
||||
const pc = activeCall.connection;
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
addIceCandidates(pc, afterCallInitializedCandidates);
|
||||
afterCallInitializedCandidates = [];
|
||||
// for debugging, returning the command for callee to use
|
||||
// resp = {
|
||||
// type: "offer",
|
||||
@@ -272,6 +277,8 @@ const processCommand = (function () {
|
||||
const answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
addIceCandidates(pc, remoteIceCandidates);
|
||||
addIceCandidates(pc, afterCallInitializedCandidates);
|
||||
afterCallInitializedCandidates = [];
|
||||
// same as command for caller to use
|
||||
resp = {
|
||||
type: "answer",
|
||||
@@ -297,17 +304,20 @@ const processCommand = (function () {
|
||||
// console.log("answer remoteIceCandidates", JSON.stringify(remoteIceCandidates))
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(answer));
|
||||
addIceCandidates(pc, remoteIceCandidates);
|
||||
addIceCandidates(pc, afterCallInitializedCandidates);
|
||||
afterCallInitializedCandidates = [];
|
||||
resp = { type: "ok" };
|
||||
}
|
||||
break;
|
||||
case "ice":
|
||||
const remoteIceCandidates = parse(command.iceCandidates);
|
||||
if (pc) {
|
||||
const remoteIceCandidates = parse(command.iceCandidates);
|
||||
addIceCandidates(pc, remoteIceCandidates);
|
||||
resp = { type: "ok" };
|
||||
}
|
||||
else {
|
||||
resp = { type: "error", message: "ice: call not started" };
|
||||
afterCallInitializedCandidates.push(...remoteIceCandidates);
|
||||
resp = { type: "error", message: "ice: call not started yet, will add candidates later" };
|
||||
}
|
||||
break;
|
||||
case "media":
|
||||
|
||||
@@ -204,7 +204,7 @@ fun startServer(onResponse: (WVAPIMessage) -> Unit): NanoWSD {
|
||||
return when {
|
||||
session.headers["upgrade"] == "websocket" -> super.handle(session)
|
||||
session.uri.contains("/simplex/call/") -> resourcesToResponse("/desktop/call.html")
|
||||
else -> resourcesToResponse(URI.create(session.uri).path)
|
||||
else -> resourcesToResponse(uriCreateOrNull(session.uri)?.path ?: return newFixedLengthResponse("Error parsing URL"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user