From c80ae1c64a8a71bc45446b99857112bbde1069f7 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 30 Jun 2025 15:22:25 +0000 Subject: [PATCH] android, desktop: compose UI (#6018) --- .../chat/simplex/common/model/SimpleXAPI.kt | 4 +- .../chat/simplex/common/views/TerminalView.kt | 2 +- ...ComposeContextInvitingContactMemberView.kt | 39 -- .../simplex/common/views/chat/ComposeView.kt | 540 +++++++++++------- .../simplex/common/views/chat/SendMsgView.kt | 57 +- .../commonMain/resources/MR/base/strings.xml | 7 + .../MR/images/ic_person_add_filled.svg | 1 + 7 files changed, 390 insertions(+), 260 deletions(-) delete mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextInvitingContactMemberView.kt create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_add_filled.svg 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 2f2ca408a2..ea1e14c497 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 @@ -3688,8 +3688,8 @@ sealed class CC { is APIPrepareGroup -> "/_prepare group $userId ${connLink.connFullLink} ${connLink.connShortLink ?: ""} ${json.encodeToString(groupShortLinkData)}" is APIChangePreparedContactUser -> "/_set contact user @$contactId $newUserId" is APIChangePreparedGroupUser -> "/_set group user #$groupId $newUserId" - is APIConnectPreparedContact -> "/_connect contact @$contactId incognito=${onOff(incognito)} ${maybeContent(msg)}" - is APIConnectPreparedGroup -> "/_connect group #$groupId incognito=${onOff(incognito)} ${maybeContent(msg)}" + is APIConnectPreparedContact -> "/_connect contact @$contactId incognito=${onOff(incognito)}${maybeContent(msg)}" + is APIConnectPreparedGroup -> "/_connect group #$groupId incognito=${onOff(incognito)}${maybeContent(msg)}" is APIConnect -> "/_connect $userId incognito=${onOff(incognito)} ${connLink.connFullLink} ${connLink.connShortLink ?: ""}" is ApiConnectContactViaAddress -> "/_connect contact $userId incognito=${onOff(incognito)} $contactId" is ApiDeleteChat -> "/_delete ${chatRef(type, id, scope = null)} ${chatDeleteMode.cmdString}" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt index 37aa7fc1d1..1bfe88ecf7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt @@ -101,7 +101,7 @@ fun TerminalLayout( sendMsgEnabled = true, userCantSendReason = null, sendButtonEnabled = true, - nextSendGrpInv = false, + nextConnect = false, needToAllowVoiceToContact = false, allowedVoiceByPrefs = false, allowVoiceToContact = {}, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextInvitingContactMemberView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextInvitingContactMemberView.kt deleted file mode 100644 index bc82bc593f..0000000000 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextInvitingContactMemberView.kt +++ /dev/null @@ -1,39 +0,0 @@ -package chat.simplex.common.views.chat - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.material.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import chat.simplex.common.ui.theme.* -import chat.simplex.common.views.helpers.generalGetString -import chat.simplex.res.MR -import dev.icerock.moko.resources.compose.painterResource -import dev.icerock.moko.resources.compose.stringResource - -@Composable -fun ComposeContextInvitingContactMemberView() { - val sentColor = MaterialTheme.appColors.sentMessage - Row( - Modifier - .height(60.dp) - .fillMaxWidth() - .padding(top = 8.dp) - .background(sentColor), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - painterResource(MR.images.ic_chat), - stringResource(MR.strings.button_send_direct_message), - modifier = Modifier - .padding(start = 12.dp, end = 8.dp) - .height(20.dp) - .width(20.dp), - tint = MaterialTheme.colors.secondary - ) - Text(generalGetString(MR.strings.compose_send_direct_message_to_connect)) - } -} 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 62b160e595..65c95c4966 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 @@ -2,6 +2,7 @@ package chat.simplex.common.views.chat import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.* @@ -30,6 +31,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.item.* import chat.simplex.common.views.helpers.* import chat.simplex.res.MR +import dev.icerock.moko.resources.ImageResource import kotlinx.coroutines.* import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.serialization.* @@ -151,7 +153,7 @@ data class ComposeState( is ComposePreview.MediaPreview -> true is ComposePreview.VoicePreview -> true is ComposePreview.FilePreview -> true - else -> message.text.isNotEmpty() || forwarding || liveMessage != null || submittingValidReport + else -> !whitespaceOnly || forwarding || liveMessage != null || submittingValidReport } hasContent && !inProgress } @@ -199,7 +201,10 @@ data class ComposeState( } val empty: Boolean - get() = message.text.isEmpty() && preview is ComposePreview.NoPreview && contextItem is ComposeContextItem.NoContextItem + get() = whitespaceOnly && preview is ComposePreview.NoPreview && contextItem is ComposeContextItem.NoContextItem + + val whitespaceOnly: Boolean + get() = message.text.all { it.isWhitespace() } companion object { fun saver(): Saver, *> = Saver( @@ -486,6 +491,7 @@ fun ComposeView( return null } + // TODO [short links] connectCheckLinkPreview fun checkLinkPreview(): MsgContent { val msgText = composeState.value.message.text return when (val composePreview = composeState.value.preview) { @@ -504,8 +510,13 @@ fun ComposeView( } } + fun sending() { + composeState.value = composeState.value.copy(inProgress = true) + } + suspend fun sendMemberContactInvitation() { val mc = checkLinkPreview() + sending() val contact = chatModel.controller.apiSendMemberContactInvitation(chat.remoteHostId, chat.chatInfo.apiId, mc) if (contact != null) { withContext(Dispatchers.Main) { @@ -513,11 +524,14 @@ fun ComposeView( clearState() chatModel.setContactNetworkStatus(contact, NetworkStatus.Connected()) } + } else { + composeState.value = composeState.value.copy(inProgress = false) } } suspend fun sendConnectPreparedContact() { val mc = checkLinkPreview() + sending() val contact = chatModel.controller.apiConnectPreparedContact( rh = chat.remoteHostId, contactId = chat.chatInfo.apiId, @@ -530,11 +544,36 @@ fun ComposeView( clearState() chatModel.setContactNetworkStatus(contact, NetworkStatus.Connected()) } + } else { + composeState.value = composeState.value.copy(inProgress = false) } } - suspend fun sendConnectPreparedGroup() { + // TODO [short links] different messages for business + fun showSendConnectPreparedContactAlert(sendConnect: () -> Unit) { + val empty = composeState.value.whitespaceOnly + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.compose_view_send_contact_request_alert_question), + text = generalGetString(MR.strings.compose_view_send_contact_request_alert_text), + confirmText = ( + if (empty) + generalGetString(MR.strings.compose_view_send_request_without_message) + else + generalGetString(MR.strings.compose_view_send_request) + ), + onConfirm = { sendConnect() }, + dismissText = ( + if (empty) + generalGetString(MR.strings.compose_view_add_message) + else + generalGetString(MR.strings.cancel_verb) + ) + ) + } + + suspend fun connectPreparedGroup() { val mc = checkLinkPreview() + sending() val groupInfo = chatModel.controller.apiConnectPreparedGroup( rh = chat.remoteHostId, groupId = chat.chatInfo.apiId, @@ -546,28 +585,8 @@ fun ComposeView( chatsCtx.updateGroup(chat.remoteHostId, groupInfo) clearState() } - } - } - - // TODO [short links] next connect button design, rework compose to not show send button, align with Swift - @Composable - fun NextConnectPreparedButton() { - TextButton(onClick = { - withBGApi { - if (chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.nextSendGrpInv) { - sendMemberContactInvitation() - } else if (chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.nextConnectPrepared) { - sendConnectPreparedContact() - } else if (chat.chatInfo is ChatInfo.Group && chat.chatInfo.groupInfo.nextConnectPrepared) { - sendConnectPreparedGroup() - } - } - }) { - if (chat.chatInfo is ChatInfo.Group) { - Text("Join") - } else { - Text("Connect") - } + } else { + composeState.value = composeState.value.copy(inProgress = false) } } @@ -578,10 +597,6 @@ fun ComposeView( var lastMessageFailedToSend: ComposeState? = null val msgText = text ?: cs.message.text - fun sending() { - composeState.value = composeState.value.copy(inProgress = true) - } - suspend fun forwardItem(rhId: Long?, forwardedItem: List, fromChatInfo: ChatInfo, ttl: Int?): List? { val chatItems = controller.apiForwardChatItems( rh = rhId, @@ -689,10 +704,7 @@ fun ComposeView( clearCurrentDraft() } - if (chat.nextSendGrpInv) { - sendMemberContactInvitation() - sent = null - } else if (cs.contextItem is ComposeContextItem.ForwardingItems) { + if (cs.contextItem is ComposeContextItem.ForwardingItems) { sent = forwardItem(chat.remoteHostId, cs.contextItem.chatItems, cs.contextItem.fromChatInfo, ttl = ttl) if (sent == null) { lastMessageFailedToSend = constructFailedMessage(cs) @@ -1046,6 +1058,250 @@ fun ComposeView( } } + val sendMsgEnabled = rememberUpdatedState(chat.chatInfo.sendMsgEnabled) + val userCantSendReason = rememberUpdatedState(chat.chatInfo.userCantSendReason) + val nextSendGrpInv = rememberUpdatedState(chat.nextSendGrpInv) + + @Composable + fun AttachmentButton() { + val isGroupAndProhibitedFiles = + chatsCtx.secondaryContextFilter == null + && chat.chatInfo is ChatInfo.Group + && !chat.chatInfo.groupInfo.fullGroupPreferences.files.on(chat.chatInfo.groupInfo.membership) + val attachmentClicked = if (isGroupAndProhibitedFiles) { + { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.files_and_media_prohibited), + text = generalGetString(MR.strings.only_owners_can_enable_files_and_media) + ) + } + } else { + showChooseAttachment + } + val attachmentEnabled = + !composeState.value.attachmentDisabled + && sendMsgEnabled.value + && !isGroupAndProhibitedFiles + && !nextSendGrpInv.value + IconButton( + attachmentClicked, + Modifier.padding(start = 3.dp, end = 1.dp, bottom = if (appPlatform.isAndroid) 2.sp.toDp() else 5.sp.toDp() * fontSizeSqrtMultiplier), + enabled = attachmentEnabled + ) { + Icon( + painterResource(MR.images.ic_attach_file_filled_500), + contentDescription = stringResource(MR.strings.attach), + tint = if (attachmentEnabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, + modifier = Modifier + .size(28.dp) + .clip(CircleShape) + ) + } + } + + @Composable + fun SendMsgView_( + disableSendButton: Boolean, + placeholder: String? = null, + sendToConnect: (() -> Unit)? = null + ) { + val allowedVoiceByPrefs = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.Voice) } + LaunchedEffect(allowedVoiceByPrefs) { + if (!allowedVoiceByPrefs && composeState.value.preview is ComposePreview.VoicePreview) { + // Voice was disabled right when this user records it, just cancel it + cancelVoice() + } + } + val needToAllowVoiceToContact = remember(chat.chatInfo) { + chat.chatInfo is ChatInfo.Direct && with(chat.chatInfo.contact.mergedPreferences.voice) { + ((userPreference as? ContactUserPref.User)?.preference?.allow == FeatureAllowed.NO || (userPreference as? ContactUserPref.Contact)?.preference?.allow == FeatureAllowed.NO) && + contactPreference.allow == FeatureAllowed.YES + } + } + LaunchedEffect(Unit) { + snapshotFlow { recState.value } + .distinctUntilChanged() + .collect { + when (it) { + is RecordingState.Started -> onAudioAdded(it.filePath, it.progressMs, false) + is RecordingState.Finished -> if (it.durationMs > 300) { + onAudioAdded(it.filePath, it.durationMs, true) + } else { + cancelVoice() + } + is RecordingState.NotStarted -> {} + } + } + } + + LaunchedEffect(rememberUpdatedState(chat.chatInfo.sendMsgEnabled).value) { + if (!chat.chatInfo.sendMsgEnabled) { + clearCurrentDraft() + clearState() + } + } + + KeyChangeEffect(chatModel.chatId.value) { prevChatId -> + val cs = composeState.value + if (cs.liveMessage != null && (cs.message.text.isNotEmpty() || cs.liveMessage.sent)) { + sendMessage(null) + resetLinkPreview() + clearPrevDraft(prevChatId) + deleteUnusedFiles() + } else if (cs.inProgress) { + clearPrevDraft(prevChatId) + } else if (!cs.empty) { + if (cs.preview is ComposePreview.VoicePreview && !cs.preview.finished) { + composeState.value = cs.copy(preview = cs.preview.copy(finished = true)) + } + if (saveLastDraft) { + chatModel.draft.value = composeState.value + chatModel.draftChatId.value = prevChatId + } + composeState.value = ComposeState(useLinkPreviews = useLinkPreviews) + } else if (chatModel.draftChatId.value == chatModel.chatId.value && chatModel.draft.value != null) { + composeState.value = chatModel.draft.value ?: ComposeState(useLinkPreviews = useLinkPreviews) + } else { + clearPrevDraft(prevChatId) + deleteUnusedFiles() + } + chatModel.removeLiveDummy() + CIFile.cachedRemoteFileRequests.clear() + } + if (appPlatform.isDesktop) { + // Don't enable this on Android, it breaks it, This method only works on desktop. For Android there is a `KeyChangeEffect(chatModel.chatId.value)` + DisposableEffect(Unit) { + onDispose { + if (chatModel.sharedContent.value is SharedContent.Forward && saveLastDraft && !composeState.value.empty) { + chatModel.draft.value = composeState.value + chatModel.draftChatId.value = chat.id + } + } + } + } + val timedMessageAllowed = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.TimedMessages) } + val sendButtonColor = + if (chat.chatInfo.incognito) + if (isInDarkTheme()) Indigo else Indigo.copy(alpha = 0.7F) + else MaterialTheme.colors.primary + SendMsgView( + composeState, + showVoiceRecordIcon = true, + recState, + chat.chatInfo is ChatInfo.Direct, + liveMessageAlertShown = chatModel.controller.appPrefs.liveMessageAlertShown, + sendMsgEnabled = sendMsgEnabled.value, + userCantSendReason = userCantSendReason.value, + sendButtonEnabled = sendMsgEnabled.value && !disableSendButton, + sendToConnect = sendToConnect, + hideSendButton = chat.chatInfo.nextConnect && !nextSendGrpInv.value && composeState.value.whitespaceOnly, + nextConnect = chat.chatInfo.nextConnect, + needToAllowVoiceToContact = needToAllowVoiceToContact, + allowedVoiceByPrefs = allowedVoiceByPrefs, + allowVoiceToContact = ::allowVoiceToContact, + sendButtonColor = sendButtonColor, + timedMessageAllowed = timedMessageAllowed, + customDisappearingMessageTimePref = chatModel.controller.appPrefs.customDisappearingMessageTime, + placeholder = placeholder ?: composeState.value.placeholder, + sendMessage = { ttl -> + sendMessage(ttl) + resetLinkPreview() + }, + sendLiveMessage = if (chat.chatInfo.chatType != ChatType.Local) ::sendLiveMessage else null, + updateLiveMessage = ::updateLiveMessage, + cancelLiveMessage = { + composeState.value = composeState.value.copy(liveMessage = null) + chatModel.removeLiveDummy() + }, + editPrevMessage = ::editPrevMessage, + onFilesPasted = { composeState.onFilesAttached(it) }, + onMessageChange = ::onMessageChange, + textStyle = textStyle, + focusRequester = focusRequester, + ) + } + + @Composable + fun SendContactRequestView( + disableSendButton: Boolean, + icon: ImageResource, + sendRequest: () -> Unit + ) { + Row( + Modifier.padding(horizontal = DEFAULT_PADDING_HALF), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + Modifier.weight(1f) + ) { + SendMsgView_( + disableSendButton = disableSendButton, + placeholder = generalGetString(MR.strings.compose_view_add_message), + sendToConnect = sendRequest + ) + } + if (composeState.value.whitespaceOnly) { + SimpleButtonIconEnded( + text = stringResource(MR.strings.compose_view_connect), + icon = painterResource(icon), + disabled = composeState.value.inProgress, + click = { withApi { sendRequest() } } + ) + } + } + } + + @Composable + fun ConnectButtonView( + text: String, + icon: ImageResource, + connect: () -> Unit + ) { + var modifier = Modifier.height(60.dp).fillMaxWidth() + modifier = if (composeState.value.inProgress) modifier else modifier.clickable(onClick = { connect() }) + Box( + modifier, + contentAlignment = Alignment.Center + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + painterResource(icon), + contentDescription = null, + tint = MaterialTheme.colors.primary, + ) + Text( + text, + style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.primary + ) + } + } + } + + @Composable + fun ContextSendMessageToConnect(s: String) { + Row( + Modifier + .height(60.dp) + .fillMaxWidth() + .padding(horizontal = DEFAULT_PADDING), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painterResource(MR.images.ic_chat), + contentDescription = null, + modifier = Modifier + .padding(end = 8.dp) + .height(20.dp) + .width(20.dp), + tint = MaterialTheme.colors.secondary + ) + Text(s) + } + } + // In case a user sent something, state is in progress, the user rotates a screen to different orientation. // Without clearing the state the user will be unable to send anything until re-enters ChatView LaunchedEffect(Unit) { @@ -1071,17 +1327,7 @@ fun ComposeView( chatModel.sharedContent.value = null } - val sendMsgEnabled = rememberUpdatedState(chat.chatInfo.sendMsgEnabled) - val userCantSendReason = rememberUpdatedState(chat.chatInfo.userCantSendReason) - val nextSendGrpInv = rememberUpdatedState(chat.nextSendGrpInv) - Column { - // TODO [short links] move button to the right of send field, rework SendMsgView to not show send button, align with Swift - if (chat.chatInfo.nextConnect) { - NextConnectPreparedButton() - } - // TODO ^^^ (this shouldn't be here) - val currentUser = chatModel.currentUser.value if (( (chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.nextConnectPrepared) @@ -1096,17 +1342,6 @@ fun ComposeView( ) } - if ( - chat.chatInfo is ChatInfo.Direct - && chat.chatInfo.contact.nextAcceptContactRequest - && chat.chatInfo.contact.contactRequestId != null - ) { - ComposeContextContactRequestActionsView( - rhId = rhId, - contactRequestId = chat.chatInfo.contact.contactRequestId - ) - } - if ( chat.chatInfo is ChatInfo.Group && chatsCtx.secondaryContextFilter is SecondaryContextFilter.GroupChatScopeContext @@ -1123,10 +1358,6 @@ fun ComposeView( ) } - if (nextSendGrpInv.value) { - ComposeContextInvitingContactMemberView() - } - val ctx = composeState.value.contextItem if (ctx is ComposeContextItem.ReportedItem) { ReportReasonView(ctx.reason) @@ -1135,6 +1366,7 @@ fun ComposeView( val simplexLinkProhibited = chatsCtx.secondaryContextFilter == null && hasSimplexLink.value && !chat.groupFeatureEnabled(GroupFeature.SimplexLinks) val fileProhibited = chatsCtx.secondaryContextFilter == null && composeState.value.attachmentPreview && !chat.groupFeatureEnabled(GroupFeature.Files) val voiceProhibited = composeState.value.preview is ComposePreview.VoicePreview && !chat.chatInfo.featureEnabled(ChatFeature.Voice) + val disableSendButton = simplexLinkProhibited || fileProhibited || voiceProhibited if (composeState.value.preview !is ComposePreview.VoicePreview || composeState.value.editing) { if (simplexLinkProhibited) { MsgNotAllowedView(generalGetString(MR.strings.simplex_links_not_allowed), icon = painterResource(MR.images.ic_link)) @@ -1159,154 +1391,68 @@ fun ComposeView( } } } + Surface(color = MaterialTheme.colors.background, contentColor = MaterialTheme.colors.onBackground) { Divider() - Row(Modifier.padding(end = 8.dp), verticalAlignment = Alignment.Bottom) { - val isGroupAndProhibitedFiles = - chatsCtx.secondaryContextFilter == null - && chat.chatInfo is ChatInfo.Group - && !chat.chatInfo.groupInfo.fullGroupPreferences.files.on(chat.chatInfo.groupInfo.membership) - val attachmentClicked = if (isGroupAndProhibitedFiles) { - { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.files_and_media_prohibited), - text = generalGetString(MR.strings.only_owners_can_enable_files_and_media) - ) - } + if (chat.chatInfo is ChatInfo.Group && chat.chatInfo.groupInfo.nextConnectPrepared) { + if (chat.chatInfo.groupInfo.businessChat == null) { + ConnectButtonView( + text = stringResource(MR.strings.compose_view_join_group), + icon = MR.images.ic_group_filled, + connect = { withApi { connectPreparedGroup() } } + ) } else { - showChooseAttachment - } - val attachmentEnabled = - !composeState.value.attachmentDisabled - && sendMsgEnabled.value - && !isGroupAndProhibitedFiles - && !nextSendGrpInv.value - IconButton( - attachmentClicked, - Modifier.padding(start = 3.dp, end = 1.dp, bottom = if (appPlatform.isAndroid) 2.sp.toDp() else 5.sp.toDp() * fontSizeSqrtMultiplier), - enabled = attachmentEnabled - ) { - Icon( - painterResource(MR.images.ic_attach_file_filled_500), - contentDescription = stringResource(MR.strings.attach), - tint = if (attachmentEnabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, - modifier = Modifier - .size(28.dp) - .clip(CircleShape) + SendContactRequestView( + disableSendButton = disableSendButton, + icon = MR.images.ic_work_filled, + sendRequest = { showSendConnectPreparedContactAlert(sendConnect = { withApi { connectPreparedGroup() } }) } ) } - val allowedVoiceByPrefs = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.Voice) } - LaunchedEffect(allowedVoiceByPrefs) { - if (!allowedVoiceByPrefs && composeState.value.preview is ComposePreview.VoicePreview) { - // Voice was disabled right when this user records it, just cancel it - cancelVoice() + } else if (nextSendGrpInv.value) { + Column { + ContextSendMessageToConnect(generalGetString(MR.strings.compose_send_direct_message_to_connect)) + Divider() + Row(Modifier.padding(end = 8.dp), verticalAlignment = Alignment.Bottom) { + AttachmentButton() + SendMsgView_( + disableSendButton = disableSendButton, + sendToConnect = { withApi { sendMemberContactInvitation() } } + ) } } - val needToAllowVoiceToContact = remember(chat.chatInfo) { - chat.chatInfo is ChatInfo.Direct && with(chat.chatInfo.contact.mergedPreferences.voice) { - ((userPreference as? ContactUserPref.User)?.preference?.allow == FeatureAllowed.NO || (userPreference as? ContactUserPref.Contact)?.preference?.allow == FeatureAllowed.NO) && - contactPreference.allow == FeatureAllowed.YES - } + } else if ( + chat.chatInfo is ChatInfo.Direct + && chat.chatInfo.contact.nextConnectPrepared + && chat.chatInfo.contact.preparedContact != null + ) { + when (chat.chatInfo.contact.preparedContact.uiConnLinkType) { + ConnectionMode.Inv -> + ConnectButtonView( + text = stringResource(MR.strings.compose_view_connect), + icon = MR.images.ic_person_add_filled, + connect = { withApi { sendConnectPreparedContact() } } + ) + ConnectionMode.Con -> + SendContactRequestView( + disableSendButton = disableSendButton, + icon = MR.images.ic_person_add_filled, + sendRequest = { showSendConnectPreparedContactAlert(sendConnect = { withApi { sendConnectPreparedContact() } }) } + ) } - LaunchedEffect(Unit) { - snapshotFlow { recState.value } - .distinctUntilChanged() - .collect { - when (it) { - is RecordingState.Started -> onAudioAdded(it.filePath, it.progressMs, false) - is RecordingState.Finished -> if (it.durationMs > 300) { - onAudioAdded(it.filePath, it.durationMs, true) - } else { - cancelVoice() - } - is RecordingState.NotStarted -> {} - } - } - } - - LaunchedEffect(rememberUpdatedState(chat.chatInfo.sendMsgEnabled).value) { - if (!chat.chatInfo.sendMsgEnabled) { - clearCurrentDraft() - clearState() - } - } - - KeyChangeEffect(chatModel.chatId.value) { prevChatId -> - val cs = composeState.value - if (cs.liveMessage != null && (cs.message.text.isNotEmpty() || cs.liveMessage.sent)) { - sendMessage(null) - resetLinkPreview() - clearPrevDraft(prevChatId) - deleteUnusedFiles() - } else if (cs.inProgress) { - clearPrevDraft(prevChatId) - } else if (!cs.empty) { - if (cs.preview is ComposePreview.VoicePreview && !cs.preview.finished) { - composeState.value = cs.copy(preview = cs.preview.copy(finished = true)) - } - if (saveLastDraft) { - chatModel.draft.value = composeState.value - chatModel.draftChatId.value = prevChatId - } - composeState.value = ComposeState(useLinkPreviews = useLinkPreviews) - } else if (chatModel.draftChatId.value == chatModel.chatId.value && chatModel.draft.value != null) { - composeState.value = chatModel.draft.value ?: ComposeState(useLinkPreviews = useLinkPreviews) - } else { - clearPrevDraft(prevChatId) - deleteUnusedFiles() - } - chatModel.removeLiveDummy() - CIFile.cachedRemoteFileRequests.clear() - } - if (appPlatform.isDesktop) { - // Don't enable this on Android, it breaks it, This method only works on desktop. For Android there is a `KeyChangeEffect(chatModel.chatId.value)` - DisposableEffect(Unit) { - onDispose { - if (chatModel.sharedContent.value is SharedContent.Forward && saveLastDraft && !composeState.value.empty) { - chatModel.draft.value = composeState.value - chatModel.draftChatId.value = chat.id - } - } - } - } - val timedMessageAllowed = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.TimedMessages) } - val sendButtonColor = - if (chat.chatInfo.incognito) - if (isInDarkTheme()) Indigo else Indigo.copy(alpha = 0.7F) - else MaterialTheme.colors.primary - SendMsgView( - composeState, - showVoiceRecordIcon = true, - recState, - chat.chatInfo is ChatInfo.Direct, - liveMessageAlertShown = chatModel.controller.appPrefs.liveMessageAlertShown, - sendMsgEnabled = sendMsgEnabled.value, - userCantSendReason = userCantSendReason.value, - sendButtonEnabled = sendMsgEnabled.value && !(simplexLinkProhibited || fileProhibited || voiceProhibited), - nextSendGrpInv = nextSendGrpInv.value, - needToAllowVoiceToContact, - allowedVoiceByPrefs, - allowVoiceToContact = ::allowVoiceToContact, - sendButtonColor = sendButtonColor, - timedMessageAllowed = timedMessageAllowed, - customDisappearingMessageTimePref = chatModel.controller.appPrefs.customDisappearingMessageTime, - placeholder = composeState.value.placeholder, - sendMessage = { ttl -> - sendMessage(ttl) - resetLinkPreview() - }, - sendLiveMessage = if (chat.chatInfo.chatType != ChatType.Local) ::sendLiveMessage else null, - updateLiveMessage = ::updateLiveMessage, - cancelLiveMessage = { - composeState.value = composeState.value.copy(liveMessage = null) - chatModel.removeLiveDummy() - }, - editPrevMessage = ::editPrevMessage, - onFilesPasted = { composeState.onFilesAttached(it) }, - onMessageChange = ::onMessageChange, - textStyle = textStyle, - focusRequester = focusRequester, + } else if ( + chat.chatInfo is ChatInfo.Direct + && chat.chatInfo.contact.nextAcceptContactRequest + && chat.chatInfo.contact.contactRequestId != null + ) { + ComposeContextContactRequestActionsView( + rhId = rhId, + contactRequestId = chat.chatInfo.contact.contactRequestId ) + } else { + Row(Modifier.padding(end = 8.dp), verticalAlignment = Alignment.Bottom) { + AttachmentButton() + SendMsgView_(disableSendButton = disableSendButton) + } } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt index 5710f09ed5..8b9caad94d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt @@ -41,7 +41,9 @@ fun SendMsgView( sendMsgEnabled: Boolean, userCantSendReason: Pair?, sendButtonEnabled: Boolean, - nextSendGrpInv: Boolean, + sendToConnect: (() -> Unit)? = null, + hideSendButton: Boolean = false, + nextConnect: Boolean, needToAllowVoiceToContact: Boolean, allowedVoiceByPrefs: Boolean, sendButtonColor: Color = MaterialTheme.colors.primary, @@ -72,7 +74,7 @@ fun SendMsgView( false } } - val showVoiceButton = !nextSendGrpInv && cs.message.text.isEmpty() && showVoiceRecordIcon && !composeState.value.editing && + val showVoiceButton = !nextConnect && cs.message.text.isEmpty() && showVoiceRecordIcon && !composeState.value.editing && !composeState.value.forwarding && cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started) && (cs.contextItem !is ComposeContextItem.ReportedItem) val showDeleteTextButton = rememberSaveable { mutableStateOf(false) } val sendMsgButtonDisabled = !sendMsgEnabled || !cs.sendEnabled() || @@ -95,7 +97,11 @@ fun SendMsgView( focusRequester ) { if (!cs.inProgress) { - sendMessage(null) + if (sendToConnect != null) { + sendToConnect() + } else { + sendMessage(null) + } } } if (clicksOnTextFieldDisabled) { @@ -133,7 +139,7 @@ fun SendMsgView( when { progressByTimeout -> ProgressIndicator() cs.contextItem is ComposeContextItem.ReportedItem -> { - SendMsgButton(painterResource(MR.images.ic_check_filled), sendButtonSize, sendButtonAlpha, sendButtonColor, !sendMsgButtonDisabled, sendMessage) + SendMsgButton(painterResource(MR.images.ic_check_filled), sendButtonSize, sendButtonAlpha, sendButtonColor, !sendMsgButtonDisabled, sendToConnect, sendMessage) } showVoiceButton && sendMsgEnabled -> { Row(verticalAlignment = Alignment.CenterVertically) { @@ -181,7 +187,7 @@ fun SendMsgView( fun MenuItems(): List<@Composable () -> Unit> { val menuItems = mutableListOf<@Composable () -> Unit>() - if (cs.liveMessage == null && !cs.editing && !nextSendGrpInv || sendMsgEnabled) { + if (cs.liveMessage == null && !cs.editing && !nextConnect || sendMsgEnabled) { if ( cs.preview !is ComposePreview.VoicePreview && cs.contextItem is ComposeContextItem.NoContextItem && @@ -215,19 +221,21 @@ fun SendMsgView( return menuItems } - val menuItems = MenuItems() - if (menuItems.isNotEmpty()) { - SendMsgButton(icon, sendButtonSize, sendButtonAlpha, sendButtonColor, !sendMsgButtonDisabled, sendMessage) { showDropdown.value = true } - DefaultDropdownMenu(showDropdown) { - menuItems.forEach { composable -> composable() } + if (!hideSendButton) { + val menuItems = MenuItems() + if (menuItems.isNotEmpty()) { + SendMsgButton(icon, sendButtonSize, sendButtonAlpha, sendButtonColor, !sendMsgButtonDisabled, sendToConnect, sendMessage) { showDropdown.value = true } + DefaultDropdownMenu(showDropdown) { + menuItems.forEach { composable -> composable() } + } + CustomDisappearingMessageDialog( + showCustomDisappearingMessageDialog, + sendMessage = sendMessage, + customDisappearingMessageTimePref = customDisappearingMessageTimePref + ) + } else { + SendMsgButton(icon, sendButtonSize, sendButtonAlpha, sendButtonColor, !sendMsgButtonDisabled, sendToConnect, sendMessage) } - CustomDisappearingMessageDialog( - showCustomDisappearingMessageDialog, - sendMessage = sendMessage, - customDisappearingMessageTimePref = customDisappearingMessageTimePref - ) - } else { - SendMsgButton(icon, sendButtonSize, sendButtonAlpha, sendButtonColor, !sendMsgButtonDisabled, sendMessage) } } } @@ -430,6 +438,7 @@ private fun SendMsgButton( alpha: Animatable, sendButtonColor: Color, enabled: Boolean, + sendToConnect: (() -> Unit)?, sendMessage: (Int?) -> Unit, onLongClick: (() -> Unit)? = null ) { @@ -438,7 +447,13 @@ private fun SendMsgButton( Box( modifier = Modifier.requiredSize(36.dp) .combinedClickable( - onClick = { sendMessage(null) }, + onClick = { + if (sendToConnect != null) { + sendToConnect() + } else { + sendMessage(null) + } + }, onLongClick = onLongClick, enabled = enabled, role = Role.Button, @@ -581,7 +596,7 @@ fun PreviewSendMsgView() { sendMsgEnabled = true, userCantSendReason = null, sendButtonEnabled = true, - nextSendGrpInv = false, + nextConnect = false, needToAllowVoiceToContact = false, allowedVoiceByPrefs = true, allowVoiceToContact = {}, @@ -616,7 +631,7 @@ fun PreviewSendMsgViewEditing() { sendMsgEnabled = true, userCantSendReason = null, sendButtonEnabled = true, - nextSendGrpInv = false, + nextConnect = false, needToAllowVoiceToContact = false, allowedVoiceByPrefs = true, allowVoiceToContact = {}, @@ -651,7 +666,7 @@ fun PreviewSendMsgViewInProgress() { sendMsgEnabled = true, userCantSendReason = null, sendButtonEnabled = true, - nextSendGrpInv = false, + nextConnect = false, needToAllowVoiceToContact = false, allowedVoiceByPrefs = true, allowVoiceToContact = {}, 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 4241505daf..5671ec0f2c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -523,6 +523,13 @@ Report other: only group moderators will see it. Report sent to moderators You can view your reports in Chat with admins. + Join group + Add message + Connect + Send contact request? + only after your request is accepted.]]> + Send request without message + Send request You can\'t send messages! contact not ready diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_add_filled.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_add_filled.svg new file mode 100644 index 0000000000..928f1de9b2 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_person_add_filled.svg @@ -0,0 +1 @@ + \ No newline at end of file