From a7f8866360075b699c29efe67ba03b18d052a494 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 10 Jul 2025 09:29:39 +0000 Subject: [PATCH] ui: plan and connect spinners (#6048) * ios: plan and connect spinners * fix cleanup * remove redundant check * revert diff * progress by timeout for join and connect buttons * accept in progress * connect in progress by timeout wip * fix observable * fix cleanup * kotlin wip * decrease delay * ios: spinner as overlay * remove duplicate stop progress, remove cleanupOnCancel * contact request progress * plan in progress wip * fix cleanup * search * fix ios * scanner * desktop paste * spinner in chat * ios: don't repeat scan while in progress * spinner in paste field * cancellable plan * cancel in more scenarios * fix cancellation * move progress to search * remove task var * pause scanner when processing qr code * kotlin * improvements * layout * layout * move spinner --------- Co-authored-by: Evgeny Poberezkin --- apps/ios/Shared/Model/ChatModel.swift | 35 ++ apps/ios/Shared/Model/SimpleXAPI.swift | 11 +- apps/ios/Shared/Views/Chat/ChatView.swift | 31 ++ .../Chat/ComposeMessage/ComposeView.swift | 28 +- .../ContextContactRequestActionsView.swift | 19 +- .../Chat/ComposeMessage/SendMessageView.swift | 14 +- .../Shared/Views/ChatList/ChatListView.swift | 7 + .../Views/NewChat/NewChatMenuButton.swift | 11 +- .../Shared/Views/NewChat/NewChatView.swift | 453 ++++++++++-------- .../chat/simplex/common/model/ChatModel.kt | 33 ++ .../chat/simplex/common/model/SimpleXAPI.kt | 63 +-- .../simplex/common/views/chat/ChatView.kt | 48 ++ ...ComposeContextContactRequestActionsView.kt | 130 +++-- .../simplex/common/views/chat/ComposeView.kt | 29 +- .../simplex/common/views/chat/SendMsgView.kt | 15 +- .../views/chatlist/ChatListNavLinkView.kt | 12 +- .../common/views/chatlist/ChatListView.kt | 108 +++-- .../common/views/newchat/ConnectPlan.kt | 26 +- .../common/views/newchat/NewChatSheet.kt | 21 +- .../common/views/newchat/NewChatView.kt | 27 +- .../commonMain/resources/MR/base/strings.xml | 1 + 21 files changed, 743 insertions(+), 379 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 2c139f8fbc..33b3fd8009 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -314,6 +314,41 @@ class ChatItemDummyModel: ObservableObject { func sendUpdate() { objectWillChange.send() } } +class ConnectProgressManager: ObservableObject { + @Published private var connectInProgress: String? = nil + @Published private var connectProgressByTimeout: Bool = false + private var onCancel: (() -> Void)? + + static let shared = ConnectProgressManager() + + func startConnectProgress(_ text: String, onCancel: (() -> Void)? = nil) { + connectInProgress = text + self.onCancel = onCancel + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + self.connectProgressByTimeout = self.connectInProgress != nil + } + } + + func stopConnectProgress() { + connectInProgress = nil + onCancel = nil + connectProgressByTimeout = false + } + + func cancelConnectProgress() { + onCancel?() + stopConnectProgress() + } + + var showConnectProgress: String? { + connectProgressByTimeout ? connectInProgress : nil + } + + var isInProgress: Bool { + connectInProgress != nil + } +} + final class ChatModel: ObservableObject { @Published var onboardingStage: OnboardingStage? @Published var setDeliveryReceipts = false diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index d9cd2a0dc6..f184e048cc 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -120,9 +120,10 @@ func chatSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDe return try apiResult(res) } -func chatApiSendCmdWithRetry(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, retryNum: Int32 = 0) async -> APIResult? { +func chatApiSendCmdWithRetry(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, inProgress: BoxedValue? = nil, retryNum: Int32 = 0) async -> APIResult? { let r: APIResult = await chatApiSendCmd(cmd, bgTask: bgTask, bgDelay: bgDelay, retryNum: retryNum) - if case let .error(e) = r, let alert = retryableNetworkErrorAlert(e) { + if inProgress == nil || inProgress?.boxedValue == true, + case let .error(e) = r, let alert = retryableNetworkErrorAlert(e) { return await withCheckedContinuation { cont in showRetryAlert( alert, @@ -130,7 +131,7 @@ func chatApiSendCmdWithRetry(_ cmd: ChatCommand, bgTask: Bool cont.resume(returning: nil) }, onRetry: { - let r1: APIResult? = await chatApiSendCmdWithRetry(cmd, bgTask: bgTask, bgDelay: bgDelay, retryNum: retryNum + 1) + let r1: APIResult? = await chatApiSendCmdWithRetry(cmd, bgTask: bgTask, bgDelay: bgDelay, inProgress: inProgress, retryNum: retryNum + 1) cont.resume(returning: r1) } ) @@ -980,12 +981,12 @@ func apiChangeConnectionUser(connId: Int64, userId: Int64) async throws -> Pendi if let r { throw r.unexpected } else { return nil } } -func apiConnectPlan(connLink: String) async -> ((CreatedConnLink, ConnectionPlan)?, Alert?) { +func apiConnectPlan(connLink: String, inProgress: BoxedValue) async -> ((CreatedConnLink, ConnectionPlan)?, Alert?) { guard let userId = ChatModel.shared.currentUser?.userId else { logger.error("apiConnectPlan: no current user") return (nil, nil) } - let r: APIResult? = await chatApiSendCmdWithRetry(.apiConnectPlan(userId: userId, connLink: connLink)) + let r: APIResult? = await chatApiSendCmdWithRetry(.apiConnectPlan(userId: userId, connLink: connLink), inProgress: inProgress) if case let .result(.connectionPlan(_, connLink, connPlan)) = r { return ((connLink, connPlan), nil) } let alert: Alert? = if let r { apiConnectResponseAlert(r) } else { nil } return (nil, alert) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 64166929aa..ed0c0aa432 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -15,6 +15,7 @@ private let memberImageSize: CGFloat = 34 struct ChatView: View { @EnvironmentObject var chatModel: ChatModel + @StateObject private var connectProgressManager = ConnectProgressManager.shared @State var revealedItems: Set = Set() @State var theme: AppTheme = buildTheme() @Environment(\.dismiss) var dismiss @@ -114,6 +115,9 @@ struct ChatView: View { } ) } + if let connectInProgressText = connectProgressManager.showConnectProgress { + connectInProgressView(connectInProgressText) + } if let connectingText { Text(connectingText) .font(.caption) @@ -262,6 +266,7 @@ struct ChatView: View { } } .onAppear { + ConnectProgressManager.shared.cancelConnectProgress() scrollView.listState.onUpdateListener = onChatItemsUpdated selectedChatItems = nil revealedItems = Set() @@ -286,6 +291,7 @@ struct ChatView: View { } } .onChange(of: chatModel.chatId) { cId in + ConnectProgressManager.shared.cancelConnectProgress() showChatInfoSheet = false selectedChatItems = nil revealedItems = Set() @@ -341,6 +347,7 @@ struct ChatView: View { } } .onDisappear { + ConnectProgressManager.shared.cancelConnectProgress() VideoPlayerView.players.removeAll() stopAudioPlayer() if chatModel.chatId == cInfo.id && !presentationMode.wrappedValue.isPresented { @@ -386,6 +393,30 @@ struct ChatView: View { } } + private func connectInProgressView(_ s: String) -> some View { + VStack(spacing: 0) { + Divider() + + HStack(spacing: 12) { + ProgressView() + Text(s) + + Spacer() + + Button { + ConnectProgressManager.shared.cancelConnectProgress() + } label: { + Image(systemName: "multiply") + } + .tint(theme.colors.primary) + } + .padding(12) + .frame(minHeight: 54) + .frame(maxWidth: .infinity, alignment: .leading) + .background(ToolbarMaterial.material(toolbarMaterial)) + } + } + @inline(__always) @ViewBuilder private func primaryPrincipalToolbarContent() -> some View { let cInfo = chat.chatInfo diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index cc31ec1855..53ce9f9bfc 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -44,6 +44,7 @@ struct ComposeState { var contextItem: ComposeContextItem var voiceMessageRecordingState: VoiceMessageRecordingState var inProgress = false + var progressByTimeout = false var useLinkPreviews: Bool = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS) var mentions: MentionedMembers = [:] @@ -410,7 +411,14 @@ struct ComposeView: View { if chat.chatInfo.groupInfo?.nextConnectPrepared == true { if chat.chatInfo.groupInfo?.businessChat == nil { Button(action: connectPreparedGroup) { - Label("Join group", systemImage: "person.2.fill") + ZStack(alignment: .trailing) { + Label("Join group", systemImage: "person.2.fill") + .frame(maxWidth: .infinity) + if composeState.progressByTimeout { + ProgressView() + .padding() + } + } } .frame(height: 60) .disabled(composeState.inProgress) @@ -429,7 +437,14 @@ struct ComposeView: View { switch linkType { case .inv: Button(action: sendConnectPreparedContact) { - Label("Connect", systemImage: "person.fill.badge.plus") + ZStack(alignment: .trailing) { + Label("Connect", systemImage: "person.fill.badge.plus") + .frame(maxWidth: .infinity) + if composeState.progressByTimeout { + ProgressView() + .padding() + } + } } .frame(height: 60) .disabled(composeState.inProgress) @@ -474,6 +489,15 @@ struct ComposeView: View { clearState() } } + .onChange(of: composeState.inProgress) { inProgress in + if inProgress { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + composeState.progressByTimeout = composeState.inProgress + } + } else { + composeState.progressByTimeout = false + } + } .confirmationDialog("Attach", isPresented: $showChooseSource, titleVisibility: .visible) { Button("Take picture") { showTakePhoto = true diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextContactRequestActionsView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextContactRequestActionsView.swift index 9c299ebc42..7835576eb7 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextContactRequestActionsView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextContactRequestActionsView.swift @@ -14,6 +14,7 @@ struct ContextContactRequestActionsView: View { var contactRequestId: Int64 @UserDefault(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial @State private var inProgress = false + @State private var progressByTimeout = false var body: some View { HStack(spacing: 0) { @@ -21,7 +22,7 @@ struct ContextContactRequestActionsView: View { Label("Reject", systemImage: "multiply") } .frame(maxWidth: .infinity, minHeight: 60) - + Button { if ChatModel.shared.addressShortLinkDataSet { acceptRequest() @@ -36,6 +37,22 @@ struct ContextContactRequestActionsView: View { .disabled(inProgress) .frame(maxWidth: .infinity) .background(ToolbarMaterial.material(toolbarMaterial)) + .opacity(progressByTimeout ? 0.4 : 1) + .overlay { + if progressByTimeout { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .onChange(of: inProgress) { inPrgrs in + if inPrgrs { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + progressByTimeout = inProgress + } + } else { + progressByTimeout = false + } + } } private func showRejectRequestAlert() { diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index ea19e20591..07cd61583b 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -44,7 +44,6 @@ struct SendMessageView: View { @State private var showCustomDisappearingMessageDialogue = false @State private var showCustomTimePicker = false @State private var selectedDisappearingMessageTime: Int? = customDisappearingMessageTimeDefault.get() - @State private var progressByTimeout = false @UserDefault(DEFAULT_LIVE_MESSAGE_ALERT_SHOWN) private var liveMessageAlertShown = false var body: some View { @@ -76,12 +75,12 @@ struct SendMessageView: View { } } .overlay(alignment: .topTrailing, content: { - if !progressByTimeout && teHeight > 100 && !composeState.inProgress { + if !composeState.progressByTimeout && teHeight > 100 && !composeState.inProgress { deleteTextButton() } }) .overlay(alignment: .bottomTrailing) { - if progressByTimeout { + if composeState.progressByTimeout { ProgressView() .scaleEffect(1.4) .frame(width: 31, height: 31, alignment: .center) @@ -97,15 +96,6 @@ struct SendMessageView: View { .clipShape(composeShape) .overlay(composeShape.strokeBorder(.secondary, lineWidth: 0.5).opacity(0.7)) .onChange(of: composeState.message, perform: { text in updateFont(text) }) - .onChange(of: composeState.inProgress) { inProgress in - if inProgress { - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { - progressByTimeout = composeState.inProgress - } - } else { - progressByTimeout = false - } - } .padding(.vertical, 8) } diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 24b057275a..9f453f1aa3 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -137,6 +137,7 @@ struct UserPickerSheetView: View { struct ChatListView: View { @EnvironmentObject var chatModel: ChatModel + @StateObject private var connectProgressManager = ConnectProgressManager.shared @EnvironmentObject var theme: AppTheme @Binding var activeUserPickerSheet: UserPickerSheet? @State private var searchMode = false @@ -572,6 +573,7 @@ struct ChatListSearchBar: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme @EnvironmentObject var chatTagsModel: ChatTagsModel + @StateObject private var connectProgressManager = ConnectProgressManager.shared @Binding var searchMode: Bool @FocusState.Binding var searchFocussed: Bool @Binding var searchText: String @@ -591,6 +593,9 @@ struct ChatListSearchBar: View { .disabled(searchShowingSimplexLink) .focused($searchFocussed) .frame(maxWidth: .infinity) + if connectProgressManager.showConnectProgress != nil { + ProgressView() + } if !searchText.isEmpty { Image(systemName: "xmark.circle.fill") .onTapGesture { @@ -634,6 +639,8 @@ struct ChatListSearchBar: View { } else { if t != "" { // if some other text is pasted, enter search mode searchFocussed = true + } else { + ConnectProgressManager.shared.cancelConnectProgress() } searchShowingSimplexLink = false searchChatFilteredBySimplexLink = nil diff --git a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift index a64f865fc8..c009bf0d35 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift @@ -16,7 +16,8 @@ struct NewChatMenuButton: View { @State private var alert: SomeAlert? = nil var body: some View { - Button { + Button { + ConnectProgressManager.shared.cancelConnectProgress() showNewChatSheet = true } label: { Image(systemName: "square.and.pencil") @@ -65,6 +66,8 @@ struct NewChatSheet: View { .alert(item: $alert) { a in return a.alert } + }.onDisappear { + ConnectProgressManager.shared.cancelConnectProgress() } if #available(iOS 16.0, *), oneHandUI { let sheetHeight: CGFloat = showArchive ? 575 : 500 @@ -315,6 +318,7 @@ struct ContactsList: View { struct ContactsListSearchBar: View { @EnvironmentObject var m: ChatModel + @StateObject private var connectProgressManager = ConnectProgressManager.shared @EnvironmentObject var theme: AppTheme @Binding var searchMode: Bool @FocusState.Binding var searchFocussed: Bool @@ -338,6 +342,9 @@ struct ContactsListSearchBar: View { .disabled(searchShowingSimplexLink) .focused($searchFocussed) .frame(maxWidth: .infinity) + if connectProgressManager.showConnectProgress != nil { + ProgressView() + } if !searchText.isEmpty { Image(systemName: "xmark.circle.fill") .resizable() @@ -384,6 +391,8 @@ struct ContactsListSearchBar: View { } else { if t != "" { // if some other text is pasted, enter search mode searchFocussed = true + } else { + connectProgressManager.cancelConnectProgress() } searchShowingSimplexLink = false searchChatFilteredBySimplexLink = nil diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index cadd551147..19e0d4180d 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -585,11 +585,13 @@ private struct ActiveProfilePicker: View { } private struct ConnectView: View { + @StateObject private var connectProgressManager = ConnectProgressManager.shared @Environment(\.dismiss) var dismiss: DismissAction @EnvironmentObject var theme: AppTheme @Binding var showQRCodeScanner: Bool @Binding var pastedLink: String @Binding var alert: NewChatViewAlert? + @State var scannerPaused: Bool = false @State private var pasteboardHasStrings = UIPasteboard.general.hasStrings var body: some View { @@ -598,36 +600,49 @@ private struct ConnectView: View { pasteLinkView() } Section(header: Text("Or scan QR code").foregroundColor(theme.colors.secondary)) { - ScannerInView(showQRCodeScanner: $showQRCodeScanner, processQRCode: processQRCode) + ScannerInView(showQRCodeScanner: $showQRCodeScanner, scannerPaused: $scannerPaused, processQRCode: processQRCode) } } + .onDisappear { + connectProgressManager.cancelConnectProgress() + } } @ViewBuilder private func pasteLinkView() -> some View { if pastedLink == "" { - Button { - if let str = UIPasteboard.general.string { - if let link = strHasSingleSimplexLink(str.trimmingCharacters(in: .whitespaces)) { - pastedLink = link.text - // It would be good to hide it, but right now it is not clear how to release camera in CodeScanner - // https://github.com/twostraws/CodeScanner/issues/121 - // No known tricks worked (changing view ID, wrapping it in another view, etc.) - // showQRCodeScanner = false - connect(pastedLink) - } else { - alert = .newChatSomeAlert(alert: SomeAlert( - alert: mkAlert(title: "Invalid link", message: "The text you pasted is not a SimpleX link."), - id: "pasteLinkView: code is not a SimpleX link" - )) + ZStack(alignment: .trailing) { + Button { + if let str = UIPasteboard.general.string { + if let link = strHasSingleSimplexLink(str.trimmingCharacters(in: .whitespaces)) { + pastedLink = link.text + // It would be good to hide it, but right now it is not clear how to release camera in CodeScanner + // https://github.com/twostraws/CodeScanner/issues/121 + // No known tricks worked (changing view ID, wrapping it in another view, etc.) + // showQRCodeScanner = false + connect(pastedLink) + } else { + alert = .newChatSomeAlert(alert: SomeAlert( + alert: mkAlert(title: "Invalid link", message: "The text you pasted is not a SimpleX link."), + id: "pasteLinkView: code is not a SimpleX link" + )) + } } + } label: { + Text("Tap to paste link") + } + .disabled(!pasteboardHasStrings) + .frame(maxWidth: .infinity, alignment: .center) + if connectProgressManager.showConnectProgress != nil { + ProgressView() } - } label: { - Text("Tap to paste link") } - .disabled(!pasteboardHasStrings) - .frame(maxWidth: .infinity, alignment: .center) } else { - linkTextView(pastedLink) + HStack { + linkTextView(pastedLink) + if connectProgressManager.showConnectProgress != nil { + ProgressView() + } + } } } @@ -653,16 +668,22 @@ private struct ConnectView: View { } private func connect(_ link: String) { + scannerPaused = true planAndConnect( link, theme: theme, - dismiss: true + dismiss: true, + cleanup: { + pastedLink = "" + scannerPaused = false + } ) } } struct ScannerInView: View { @Binding var showQRCodeScanner: Bool + var scannerPaused: Binding? = nil let processQRCode: (_ resp: Result) -> Void @State private var cameraAuthorizationStatus: AVAuthorizationStatus? var scanMode: ScanMode = .continuous @@ -670,7 +691,7 @@ struct ScannerInView: View { var body: some View { Group { if showQRCodeScanner, case .authorized = cameraAuthorizationStatus { - CodeScannerView(codeTypes: [.qr], scanMode: scanMode, completion: processQRCode) + CodeScannerView(codeTypes: [.qr], scanMode: scanMode, isPaused: scannerPaused?.wrappedValue ?? false, completion: processQRCode) .aspectRatio(1, contentMode: .fit) .cornerRadius(12) .listRowBackground(Color.clear) @@ -1148,231 +1169,249 @@ func planAndConnect( filterKnownContact: ((Contact) -> Void)? = nil, filterKnownGroup: ((GroupInfo) -> Void)? = nil ) { - Task { - let (result, alert) = await apiConnectPlan(connLink: shortOrFullLink) - if let (connectionLink, connectionPlan) = result { - switch connectionPlan { - case let .invitationLink(ilp): - switch ilp { - case let .ok(contactSLinkData_): - if let contactSLinkData = contactSLinkData_ { - logger.debug("planAndConnect, .invitationLink, .ok, short link data present") - await MainActor.run { - showPrepareContactAlert( - connectionLink: connectionLink, - contactShortLinkData: contactSLinkData, - theme: theme, - dismiss: dismiss, - cleanup: cleanup - ) + ConnectProgressManager.shared.cancelConnectProgress() + let inProgress = BoxedValue(true) + connectTask(inProgress) + ConnectProgressManager.shared.startConnectProgress(NSLocalizedString("Loading profile…", comment: "in progress text")) { + inProgress.boxedValue = false + cleanup?() + } + + func connectTask(_ inProgress: BoxedValue) { + Task { + let (result, alert) = await apiConnectPlan(connLink: shortOrFullLink, inProgress: inProgress) + await MainActor.run { + ConnectProgressManager.shared.stopConnectProgress() + } + if !inProgress.boxedValue { return } + if let (connectionLink, connectionPlan) = result { + switch connectionPlan { + case let .invitationLink(ilp): + switch ilp { + case let .ok(contactSLinkData_): + if let contactSLinkData = contactSLinkData_ { + logger.debug("planAndConnect, .invitationLink, .ok, short link data present") + await MainActor.run { + showPrepareContactAlert( + connectionLink: connectionLink, + contactShortLinkData: contactSLinkData, + theme: theme, + dismiss: dismiss, + cleanup: cleanup + ) + } + } else { + logger.debug("planAndConnect, .invitationLink, .ok, no short link data") + await MainActor.run { + showAskCurrentOrIncognitoProfileSheet( + title: NSLocalizedString("Connect via one-time link", comment: "new chat sheet title"), + connectionLink: connectionLink, + connectionPlan: connectionPlan, + dismiss: dismiss, + cleanup: cleanup + ) + } } - } else { - logger.debug("planAndConnect, .invitationLink, .ok, no short link data") + case .ownLink: + logger.debug("planAndConnect, .invitationLink, .ownLink") await MainActor.run { showAskCurrentOrIncognitoProfileSheet( - title: NSLocalizedString("Connect via one-time link", comment: "new chat sheet title"), + title: NSLocalizedString("Connect to yourself?\nThis is your own one-time link!", comment: "new chat sheet title"), + actionStyle: .destructive, connectionLink: connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, cleanup: cleanup ) } - } - case .ownLink: - logger.debug("planAndConnect, .invitationLink, .ownLink") - await MainActor.run { - showAskCurrentOrIncognitoProfileSheet( - title: NSLocalizedString("Connect to yourself?\nThis is your own one-time link!", comment: "new chat sheet title"), - actionStyle: .destructive, - connectionLink: connectionLink, - connectionPlan: connectionPlan, - dismiss: dismiss, - cleanup: cleanup - ) - } - case let .connecting(contact_): - logger.debug("planAndConnect, .invitationLink, .connecting") - await MainActor.run { - if let contact = contact_ { + case let .connecting(contact_): + logger.debug("planAndConnect, .invitationLink, .connecting") + await MainActor.run { + if let contact = contact_ { + if let f = filterKnownContact { + f(contact) + } else { + showOpenKnownContactAlert(contact, theme: theme, dismiss: dismiss) + } + } else { + showInvitationLinkConnectingAlert(cleanup: cleanup) + } + } + case let .known(contact): + logger.debug("planAndConnect, .invitationLink, .known") + await MainActor.run { if let f = filterKnownContact { f(contact) } else { showOpenKnownContactAlert(contact, theme: theme, dismiss: dismiss) } - } else { - showInvitationLinkConnectingAlert(cleanup: cleanup) } } - case let .known(contact): - logger.debug("planAndConnect, .invitationLink, .known") - await MainActor.run { - if let f = filterKnownContact { - f(contact) + case let .contactAddress(cap): + switch cap { + case let .ok(contactSLinkData_): + if let contactSLinkData = contactSLinkData_ { + logger.debug("planAndConnect, .contactAddress, .ok, short link data present") + await MainActor.run { + showPrepareContactAlert( + connectionLink: connectionLink, + contactShortLinkData: contactSLinkData, + theme: theme, + dismiss: dismiss, + cleanup: cleanup + ) + } } else { - showOpenKnownContactAlert(contact, theme: theme, dismiss: dismiss) + logger.debug("planAndConnect, .contactAddress, .ok, no short link data") + await MainActor.run { + showAskCurrentOrIncognitoProfileSheet( + title: NSLocalizedString("Connect via contact address", comment: "new chat sheet title"), + connectionLink: connectionLink, + connectionPlan: connectionPlan, + dismiss: dismiss, + cleanup: cleanup + ) + } } - } - } - case let .contactAddress(cap): - switch cap { - case let .ok(contactSLinkData_): - if let contactSLinkData = contactSLinkData_ { - logger.debug("planAndConnect, .contactAddress, .ok, short link data present") - await MainActor.run { - showPrepareContactAlert( - connectionLink: connectionLink, - contactShortLinkData: contactSLinkData, - theme: theme, - dismiss: dismiss, - cleanup: cleanup - ) - } - } else { - logger.debug("planAndConnect, .contactAddress, .ok, no short link data") + case .ownLink: + logger.debug("planAndConnect, .contactAddress, .ownLink") await MainActor.run { showAskCurrentOrIncognitoProfileSheet( - title: NSLocalizedString("Connect via contact address", comment: "new chat sheet title"), + title: NSLocalizedString("Connect to yourself?\nThis is your own SimpleX address!", comment: "new chat sheet title"), + actionStyle: .destructive, connectionLink: connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, cleanup: cleanup ) } - } - case .ownLink: - logger.debug("planAndConnect, .contactAddress, .ownLink") - await MainActor.run { - showAskCurrentOrIncognitoProfileSheet( - title: NSLocalizedString("Connect to yourself?\nThis is your own SimpleX address!", comment: "new chat sheet title"), - actionStyle: .destructive, - connectionLink: connectionLink, - connectionPlan: connectionPlan, - dismiss: dismiss, - cleanup: cleanup - ) - } - case .connectingConfirmReconnect: - logger.debug("planAndConnect, .contactAddress, .connectingConfirmReconnect") - await MainActor.run { - showAskCurrentOrIncognitoProfileSheet( - title: NSLocalizedString("You have already requested connection!\nRepeat connection request?", comment: "new chat sheet title"), - actionStyle: .destructive, - connectionLink: connectionLink, - connectionPlan: connectionPlan, - dismiss: dismiss, - cleanup: cleanup - ) - } - case let .connectingProhibit(contact): - logger.debug("planAndConnect, .contactAddress, .connectingProhibit") - await MainActor.run { - if let f = filterKnownContact { - f(contact) - } else { - showOpenKnownContactAlert(contact, theme: theme, dismiss: dismiss) - } - } - case let .known(contact): - logger.debug("planAndConnect, .contactAddress, .known") - await MainActor.run { - if let f = filterKnownContact { - f(contact) - } else { - showOpenKnownContactAlert(contact, theme: theme, dismiss: dismiss) - } - } - case let .contactViaAddress(contact): - logger.debug("planAndConnect, .contactAddress, .contactViaAddress") - await MainActor.run { - showAskCurrentOrIncognitoProfileConnectContactViaAddressSheet( - contact: contact, - dismiss: dismiss, - cleanup: cleanup - ) - } - } - case let .groupLink(glp): - switch glp { - case let .ok(groupSLinkData_): - if let groupSLinkData = groupSLinkData_ { - logger.debug("planAndConnect, .groupLink, .ok, short link data present") - await MainActor.run { - showPrepareGroupAlert( - connectionLink: connectionLink, - groupShortLinkData: groupSLinkData, - theme: theme, - dismiss: dismiss, - cleanup: cleanup - ) - } - } else { - logger.debug("planAndConnect, .groupLink, .ok, no short link data") + case .connectingConfirmReconnect: + logger.debug("planAndConnect, .contactAddress, .connectingConfirmReconnect") await MainActor.run { showAskCurrentOrIncognitoProfileSheet( - title: NSLocalizedString("Join group", comment: "new chat sheet title"), + title: NSLocalizedString("You have already requested connection!\nRepeat connection request?", comment: "new chat sheet title"), + actionStyle: .destructive, connectionLink: connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, cleanup: cleanup ) } - } - case let .ownLink(groupInfo): - logger.debug("planAndConnect, .groupLink, .ownLink") - await MainActor.run { - if let f = filterKnownGroup { - f(groupInfo) + case let .connectingProhibit(contact): + logger.debug("planAndConnect, .contactAddress, .connectingProhibit") + await MainActor.run { + if let f = filterKnownContact { + f(contact) + } else { + showOpenKnownContactAlert(contact, theme: theme, dismiss: dismiss) + } + } + case let .known(contact): + logger.debug("planAndConnect, .contactAddress, .known") + await MainActor.run { + if let f = filterKnownContact { + f(contact) + } else { + showOpenKnownContactAlert(contact, theme: theme, dismiss: dismiss) + } + } + case let .contactViaAddress(contact): + logger.debug("planAndConnect, .contactAddress, .contactViaAddress") + await MainActor.run { + showAskCurrentOrIncognitoProfileConnectContactViaAddressSheet( + contact: contact, + dismiss: dismiss, + cleanup: cleanup + ) } - showOwnGroupLinkConfirmConnectSheet( - groupInfo: groupInfo, - connectionLink: connectionLink, - connectionPlan: connectionPlan, - dismiss: dismiss, - cleanup: cleanup - ) } - case .connectingConfirmReconnect: - logger.debug("planAndConnect, .groupLink, .connectingConfirmReconnect") - await MainActor.run { - showAskCurrentOrIncognitoProfileSheet( - title: NSLocalizedString("You are already joining the group!\nRepeat join request?", comment: "new chat sheet title"), - actionStyle: .destructive, - connectionLink: connectionLink, - connectionPlan: connectionPlan, - dismiss: dismiss, - cleanup: cleanup - ) - } - case let .connectingProhibit(groupInfo_): - logger.debug("planAndConnect, .groupLink, .connectingProhibit") - await MainActor.run { - showGroupLinkConnectingAlert(groupInfo: groupInfo_, cleanup: cleanup) - } - case let .known(groupInfo): - logger.debug("planAndConnect, .groupLink, .known") - await MainActor.run { - if let f = filterKnownGroup { - f(groupInfo) + case let .groupLink(glp): + switch glp { + case let .ok(groupSLinkData_): + if let groupSLinkData = groupSLinkData_ { + logger.debug("planAndConnect, .groupLink, .ok, short link data present") + await MainActor.run { + showPrepareGroupAlert( + connectionLink: connectionLink, + groupShortLinkData: groupSLinkData, + theme: theme, + dismiss: dismiss, + cleanup: cleanup + ) + } } else { - showOpenKnownGroupAlert(groupInfo, theme: theme, dismiss: dismiss) + logger.debug("planAndConnect, .groupLink, .ok, no short link data") + await MainActor.run { + showAskCurrentOrIncognitoProfileSheet( + title: NSLocalizedString("Join group", comment: "new chat sheet title"), + connectionLink: connectionLink, + connectionPlan: connectionPlan, + dismiss: dismiss, + cleanup: cleanup + ) + } + } + case let .ownLink(groupInfo): + logger.debug("planAndConnect, .groupLink, .ownLink") + await MainActor.run { + if let f = filterKnownGroup { + f(groupInfo) + } + showOwnGroupLinkConfirmConnectSheet( + groupInfo: groupInfo, + connectionLink: connectionLink, + connectionPlan: connectionPlan, + dismiss: dismiss, + cleanup: cleanup + ) + } + case .connectingConfirmReconnect: + logger.debug("planAndConnect, .groupLink, .connectingConfirmReconnect") + await MainActor.run { + showAskCurrentOrIncognitoProfileSheet( + title: NSLocalizedString("You are already joining the group!\nRepeat join request?", comment: "new chat sheet title"), + actionStyle: .destructive, + connectionLink: connectionLink, + connectionPlan: connectionPlan, + dismiss: dismiss, + cleanup: cleanup + ) + } + case let .connectingProhibit(groupInfo_): + logger.debug("planAndConnect, .groupLink, .connectingProhibit") + await MainActor.run { + showGroupLinkConnectingAlert(groupInfo: groupInfo_, cleanup: cleanup) + } + case let .known(groupInfo): + logger.debug("planAndConnect, .groupLink, .known") + await MainActor.run { + if let f = filterKnownGroup { + f(groupInfo) + } else { + showOpenKnownGroupAlert(groupInfo, theme: theme, dismiss: dismiss) + } } } + case let .error(chatError): + logger.debug("planAndConnect, .error \(chatErrorString(chatError))") + showAskCurrentOrIncognitoProfileSheet( + title: NSLocalizedString("Connect via link", comment: "new chat sheet title"), + connectionLink: connectionLink, + connectionPlan: nil, + dismiss: dismiss, + cleanup: cleanup + ) } - case let .error(chatError): - logger.debug("planAndConnect, .error \(chatErrorString(chatError))") - showAskCurrentOrIncognitoProfileSheet( - title: NSLocalizedString("Connect via link", comment: "new chat sheet title"), - connectionLink: connectionLink, - connectionPlan: nil, - dismiss: dismiss, - cleanup: cleanup - ) - } - } else if let alert { - await MainActor.run { - dismissAllSheets(animated: true) { - AlertManager.shared.showAlert(alert) - cleanup?() + } else { + await MainActor.run { + if let alert { + dismissAllSheets(animated: true) { + AlertManager.shared.showAlert(alert) + cleanup?() + } + } else { + cleanup?() + } } } } 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 0a27cdddbf..87f6069cfe 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 @@ -43,6 +43,39 @@ import kotlin.collections.ArrayList import kotlin.random.Random import kotlin.time.* +object ConnectProgressManager { + private val connectInProgress = mutableStateOf(null) + private val connectProgressByTimeout = mutableStateOf(false) + private var onCancel: (() -> Unit)? = null + + private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + fun startConnectProgress(text: String, onCancel: (() -> Unit)? = null) { + connectInProgress.value = text + this.onCancel = onCancel + coroutineScope.launch { + delay(1000) + connectProgressByTimeout.value = connectInProgress.value != null + } + } + + fun stopConnectProgress() { + connectInProgress.value = null + onCancel = null + connectProgressByTimeout.value = false + } + + fun cancelConnectProgress() { + onCancel?.invoke() + stopConnectProgress() + } + + val showConnectProgress: String? get() = + if (connectProgressByTimeout.value) connectInProgress.value else null +} + +val connectProgressManager = ConnectProgressManager + /* * Without this annotation an animation from ChatList to ChatView has 1 frame per the whole animation. Don't delete it * */ 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 53f58bba0b..136d38e215 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 @@ -673,32 +673,35 @@ object ChatController { } } - private suspend fun sendCmdWithRetry(rhId: Long?, cmd: CC, retryNum: Int = 0): API? { + private suspend fun sendCmdWithRetry(rhId: Long?, cmd: CC, inProgress: MutableState? = null, retryNum: Int = 0): API? { val r = sendCmd(rhId, cmd, retryNum = retryNum) val alert = if (r is API.Error) retryableNetworkErrorAlert(r.err) else null - if (alert == null) return r - else return suspendCancellableCoroutine { cont -> - showRetryAlert( - alert, - onCancel = { - cont.resumeWith(Result.success(null)) - }, - onRetry = { - withLongRunningApi { - cont.resumeWith( - runCatching { - coroutineScope { - sendCmdWithRetry(rhId, cmd, retryNum = retryNum + 1) + if ((inProgress == null || inProgress.value) && alert != null) { + return suspendCancellableCoroutine { cont -> + showRetryAlert( + alert, + onCancel = { + cont.resumeWith(Result.success(null)) + }, + onRetry = { + withLongRunningApi { + cont.resumeWith( + runCatching { + coroutineScope { + sendCmdWithRetry(rhId, cmd, inProgress = inProgress, retryNum = retryNum + 1) + } } - } - ) + ) + } } - } - ) + ) - cont.invokeOnCancellation { - cont.resumeWith(Result.success(null)) + cont.invokeOnCancellation { + cont.resumeWith(Result.success(null)) + } } + } else { + return r } } @@ -1408,11 +1411,11 @@ object ChatController { return null } - suspend fun apiConnectPlan(rh: Long?, connLink: String): Pair? { + suspend fun apiConnectPlan(rh: Long?, connLink: String, inProgress: MutableState): Pair? { val userId = kotlin.runCatching { currentUserId("apiConnectPlan") }.getOrElse { return null } - val r = sendCmdWithRetry(rh, CC.APIConnectPlan(userId, connLink)) + val r = sendCmdWithRetry(rh, CC.APIConnectPlan(userId, connLink), inProgress = inProgress) if (r is API.Result && r.res is CR.CRConnectionPlan) return r.res.connLink to r.res.connectionPlan - if (r != null) apiConnectResponseAlert(r) + if (inProgress.value && r != null) apiConnectResponseAlert(r) return null } @@ -1517,10 +1520,9 @@ object ChatController { suspend fun apiConnectPreparedContact(rh: Long?, contactId: Long, incognito: Boolean, msg: MsgContent?): Contact? { val r = sendCmdWithRetry(rh, CC.APIConnectPreparedContact(contactId, incognito, msg)) if (r is API.Result && r.res is CR.StartedConnectionToContact) return r.res.contact - if (r == null) return null - Log.e(TAG, "apiConnectPreparedContact bad response: ${r.responseType} ${r.details}") - if (!(networkErrorAlert(r))) { - apiErrorAlert("apiConnectPreparedContact", generalGetString(MR.strings.connection_error), r) + if (r != null) { + Log.e(TAG, "apiConnectPreparedContact bad response: ${r.responseType} ${r.details}") + apiConnectResponseAlert(r) } return null } @@ -1528,10 +1530,9 @@ object ChatController { suspend fun apiConnectPreparedGroup(rh: Long?, groupId: Long, incognito: Boolean, msg: MsgContent?): GroupInfo? { val r = sendCmdWithRetry(rh, CC.APIConnectPreparedGroup(groupId, incognito, msg)) if (r is API.Result && r.res is CR.StartedConnectionToGroup) return r.res.groupInfo - if (r == null) return null - Log.e(TAG, "apiConnectPreparedGroup bad response: ${r.responseType} ${r.details}") - if (!(networkErrorAlert(r))) { - apiErrorAlert("apiConnectPreparedGroup", generalGetString(MR.strings.connection_error), r) + if (r != null) { + Log.e(TAG, "apiConnectPreparedGroup bad response: ${r.responseType} ${r.details}") + apiConnectResponseAlert(r) } return 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 b77713831b..444255a5fb 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 @@ -53,6 +53,40 @@ import kotlin.math.* @Stable data class ItemSeparation(val timestamp: Boolean, val largeGap: Boolean, val date: Instant?) +@Composable +fun ConnectInProgressView(s: String) { + Surface(color = MaterialTheme.colors.background) { + Divider() + Row( + Modifier + .height(60.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Row( + Modifier + .padding(start = 10.dp) + .fillMaxWidth() + .weight(1F), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + ComposeProgressIndicator() + Text(s) + } + + IconButton(onClick = { connectProgressManager.cancelConnectProgress() }) { + Icon( + painterResource(MR.images.ic_close), + contentDescription = stringResource(MR.strings.cancel_verb), + tint = MaterialTheme.colors.primary, + modifier = Modifier.padding(10.dp) + ) + } + } + } +} + @Composable // staleChatId means the id that was before chatModel.chatId becomes null. It's needed for Android only to make transition from chat // to chat list smooth. Otherwise, chat view will become blank right before the transition starts @@ -102,6 +136,13 @@ fun ChatView( val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) val scope = rememberCoroutineScope() val selectedChatItems = rememberSaveable { mutableStateOf(null as Set?) } + if (appPlatform.isAndroid) { + DisposableEffect(Unit) { + onDispose { + connectProgressManager.cancelConnectProgress() + } + } + } LaunchedEffect(Unit) { // snapshotFlow here is because it reacts much faster on changes in chatModel.chatId.value. // With LaunchedEffect(chatModel.chatId.value) there is a noticeable delay before reconstruction of the view @@ -110,6 +151,9 @@ fun ChatView( .distinctUntilChanged() .filterNotNull() .collect { chatId -> + if (appPlatform.isAndroid) { + connectProgressManager.cancelConnectProgress() + } if (chatsCtx.secondaryContextFilter == null) { markUnreadChatAsRead(chatId) } @@ -177,6 +221,10 @@ fun ChatView( Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { + val connectInProgressText = connectProgressManager.showConnectProgress + if (appPlatform.isAndroid && connectInProgressText != null) { + ConnectInProgressView(connectInProgressText) + } val connectingText = connectingText(chatInfo) if (connectingText != null) { Text( 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 2938cdd5c3..332d205cf1 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 @@ -6,9 +6,11 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.material.* -import androidx.compose.runtime.Composable +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 import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -19,68 +21,94 @@ import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.delay @Composable fun ComposeContextContactRequestActionsView( rhId: Long?, contactRequestId: Long ) { - Column( - Modifier - .height(60.dp) - .background(MaterialTheme.colors.surface) + val inProgress = rememberSaveable { mutableStateOf(false) } + var progressByTimeout by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(inProgress.value) { + progressByTimeout = if (inProgress.value) { + delay(1000) + inProgress.value + } else { + false + } + } + + Box( + Modifier.height(60.dp), + contentAlignment = Alignment.Center ) { - Divider() - - Row( + Column( Modifier - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly, + .background(MaterialTheme.colors.surface) + .alpha(if (progressByTimeout) 0.6f else 1f) ) { - Row( - Modifier - .fillMaxWidth() - .fillMaxHeight() - .weight(1F) - .clickable { - showRejectRequestAlert(rhId, contactRequestId) - }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - Icon( - painterResource(MR.images.ic_close), - contentDescription = null, - tint = Color.Red, - ) - TextIconSpaced(false) - Text(stringResource(MR.strings.reject_contact_button), color = Color.Red) - } + Divider() Row( Modifier - .fillMaxWidth() - .fillMaxHeight() - .weight(1F) - .clickable { - if (chatModel.addressShortLinkDataSet()) { - acceptContactRequest(rhId, incognito = false, contactRequestId, isCurrentUser = true, chatModel) - } else { - showAcceptRequestAlert(rhId, contactRequestId) - } - }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, ) { - Icon( - painterResource(MR.images.ic_check), - contentDescription = null, - tint = MaterialTheme.colors.primary, - ) - TextIconSpaced(false) - Text(stringResource(MR.strings.accept_contact_button), color = MaterialTheme.colors.primary) + var rejectButtonModifier = Modifier.fillMaxWidth().fillMaxHeight().weight(1F) + rejectButtonModifier = + if (inProgress.value) rejectButtonModifier + else rejectButtonModifier.clickable { showRejectRequestAlert(rhId, contactRequestId) } + Row( + rejectButtonModifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + painterResource(MR.images.ic_close), + contentDescription = null, + tint = if (inProgress.value) MaterialTheme.colors.secondary else Color.Red, + ) + TextIconSpaced(false) + Text( + stringResource(MR.strings.reject_contact_button), + color = if (inProgress.value) MaterialTheme.colors.secondary else Color.Red + ) + } + var acceptButtonModifier = Modifier.fillMaxWidth().fillMaxHeight().weight(1F) + acceptButtonModifier = + if (inProgress.value) acceptButtonModifier + else + acceptButtonModifier.clickable { + if (chatModel.addressShortLinkDataSet()) { + acceptContactRequest(rhId, incognito = false, contactRequestId, isCurrentUser = true, chatModel = chatModel, close = null, inProgress = inProgress) + } else { + showAcceptRequestAlert(rhId, contactRequestId, inProgress = inProgress) + } + } + Row( + acceptButtonModifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + painterResource(MR.images.ic_check), + contentDescription = null, + tint = if (inProgress.value) MaterialTheme.colors.secondary else MaterialTheme.colors.primary, + ) + TextIconSpaced(false) + Text( + stringResource(MR.strings.accept_contact_button), + color = if (inProgress.value) MaterialTheme.colors.secondary else MaterialTheme.colors.primary + ) + } } } + + if (progressByTimeout) { + ComposeProgressIndicator() + } } } @@ -98,7 +126,7 @@ fun showRejectRequestAlert(rhId: Long?, contactRequestId: Long) { ) } -fun showAcceptRequestAlert(rhId: Long?, contactRequestId: Long) { +fun showAcceptRequestAlert(rhId: Long?, contactRequestId: Long, inProgress: MutableState) { AlertManager.shared.showAlertDialogButtonsColumn( title = generalGetString(MR.strings.accept_contact_request), buttons = { @@ -106,14 +134,14 @@ fun showAcceptRequestAlert(rhId: Long?, contactRequestId: Long) { // Accept SectionItemView({ AlertManager.shared.hideAlert() - acceptContactRequest(rhId, incognito = false, contactRequestId, isCurrentUser = true, chatModel) + acceptContactRequest(rhId, incognito = false, contactRequestId, isCurrentUser = true, chatModel = chatModel, close = null, inProgress = inProgress) }) { 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) + acceptContactRequest(rhId, incognito = true, contactRequestId, isCurrentUser = true, chatModel = chatModel, close = null, inProgress = inProgress) }) { 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 5e34bfc0df..f4ae0d7607 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 @@ -97,6 +97,7 @@ data class ComposeState( val preview: ComposePreview = ComposePreview.NoPreview, val contextItem: ComposeContextItem = ComposeContextItem.NoContextItem, val inProgress: Boolean = false, + val progressByTimeout: Boolean = false, val useLinkPreviews: Boolean, val mentions: MentionedMembers = emptyMap() ) { @@ -427,7 +428,7 @@ fun ComposeView( fun clearState(live: Boolean = false) { if (live) { - composeState.value = composeState.value.copy(inProgress = false) + composeState.value = composeState.value.copy(inProgress = false, progressByTimeout = false) } else { composeState.value = ComposeState(useLinkPreviews = useLinkPreviews) resetLinkPreview() @@ -1245,6 +1246,7 @@ fun ComposeView( SimpleButtonIconEnded( text = stringResource(MR.strings.compose_view_connect), icon = painterResource(icon), + color = if (composeState.value.inProgress) MaterialTheme.colors.secondary else MaterialTheme.colors.primary, disabled = composeState.value.inProgress, click = { withApi { sendRequest() } } ) @@ -1265,19 +1267,28 @@ fun ComposeView( contentAlignment = Alignment.Center ) { Row( - horizontalArrangement = Arrangement.spacedBy(8.dp) + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically ) { Icon( painterResource(icon), contentDescription = null, - tint = MaterialTheme.colors.primary, + tint = if (composeState.value.inProgress) MaterialTheme.colors.secondary else MaterialTheme.colors.primary ) Text( text, style = MaterialTheme.typography.caption, - color = MaterialTheme.colors.primary + color = if (composeState.value.inProgress) MaterialTheme.colors.secondary else MaterialTheme.colors.primary ) } + if (composeState.value.progressByTimeout) { + Box( + Modifier.fillMaxWidth().padding(end = DEFAULT_PADDING_HALF), + contentAlignment = Alignment.CenterEnd + ) { + ComposeProgressIndicator() + } + } } } @@ -1328,6 +1339,16 @@ fun ComposeView( chatModel.sharedContent.value = null } + LaunchedEffect(composeState.value.inProgress) { + val newProgressByTimeout = if (composeState.value.inProgress) { + delay(1000) + composeState.value.inProgress + } else { + false + } + composeState.value = composeState.value.copy(progressByTimeout = newProgressByTimeout) + } + Column { val currentUser = chatModel.currentUser.value if (chat.chatInfo.nextConnectPrepared && currentUser != null) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt index 8b9caad94d..467f1e52af 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt @@ -65,15 +65,6 @@ fun SendMsgView( val padding = if (appPlatform.isAndroid) PaddingValues(vertical = 8.dp) else PaddingValues(top = 3.dp, bottom = 4.dp) Box(Modifier.padding(padding)) { val cs = composeState.value - var progressByTimeout by rememberSaveable { mutableStateOf(false) } - LaunchedEffect(composeState.value.inProgress) { - progressByTimeout = if (composeState.value.inProgress) { - delay(500) - composeState.value.inProgress - } else { - false - } - } val showVoiceButton = !nextConnect && cs.message.text.isEmpty() && showVoiceRecordIcon && !composeState.value.editing && !composeState.value.forwarding && cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started) && (cs.contextItem !is ComposeContextItem.ReportedItem) val showDeleteTextButton = rememberSaveable { mutableStateOf(false) } @@ -137,7 +128,7 @@ fun SendMsgView( } } when { - progressByTimeout -> ProgressIndicator() + cs.progressByTimeout -> ComposeProgressIndicator() cs.contextItem is ComposeContextItem.ReportedItem -> { SendMsgButton(painterResource(MR.images.ic_check_filled), sendButtonSize, sendButtonAlpha, sendButtonColor, !sendMsgButtonDisabled, sendToConnect, sendMessage) } @@ -411,8 +402,8 @@ private fun RecordVoiceButton(interactionSource: MutableInteractionSource) { } @Composable -private fun ProgressIndicator() { - CircularProgressIndicator(Modifier.size(36.dp).padding(4.dp), color = MaterialTheme.colors.secondary, strokeWidth = 3.dp) +fun ComposeProgressIndicator() { + CircularProgressIndicator(Modifier.size(36.dp).padding(4.dp), color = MaterialTheme.colors.secondary, strokeWidth = 2.5.dp) } @Composable 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 feba9acd79..4f06277e6f 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 @@ -695,9 +695,19 @@ fun contactRequestAlertDialog(rhId: Long?, contactRequest: ChatInfo.ContactReque ) } -fun acceptContactRequest(rhId: Long?, incognito: Boolean, contactRequestId: Long, isCurrentUser: Boolean, chatModel: ChatModel, close: ((chat: Chat) -> Unit)? = null ) { +fun acceptContactRequest( + rhId: Long?, + incognito: Boolean, + contactRequestId: Long, + isCurrentUser: Boolean, + chatModel: ChatModel, + close: ((chat: Chat) -> Unit)? = null, + inProgress: MutableState? = null +) { withBGApi { + inProgress?.value = true val contact = chatModel.controller.apiAcceptContactRequest(rhId, incognito, contactRequestId) + inProgress?.value = false if (contact != null && isCurrentUser) { val chat = Chat(remoteHostId = rhId, ChatInfo.Direct(contact), listOf()) withContext(Dispatchers.Main) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 91d2699b7d..5e9bac515d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -55,6 +55,7 @@ sealed class ActiveFilter { } private fun showNewChatSheet(oneHandUI: State) { + connectProgressManager.cancelConnectProgress() ModalManager.start.closeModals() ModalManager.end.closeModals() chatModel.newChatSheetVisible.value = true @@ -348,22 +349,64 @@ private fun ChatListToolbar(userPickerState: MutableStateFlow val updatingProgress = remember { chatModel.updatingProgress }.value val oneHandUI = remember { appPrefs.oneHandUI.state } - if (oneHandUI.value) { - val sp16 = with(LocalDensity.current) { 16.sp.toDp() } - - if (appPlatform.isDesktop && oneHandUI.value) { - val call = remember { chatModel.activeCall } - if (call.value != null) { - barButtons.add { - val c = call.value - if (c != null) { - ActiveCallInteractiveArea(c) - Spacer(Modifier.width(5.dp)) - } + if (updatingProgress != null) { + barButtons.add { + val interactionSource = remember { MutableInteractionSource() } + val hovered = interactionSource.collectIsHoveredAsState().value + IconButton(onClick = { + chatModel.updatingRequest?.close() + }, Modifier.hoverable(interactionSource)) { + if (hovered) { + Icon(painterResource(MR.images.ic_close), null, tint = WarningOrange) + } else if (updatingProgress == -1f) { + CIFileViewScope.progressIndicator() + } else { + CIFileViewScope.progressCircle((updatingProgress * 100).toLong(), 100) } } } - if (!stopped) { + } + + if (stopped) { + barButtons.add { + IconButton(onClick = { + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.chat_is_stopped_indication), + generalGetString(MR.strings.you_can_start_chat_via_setting_or_by_restarting_the_app) + ) + }) { + Icon( + painterResource(MR.images.ic_report_filled), + generalGetString(MR.strings.chat_is_stopped_indication), + tint = Color.Red, + ) + } + } + } else { + if (connectProgressManager.showConnectProgress != null) { + barButtons.add { + Box(Modifier.padding(horizontal = DEFAULT_PADDING_HALF)) { + CIFileViewScope.progressIndicator() + } + } + } + + if (oneHandUI.value) { + val sp16 = with(LocalDensity.current) { 16.sp.toDp() } + + if (appPlatform.isDesktop && oneHandUI.value) { + val call = remember { chatModel.activeCall } + if (call.value != null) { + barButtons.add { + val c = call.value + if (c != null) { + ActiveCallInteractiveArea(c) + Spacer(Modifier.width(5.dp)) + } + } + } + } + barButtons.add { IconButton( onClick = { @@ -388,38 +431,6 @@ private fun ChatListToolbar(userPickerState: MutableStateFlow } } - if (updatingProgress != null) { - barButtons.add { - val interactionSource = remember { MutableInteractionSource() } - val hovered = interactionSource.collectIsHoveredAsState().value - IconButton(onClick = { - chatModel.updatingRequest?.close() - }, Modifier.hoverable(interactionSource)) { - if (hovered) { - Icon(painterResource(MR.images.ic_close), null, tint = WarningOrange) - } else if (updatingProgress == -1f) { - CIFileViewScope.progressIndicator() - } else { - CIFileViewScope.progressCircle((updatingProgress * 100).toLong(), 100) - } - } - } - } else if (stopped) { - barButtons.add { - IconButton(onClick = { - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.chat_is_stopped_indication), - generalGetString(MR.strings.you_can_start_chat_via_setting_or_by_restarting_the_app) - ) - }) { - Icon( - painterResource(MR.images.ic_report_filled), - generalGetString(MR.strings.chat_is_stopped_indication), - tint = Color.Red, - ) - } - } - } val clipboard = LocalClipboardManager.current val scope = rememberCoroutineScope() val canScrollToZero = remember { derivedStateOf { listState.firstVisibleItemIndex != 0 || listState.firstVisibleItemScrollOffset != 0 } } @@ -658,8 +669,11 @@ private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState if (it.isNotEmpty()) { // if some other text is pasted, enter search mode focusRequester.requestFocus() - } else if (listState.layoutInfo.totalItemsCount > 0) { - listState.scrollToItem(0) + } else { + connectProgressManager.cancelConnectProgress() + if (listState.layoutInfo.totalItemsCount > 0) { + listState.scrollToItem(0) + } } searchShowingSimplexLink.value = false searchChatFilteredBySimplexLink.value = null diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt index 73c59f61a2..786e3483c5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt @@ -3,6 +3,8 @@ package chat.simplex.common.views.newchat import SectionItemView import androidx.compose.foundation.layout.* import androidx.compose.material.* +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -25,6 +27,24 @@ suspend fun planAndConnect( cleanup: (() -> Unit)? = null, filterKnownContact: ((Contact) -> Unit)? = null, filterKnownGroup: ((GroupInfo) -> Unit)? = null, +): CompletableDeferred { + connectProgressManager.cancelConnectProgress() + val inProgress = mutableStateOf(true) + connectProgressManager.startConnectProgress(generalGetString(MR.strings.loading_profile)) { + inProgress.value = false + cleanup?.invoke() + } + return planAndConnectTask(rhId, shortOrFullLink, close, cleanup, filterKnownContact, filterKnownGroup, inProgress) +} + +private suspend fun planAndConnectTask( + rhId: Long?, + shortOrFullLink: String, + close: (() -> Unit)?, + cleanup: (() -> Unit)? = null, + filterKnownContact: ((Contact) -> Unit)? = null, + filterKnownGroup: ((GroupInfo) -> Unit)? = null, + inProgress: MutableState ): CompletableDeferred { val completable = CompletableDeferred() val close: (() -> Unit) = { @@ -36,7 +56,9 @@ suspend fun planAndConnect( cleanup?.invoke() completable.complete(!completable.isActive) } - val result = chatModel.controller.apiConnectPlan(rhId, shortOrFullLink) + val result = chatModel.controller.apiConnectPlan(rhId, shortOrFullLink, inProgress = inProgress) + connectProgressManager.stopConnectProgress() + if (!inProgress.value) { return completable } if (result != null) { val (connectionLink, connectionPlan) = result val link = strHasSingleSimplexLink(shortOrFullLink.trim()) @@ -259,6 +281,8 @@ suspend fun planAndConnect( ) } } + } else { + cleanup() } return completable } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt index 54620f8d60..1cb1903a14 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt @@ -29,6 +29,7 @@ import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs 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.chatlist.* import chat.simplex.common.views.contacts.* @@ -40,6 +41,12 @@ import kotlinx.coroutines.flow.filter @Composable fun ModalData.NewChatSheet(rh: RemoteHostInfo?, close: () -> Unit) { + DisposableEffect(Unit) { + onDispose { + connectProgressManager.cancelConnectProgress() + } + } + val oneHandUI = remember { appPrefs.oneHandUI.state } Box { @@ -472,6 +479,13 @@ private fun ContactsSearchBar( ) { searchText.value = searchText.value.copy(it) } + + if (connectProgressManager.showConnectProgress != null) { + Box(Modifier.padding(end = DEFAULT_PADDING_HALF)) { + CIFileViewScope.progressIndicator(sizeMultiplier = 0.75f) + } + } + val hasText = remember { derivedStateOf { searchText.value.text.isNotEmpty() } } if (hasText.value) { val hideSearchOnBack: () -> Unit = { searchText.value = TextFieldValue() } @@ -522,8 +536,11 @@ private fun ContactsSearchBar( if (it.isNotEmpty()) { // if some other text is pasted, enter search mode focusRequester.requestFocus() - } else if (listState.layoutInfo.totalItemsCount > 0) { - listState.scrollToItem(0) + } else { + connectProgressManager.cancelConnectProgress() + if (listState.layoutInfo.totalItemsCount > 0) { + listState.scrollToItem(0) + } } searchShowingSimplexLink.value = false searchChatFilteredBySimplexLink.value = null diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt index 6c8f9f944c..b0846b4bb8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.* import androidx.compose.runtime.* @@ -34,6 +35,7 @@ import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel.controller 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.helpers.* import chat.simplex.common.views.usersettings.* @@ -574,6 +576,12 @@ fun AddContactLearnMoreButton() { @Composable private fun ConnectView(rhId: Long?, showQRCodeScanner: MutableState, pastedLink: MutableState, close: () -> Unit) { + DisposableEffect(Unit) { + onDispose { + connectProgressManager.cancelConnectProgress() + } + } + SectionView(stringResource(MR.strings.paste_the_link_you_received).uppercase(), headerBottomPadding = 5.dp) { PasteLinkView(rhId, pastedLink, showQRCodeScanner, close) } @@ -614,10 +622,25 @@ private fun PasteLinkView(rhId: Long?, pastedLink: MutableState, showQRC ) } }) { - Text(stringResource(MR.strings.tap_to_paste_link)) + Box(Modifier.weight(1f)) { + Text(stringResource(MR.strings.tap_to_paste_link)) + } + if (connectProgressManager.showConnectProgress != null) { + CIFileViewScope.progressIndicator(sizeMultiplier = 0.6f) + } } } else { - LinkTextView(pastedLink.value, false) + Row( + Modifier.padding(end = DEFAULT_PADDING), + verticalAlignment = Alignment.CenterVertically + ) { + Box(Modifier.weight(1f)) { + LinkTextView(pastedLink.value, false) + } + if (connectProgressManager.showConnectProgress != null) { + CIFileViewScope.progressIndicator(sizeMultiplier = 0.6f) + } + } } } diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index c518c088fb..3b9d57090a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -862,6 +862,7 @@ Paste the link you received The text you pasted is not a SimpleX link. Tap to paste link + Loading profile… Invalid QR code The code you scanned is not a SimpleX link QR code.