From e582d2d742120ccc208b208f70f9d95878f91551 Mon Sep 17 00:00:00 2001 From: Diogo Date: Tue, 27 Aug 2024 14:32:54 +0100 Subject: [PATCH] android, desktop: allow for chat profile selection on new chat screen (#4741) * add api and types * basic ui * add search on profiles * profile images on select chat profile * incognito adjustments * basic api connection * handling errors * add loading state * header to scroll * selected profile on top (profile or incognito) * adjust share profile copy * avoid list moving around on selection commit * bigger profile pick * info icon interactive area * thumbs to match contacts list size * incognito sizes matching icons * title to section padding * add chevron * align borders and other chevron icon * prevent click on self * only prevent selection * update * selectable item area * no need for oninfo to be composable * simplify * wrap apis in try * remove redundant derivedStateOf * closure fns capital naming * simplify current user null check --------- Co-authored-by: Evgeny Poberezkin --- .../chat/simplex/common/model/ChatModel.kt | 3 +- .../chat/simplex/common/model/SimpleXAPI.kt | 35 +- .../newchat/ContactConnectionInfoView.kt | 2 +- .../common/views/newchat/NewChatView.kt | 338 +++++++++++++++++- .../views/usersettings/UserProfilesView.kt | 4 +- .../commonMain/resources/MR/base/strings.xml | 4 + 6 files changed, 363 insertions(+), 23 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index e92b3d714a..de13f05dbd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -784,7 +784,8 @@ object ChatModel { data class ShowingInvitation( val connId: String, val connReq: String, - val connChatUsed: Boolean + val connChatUsed: Boolean, + val conn: PendingContactConnection ) enum class ChatType(val type: String) { 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 060e75a9a1..c621b9eacf 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 @@ -1132,9 +1132,30 @@ object ChatController { suspend fun apiSetConnectionIncognito(rh: Long?, connId: Long, incognito: Boolean): PendingContactConnection? { val r = sendCmd(rh, CC.ApiSetConnectionIncognito(connId, incognito)) - if (r is CR.ConnectionIncognitoUpdated) return r.toConnection - Log.e(TAG, "apiSetConnectionIncognito bad response: ${r.responseType} ${r.details}") - return null + + return when (r) { + is CR.ConnectionIncognitoUpdated -> r.toConnection + else -> { + if (!(networkErrorAlert(r))) { + apiErrorAlert("apiSetConnectionIncognito", generalGetString(MR.strings.error_sending_message), r) + } + null + } + } + } + + suspend fun apiChangeConnectionUser(rh: Long?, connId: Long, userId: Long): PendingContactConnection? { + val r = sendCmd(rh, CC.ApiChangeConnectionUser(connId, userId)) + + return when (r) { + is CR.ConnectionUserChanged -> r.toConnection + else -> { + if (!(networkErrorAlert(r))) { + apiErrorAlert("apiChangeConnectionUser", generalGetString(MR.strings.error_sending_message), r) + } + null + } + } } suspend fun apiConnectPlan(rh: Long?, connReq: String): ConnectionPlan? { @@ -2916,6 +2937,7 @@ sealed class CC { class APIVerifyGroupMember(val groupId: Long, val groupMemberId: Long, val connectionCode: String?): CC() class APIAddContact(val userId: Long, val incognito: Boolean): CC() class ApiSetConnectionIncognito(val connId: Long, val incognito: Boolean): CC() + class ApiChangeConnectionUser(val connId: Long, val userId: Long): CC() class APIConnectPlan(val userId: Long, val connReq: String): CC() class APIConnect(val userId: Long, val incognito: Boolean, val connReq: String): CC() class ApiConnectContactViaAddress(val userId: Long, val incognito: Boolean, val contactId: Long): CC() @@ -3071,6 +3093,7 @@ sealed class CC { is APIVerifyGroupMember -> "/_verify code #$groupId $groupMemberId" + if (connectionCode != null) " $connectionCode" else "" is APIAddContact -> "/_connect $userId incognito=${onOff(incognito)}" is ApiSetConnectionIncognito -> "/_set incognito :$connId ${onOff(incognito)}" + is ApiChangeConnectionUser -> "/_set conn user :$connId $userId" is APIConnectPlan -> "/_connect plan $userId $connReq" is APIConnect -> "/_connect $userId incognito=${onOff(incognito)} $connReq" is ApiConnectContactViaAddress -> "/_connect contact $userId incognito=${onOff(incognito)} $contactId" @@ -3213,6 +3236,7 @@ sealed class CC { is APIVerifyGroupMember -> "apiVerifyGroupMember" is APIAddContact -> "apiAddContact" is ApiSetConnectionIncognito -> "apiSetConnectionIncognito" + is ApiChangeConnectionUser -> "apiChangeConnectionUser" is APIConnectPlan -> "apiConnectPlan" is APIConnect -> "apiConnect" is ApiConnectContactViaAddress -> "apiConnectContactViaAddress" @@ -4757,6 +4781,7 @@ sealed class CR { @Serializable @SerialName("connectionVerified") class ConnectionVerified(val user: UserRef, val verified: Boolean, val expectedCode: String): CR() @Serializable @SerialName("invitation") class Invitation(val user: UserRef, val connReqInvitation: String, val connection: PendingContactConnection): CR() @Serializable @SerialName("connectionIncognitoUpdated") class ConnectionIncognitoUpdated(val user: UserRef, val toConnection: PendingContactConnection): CR() + @Serializable @SerialName("connectionUserChanged") class ConnectionUserChanged(val user: UserRef, val fromConnection: PendingContactConnection, val toConnection: PendingContactConnection, val newUser: UserRef): CR() @Serializable @SerialName("connectionPlan") class CRConnectionPlan(val user: UserRef, val connectionPlan: ConnectionPlan): CR() @Serializable @SerialName("sentConfirmation") class SentConfirmation(val user: UserRef, val connection: PendingContactConnection): CR() @Serializable @SerialName("sentInvitation") class SentInvitation(val user: UserRef, val connection: PendingContactConnection): CR() @@ -4935,6 +4960,7 @@ sealed class CR { is ConnectionVerified -> "connectionVerified" is Invitation -> "invitation" is ConnectionIncognitoUpdated -> "connectionIncognitoUpdated" + is ConnectionUserChanged -> "ConnectionUserChanged" is CRConnectionPlan -> "connectionPlan" is SentConfirmation -> "sentConfirmation" is SentInvitation -> "sentInvitation" @@ -5103,6 +5129,7 @@ sealed class CR { is ConnectionVerified -> withUser(user, "verified: $verified\nconnectionCode: $expectedCode") is Invitation -> withUser(user, "connReqInvitation: $connReqInvitation\nconnection: $connection") is ConnectionIncognitoUpdated -> withUser(user, json.encodeToString(toConnection)) + is ConnectionUserChanged -> withUser(user, "fromConnection: ${json.encodeToString(fromConnection)}\ntoConnection: ${json.encodeToString(toConnection)}\nnewUser: ${json.encodeToString(newUser)}" ) is CRConnectionPlan -> withUser(user, json.encodeToString(connectionPlan)) is SentConfirmation -> withUser(user, json.encodeToString(connection)) is SentInvitation -> withUser(user, json.encodeToString(connection)) @@ -5553,6 +5580,7 @@ sealed class ChatErrorType { is AgentCommandError -> "agentCommandError" is InvalidFileDescription -> "invalidFileDescription" is ConnectionIncognitoChangeProhibited -> "connectionIncognitoChangeProhibited" + is ConnectionUserChangeProhibited -> "connectionUserChangeProhibited" is PeerChatVRangeIncompatible -> "peerChatVRangeIncompatible" is InternalError -> "internalError" is CEException -> "exception $message" @@ -5630,6 +5658,7 @@ sealed class ChatErrorType { @Serializable @SerialName("agentCommandError") class AgentCommandError(val message: String): ChatErrorType() @Serializable @SerialName("invalidFileDescription") class InvalidFileDescription(val message: String): ChatErrorType() @Serializable @SerialName("connectionIncognitoChangeProhibited") object ConnectionIncognitoChangeProhibited: ChatErrorType() + @Serializable @SerialName("connectionUserChangeProhibited") object ConnectionUserChangeProhibited: ChatErrorType() @Serializable @SerialName("peerChatVRangeIncompatible") object PeerChatVRangeIncompatible: ChatErrorType() @Serializable @SerialName("internalError") class InternalError(val message: String): ChatErrorType() @Serializable @SerialName("exception") class CEException(val message: String): ChatErrorType() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt index 88e483e92d..64ff7e4f40 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt @@ -39,7 +39,7 @@ fun ContactConnectionInfoView( ) { LaunchedEffect(connReqInvitation) { if (connReqInvitation != null) { - chatModel.showingInvitation.value = ShowingInvitation(contactConnection.id, connReqInvitation, false) + chatModel.showingInvitation.value = ShowingInvitation(contactConnection.id, connReqInvitation, false, conn = contactConnection) } } /** When [AddContactLearnMore] is open, we don't need to drop [ChatModel.showingInvitation]. diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt index d2e8ac7a6c..544f5f72bb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt @@ -2,20 +2,25 @@ package chat.simplex.common.views.newchat import SectionBottomSpacer import SectionItemView +import SectionSpacer import SectionTextFooter import SectionView +import TextIconSpaced import androidx.compose.foundation.* -import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.TextStyle @@ -32,6 +37,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* import chat.simplex.res.MR +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.net.URI @@ -43,7 +49,7 @@ enum class NewChatOption { fun ModalData.NewChatView(rh: RemoteHostInfo?, selection: NewChatOption, showQRCodeScanner: Boolean = false, close: () -> Unit) { val selection = remember { stateGetOrPut("selection") { selection } } val showQRCodeScanner = remember { stateGetOrPut("showQRCodeScanner") { showQRCodeScanner } } - val contactConnection: MutableState = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(null) } + val contactConnection: MutableState = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(chatModel.showingInvitation.value?.conn) } val connReqInvitation by remember { derivedStateOf { chatModel.showingInvitation.value?.connReq ?: "" } } val creatingConnReq = rememberSaveable { mutableStateOf(false) } val pastedLink = rememberSaveable { mutableStateOf("") } @@ -177,6 +183,15 @@ private fun CreatingLinkProgressView() { DefaultProgressView(stringResource(MR.strings.creating_link)) } +private fun updateShownConnection(conn: PendingContactConnection) { + chatModel.showingInvitation.value = chatModel.showingInvitation.value?.copy( + conn = conn, + connId = conn.id, + connReq = conn.connReqInv ?: "", + connChatUsed = true + ) +} + @Composable private fun RetryButton(onClick: () -> Unit) { Column( @@ -192,6 +207,248 @@ private fun RetryButton(onClick: () -> Unit) { } } +@Composable +private fun ProfilePickerOption( + title: String, + selected: Boolean, + disabled: Boolean, + onSelected: () -> Unit, + image: @Composable () -> Unit, + onInfo: (() -> Unit)? = null +) { + Row( + Modifier + .fillMaxWidth() + .sizeIn(minHeight = DEFAULT_MIN_SECTION_ITEM_HEIGHT + 8.dp) + .clickable(enabled = !disabled, onClick = onSelected) + .padding(horizontal = DEFAULT_PADDING, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + image() + TextIconSpaced(false) + Text(title, modifier = Modifier.align(Alignment.CenterVertically)) + if (onInfo != null) { + Spacer(Modifier.padding(6.dp)) + Column(Modifier + .size(48.dp) + .clip(CircleShape) + .clickable( + enabled = !disabled, + onClick = { ModalManager.start.showModal { IncognitoView() } } + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + painterResource(MR.images.ic_info), + stringResource(MR.strings.incognito), + tint = MaterialTheme.colors.primary + ) + } + } + Spacer(Modifier.weight(1f)) + if (selected) { + Icon( + painterResource( + MR.images.ic_check + ), + title, + Modifier.size(20.dp), + tint = MaterialTheme.colors.primary, + ) + } + } + Divider( + Modifier.padding( + start = DEFAULT_PADDING_HALF, + end = DEFAULT_PADDING_HALF, + ) + ) +} + +private fun filteredProfiles(users: List, searchTextOrPassword: String): List { + val s = searchTextOrPassword.trim() + val lower = s.lowercase() + return users.filter { u -> + if ((u.activeUser || !u.hidden) && (s == "" || u.anyNameContains(lower))) { + true + } else { + correctPassword(u, s) + } + } +} + +@Composable +private fun ActiveProfilePicker( + search: MutableState, + contactConnection: PendingContactConnection?, + close: () -> Unit, + rhId: Long? +) { + val switchingProfile = remember { mutableStateOf(false) } + val incognito = remember { + chatModel.showingInvitation.value?.conn?.incognito ?: controller.appPrefs.incognito.get() + } + val selectedProfile by remember { chatModel.currentUser } + val searchTextOrPassword = rememberSaveable { search } + val profiles = remember { + chatModel.users.map { it.user }.sortedBy { !it.activeUser } + } + val filteredProfiles by remember { + derivedStateOf { filteredProfiles(profiles, searchTextOrPassword.value) } + } + + var progressByTimeout by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(switchingProfile.value) { + progressByTimeout = if (switchingProfile.value) { + delay(500) + switchingProfile.value + } else { + false + } + } + + @Composable + fun ProfilePickerUserOption(user: User) { + val selected = selectedProfile?.userId == user.userId && !incognito + + ProfilePickerOption( + title = user.chatViewName, + disabled = switchingProfile.value || selected, + selected = selected, + onSelected = { + switchingProfile.value = true + withApi { + try { + if (contactConnection != null) { + val conn = controller.apiChangeConnectionUser(rhId, contactConnection.pccConnId, user.userId) + if (conn != null) { + withChats { + updateContactConnection(rhId, conn) + updateShownConnection(conn) + } + controller.changeActiveUser_( + rhId = user.remoteHostId, + toUserId = user.userId, + viewPwd = if (user.hidden) searchTextOrPassword.value else null + ) + + if (chatModel.currentUser.value?.userId != user.userId) { + AlertManager.shared.showAlertMsg(generalGetString( + MR.strings.switching_profile_error_title), + String.format(generalGetString(MR.strings.switching_profile_error_message), user.chatViewName) + ) + } + + withChats { + updateContactConnection(user.remoteHostId, conn) + } + close.invoke() + } + } + } finally { + switchingProfile.value = false + } + } + }, + image = { ProfileImage(size = 42.dp, image = user.image) } + ) + } + + @Composable + fun IncognitoUserOption() { + ProfilePickerOption( + disabled = switchingProfile.value, + title = stringResource(MR.strings.incognito), + selected = incognito, + onSelected = { + if (!incognito) { + switchingProfile.value = true + withApi { + try { + if (contactConnection != null) { + val conn = controller.apiSetConnectionIncognito(rhId, contactConnection.pccConnId, true) + + if (conn != null) { + withChats { + updateContactConnection(rhId, conn) + updateShownConnection(conn) + } + close.invoke() + } + } + } finally { + switchingProfile.value = false + } + } + } + }, + image = { + Spacer(Modifier.width(8.dp)) + Icon( + painterResource(MR.images.ic_theater_comedy_filled), + contentDescription = stringResource(MR.strings.incognito), + Modifier.size(32.dp), + tint = Indigo, + ) + Spacer(Modifier.width(2.dp)) + }, + onInfo = { ModalManager.start.showModal { IncognitoView() } }, + ) + } + + BoxWithConstraints { + Column( + Modifier + .fillMaxSize() + .alpha(if (progressByTimeout) 0.6f else 1f) + ) { + LazyColumnWithScrollBar(userScrollEnabled = !switchingProfile.value) { + item { + AppBarTitle(stringResource(MR.strings.select_chat_profile), hostDevice(rhId), bottomPadding = DEFAULT_PADDING) + } + val activeProfile = filteredProfiles.firstOrNull { it.activeUser } + + if (activeProfile != null) { + val otherProfiles = filteredProfiles.filter { it.userId != activeProfile.userId } + + if (incognito) { + item { + IncognitoUserOption() + } + item { + ProfilePickerUserOption(activeProfile) + } + } else { + item { + ProfilePickerUserOption(activeProfile) + } + item { + IncognitoUserOption() + } + } + + itemsIndexed(otherProfiles) { _, p -> + ProfilePickerUserOption(p) + } + } else { + item { + IncognitoUserOption() + } + itemsIndexed(filteredProfiles) { _, p -> + ProfilePickerUserOption(p) + } + } + } + } + if (progressByTimeout) { + DefaultProgressView("") + } + } +} + @Composable private fun InviteView(rhId: Long?, connReqInvitation: String, contactConnection: MutableState) { SectionView(stringResource(MR.strings.share_this_1_time_link).uppercase(), headerBottomPadding = 5.dp) { @@ -204,23 +461,72 @@ private fun InviteView(rhId: Long?, connReqInvitation: String, contactConnection SimpleXLinkQRCode(connReqInvitation, onShare = { chatModel.markShowingInvitationUsed() }) } - Spacer(Modifier.height(10.dp)) - val incognito = remember { mutableStateOf(controller.appPrefs.incognito.get()) } - IncognitoToggle(controller.appPrefs.incognito, incognito) { - ModalManager.start.showModal { IncognitoView() } + Spacer(Modifier.height(DEFAULT_PADDING)) + val incognito by remember(chatModel.showingInvitation.value?.conn?.incognito, controller.appPrefs.incognito.get()) { + derivedStateOf { + chatModel.showingInvitation.value?.conn?.incognito ?: controller.appPrefs.incognito.get() + } } - KeyChangeEffect(incognito.value) { - withBGApi { - val contactConn = contactConnection.value ?: return@withBGApi - val conn = controller.apiSetConnectionIncognito(rhId, contactConn.pccConnId, incognito.value) ?: return@withBGApi - withChats { - contactConnection.value = conn - updateContactConnection(rhId, conn) + val currentUser = remember { chatModel.currentUser }.value + + if (currentUser != null) { + SectionView(stringResource(MR.strings.new_chat_share_profile).uppercase(), headerBottomPadding = 5.dp) { + SectionItemView( + padding = PaddingValues( + top = 0.dp, + bottom = 0.dp, + start = 16.dp, + end = 16.dp + ), + click = { + ModalManager.start.showCustomModal { close -> + val search = rememberSaveable { mutableStateOf("") } + ModalView( + { close() }, + endButtons = { + SearchTextField(Modifier.fillMaxWidth(), placeholder = stringResource(MR.strings.search_verb), alwaysVisible = true) { search.value = it } + }, + content = { + ActiveProfilePicker( + search = search, + close = close, + rhId = rhId, + contactConnection = contactConnection.value + ) + }) + } + } + ) { + if (incognito) { + Spacer(Modifier.width(8.dp)) + Icon( + painterResource(MR.images.ic_theater_comedy_filled), + contentDescription = stringResource(MR.strings.incognito), + tint = Indigo, + modifier = Modifier.size(32.dp) + ) + Spacer(Modifier.width(2.dp)) + } else { + ProfileImage(size = 42.dp, image = currentUser.image) + } + TextIconSpaced(false) + Text( + text = if (incognito) stringResource(MR.strings.incognito) else currentUser.chatViewName, + color = MaterialTheme.colors.onBackground + ) + Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.End) { + Icon( + painter = painterResource(MR.images.ic_arrow_forward_ios), + contentDescription = stringResource(MR.strings.new_chat_share_profile), + tint = MaterialTheme.colors.secondary, + ) + } } } - chatModel.markShowingInvitationUsed() + if (incognito) { + SectionTextFooter(generalGetString(MR.strings.connect__a_new_random_profile_will_be_shared)) + } } - SectionTextFooter(sharedProfileInfo(chatModel, incognito.value)) } @Composable @@ -366,7 +672,7 @@ private fun createInvitation( if (r != null) { withChats { updateContactConnection(rhId, r.second) - chatModel.showingInvitation.value = ShowingInvitation(connId = r.second.id, connReq = simplexChatLink(r.first), connChatUsed = false) + chatModel.showingInvitation.value = ShowingInvitation(connId = r.second.id, connReq = simplexChatLink(r.first), connChatUsed = false, conn = r.second) contactConnection.value = r.second } } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt index a7bf5920e4..d4334dfed2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt @@ -303,7 +303,7 @@ private fun ProfileActionView(action: UserProfileAction, user: User, doAction: ( } } -private fun filteredUsers(m: ChatModel, searchTextOrPassword: String): List { +fun filteredUsers(m: ChatModel, searchTextOrPassword: String): List { val s = searchTextOrPassword.trim() val lower = s.lowercase() return m.users.filter { u -> @@ -317,7 +317,7 @@ private fun filteredUsers(m: ChatModel, searchTextOrPassword: String): List !u.user.hidden }.size -private fun correctPassword(user: User, pwd: String): Boolean { +fun correctPassword(user: User, pwd: String): Boolean { val ph = user.viewPwdHash return ph != null && pwd != "" && chatPasswordHash(pwd, ph.salt) == ph.hash } 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 f5272ce7fc..9e9beef86b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -665,6 +665,10 @@ 1-time link SimpleX address Or show this code + Share profile + Select chat profile + Error switching profile + Your connection was moved to %s but an unexpected error occurred while redirecting you to the profile. Or scan QR code Keep unused invitation? You can view invitation link again in connection details.