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 <evgeny@poberezkin.com>
This commit is contained in:
spaced4ndyandEvgeny Poberezkin authored and GitHub committed 2025-07-10 09:29:39 +00:00
1 parent ca22cd4367
commit a7f8866360
21 files changed
+743 -379

No files matched your search

+35
View File
@@ -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
+6 -5
View File
@@ -120,9 +120,10 @@ func chatSendCmd<R: ChatAPIResult>(_ cmd: ChatCommand, bgTask: Bool = true, bgDe
return try apiResult(res)
}
func chatApiSendCmdWithRetry<R: ChatAPIResult>(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, retryNum: Int32 = 0) async -> APIResult<R>? {
func chatApiSendCmdWithRetry<R: ChatAPIResult>(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, inProgress: BoxedValue<Bool>? = nil, retryNum: Int32 = 0) async -> APIResult<R>? {
let r: APIResult<R> = 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<R: ChatAPIResult>(_ cmd: ChatCommand, bgTask: Bool
cont.resume(returning: nil)
},
onRetry: {
let r1: APIResult<R>? = await chatApiSendCmdWithRetry(cmd, bgTask: bgTask, bgDelay: bgDelay, retryNum: retryNum + 1)
let r1: APIResult<R>? = 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<Bool>) async -> ((CreatedConnLink, ConnectionPlan)?, Alert?) {
guard let userId = ChatModel.shared.currentUser?.userId else {
logger.error("apiConnectPlan: no current user")
return (nil, nil)
}
let r: APIResult<ChatResponse1>? = await chatApiSendCmdWithRetry(.apiConnectPlan(userId: userId, connLink: connLink))
let r: APIResult<ChatResponse1>? = 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)
+31
View File
@@ -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<Int64> = 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
@@ -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
@@ -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() {
@@ -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)
}
@@ -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
@@ -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
+246 -207
View File
@@ -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<Bool>? = nil
let processQRCode: (_ resp: Result<ScanResult, ScanError>) -> 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<Bool>) {
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?()
}
}
}
}
@@ -43,6 +43,39 @@ import kotlin.collections.ArrayList
import kotlin.random.Random
import kotlin.time.*
object ConnectProgressManager {
private val connectInProgress = mutableStateOf<String?>(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
* */
@@ -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<Boolean>? = 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<CreatedConnLink, ConnectionPlan>? {
suspend fun apiConnectPlan(rh: Long?, connLink: String, inProgress: MutableState<Boolean>): Pair<CreatedConnLink, ConnectionPlan>? {
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
}
@@ -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<Long>?) }
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(
@@ -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<Boolean>) {
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)
}
@@ -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) {
@@ -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
@@ -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<Boolean>? = 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) {
@@ -55,6 +55,7 @@ sealed class ActiveFilter {
}
private fun showNewChatSheet(oneHandUI: State<Boolean>) {
connectProgressManager.cancelConnectProgress()
ModalManager.start.closeModals()
ModalManager.end.closeModals()
chatModel.newChatSheetVisible.value = true
@@ -348,22 +349,64 @@ private fun ChatListToolbar(userPickerState: MutableStateFlow<AnimatedViewState>
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<AnimatedViewState>
}
}
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
@@ -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<Boolean> {
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<Boolean>
): CompletableDeferred<Boolean> {
val completable = CompletableDeferred<Boolean>()
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
}
@@ -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
@@ -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<Boolean>, pastedLink: MutableState<String>, 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<String>, 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)
}
}
}
}
@@ -862,6 +862,7 @@
<string name="paste_the_link_you_received">Paste the link you received</string>
<string name="the_text_you_pasted_is_not_a_link">The text you pasted is not a SimpleX link.</string>
<string name="tap_to_paste_link">Tap to paste link</string>
<string name="loading_profile">Loading profile…</string>
<string name="invalid_qr_code">Invalid QR code</string>
<string name="code_you_scanned_is_not_simplex_link_qr_code">The code you scanned is not a SimpleX link QR code.</string>