From 7ca92217ab73fbe8a22a2549d23023f7de6eb0e3 Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:27:46 +0000 Subject: [PATCH] android, desktop: offer creating a profile for an invitation Both profile pickers get an "Add profile" row. It opens the existing CreateProfile form, which gains an optional submit callback, and hands the created profile to the picker's existing selection path - so the create flow and a row tap run the same code. ActiveProfilePicker's row handler is extracted to selectProfile() for that; the body is unchanged apart from indentation. The form opens in ModalManager.fullscreen: on desktop the other placements are panes, where center closes the chat the invitation is in and start and end leave the picker beside it usable while apiChangeConnectionUser invalidates the connection id. The list is refreshed after creating, because keepActiveUser skips the switch that would otherwise do it and nothing else re-lists, and the reassignment is skipped if the form was closed while the profile was being created, when close() would pop the picker instead. --- .../chat/simplex/common/model/SimpleXAPI.kt | 11 +- .../chat/simplex/common/views/WelcomeView.kt | 24 +++- .../chat/ComposeContextProfilePickerView.kt | 32 +++++ .../simplex/common/views/helpers/ModalView.kt | 3 +- .../common/views/newchat/NewChatView.kt | 119 +++++++++++------- .../multiplatform/product/flows/onboarding.md | 4 +- apps/multiplatform/spec/api.md | 2 +- 7 files changed, 140 insertions(+), 55 deletions(-) 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) |