From 58aee54ad65eb1ae0ac56269315c2fa2fb82e2d0 Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:43:11 +0000 Subject: [PATCH] android, desktop, ios: do not offer to accept or join twice An invitation being accepted is a fact about the invitation, not about the view that shows it, but each view kept its own flag. The chat list row, the long-press menu, the accept alert, the composer banner, the chat item "tap to join" and the notification action therefore disagreed: accepting from one left the others fully enabled, and the second accept reached the core with an invitation the first one had already used. ChatModel now records which contact requests are being accepted and which groups are being joined. The shared acceptContactRequest and the new joinGroup maintain it, so every caller participates without changing its call site, and every view derives from it instead of owning a flag. The record is taken before the request is sent, so a second tap in the same view is blocked as well. The connect composer no longer renders in a group's support scope: on desktop that is a second view of the same group next to the main one, and it offered its own Join button. --- apps/ios/Shared/Model/ChatModel.swift | 4 + apps/ios/Shared/Model/SimpleXAPI.swift | 8 +- .../Chat/ChatItem/CIGroupInvitationView.swift | 10 +- .../ContextContactRequestActionsView.swift | 6 +- .../Views/ChatList/ChatListNavLink.swift | 78 ++++++++------ .../chat/simplex/common/model/ChatModel.kt | 4 +- .../simplex/common/views/chat/ChatView.kt | 15 +-- ...ComposeContextContactRequestActionsView.kt | 22 ++-- .../simplex/common/views/chat/ComposeView.kt | 19 ++-- .../views/chat/item/CIGroupInvitationView.kt | 16 ++- .../common/views/chat/item/ChatItemView.kt | 6 +- .../views/chatlist/ChatListNavLinkView.kt | 102 +++++++++--------- 12 files changed, 151 insertions(+), 139 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index dedb03b5aa..bff6b8d01d 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -383,6 +383,10 @@ final class ChatModel: ObservableObject { // list of chat "previews" @Published private(set) var chats: [Chat] = [] @Published var deletedChats: Set = [] + // ids of contact requests being accepted and of groups being joined, + // to not offer accepting the same invitation from another view + @Published var acceptingContactRequests: Set = [] + @Published var joiningGroups: Set = [] // current chat @Published var chatId: String? @Published var chatAgentConnId: String? diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 7a934fc746..b02632f937 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1770,8 +1770,8 @@ func networkErrorAlert(_ res: APIResult) -> (title: String, message: Strin } } -func acceptContactRequest(incognito: Bool, contactRequestId: Int64, inProgress: Binding? = nil) async { - await MainActor.run { inProgress?.wrappedValue = true } +func acceptContactRequest(incognito: Bool, contactRequestId: Int64) async { + await MainActor.run { _ = ChatModel.shared.acceptingContactRequests.insert(contactRequestId) } if let contact = await apiAcceptContactRequest(incognito: incognito, contactReqId: contactRequestId) { let chat = Chat(chatInfo: ChatInfo.direct(contact: contact), chatItems: []) await MainActor.run { @@ -1780,7 +1780,7 @@ func acceptContactRequest(incognito: Bool, contactRequestId: Int64, inProgress: } else { ChatModel.shared.replaceChat(contactRequestChatId(contactRequestId), chat) } - inProgress?.wrappedValue = false + ChatModel.shared.acceptingContactRequests.remove(contactRequestId) } if contact.sndReady { let chatId = chat.id @@ -1791,7 +1791,7 @@ func acceptContactRequest(incognito: Bool, contactRequestId: Int64, inProgress: } } } else { - await MainActor.run { inProgress?.wrappedValue = false } + await MainActor.run { _ = ChatModel.shared.acceptingContactRequests.remove(contactRequestId) } } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift index b34076eafd..af9b26317a 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift @@ -21,9 +21,10 @@ struct CIGroupInvitationView: View { var memberRole: GroupMemberRole var chatIncognito: Bool = false @State private var frameWidth: CGFloat = 0 - @State private var inProgress = false @State private var progressByTimeout = false + private var inProgress: Bool { chatModel.joiningGroups.contains(groupInvitation.groupId) } + @AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false var body: some View { @@ -78,12 +79,7 @@ struct CIGroupInvitationView: View { .progressByTimeout(inProgress, $progressByTimeout) if action { - v.simultaneousGesture(TapGesture().onEnded { - inProgress = true - joinGroup(groupInvitation.groupId) { - await MainActor.run { inProgress = false } - } - }) + v.simultaneousGesture(TapGesture().onEnded { joinGroup(groupInvitation.groupId) }) .disabled(inProgress) } else { v diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextContactRequestActionsView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextContactRequestActionsView.swift index 41794f2dad..b3a2c71fdb 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextContactRequestActionsView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextContactRequestActionsView.swift @@ -10,12 +10,14 @@ import SwiftUI import SimpleXChat struct ContextContactRequestActionsView: View { + @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var theme: AppTheme var contactRequestId: Int64 @UserDefault(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial - @State private var inProgress = false @State private var progressByTimeout = false + private var inProgress: Bool { chatModel.acceptingContactRequests.contains(contactRequestId) } + var body: some View { HStack(spacing: 0) { Button(role: .destructive, action: showRejectRequestAlert) { @@ -77,7 +79,7 @@ struct ContextContactRequestActionsView: View { private func acceptRequest(incognito: Bool = false) { Task { - await acceptContactRequest(incognito: incognito, contactRequestId: contactRequestId, inProgress: $inProgress) + await acceptContactRequest(incognito: incognito, contactRequestId: contactRequestId) } } } diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index e7e871a20e..296dba8857 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -57,11 +57,26 @@ struct ChatListNavLink: View { @State private var actionSheet: SomeActionSheet? = nil @State private var sheet: SomeSheet? = nil @State private var showConnectContactViaAddressDialog = false - @State private var inProgress = false @State private var progressByTimeout = false var dynamicRowHeight: CGFloat { dynamicSize(userFont).rowHeight } + private var inProgress: Bool { + switch chat.chatInfo { + case let .group(groupInfo, _): + return chatModel.joiningGroups.contains(groupInfo.groupId) + case let .direct(contact): + if let contactRequestId = contact.contactRequestId { + return chatModel.acceptingContactRequests.contains(contactRequestId) + } + return false + case let .contactRequest(contactRequest): + return chatModel.acceptingContactRequests.contains(contactRequest.apiId) + default: + return false + } + } + var body: some View { Group { switch chat.chatInfo { @@ -126,17 +141,19 @@ struct ChatListNavLink: View { .swipeActions(edge: .trailing, allowsFullSwipe: true) { if contact.nextAcceptContactRequest { if let contactRequestId = contact.contactRequestId { - Button { - Task { await acceptContactRequest(incognito: false, contactRequestId: contactRequestId) } - } label: { SwipeLabel(NSLocalizedString("Accept", comment: "swipe action"), systemImage: "checkmark", inverted: oneHandUI) } - .tint(theme.colors.primary) - if !ChatModel.shared.addressShortLinkDataSet { + if !inProgress { Button { - Task { await acceptContactRequest(incognito: true, contactRequestId: contactRequestId) } - } label: { - SwipeLabel(NSLocalizedString("Accept incognito", comment: "swipe action"), systemImage: "theatermasks.fill", inverted: oneHandUI) + Task { await acceptContactRequest(incognito: false, contactRequestId: contactRequestId) } + } label: { SwipeLabel(NSLocalizedString("Accept", comment: "swipe action"), systemImage: "checkmark", inverted: oneHandUI) } + .tint(theme.colors.primary) + if !ChatModel.shared.addressShortLinkDataSet { + Button { + Task { await acceptContactRequest(incognito: true, contactRequestId: contactRequestId) } + } label: { + SwipeLabel(NSLocalizedString("Accept incognito", comment: "swipe action"), systemImage: "theatermasks.fill", inverted: oneHandUI) + } + .tint(.indigo) } - .tint(.indigo) } Button { AlertManager.shared.showAlert(rejectContactRequestAlert(contactRequestId)) @@ -212,7 +229,9 @@ struct ChatListNavLink: View { ChatPreviewView(chat: chat, progressByTimeout: $progressByTimeout) .frameCompat(height: dynamicRowHeight) .swipeActions(edge: .trailing, allowsFullSwipe: true) { - joinGroupButton() + if !inProgress { + joinGroupButton() + } if groupInfo.canDelete { deleteGroupChatButton(groupInfo) } @@ -220,10 +239,7 @@ struct ChatListNavLink: View { .onTapGesture { showJoinGroupDialog = true } .confirmationDialog("Group invitation", isPresented: $showJoinGroupDialog, titleVisibility: .visible) { Button(chat.chatInfo.incognito ? "Join incognito" : "Join group") { - inProgress = true - joinGroup(groupInfo.groupId) { - await MainActor.run { inProgress = false } - } + joinGroup(groupInfo.groupId) } Button("Delete invitation", role: .destructive) { Task { await deleteChat(chat) } } } @@ -311,10 +327,7 @@ struct ChatListNavLink: View { private func joinGroupButton() -> some View { Button { - inProgress = true - joinGroup(chat.chatInfo.apiId) { - await MainActor.run { inProgress = false } - } + joinGroup(chat.chatInfo.apiId) } label: { SwipeLabel(NSLocalizedString("Join", comment: "swipe action"), systemImage: chat.chatInfo.incognito ? "theatermasks" : "ipad.and.arrow.forward", inverted: oneHandUI) } @@ -488,17 +501,19 @@ struct ChatListNavLink: View { ContactRequestView(contactRequest: contactRequest, chat: chat) .frameCompat(height: dynamicRowHeight) .swipeActions(edge: .trailing, allowsFullSwipe: true) { - Button { - Task { await acceptContactRequest(incognito: false, contactRequestId: contactRequest.apiId) } - } label: { SwipeLabel(NSLocalizedString("Accept", comment: "swipe action"), systemImage: "checkmark", inverted: oneHandUI) } - .tint(theme.colors.primary) - if !ChatModel.shared.addressShortLinkDataSet { + if !inProgress { Button { - Task { await acceptContactRequest(incognito: true, contactRequestId: contactRequest.apiId) } - } label: { - SwipeLabel(NSLocalizedString("Accept incognito", comment: "swipe action"), systemImage: "theatermasks.fill", inverted: oneHandUI) + Task { await acceptContactRequest(incognito: false, contactRequestId: contactRequest.apiId) } + } label: { SwipeLabel(NSLocalizedString("Accept", comment: "swipe action"), systemImage: "checkmark", inverted: oneHandUI) } + .tint(theme.colors.primary) + if !ChatModel.shared.addressShortLinkDataSet { + Button { + Task { await acceptContactRequest(incognito: true, contactRequestId: contactRequest.apiId) } + } label: { + SwipeLabel(NSLocalizedString("Accept incognito", comment: "swipe action"), systemImage: "theatermasks.fill", inverted: oneHandUI) + } + .tint(.indigo) } - .tint(.indigo) } Button { AlertManager.shared.showAlert(rejectContactRequestAlert(contactRequest.apiId)) @@ -509,6 +524,7 @@ struct ChatListNavLink: View { } .contentShape(Rectangle()) .onTapGesture { showContactRequestDialog = true } + .disabled(inProgress) .confirmationDialog("Accept connection request?", isPresented: $showContactRequestDialog, titleVisibility: .visible) { Button("Accept") { Task { await acceptContactRequest(incognito: false, contactRequestId: contactRequest.apiId) } } if !ChatModel.shared.addressShortLinkDataSet { @@ -724,7 +740,8 @@ func connectContactViaAddress(_ contactId: Int64, _ incognito: Bool, showAlert: return false } -func joinGroup(_ groupId: Int64, _ onComplete: @escaping () async -> Void) { +func joinGroup(_ groupId: Int64) { + ChatModel.shared.joiningGroups.insert(groupId) Task { logger.debug("joinGroup") do { @@ -740,13 +757,12 @@ func joinGroup(_ groupId: Int64, _ onComplete: @escaping () async -> Void) { await deleteGroup() } } - await onComplete() } catch let error { - await onComplete() await MainActor.run { showErrorAlert(error, NSLocalizedString("Error joining group", comment: "")) } } + await MainActor.run { _ = ChatModel.shared.joiningGroups.remove(groupId) } func deleteGroup() async { do { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 7881d363bf..cafc7ea616 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -122,8 +122,10 @@ object ChatModel { val incompleteInitializedDbRemoved = mutableStateOf(false) // map of connections network statuses, key is agent connection id val switchingUsersAndHosts = mutableStateOf(false) - // ids of contact requests being accepted, to not offer accepting the same request from another view + // ids of contact requests being accepted and of groups being joined, + // to not offer accepting the same invitation from another view val acceptingContactRequests = mutableStateListOf() + val joiningGroups = mutableStateListOf() // current chat val chatId = mutableStateOf(null) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 288ad41d4a..203ab9ad04 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -586,12 +586,7 @@ fun ChatView( cancelFile = { fileId -> withBGApi { chatModel.controller.cancelFile(chatRh, user, fileId) } }, - joinGroup = { groupId, onComplete -> - withBGApi { - chatModel.controller.apiJoinGroup(chatRh, groupId) - onComplete.invoke() - } - }, + joinGroup = { groupId -> joinGroup(chatRh, groupId) }, startCall = out@{ media -> startChatCall(chatRh, chatInfo, media) }, endCall = { val call = chatModel.activeCall.value @@ -923,7 +918,7 @@ fun ChatLayout( archiveReports: (List, Boolean) -> Unit, receiveFile: (Long) -> Unit, cancelFile: (Long) -> Unit, - joinGroup: (Long, () -> Unit) -> Unit, + joinGroup: (Long) -> Unit, startCall: (CallMediaType) -> Unit, endCall: () -> Unit, acceptCall: (Contact) -> Unit, @@ -1750,7 +1745,7 @@ fun BoxScope.ChatItemsList( archiveReports: (List, Boolean) -> Unit, receiveFile: (Long) -> Unit, cancelFile: (Long) -> Unit, - joinGroup: (Long, () -> Unit) -> Unit, + joinGroup: (Long) -> Unit, acceptCall: (Contact) -> Unit, acceptFeature: (Contact, ChatFeature, Int?) -> Unit, openDirectChat: (Long) -> Unit, @@ -3835,7 +3830,7 @@ fun PreviewChatLayout() { archiveReports = { _, _ -> }, receiveFile = { _ -> }, cancelFile = {}, - joinGroup = { _, _ -> }, + joinGroup = {}, startCall = {}, endCall = {}, acceptCall = { _ -> }, @@ -3918,7 +3913,7 @@ fun PreviewGroupChatLayout() { archiveReports = { _, _ -> }, receiveFile = { _ -> }, cancelFile = {}, - joinGroup = { _, _ -> }, + joinGroup = {}, startCall = {}, endCall = {}, acceptCall = { _ -> }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextContactRequestActionsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextContactRequestActionsView.kt index 86b3f7e14d..40ac86add6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextContactRequestActionsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextContactRequestActionsView.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -16,6 +15,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import chat.simplex.common.platform.chatModel import chat.simplex.common.views.chatlist.acceptContactRequest +import chat.simplex.common.views.chatlist.rememberAcceptingContactRequest import chat.simplex.common.views.chatlist.rejectContactRequest import chat.simplex.common.views.helpers.* import chat.simplex.res.MR @@ -27,17 +27,9 @@ fun ComposeContextContactRequestActionsView( rhId: Long?, contactRequestId: Long ) { - val inProgressLocal = rememberSaveable { mutableStateOf(false) } - // the request can also be accepted from another view, e.g. via notification - val inProgress = remember(contactRequestId) { derivedStateOf { inProgressLocal.value || contactRequestId in chatModel.acceptingContactRequests } } + val inProgress = rememberAcceptingContactRequest(contactRequestId) val progressByTimeout by rememberProgressByTimeout(inProgress) - KeyChangeEffect(chatModel.chatId.value) { - if (inProgressLocal.value) { - inProgressLocal.value = false - } - } - Box( Modifier.height(60.dp), contentAlignment = Alignment.Center @@ -80,9 +72,9 @@ fun ComposeContextContactRequestActionsView( else acceptButtonModifier.clickable { if (chatModel.addressShortLinkDataSet()) { - acceptContactRequest(rhId, incognito = false, contactRequestId, isCurrentUser = true, chatModel = chatModel, close = null, inProgress = inProgressLocal) + acceptContactRequest(rhId, incognito = false, contactRequestId, isCurrentUser = true, chatModel = chatModel, close = null) } else { - showAcceptRequestAlert(rhId, contactRequestId, inProgress = inProgressLocal) + showAcceptRequestAlert(rhId, contactRequestId) } } Row( @@ -124,7 +116,7 @@ fun showRejectRequestAlert(rhId: Long?, contactRequestId: Long) { ) } -fun showAcceptRequestAlert(rhId: Long?, contactRequestId: Long, inProgress: MutableState) { +fun showAcceptRequestAlert(rhId: Long?, contactRequestId: Long) { AlertManager.shared.showAlertDialogButtonsColumn( title = generalGetString(MR.strings.accept_contact_request), buttons = { @@ -132,14 +124,14 @@ fun showAcceptRequestAlert(rhId: Long?, contactRequestId: Long, inProgress: Muta // Accept SectionItemView({ AlertManager.shared.hideAlert() - acceptContactRequest(rhId, incognito = false, contactRequestId, isCurrentUser = true, chatModel = chatModel, close = null, inProgress = inProgress) + acceptContactRequest(rhId, incognito = false, contactRequestId, isCurrentUser = true, chatModel = chatModel, close = null) }) { Text(generalGetString(MR.strings.accept_contact_button), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } // Accept incognito SectionItemView({ AlertManager.shared.hideAlert() - acceptContactRequest(rhId, incognito = true, contactRequestId, isCurrentUser = true, chatModel = chatModel, close = null, inProgress = inProgress) + acceptContactRequest(rhId, incognito = true, contactRequestId, isCurrentUser = true, chatModel = chatModel, close = null) }) { Text(generalGetString(MR.strings.accept_contact_incognito_button), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index 20776957af..0b79b42185 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -654,12 +654,17 @@ fun ComposeView( val mc = checkLinkPreview() sending() val incognito = if (chat.chatInfo.profileChangeProhibited) chat.chatInfo.incognito else chatModel.controller.appPrefs.incognito.get() - val result = chatModel.controller.apiConnectPreparedGroup( - rh = chat.remoteHostId, - groupId = chat.chatInfo.apiId, - incognito = incognito, - msg = mc - ) + chatModel.joiningGroups.add(chat.chatInfo.apiId) + val result = try { + chatModel.controller.apiConnectPreparedGroup( + rh = chat.remoteHostId, + groupId = chat.chatInfo.apiId, + incognito = incognito, + msg = mc + ) + } finally { + chatModel.joiningGroups.remove(chat.chatInfo.apiId) + } if (result != null) { val (groupInfo, relayResults) = result withContext(Dispatchers.Main) { @@ -1657,7 +1662,7 @@ fun ComposeView( Surface(color = MaterialTheme.colors.background, contentColor = MaterialTheme.colors.onBackground) { Divider() - if (chat.chatInfo is ChatInfo.Group && chat.chatInfo.groupInfo.nextConnectPrepared) { + if (chat.chatInfo is ChatInfo.Group && chat.chatInfo.groupInfo.nextConnectPrepared && chat.chatInfo.groupChatScope() == null) { if (chat.chatInfo.groupInfo.businessChat == null) { val isChannel = chat.chatInfo.groupInfo.useRelays ConnectButtonView( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt index 86f03f15ac..0ff8218e9e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chatlist.rememberJoiningGroup import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* import chat.simplex.res.MR @@ -27,12 +28,12 @@ fun CIGroupInvitationView( memberRole: GroupMemberRole, showTimestamp: Boolean, chatIncognito: Boolean = false, - joinGroup: (Long, () -> Unit) -> Unit, + joinGroup: (Long) -> Unit, timedMessagesTTL: Int? ) { val sent = ci.chatDir.sent val action = !sent && groupInvitation.status == CIGroupInvitationStatus.Pending - val inProgress = remember { mutableStateOf(false) } + val inProgress = rememberJoiningGroup(groupInvitation.groupId) val progressByTimeout by rememberProgressByTimeout(inProgress) @Composable @@ -75,10 +76,7 @@ fun CIGroupInvitationView( val sentColor = MaterialTheme.appColors.sentMessage val receivedColor = MaterialTheme.appColors.receivedMessage Surface( - modifier = if (action && !inProgress.value) Modifier.clickable(onClick = { - inProgress.value = true - joinGroup(groupInvitation.groupId) { inProgress.value = false } - }) else Modifier, + modifier = if (action && !inProgress.value) Modifier.clickable(onClick = { joinGroup(groupInvitation.groupId) }) else Modifier, shape = RoundedCornerShape(18.dp), color = if (sent) sentColor else receivedColor, contentColor = LocalContentColor.current @@ -151,7 +149,7 @@ fun PendingCIGroupInvitationViewPreview() { ci = ChatItem.getGroupInvitationSample(), groupInvitation = CIGroupInvitation.getSample(), memberRole = GroupMemberRole.Admin, - joinGroup = { _, _ -> }, + joinGroup = {}, timedMessagesTTL = null, showTimestamp = true, ) @@ -169,7 +167,7 @@ fun CIGroupInvitationViewAcceptedPreview() { ci = ChatItem.getGroupInvitationSample(), groupInvitation = CIGroupInvitation.getSample(status = CIGroupInvitationStatus.Accepted), memberRole = GroupMemberRole.Admin, - joinGroup = { _, _ -> }, + joinGroup = {}, timedMessagesTTL = null, showTimestamp = true, ) @@ -187,7 +185,7 @@ fun CIGroupInvitationViewLongNamePreview() { status = CIGroupInvitationStatus.Accepted ), memberRole = GroupMemberRole.Admin, - joinGroup = { _, _ -> }, + joinGroup = {}, timedMessagesTTL = null, showTimestamp = true, ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index bab6576646..ac10f36d79 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -88,7 +88,7 @@ fun ChatItemView( archiveReports: (List, Boolean) -> Unit, receiveFile: (Long) -> Unit, cancelFile: (Long) -> Unit, - joinGroup: (Long, () -> Unit) -> Unit, + joinGroup: (Long) -> Unit, acceptCall: (Contact) -> Unit, scrollToItem: (Long) -> Unit, scrollToItemId: MutableState, @@ -1507,7 +1507,7 @@ fun PreviewChatItemView( archiveReports = { _, _ -> }, receiveFile = { _ -> }, cancelFile = {}, - joinGroup = { _, _ -> }, + joinGroup = {}, acceptCall = { _ -> }, scrollToItem = {}, scrollToItemId = remember { mutableStateOf(null) }, @@ -1558,7 +1558,7 @@ fun PreviewChatItemViewDeletedContent() { archiveReports = { _, _ -> }, receiveFile = { _ -> }, cancelFile = {}, - joinGroup = { _, _ -> }, + joinGroup = {}, acceptCall = { _ -> }, scrollToItem = {}, scrollToItemId = remember { mutableStateOf(null) }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index d594881014..92f158f362 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -47,8 +47,6 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { } val selectedChat = remember(chat.id) { derivedStateOf { chat.id == chatModel.chatId.value } } val showChatPreviews = chatModel.showChatPreviews.value - val inProgress = remember { mutableStateOf(false) } - val progressByTimeout by rememberProgressByTimeout(inProgress) val scope = rememberCoroutineScope() @@ -74,7 +72,9 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { ) } is ChatInfo.Group -> { - val defaultClickAction = { if (!inProgress.value && chatModel.chatId.value != chat.id) scope.launch { groupChatAction(chat.remoteHostId, chat.chatInfo.groupInfo, chatModel, inProgress) } } + val inProgress = rememberJoiningGroup(chat.chatInfo.groupInfo.groupId) + val progressByTimeout by rememberProgressByTimeout(inProgress) + val defaultClickAction = { if (!inProgress.value && chatModel.chatId.value != chat.id) scope.launch { groupChatAction(chat.remoteHostId, chat.chatInfo.groupInfo, chatModel) } } ChatListNavLinkLayout( chatLinkPreview = { tryOrShowError("${chat.id}ChatListNavLink", error = { ErrorChatListItem() }) { @@ -84,7 +84,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { click = defaultClickAction, dropdownMenuItems = { tryOrShowError("${chat.id}ChatListNavLinkDropdown", error = {}) { - GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, inProgress, showMarkRead) + GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, showMarkRead) } }, showMenu, @@ -113,14 +113,15 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { nextChatSelected, ) } - is ChatInfo.ContactRequest -> + is ChatInfo.ContactRequest -> { + val inProgress = rememberAcceptingContactRequest(chat.chatInfo.apiId) ChatListNavLinkLayout( chatLinkPreview = { tryOrShowError("${chat.id}ChatListNavLink", error = { ErrorChatListItem() }) { ContactRequestView(chat.chatInfo) } }, - click = { contactRequestAlertDialog(chat.remoteHostId, chat.chatInfo, chatModel) { onRequestAccepted(it) } }, + click = { if (!inProgress.value) contactRequestAlertDialog(chat.remoteHostId, chat.chatInfo, chatModel) { onRequestAccepted(it) } }, dropdownMenuItems = { tryOrShowError("${chat.id}ChatListNavLinkDropdown", error = {}) { ContactRequestMenuItems(chat.remoteHostId, contactRequestId = chat.chatInfo.apiId, chatModel, showMenu) @@ -131,6 +132,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { selectedChat, nextChatSelected, ) + } is ChatInfo.ContactConnection -> ChatListNavLinkLayout( chatLinkPreview = { @@ -184,9 +186,9 @@ suspend fun directChatAction(rhId: Long?, contact: Contact, chatModel: ChatModel } } -suspend fun groupChatAction(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, inProgress: MutableState? = null) { +suspend fun groupChatAction(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) { when (groupInfo.membership.memberStatus) { - GroupMemberStatus.MemInvited -> acceptGroupInvitationAlertDialog(rhId, groupInfo, chatModel, inProgress) + GroupMemberStatus.MemInvited -> acceptGroupInvitationAlertDialog(rhId, groupInfo, chatModel) GroupMemberStatus.MemAccepted -> groupInvitationAcceptedAlert(rhId) else -> openGroupChat(rhId, groupInfo.groupId) } @@ -294,13 +296,12 @@ fun GroupMenuItems( groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState, - inProgress: MutableState, showMarkRead: Boolean ) { when (groupInfo.membership.memberStatus) { GroupMemberStatus.MemInvited -> { - if (!inProgress.value) { - JoinGroupAction(chat, groupInfo, chatModel, showMenu, inProgress) + if (groupInfo.groupId !in chatModel.joiningGroups) { + JoinGroupAction(chat, groupInfo, showMenu) } if (groupInfo.canDelete) { DeleteGroupAction(chat, groupInfo, chatModel, showMenu) @@ -476,23 +477,14 @@ fun DeleteGroupAction(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, sh fun JoinGroupAction( chat: Chat, groupInfo: GroupInfo, - chatModel: ChatModel, - showMenu: MutableState, - inProgress: MutableState + showMenu: MutableState ) { - val joinGroup: () -> Unit = { - withBGApi { - inProgress.value = true - chatModel.controller.apiJoinGroup(chat.remoteHostId, groupInfo.groupId) - inProgress.value = false - } - } ItemAction( if (chat.chatInfo.incognito) stringResource(MR.strings.join_group_incognito_button) else stringResource(MR.strings.join_group_button), if (chat.chatInfo.incognito) painterResource(MR.images.ic_theater_comedy_filled) else painterResource(MR.images.ic_login), color = if (chat.chatInfo.incognito) Indigo else MaterialTheme.colors.onBackground, onClick = { - joinGroup() + joinGroup(chat.remoteHostId, groupInfo.groupId) showMenu.value = false } ) @@ -513,25 +505,27 @@ fun LeaveGroupAction(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, sh @Composable fun ContactRequestMenuItems(rhId: Long?, contactRequestId: Long, chatModel: ChatModel, showMenu: MutableState, onSuccess: ((chat: Chat) -> Unit)? = null) { - ItemAction( - stringResource(MR.strings.accept_contact_button), - painterResource(MR.images.ic_check), - color = MaterialTheme.colors.onBackground, - onClick = { - acceptContactRequest(rhId, incognito = false, contactRequestId, true, chatModel, onSuccess) - showMenu.value = false - } - ) - if (!chatModel.addressShortLinkDataSet()) { + if (contactRequestId !in chatModel.acceptingContactRequests) { ItemAction( - stringResource(MR.strings.accept_contact_incognito_button), - painterResource(MR.images.ic_theater_comedy), + stringResource(MR.strings.accept_contact_button), + painterResource(MR.images.ic_check), color = MaterialTheme.colors.onBackground, onClick = { - acceptContactRequest(rhId, incognito = true, contactRequestId, true, chatModel, onSuccess) + acceptContactRequest(rhId, incognito = false, contactRequestId, true, chatModel, onSuccess) showMenu.value = false } ) + if (!chatModel.addressShortLinkDataSet()) { + ItemAction( + stringResource(MR.strings.accept_contact_incognito_button), + painterResource(MR.images.ic_theater_comedy), + color = MaterialTheme.colors.onBackground, + onClick = { + acceptContactRequest(rhId, incognito = true, contactRequestId, true, chatModel, onSuccess) + showMenu.value = false + } + ) + } } ItemAction( stringResource(MR.strings.reject_contact_button), @@ -721,12 +715,10 @@ fun acceptContactRequest( contactRequestId: Long, isCurrentUser: Boolean, chatModel: ChatModel, - close: ((chat: Chat) -> Unit)? = null, - inProgress: MutableState? = null + close: ((chat: Chat) -> Unit)? = null ) { + chatModel.acceptingContactRequests.add(contactRequestId) withBGApi { - inProgress?.value = true - chatModel.acceptingContactRequests.add(contactRequestId) try { val contact = chatModel.controller.apiAcceptContactRequest(rhId, incognito, contactRequestId) if (contact != null && isCurrentUser) { @@ -737,11 +729,8 @@ fun acceptContactRequest( } else { chatModel.chatsContext.replaceChat(rhId, contactRequestChatId(contactRequestId), chat) } - inProgress?.value = false } close?.invoke(chat) - } else { - inProgress?.value = false } } finally { chatModel.acceptingContactRequests.remove(contactRequestId) @@ -749,6 +738,25 @@ fun acceptContactRequest( } } +fun joinGroup(rhId: Long?, groupId: Long) { + chatModel.joiningGroups.add(groupId) + withBGApi { + try { + chatModel.controller.apiJoinGroup(rhId, groupId) + } finally { + chatModel.joiningGroups.remove(groupId) + } + } +} + +@Composable +fun rememberAcceptingContactRequest(contactRequestId: Long): State = + remember(contactRequestId) { derivedStateOf { contactRequestId in chatModel.acceptingContactRequests } } + +@Composable +fun rememberJoiningGroup(groupId: Long): State = + remember(groupId) { derivedStateOf { groupId in chatModel.joiningGroups } } + fun rejectContactRequest(rhId: Long?, contactRequestId: Long, chatModel: ChatModel, dismissToChatList: Boolean = false) { withBGApi { val contact_ = chatModel.controller.apiRejectContactRequest(rhId, contactRequestId) @@ -892,18 +900,12 @@ suspend fun connectContactViaAddress(chatModel: ChatModel, rhId: Long?, contactI return false } -fun acceptGroupInvitationAlertDialog(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, inProgress: MutableState? = null) { +fun acceptGroupInvitationAlertDialog(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.join_group_question), text = generalGetString(MR.strings.you_are_invited_to_group_join_to_connect_with_group_members), confirmText = if (groupInfo.membership.memberIncognito) generalGetString(MR.strings.join_group_incognito_button) else generalGetString(MR.strings.join_group_button), - onConfirm = { - withBGApi { - inProgress?.value = true - chatModel.controller.apiJoinGroup(rhId, groupInfo.groupId) - inProgress?.value = false - } - }, + onConfirm = { joinGroup(rhId, groupInfo.groupId) }, dismissText = generalGetString(MR.strings.delete_verb), onDismiss = { deleteGroup(rhId, groupInfo, chatModel) }, hostDevice = hostDevice(rhId),