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: