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 63d0f4f82a..612f5da97f 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 @@ -880,8 +880,8 @@ object ChatController { return null } - suspend fun apiCreateActiveUser(rh: Long?, p: Profile?, pastTimestamp: Boolean = false, ctrl: ChatCtrl? = null): User? { - val r = sendCmd(rh, CC.CreateActiveUser(p, pastTimestamp = pastTimestamp), ctrl) + suspend fun apiCreateActiveUser(rh: Long?, p: Profile?, pastTimestamp: Boolean = false, keepActiveUser: Boolean = false, ctrl: ChatCtrl? = null): User? { + val r = sendCmd(rh, CC.CreateActiveUser(p, pastTimestamp = pastTimestamp, keepActiveUser = keepActiveUser), ctrl) if (r is API.Result && r.res is CR.ActiveUser) return r.res.user.updateRemoteHostId(rh) val e = (r as? API.Error)?.err if ( @@ -3774,7 +3774,7 @@ class SharedPreference(val get: () -> T, set: (T) -> Unit) { sealed class CC { class Console(val cmd: String): CC() class ShowActiveUser: CC() - class CreateActiveUser(val profile: Profile?, val pastTimestamp: Boolean): CC() + class CreateActiveUser(val profile: Profile?, val pastTimestamp: Boolean, val keepActiveUser: Boolean): CC() class ListUsers: CC() class ApiSetActiveUser(val userId: Long, val viewPwd: String?): CC() class SetAllContactReceipts(val enable: Boolean): CC() @@ -3957,7 +3957,7 @@ sealed class CC { is Console -> cmd is ShowActiveUser -> "/u" is CreateActiveUser -> { - val user = NewUser(profile, pastTimestamp = pastTimestamp) + val user = NewUser(profile, pastTimestamp = pastTimestamp, keepActiveUser = keepActiveUser) "/_create user ${json.encodeToString(user)}" } is ListUsers -> "/users" @@ -4408,7 +4408,8 @@ fun onOff(b: Boolean): String = if (b) "on" else "off" data class NewUser( val profile: Profile?, val pastTimestamp: Boolean, - val userChatRelay: Boolean = false + val userChatRelay: Boolean = false, + val keepActiveUser: Boolean = false ) sealed class ChatPagination { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt index 843bacecec..ab0117414b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/WelcomeView.kt @@ -58,7 +58,7 @@ fun bioFitsLimit(bio: String): Boolean { } @Composable -fun CreateProfile(chatModel: ChatModel, close: () -> Unit) { +fun CreateProfile(chatModel: ChatModel, close: () -> Unit, createProfile: ((String, String, String?) -> Unit)? = null) { val scope = rememberCoroutineScope() val scrollState = rememberScrollState() val keyboardState by getKeyboardState() @@ -161,7 +161,9 @@ fun CreateProfile(chatModel: ChatModel, close: () -> Unit) { textColor = MaterialTheme.colors.primary, iconColor = MaterialTheme.colors.primary, click = { - if (chatModel.localUserCreated.value == true) { + if (createProfile != null) { + createProfile(displayName.value, shortDescr.value, profileImage.value) + } else if (chatModel.localUserCreated.value == true) { createProfileInProfiles(chatModel, displayName.value, shortDescr.value, profileImage.value, close) } else { createProfileInNoProfileSetup(displayName.value, profileImage.value, close) @@ -353,6 +355,24 @@ private fun CreateFirstProfileDesktop(chatModel: ChatModel, close: () -> Unit) { } } +fun createProfileForInvitation(rhId: Long?, onCreated: (User) -> Unit) { + val modalManager = ModalManager.fullscreen + modalManager.showModalCloseable(id = ModalViewId.CONTEXT_USER_PICKER_NEW_PROFILE) { close -> + CreateProfile(chatModel, close, createProfile = { displayName, shortDescr, image -> + withBGApi { + val profile = Profile(displayName.trim(), "", shortDescr.trim().ifEmpty { null }, image = image) + val user = controller.apiCreateActiveUser(rhId, profile, keepActiveUser = true) ?: return@withBGApi + val users = controller.listUsers(rhId) + chatModel.users.clear() + chatModel.users.addAll(users) + if (!modalManager.isLastModalOpen(ModalViewId.CONTEXT_USER_PICKER_NEW_PROFILE)) return@withBGApi + close() + onCreated(user) + } + }) + } +} + fun createProfileInNoProfileSetup(displayName: String, image: String? = null, close: () -> Unit) { withBGApi { val user = controller.apiCreateActiveUser(null, Profile(displayName.trim(), "", null, image)) ?: return@withBGApi diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextProfilePickerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextProfilePickerView.kt index b7be49055e..06abd79cdb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextProfilePickerView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextProfilePickerView.kt @@ -18,7 +18,9 @@ import androidx.compose.ui.unit.* import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.createProfileForInvitation import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.newchat.AddProfileOptionImage import chat.simplex.common.views.newchat.IncognitoOptionImage import chat.simplex.common.views.usersettings.IncognitoView import chat.simplex.res.MR @@ -221,6 +223,31 @@ fun ComposeContextProfilePickerView( } } + @Composable + fun NewProfileOption() { + Row( + Modifier + .fillMaxWidth() + .sizeIn(minHeight = DEFAULT_MIN_SECTION_ITEM_HEIGHT + 8.dp) + .clickable(onClick = { + if (chat.chatInfo.profileChangeProhibited) { + showCantChangeProfileAlert() + } else { + createProfileForInvitation(rhId) { changeProfile(it) } + } + }) + .padding(horizontal = DEFAULT_PADDING_HALF, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AddProfileOptionImage() + TextIconSpaced(false) + Text( + stringResource(MR.strings.users_add), + color = MaterialTheme.colors.primary, + ) + } + } + @Composable fun ProfilePicker() { LazyColumnWithScrollBarNoAppBar( @@ -266,6 +293,11 @@ fun ComposeContextProfilePickerView( ) ProfilePickerUserOption(user) } + + item { + NewProfileOption() + if (otherUsers.isEmpty()) Divider(Modifier.padding(horizontal = DEFAULT_PADDING_HALF)) + } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt index 02c0b45de4..20fe22b641 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt @@ -91,7 +91,8 @@ class ModalData(val keyboardCoversBar: Boolean = true) { enum class ModalViewId { SECONDARY_CHAT, - CONTEXT_USER_PICKER_INCOGNITO + CONTEXT_USER_PICKER_INCOGNITO, + CONTEXT_USER_PICKER_NEW_PROFILE } class ModalManager(private val placement: ModalPlacement? = null) { 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 f3006d221b..5805aee0c2 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 @@ -39,6 +39,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.item.CIFileViewScope import chat.simplex.common.views.chat.topPaddingToContent +import chat.simplex.common.views.createProfileForInvitation import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* import chat.simplex.common.BuildConfigCommon @@ -314,6 +315,51 @@ fun ActiveProfilePicker( } } + fun selectProfile(user: User) { + switchingProfile.value = true + withApi { + try { + appPreferences.incognito.set(false) + var updatedConn: PendingContactConnection? = null; + + if (contactConnection != null) { + updatedConn = controller.apiChangeConnectionUser(rhId, contactConnection.pccConnId, user.userId) + if (updatedConn != null) { + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContactConnection(rhId, updatedConn) + updateShownConnection(updatedConn) + } + } + } + + if ((contactConnection != null && updatedConn != null) || contactConnection == null) { + 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) + ) + } + } + + if (updatedConn != null) { + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContactConnection(user.remoteHostId, updatedConn) + } + } + + close() + } finally { + switchingProfile.value = false + } + } + } + @Composable fun ProfilePickerUserOption(user: User) { val selected = selectedProfile?.userId == user.userId && !incognito @@ -322,55 +368,23 @@ fun ActiveProfilePicker( title = user.chatViewName, disabled = switchingProfile.value || selected, selected = selected, - onSelected = { - switchingProfile.value = true - withApi { - try { - appPreferences.incognito.set(false) - var updatedConn: PendingContactConnection? = null; - - if (contactConnection != null) { - updatedConn = controller.apiChangeConnectionUser(rhId, contactConnection.pccConnId, user.userId) - if (updatedConn != null) { - withContext(Dispatchers.Main) { - chatModel.chatsContext.updateContactConnection(rhId, updatedConn) - updateShownConnection(updatedConn) - } - } - } - - if ((contactConnection != null && updatedConn != null) || contactConnection == null) { - 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) - ) - } - } - - if (updatedConn != null) { - withContext(Dispatchers.Main) { - chatModel.chatsContext.updateContactConnection(user.remoteHostId, updatedConn) - } - } - - close() - } finally { - switchingProfile.value = false - } - } - }, + onSelected = { selectProfile(user) }, image = { ProfileImage(size = 42.dp, image = user.image) }, badge = user.profile.localBadge ) } + @Composable + fun NewProfileOption() { + ProfilePickerOption( + title = stringResource(MR.strings.users_add), + disabled = switchingProfile.value, + selected = false, + onSelected = { createProfileForInvitation(rhId) { selectProfile(it) } }, + image = { AddProfileOptionImage() } + ) + } + @Composable fun IncognitoUserOption() { ProfilePickerOption( @@ -453,6 +467,11 @@ fun ActiveProfilePicker( ProfilePickerUserOption(p) } } + if (contactConnection != null) { + item { + NewProfileOption() + } + } item { Spacer(Modifier.imePadding().padding(bottom = DEFAULT_BOTTOM_PADDING)) } @@ -595,6 +614,18 @@ fun ToggleShortLinkButton(short: MutableState) { } } +@Composable +fun AddProfileOptionImage() { + Box(Modifier.size(42.dp), contentAlignment = Alignment.Center) { + Icon( + painterResource(MR.images.ic_manage_accounts), + contentDescription = null, + Modifier.size(24.dp), + tint = MaterialTheme.colors.primary, + ) + } +} + @Composable fun IncognitoOptionImage() { Spacer(Modifier.width(8.dp)) diff --git a/apps/multiplatform/product/flows/onboarding.md b/apps/multiplatform/product/flows/onboarding.md index b6b3e835a5..5410b649d8 100644 --- a/apps/multiplatform/product/flows/onboarding.md +++ b/apps/multiplatform/product/flows/onboarding.md @@ -114,9 +114,9 @@ enum class OnboardingStage { 2. User enters a display name (validated via `chatValidName` JNI) and optional full name. 3. On submit, `ChatController.apiCreateActiveUser(rh, profile)` is called: ```kotlin - suspend fun apiCreateActiveUser(rh: Long?, p: Profile?, pastTimestamp: Boolean = false, ctrl: ChatCtrl? = null): User? + suspend fun apiCreateActiveUser(rh: Long?, p: Profile?, pastTimestamp: Boolean = false, keepActiveUser: Boolean = false, ctrl: ChatCtrl? = null): User? ``` -4. The core command `CC.CreateActiveUser(p, pastTimestamp)` creates the user in the database. +4. The core command `CC.CreateActiveUser(p, pastTimestamp, keepActiveUser)` creates the user in the database. 5. On success, `CR.ActiveUser` returns the new `User` object. 6. `ChatModel.currentUser` is set. 7. If the chat is not yet running, `startChat(user)` is called: diff --git a/apps/multiplatform/spec/api.md b/apps/multiplatform/spec/api.md index 4114e9de4f..fdc072f080 100644 --- a/apps/multiplatform/spec/api.md +++ b/apps/multiplatform/spec/api.md @@ -90,7 +90,7 @@ All functions below are `suspend fun` members of `ChatController` ([SimpleXAPI.k | Command | Parameters | Description | Line | |---------|-----------|-------------|------| | `apiGetActiveUser` | `rh: Long?, ctrl: ChatCtrl?` | Fetch the currently active user profile | [L841](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L841) | -| `apiCreateActiveUser` | `rh: Long?, p: Profile?, pastTimestamp: Boolean, ctrl: ChatCtrl?` | Create a new user profile and set it as active | [L851](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L851) | +| `apiCreateActiveUser` | `rh: Long?, p: Profile?, pastTimestamp: Boolean, keepActiveUser: Boolean, ctrl: ChatCtrl?` | Create a new user profile and set it as active, unless `keepActiveUser` | [L851](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L851) | | `listUsers` | `rh: Long?` | List all user profiles sorted by display name | [L871](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L871) | | `apiSetActiveUser` | `rh: Long?, userId: Long, viewPwd: String?` | Switch the active user to a different profile | [L881](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L881) | | `apiSetAllContactReceipts` | `rh: Long?, enable: Boolean` | Enable/disable delivery receipts for all contacts globally | [L888](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L888) |