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.
This commit is contained in:
Narasimha-sc
2026-08-03 13:12:00 +00:00
parent 4375a839f6
commit b0b852a10f
5 changed files with 108 additions and 8 deletions
@@ -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<T>(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 {
@@ -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<Boolean>, 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.
@@ -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()
}
}
}
@@ -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) {
@@ -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: