From b0b852a10f1994e8bcc5bc7e4adf4ca8d3e5cc8a Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Sat, 1 Aug 2026 15:43:35 +0000 Subject: [PATCH] android, desktop: offer creating a profile when accepting an invitation Opening someone's link prepares a chat and shows the profile picker above the compose box. It listed existing profiles and Incognito, so connecting as someone new meant leaving the invitation, creating a profile in settings, and coming back to the link. Adds "Add profile" to that picker, reusing the existing users_add string. The profile is created without becoming active, so the profile that owns the prepared chat stays active for the reassignment, which then switches once - rather than switching away and back. The row is emitted last, so with the reversed layout it renders at the top of the expanded list with the current selection nearest the compose box. After switching, the chat is reopened explicitly: keepingChatId only preserves its place in the reloaded list, so without that the switch lands on the chat list of the new profile rather than the invitation it was chosen for. This applies to picking an existing profile too. The form is only dismissed once creation succeeds, so a rejected duplicate name does not discard what was typed. A dismissal guard keeps a back-tap during creation from switching the profile anyway, and the created profile is put into the picker list immediately so it stays visible if the reassignment fails. If the core activated the profile despite keepActiveUser - an older remote host ignoring the field - the reassignment would fail, so that case resyncs to what the host actually did instead. Updates the onboarding flow doc, which names the arguments of the command that gains the field. --- .../chat/simplex/common/model/SimpleXAPI.kt | 22 ++++++--- .../chat/simplex/common/views/WelcomeView.kt | 44 ++++++++++++++++++ .../chat/ComposeContextProfilePickerView.kt | 45 +++++++++++++++++++ .../simplex/common/views/helpers/ModalView.kt | 3 +- .../multiplatform/product/flows/onboarding.md | 2 +- 5 files changed, 108 insertions(+), 8 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 037c02b1c2..c40e2cbe7a 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,17 @@ 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, ctrl: ChatCtrl? = null): User? = + createUser(rh, p, pastTimestamp = pastTimestamp, keepActiveUser = false, ctrl = ctrl) + + /** Creates a profile *without* activating it: [apiChangePreparedContactUser] resolves the + * prepared chat under the active user, so the profile that owns it must stay active until + * the chat has moved. The returned user is therefore not the active one. */ + suspend fun apiCreateProfileKeepingActive(rh: Long?, p: Profile): User? = + createUser(rh, p, pastTimestamp = false, keepActiveUser = true, ctrl = null) + + private suspend fun createUser(rh: Long?, p: Profile?, pastTimestamp: Boolean, keepActiveUser: Boolean, ctrl: ChatCtrl?): 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 ( @@ -896,7 +905,7 @@ object ChatController { } else { AlertManager.shared.showAlertMsg(generalGetString(MR.strings.failed_to_create_user_title), r.details) } - Log.d(TAG, "apiCreateActiveUser: ${r.responseType} ${r.details}") + Log.d(TAG, "createUser (keepActiveUser=$keepActiveUser): ${r.responseType} ${r.details}") return null } @@ -3768,7 +3777,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() @@ -3950,7 +3959,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" @@ -4399,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 32cb6919ad..ec914ee237 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 @@ -347,6 +347,50 @@ private fun CreateFirstProfileDesktop(chatModel: ChatModel, close: () -> Unit) { } } +// Creates a profile for an invitation and hands it to onCreated, which moves the +// invitation onto it. The profile is created *without* becoming active: the reassignment +// APIs resolve the prepared chat or connection under the active user, so the profile that +// owns the invitation has to stay active until onCreated has run. +fun createProfileForInvitation(rhId: Long?, creating: MutableState, onCreated: (User) -> Unit) { + // Two taps before the modal renders would otherwise stack two modals sharing one id, + // after which close() could dismiss the wrong one. + if (ModalManager.center.hasModalOpen(ModalViewId.CONTEXT_USER_PICKER_NEW_PROFILE)) return + ModalManager.center.showModalCloseable(id = ModalViewId.CONTEXT_USER_PICKER_NEW_PROFILE) { close -> + CreateProfile { displayName, shortDescr, image -> + if (creating.value) return@CreateProfile + creating.value = true + withBGApi { + try { + val profile = Profile(displayName.trim(), "", shortDescr.trim().ifEmpty { null }, image = image) + val newUser = controller.apiCreateProfileKeepingActive(rhId, profile) ?: return@withBGApi + if (newUser.activeUser) { + // The core did not honour keepActiveUser and activated the profile - an older + // remote host ignoring the unknown field. Reassigning would now fail, so resync + // to what the host actually did and report it. Not switching_profile_error_message: + // that says the invitation was moved, and on this path it was not. + controller.changeActiveUser(newUser.remoteHostId, newUser.userId, null) + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_changing_user)) + return@withBGApi + } + // Keep chatModel.users current even if onCreated's reassignment fails - it only + // refreshes when it actually switches. listUsers throws and withBGApi does not + // catch, so this cosmetic refresh is guarded. + runCatching { controller.listUsers(rhId) }.getOrNull()?.let { updatedUsers -> + chatModel.users.clear() + chatModel.users.addAll(updatedUsers) + } + if (ModalManager.center.isLastModalOpen(ModalViewId.CONTEXT_USER_PICKER_NEW_PROFILE)) { + close() + } + onCreated(newUser) + } finally { + creating.value = false + } + } + } + } +} + // The two ordinary "add a profile" paths, where the new profile becomes the active // one. Creating one for an invitation takes neither, which is why the form itself // no longer chooses. 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 875021df68..5230e7d600 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,6 +18,7 @@ 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.IncognitoOptionImage import chat.simplex.common.views.usersettings.IncognitoView @@ -40,6 +41,8 @@ fun ComposeContextProfilePickerView( val incognitoDefault = chatModel.controller.appPrefs.incognito.get() val users = chatModel.users.map { it.user }.filter { u -> u.activeUser || !u.hidden } val listExpanded = remember { mutableStateOf(false) } + // Not rememberSaveable, and hoisted out of the lazy item: either strands it true. + val creatingProfile = remember { mutableStateOf(false) } val maxHeightInPx = with(LocalDensity.current) { windowHeight().toPx() } val isVisible = remember { mutableStateOf(false) } @@ -108,6 +111,11 @@ fun ComposeContextProfilePickerView( viewPwd = null, keepingChatId = chat.id ) + // Reopen the chat under the new profile. keepingChatId only preserves its + // place in the reloaded list, so without this the switch lands on the chat + // list of the new profile rather than the invitation it was chosen for. + // The id is unchanged by the reassignment - it is the contact/group id. + chatModel.chatId.value = chat.id if (chatModel.currentUser.value?.userId != newUser.userId) { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.switching_profile_error_title), @@ -228,6 +236,36 @@ fun ComposeContextProfilePickerView( } } + @Composable + fun NewProfileOption() { + Row( + Modifier + .fillMaxWidth() + .sizeIn(minHeight = DEFAULT_MIN_SECTION_ITEM_HEIGHT + 8.dp) + .clickable(onClick = { createProfileForInvitation(rhId, creatingProfile) { changeProfile(it) } }) + .padding(horizontal = DEFAULT_PADDING_HALF, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Box(Modifier.size(USER_ROW_AVATAR_SIZE), contentAlignment = Alignment.Center) { + Icon( + painterResource(MR.images.ic_manage_accounts), + contentDescription = null, + Modifier.size(24.dp), + tint = MaterialTheme.colors.primary, + ) + } + TextIconSpaced(false) + Text( + stringResource(MR.strings.users_add), + modifier = Modifier.align(Alignment.CenterVertically), + color = MaterialTheme.colors.primary, + ) + + Spacer(Modifier.weight(1f)) + } + } + @Composable fun ProfilePicker() { LazyColumnWithScrollBarNoAppBar( @@ -273,6 +311,13 @@ fun ComposeContextProfilePickerView( ) ProfilePickerUserOption(user) } + + // Emitted last, so with reverseLayout it renders at the top of the expanded + // list - furthest from the compose box, with the current selection nearest. + item { + Divider(Modifier.padding(horizontal = DEFAULT_PADDING_HALF)) + NewProfileOption() + } } } 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/product/flows/onboarding.md b/apps/multiplatform/product/flows/onboarding.md index b6b3e835a5..f922fa28a5 100644 --- a/apps/multiplatform/product/flows/onboarding.md +++ b/apps/multiplatform/product/flows/onboarding.md @@ -116,7 +116,7 @@ enum class OnboardingStage { ```kotlin suspend fun apiCreateActiveUser(rh: Long?, p: Profile?, pastTimestamp: 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 (onboarding always passes `keepActiveUser = false`, so the new profile becomes active). 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: