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.
This commit is contained in:
Narasimha-sc
2026-09-05 11:27:46 +00:00
parent b137ff459e
commit 7ca92217ab
7 changed files with 140 additions and 55 deletions
@@ -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<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()
@@ -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 {
@@ -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
@@ -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))
}
}
}
@@ -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) {
@@ -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<Boolean>) {
}
}
@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))
@@ -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:
+1 -1
View File
@@ -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) |