core, ui: strip PR comments, fix Android profile-for-invitation issues

- remove all comments introduced by this PR across the touched files,
  per review decision
- fix the "New Profile" row being hidden behind the bottom app bar in
  one-handed UI mode once the profile list grows long enough to reach it
- stop force-setting connChatUsed on reassignment, so the "keep unused
  invitation?" prompt correctly fires for a genuinely unused invitation
- fix NewChatView's contactConnection state going stale after a
  reassignment (it never reflected the new connection), causing both
  the keep/delete decision and any later picker reopen to reference an
  already-superseded connection
This commit is contained in:
Narasimha-sc
2026-09-01 17:29:28 +00:00
parent 5c4650b98b
commit 0853b5919c
8 changed files with 7 additions and 183 deletions
-4
View File
@@ -280,10 +280,6 @@ func apiGetActiveUser(ctrl: chat_ctrl? = nil) throws -> User? {
}
}
/// keepActiveUser creates the profile *without* activating it, for the invitation
/// pickers: the reassignment APIs resolve the prepared chat or connection under the active
/// user, so the profile that owns it has to stay active until it has moved. The response
/// is the created user either way, which is then not the active one.
func apiCreateActiveUser(_ p: Profile?, pastTimestamp: Bool = false, keepActiveUser: Bool = false, ctrl: chat_ctrl? = nil) throws -> User {
let r: ChatResponse0 = try chatSendCmdSync(.createActiveUser(profile: p, pastTimestamp: pastTimestamp, keepActiveUser: keepActiveUser), ctrl: ctrl)
if case let .activeUser(user) = r { return user }
@@ -137,7 +137,7 @@ struct ContextProfilePickerView: View {
}
}
}
.frame(maxHeight: USER_ROW_SIZE * min(MAX_VISIBLE_USER_ROWS, CGFloat(users.count + 2))) // + 1 for incognito, + 1 for "Add profile"
.frame(maxHeight: USER_ROW_SIZE * min(MAX_VISIBLE_USER_ROWS, CGFloat(users.count + 2)))
.onAppear {
DispatchQueue.main.async {
withAnimation(nil) {
@@ -177,13 +177,10 @@ struct ContextProfilePickerView: View {
if !incognitoDefault {
listExpanded.toggle()
} else if !busy {
// Gated like the sibling write in incognitoOption, and like both of
// the Kotlin ones: only expand/collapse stays live while busy.
incognitoDefault = false
listExpanded = false
}
} else if selectedUser != user {
// Only the branch that starts work; expand/collapse is local
if busy { return }
changingProfile = true
changeProfile(user)
@@ -251,11 +248,7 @@ struct ContextProfilePickerView: View {
.disabled(busy)
}
// Created without becoming active: changeProfile below resolves the prepared chat
// under the active user, so the profile that owns it must stay active until it moves.
private func createProfileForChat(_ displayName: String, _ shortDescr: String?, _ image: String?) async throws {
// Atomic check-and-set: check-then-set lets two submits through, and @State
// must not be read off the main actor.
let alreadyCreating = await MainActor.run { () -> Bool in
if creatingProfile { return true }
creatingProfile = true
@@ -266,32 +259,20 @@ struct ContextProfilePickerView: View {
let ownerUserId = await MainActor.run { chatModel.currentUser?.userId }
let profile = Profile(displayName: displayName, fullName: "", shortDescr: shortDescr, image: image)
let newUser = try apiCreateActiveUser(profile, keepActiveUser: true)
// Checked before refreshing the lists below: on this path the core has already
// activated the new profile, so they would disagree with chatModel.currentUser
// until the resync lands - and changeActiveUserAsync_ refreshes them anyway.
if newUser.activeUser {
// An older remote host ignored keepActiveUser, so the reassignment would
// fail. Resync and report - not rethrown, or the form blames the creation.
do {
try await changeActiveUserAsync_(newUser.userId, viewPwd: nil)
} catch {
logger.error("changeActiveUserAsync_ error: \(responseError(error))")
}
await MainActor.run {
// Unconditional: the switch only removes this view when it succeeded
activeSheet = nil
// Registered here rather than trusting the resync: changeActiveUserAsync_
// calls apiSetActiveUserAsync and listUsersAsync before the MainActor.run
// that writes m.users, so a throw leaves both lists without the profile and
// a retry with the same name is refused as a duplicate.
if !chatModel.users.contains(where: { $0.user.userId == newUser.userId }) {
chatModel.users.append(UserInfo(user: newUser, unreadCount: 0))
}
if !users.contains(where: { $0.userId == newUser.userId }) {
users.append(newUser)
}
// Only if it switched, which the active user tells us: the prepared chat
// is then gone from the reloaded list and would render blank.
if chatModel.currentUser?.userId == newUser.userId && chatModel.chatId == chat.id {
chatModel.chatId = nil
}
@@ -299,11 +280,6 @@ struct ContextProfilePickerView: View {
alertAfterDismissal(NSLocalizedString("Error changing chat profile", comment: "alert title"))
return
}
// Below the branch above, which returns and registers its own copy: appending here
// as well would leave two entries flagged activeUser. Above the guard below, which
// returns without refreshing either list - the profile exists by now, so it has to
// appear in both or the next attempt at the same name is a duplicate.
// users is otherwise only filled in onAppear.
await MainActor.run {
if !chatModel.users.contains(where: { $0.user.userId == newUser.userId }) {
chatModel.users.append(UserInfo(user: newUser, unreadCount: 0))
@@ -313,10 +289,6 @@ struct ContextProfilePickerView: View {
}
}
let updatedUsers = try? await listUsersAsync()
// changeProfile resolves the prepared chat under whatever is active when it runs,
// and a notification action can have switched it while we were creating. After the
// await above, not before it: checked first, that await reopens the very window
// this closes. Kotlin orders it the same way.
let activeUserId = await MainActor.run { chatModel.currentUser?.userId }
guard activeUserId == ownerUserId else {
await MainActor.run { activeSheet = nil }
@@ -326,24 +298,16 @@ struct ContextProfilePickerView: View {
await MainActor.run {
if let updatedUsers = updatedUsers {
chatModel.users = updatedUsers
// Only filled in onAppear otherwise, so the new profile is missing here
users = updatedUsers.map { $0.user }.filter { u in u.activeUser || !u.hidden }
}
// changingProfile here too: the defer clears creatingProfile as soon as this returns
activeSheet = nil
changingProfile = true
}
changeProfile(newUser, dismissingSheet: true)
}
/// [dismissingSheet] only on the create path, which closes the form first: the delay is
/// there to outlast a sheet dismissal, and on the plain row tap there is no sheet, so it
/// would just detach the error from the tap that caused it - master alerted at once.
private func changeProfile(_ newUser: User, dismissingSheet: Bool = false) {
func report(_ title: String, _ message: String? = nil) {
// Both hop to main: this runs inside the Task below, and showAlert presents a
// UIAlertController. alertAfterDismissal does it via asyncAfter, so the
// immediate path has to do it too rather than calling showAlert here.
if dismissingSheet {
alertAfterDismissal(title, message)
} else {
@@ -403,9 +367,6 @@ struct ContextProfilePickerView: View {
if incognitoDefault {
listExpanded.toggle()
} else if !busy {
// As in profilerPickerUserOption: a failed changeProfile would leave the
// incognito default on while the picker still shows the chat profile.
// Only this branch - expanding and collapsing is local, as on Kotlin.
incognitoDefault = true
listExpanded = false
}
@@ -79,8 +79,6 @@ func showAlertReturningController(
return alert
}
/// getTopViewController() keeps returning a sheet until its dismissal transition ends, so
/// an alert raised right after dismissing one is presented on it and dropped.
func alertAfterDismissal(_ title: String, _ message: String? = nil) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
showAlert(title, message: message)
@@ -428,11 +428,6 @@ private struct ActiveProfilePicker: View {
dismiss()
}
} else {
// Backstop for a nil connection coming back without a throw: the
// row above cannot start this with contactConnection nil. Without
// it the status stays .switchingIncognito and busy leaves the
// whole picker - including "Add profile" - permanently dead. Both
// writes in one hop, so the re-entrant onChange sees .idle.
await MainActor.run {
profileSwitchStatus = .idle
incognitoEnabled = !incognito
@@ -492,10 +487,6 @@ private struct ActiveProfilePicker: View {
}
}
} else {
// apiChangeConnectionUser returns nil rather than throwing when
// the retry is cancelled - offline. Without this the status stays
// .switchingUser, switchingProfileByTimeout latches, and the
// picker is left permanently behind its spinner.
await MainActor.run {
profileSwitchStatus = .idle
selectedProfile = chatModel.currentUser ?? selectedProfile
@@ -528,10 +519,6 @@ private struct ActiveProfilePicker: View {
}
// The picker must be dead from the moment work starts: switchingProfileByTimeout only
// latches half a second later, to keep the spinner from flickering, and in that window
// a second row tap would run apiChangeConnectionUser against a connection that
// recreateConn is already deleting.
private var busy: Bool { creatingProfile || switchingProfileByTimeout || profileSwitchStatus != .idle }
@ViewBuilder private func viewBody() -> some View {
@@ -561,8 +548,6 @@ private struct ActiveProfilePicker: View {
private func profilerPickerUserOption(_ user: User) -> some View {
Button {
// contactConnection as in incognitoOption: with nothing to change the handler
// does nothing and the backstop writes the app-wide default straight back.
if selectedProfile == user && incognitoEnabled && contactConnection != nil {
incognitoEnabled = false
profileSwitchStatus = .switchingIncognito
@@ -604,11 +589,7 @@ private struct ActiveProfilePicker: View {
}
}
// Created without activating, then routed through the same selectedProfile path as
// picking an existing profile, so the connection change and the switch cannot drift.
private func createProfileForConnection(_ displayName: String, _ shortDescr: String?, _ image: String?) async throws {
// Atomic check-and-set on the main actor: a plain check-then-set leaves a window
// where two submits both pass, and @State must not be read off the main actor.
let alreadyCreating = await MainActor.run { () -> Bool in
if creatingProfile { return true }
creatingProfile = true
@@ -619,43 +600,24 @@ private struct ActiveProfilePicker: View {
let ownerUserId = await MainActor.run { chatModel.currentUser?.userId }
let profile = Profile(displayName: displayName, fullName: "", shortDescr: shortDescr, image: image)
let newUser = try apiCreateActiveUser(profile, keepActiveUser: true)
// Checked before refreshing the lists below: on this path the core has already
// activated the new profile, so they would disagree with chatModel.currentUser
// until the resync lands - and changeActiveUserAsync_ refreshes them anyway.
if newUser.activeUser {
// An older core ignored keepActiveUser, so the connection change would fail
do {
try await changeActiveUserAsync_(newUser.userId, viewPwd: nil)
} catch {
logger.error("changeActiveUserAsync_ error: \(responseError(error))")
}
// Dismiss: the app now shows a different profile and the connection stayed
// with the previous one, so a second attempt would fail the same way.
await MainActor.run {
showAddProfile = false
profileSwitchStatus = .idle
// Whatever the resync above ended up with - newUser if it switched, the
// previous profile if it threw. Not newUser unconditionally, or a failed
// switch leaves the checkmark on a profile that is not active.
selectedProfile = chatModel.currentUser ?? selectedProfile
// Registered here rather than trusting the resync: changeActiveUserAsync_
// calls apiSetActiveUserAsync and listUsersAsync before the MainActor.run
// that writes m.users, so a throw leaves that list untouched.
if !chatModel.users.contains(where: { $0.user.userId == newUser.userId }) {
chatModel.users.append(UserInfo(user: newUser, unreadCount: 0))
}
// And this snapshot is otherwise only filled in onAppear, so without it the
// picker lists the old profiles and no row shows a checkmark.
profiles = chatModel.users.map { $0.user }
}
alertAfterDismissal(NSLocalizedString("Error changing chat profile", comment: "alert title"))
return
}
// Below the branch above, which returns: adding it there would leave two entries
// flagged activeUser, and its own resync refreshes the list anyway. Above the guard
// below, which returns without refreshing either list - the profile exists by now,
// so it has to appear in both or the next attempt at the same name is a duplicate.
// profiles is otherwise only filled in onAppear.
await MainActor.run {
if !chatModel.users.contains(where: { $0.user.userId == newUser.userId }) {
chatModel.users.append(UserInfo(user: newUser, unreadCount: 0))
@@ -663,10 +625,6 @@ private struct ActiveProfilePicker: View {
profiles = chatModel.users.map { $0.user }
}
let updatedUsers = try? await listUsersAsync()
// apiChangeConnectionUser resolves pccConnId under whatever is active when the
// selectedProfile handler runs, and a notification action can have switched it.
// After the await above, not before it: checked first, that await reopens the very
// window this closes. Kotlin orders it the same way.
let activeUserId = await MainActor.run { chatModel.currentUser?.userId }
guard activeUserId == ownerUserId else {
await MainActor.run { showAddProfile = false }
@@ -675,8 +633,6 @@ private struct ActiveProfilePicker: View {
}
await MainActor.run {
if let updatedUsers = updatedUsers { chatModel.users = updatedUsers }
// Derived from chatModel.users, which already holds the new profile - without
// it selectedProfile below points at a profile that has no row in the picker.
profiles = chatModel.users.map { $0.user }
showAddProfile = false
selectedProfile = newUser
@@ -686,10 +642,6 @@ private struct ActiveProfilePicker: View {
@ViewBuilder private func profilePicker() -> some View {
let incognitoOption = Button {
// contactConnection too, as Kotlin's IncognitoUserOption checks: with nothing to
// change the handler below does nothing, and incognitoEnabled is a binding onto
// the app-wide default, so toggling it here would write that preference twice
// for an action that cannot happen.
if !incognitoEnabled && contactConnection != nil {
incognitoEnabled = true
profileSwitchStatus = .switchingIncognito
@@ -741,24 +693,17 @@ private struct ActiveProfilePicker: View {
}
}
// Outside the branch above: inside it the row would disappear whenever the
// active profile is filtered out by the search text. Only offered when there
// is a connection to move to the new profile.
if contactConnection != nil {
addProfileOption
}
}
.opacity(switchingProfileByTimeout ? 0.4 : 1)
// Attached to the picker, not to the row: the row lives in a lazy container that
// may dispose it, taking the presented sheet with it. Unlike the compose picker
// this view is never replaced, so the picker is a stable enough owner.
.sheet(isPresented: $showAddProfile) {
NavigationView {
CreateProfile(onSubmit: { displayName, shortDescr, image in
try await createProfileForConnection(displayName, shortDescr, image)
}, submitting: creatingProfile)
}
// The submit Task is unstructured and SwiftUI will not cancel it on dismissal
.interactiveDismissDisabled(creatingProfile)
}
}
@@ -880,10 +880,6 @@ object ChatController {
return null
}
/** [keepActiveUser] creates the profile *without* activating it, for the invitation
* pickers: the reassignment APIs resolve the prepared chat or connection under the
* active user, so the profile that owns it has to stay active until it has moved. The
* response is the created user either way, which is then not the active one. */
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)
@@ -60,10 +60,6 @@ fun bioFitsLimit(bio: String): Boolean {
return chatJsonLength(bio) <= MAX_BIO_LENGTH_BYTES
}
/** [onSubmit] replaces the default create-and-activate action, so the same form can be
* used to create a profile for an invitation - which must not switch the active user
* until the invitation has been moved onto it. Optional, so the existing call sites are
* untouched. */
@Composable
fun CreateProfile(chatModel: ChatModel, close: () -> Unit, submitting: Boolean = false, onSubmit: ((displayName: String, shortDescr: String, image: String?) -> Unit)? = null) {
val scope = rememberCoroutineScope()
@@ -362,44 +358,23 @@ 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. [onCreated] suspends
* so the in-flight flag covers the reassignment as well as the creation. */
fun createProfileForInvitation(rhId: Long?, onCreated: suspend (User) -> Unit) {
if (chatModel.creatingProfileForInvitation.value) return
// fullscreen: center nulls chatId, closing the chat the invitation is in; end leaves the
// compose picker live in the pane beside the form; start disposes the picker that opened it.
val modalManager = ModalManager.fullscreen
if (modalManager.isLastModalOpenNotClosing(ModalViewId.CONTEXT_USER_PICKER_NEW_PROFILE)) return
modalManager.showCustomModal(id = ModalViewId.CONTEXT_USER_PICKER_NEW_PROFILE) { close ->
// Back, Esc and the back arrow must not dismiss the form while the profile is being
// created: it would exist with the invitation never moved onto it and nothing said,
// and the next attempt at the same name fails as a duplicate. iOS closes the same
// window with interactiveDismissDisabled.
ModalView(close, enableClose = !chatModel.creatingProfileForInvitation.value) {
// Consume Back rather than leaving it unhandled: ModalView's own handler is disabled
// above, and Compose would then pass the event down to ChatView's, closing the chat
// behind the form. Registered after ModalView's, so it wins while it is enabled.
BackHandler(enabled = chatModel.creatingProfileForInvitation.value, onBack = {})
CreateProfile(chatModel, close, submitting = chatModel.creatingProfileForInvitation.value) { displayName, shortDescr, image ->
if (chatModel.creatingProfileForInvitation.value) return@CreateProfile
chatModel.creatingProfileForInvitation.value = true
// On Main, like the pickers' own handlers: every call here suspends into IO, and the
// chat model is updated on Main by the receiver loop.
val job = withApi {
try {
val ownerUserId = chatModel.currentUser.value?.userId
val profile = Profile(displayName.trim(), "", shortDescr.trim().ifEmpty { null }, image = image)
val newUser = controller.apiCreateActiveUser(rhId, profile, keepActiveUser = true) ?: return@withApi
if (newUser.activeUser) {
// An older remote host ignored the flag and activated it, so the reassignment
// would fail. Resync to what the host did and report it, with the form
// dismissed - the app is about to be showing a different profile.
if (modalManager.isLastModalOpenNotClosing(ModalViewId.CONTEXT_USER_PICKER_NEW_PROFILE)) close()
// changeActiveUser_, not changeActiveUser: the latter shows its own alert on
// failure, which would stack with the one below reporting the same thing.
suspend fun tryResync(): Boolean {
return try {
controller.changeActiveUser_(newUser.remoteHostId, newUser.userId, null)
@@ -412,9 +387,6 @@ fun createProfileForInvitation(rhId: Long?, onCreated: suspend (User) -> Unit) {
}
}
if (!tryResync()) tryResync()
// Not relying on that resync to list it: changeActiveUser_ writes currentUser
// inside its mutex before it calls listUsers, so a host that drops between the
// two leaves the model pointing at a profile its own list does not contain.
chatModel.changingActiveUserMutex.withLock {
if (chatModel.remoteHostId() == rhId && chatModel.users.none { it.user.userId == newUser.userId }) {
val isActive = chatModel.currentUser.value?.userId == newUser.userId
@@ -429,39 +401,20 @@ fun createProfileForInvitation(rhId: Long?, onCreated: suspend (User) -> Unit) {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_changing_user))
return@withApi
}
// Below the branch above, which returns and registers its own copy: appending
// here as well would leave two entries flagged activeUser. Above the
// check below, which returns without listing it - the profile exists by now, so
// it has to be in the list or the next attempt at the same name is a duplicate.
// Just this row, not a listUsers refresh: that clears and refills the whole list
// from Main while changeActiveUser_ can be doing the same from withBGApi, and it
// has nothing to add - newUser is the API's own record and a profile created a
// moment ago has no unread messages.
chatModel.changingActiveUserMutex.withLock {
if (chatModel.remoteHostId() == rhId && chatModel.users.none { it.user.userId == newUser.userId }) {
chatModel.users.add(UserInfo(newUser, 0))
}
}
// onCreated resolves the invitation under whatever is active when it runs, and a
// notification tap or a host switch can have changed that while we were creating.
if (chatModel.currentUser.value?.userId != ownerUserId || chatModel.remoteHostId() != rhId) {
// Closed: the profile exists, so leaving the form up means the next Create
// fails on the duplicate name for as long as the name is unchanged.
if (modalManager.isLastModalOpenNotClosing(ModalViewId.CONTEXT_USER_PICKER_NEW_PROFILE)) close()
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_changing_user))
return@withApi
}
// Form no longer on top. Don't move the invitation under a screen the user left -
// but the two ways to get here need different treatment.
if (!modalManager.isLastModalOpenNotClosing(ModalViewId.CONTEXT_USER_PICKER_NEW_PROFILE)) {
if (modalManager.isModalOpenNotClosing(ModalViewId.CONTEXT_USER_PICKER_NEW_PROFILE)) {
// Still there, just covered - a deep link or notification action opened a modal
// over it, which on Android shares the one stack. The form comes back with the
// name still typed, and Create would then fail as a duplicate, so say what
// happened rather than leaving the only silent branch in this function.
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_changing_user))
} else {
// Genuinely dismissed - don't report an error for something they chose.
Log.i(TAG, "createProfileForInvitation: form closed before the invitation moved")
}
return@withApi
@@ -470,10 +423,6 @@ fun createProfileForInvitation(rhId: Long?, onCreated: suspend (User) -> Unit) {
try {
onCreated(newUser)
} catch (e: Exception) {
// Not a cancellation: this catch exists because changeActiveUser_ throws and
// withApi does not catch, so the global handler would close a modal or clear
// chatId with nothing said - but reporting a cancelled coroutine as a failure
// would be wrong, and rethrowing keeps it cancelling.
if (e is CancellationException) throw e
Log.e(TAG, "createProfileForInvitation: moving the invitation failed: ${e.stackTraceToString()}")
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_changing_user))
@@ -43,8 +43,6 @@ fun ComposeContextProfilePickerView(
val users = chatModel.users.map { it.user }.filter { u -> u.activeUser || !u.hidden }
val listExpanded = remember { mutableStateOf(false) }
val changingProfile = remember { mutableStateOf(false) }
// Creating stays set until the invitation has moved, which is after the form has closed
// and this picker is interactive again
val busy = changingProfile.value || chatModel.creatingProfileForInvitation.value
val maxHeightInPx = with(LocalDensity.current) { windowHeight().toPx() }
@@ -104,8 +102,6 @@ fun ComposeContextProfilePickerView(
chatMoved = true
}
}
// Only switch if the chat moved, or the user ends up in another profile with the
// invitation left behind. apiChangePrepared*User reports the failure itself.
if (chatMoved) {
val switched = try {
chatModel.controller.changeActiveUser_(
@@ -132,8 +128,6 @@ fun ComposeContextProfilePickerView(
}
fun changeProfile(newUser: User) {
// Set before withApi, which dispatches - the rows would be live until it runs.
// The create path is covered by creatingProfileForInvitation instead.
changingProfile.value = true
withApi {
try { changeProfileTo(newUser) } finally { changingProfile.value = false }
@@ -153,9 +147,6 @@ fun ComposeContextProfilePickerView(
Modifier
.fillMaxWidth()
.sizeIn(minHeight = DEFAULT_MIN_SECTION_ITEM_HEIGHT + 8.dp)
// busy gates the branches that change something, not the row: this picker has no
// spinner or dimming, so gating the row left it inert with no feedback and not even
// collapsible during a slow change. Expanding and collapsing is local. As on iOS.
.clickable(onClick = {
if (!chat.chatInfo.profileChangeProhibited) {
if (selectedUser.value.userId == user.userId) {
@@ -75,7 +75,7 @@ fun ModalData.NewChatView(rh: RemoteHostInfo?, selection: NewChatOption, showQRC
* It will be dropped automatically when connection established or when user goes away from this screen.
**/
if (chatModel.showingInvitation.value != null && ModalManager.start.openModalCount() <= 1) {
val conn = contactConnection.value
val conn = chatModel.showingInvitation.value?.conn
if (chatModel.showingInvitation.value?.connChatUsed == false && conn != null) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.keep_unused_invitation_question),
@@ -205,8 +205,7 @@ private fun updateShownConnection(previousConnId: String, conn: PendingContactCo
chatModel.showingInvitation.value = chatModel.showingInvitation.value?.copy(
conn = conn,
connId = conn.id,
connLink = conn.connLinkInv ?: CreatedConnLink("", null),
connChatUsed = true
connLink = conn.connLinkInv ?: CreatedConnLink("", null)
)
}
@@ -295,21 +294,19 @@ fun ActiveProfilePicker(
showIncognito: Boolean = true
) {
val switchingProfile = remember { mutableStateOf(false) }
val oneHandUI = remember { appPrefs.oneHandUI.state }
val currentContactConnection = remember { mutableStateOf(contactConnection) }
val incognito = remember {
chatModel.showingInvitation.value?.conn?.incognito ?: controller.appPrefs.incognito.get()
}
val selectedProfile by remember { chatModel.currentUser }
val searchTextOrPassword = rememberSaveable { search }
// Intentionally don't use derivedStateOf in order to NOT change an order after user was selected.
// Keyed on the count too, or a profile created from this picker is missing from it.
val filteredProfiles = remember(searchTextOrPassword.value, chatModel.users.size) {
filteredProfiles(chatModel.users.map { it.user }.sortedBy { !it.activeUser }, searchTextOrPassword.value)
}
var progressByTimeout by rememberSaveable { mutableStateOf(false) }
// Busy until the connection has moved onto the new profile, not just until it exists
val busy = switchingProfile.value || chatModel.creatingProfileForInvitation.value
LaunchedEffect(busy) {
@@ -328,8 +325,6 @@ fun ActiveProfilePicker(
val conn = currentContactConnection.value
if (conn != null) {
updatedConn = controller.apiChangeConnectionUser(rhId, conn.pccConnId, user.userId)
// Not moved - leave the picker open rather than stranding a profile just created
// for this invitation. This call provisions a new queue, so it is what fails offline.
if (updatedConn == null) return
currentContactConnection.value = updatedConn
withContext(Dispatchers.Main) {
@@ -337,8 +332,6 @@ fun ActiveProfilePicker(
updateShownConnection(conn.id, updatedConn)
}
}
// After the move, not before: the picker now stays open on failure, and clearing the
// app-wide default there would leave it off with Incognito still ticked in the picker
appPreferences.incognito.set(false)
val switched = try {
@@ -430,9 +423,6 @@ fun ActiveProfilePicker(
val updatedConn = controller.apiSetConnectionIncognito(rhId, conn.pccConnId, true)
if (updatedConn != null) {
currentContactConnection.value = updatedConn
// Only once the connection is actually incognito, as on the profile path
// above: set before the call, a failure leaves the app-wide default on and
// the next connection silently uses a random profile.
appPreferences.incognito.set(true)
withContext(Dispatchers.Main) {
chatModel.chatsContext.updateContactConnection(rhId, updatedConn)
@@ -460,7 +450,6 @@ fun ActiveProfilePicker(
) {
LazyColumnWithScrollBar(Modifier.padding(top = topPaddingToContent(false)), userScrollEnabled = !busy) {
item {
val oneHandUI = remember { appPrefs.oneHandUI.state }
if (oneHandUI.value) {
Spacer(Modifier.padding(top = DEFAULT_PADDING + 5.dp))
}
@@ -501,15 +490,14 @@ fun ActiveProfilePicker(
ProfilePickerUserOption(p)
}
}
// Outside the branch above, or the row disappears when search filters the active
// profile out. Only with a connection to move - the share list has none.
if (contactConnection != null) {
item {
NewProfileOption()
}
}
item {
Spacer(Modifier.imePadding().padding(bottom = DEFAULT_BOTTOM_PADDING))
val bottomBarClearance = if (oneHandUI.value) WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + AppBarHeight * fontSizeSqrtMultiplier else 0.dp
Spacer(Modifier.imePadding().padding(bottom = DEFAULT_BOTTOM_PADDING + bottomBarClearance))
}
}
}
@@ -597,7 +585,7 @@ private fun InviteView(rhId: Long?, connLinkInvitation: CreatedConnLink, contact
search = search,
close = close,
rhId = rhId,
contactConnection = contactConnection.value
contactConnection = chatModel.showingInvitation.value?.conn
)
})
}