diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 24a77cb3d..d1a16f73a 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1172,7 +1172,7 @@ func filterMembersToAdd(_ ms: [GMember]) -> [Contact] { let memberContactIds = ms.compactMap{ m in m.wrapped.memberCurrent ? m.wrapped.memberContactId : nil } return ChatModel.shared.chats .compactMap{ $0.chatInfo.contact } - .filter{ !memberContactIds.contains($0.apiId) } + .filter{ c in c.ready && c.active && !memberContactIds.contains(c.apiId) } .sorted{ $0.displayName.lowercased() < $1.displayName.lowercased() } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift index dfa4a97fc..cb0b61f53 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift @@ -65,6 +65,8 @@ struct MarkedDeletedItemView: View { } } + // same texts are in markedDeletedText in ChatPreviewView, but it returns String; + // can be refactored into a single function if functions calling these are changed to return same type var markedDeletedText: LocalizedStringKey { switch chatItem.meta.itemDeleted { case let .moderated(_, byGroupMember): "moderated by \(byGroupMember.displayName)" diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index af53e7e47..35caf655e 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -159,12 +159,13 @@ struct ChatView: View { switch cInfo { case let .direct(contact): HStack { - if contact.allowsFeature(.calls) { + let callsPrefEnabled = contact.mergedPreferences.calls.enabled.forUser + if callsPrefEnabled { callButton(contact, .audio, imageName: "phone") .disabled(!contact.ready || !contact.active) } Menu { - if contact.allowsFeature(.calls) { + if callsPrefEnabled { Button { CallController.shared.startCall(contact, .video) } label: { @@ -748,7 +749,9 @@ struct ChatView: View { if ci.meta.editable && !mc.isVoice && !live { menu.append(editAction(ci)) } - menu.append(viewInfoUIAction(ci)) + if !ci.isLiveDummy { + menu.append(viewInfoUIAction(ci)) + } if revealed { menu.append(hideUIAction()) } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index b59792609..604e0a276 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -978,6 +978,9 @@ struct ComposeView: View { } private func cancelLinkPreview() { + if let pendingLink = pendingLinkUrl?.absoluteString { + cancelledLinks.insert(pendingLink) + } if let uri = composeState.linkPreview?.uri.absoluteString { cancelledLinks.insert(uri) } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 3879e78d3..dbea6a17e 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -370,7 +370,11 @@ struct GroupChatInfoView: View { private func addOrEditWelcomeMessage() -> some View { NavigationLink { - GroupWelcomeView(groupId: groupInfo.groupId, groupInfo: $groupInfo) + GroupWelcomeView( + groupInfo: $groupInfo, + groupProfile: groupInfo.groupProfile, + welcomeText: groupInfo.groupProfile.description ?? "" + ) .navigationTitle("Welcome message") .navigationBarTitleDisplayMode(.large) } label: { diff --git a/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift b/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift index e5ff644a9..d6dbf06ef 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupWelcomeView.swift @@ -11,29 +11,32 @@ import SimpleXChat struct GroupWelcomeView: View { @Environment(\.dismiss) var dismiss: DismissAction - @EnvironmentObject private var m: ChatModel - var groupId: Int64 @Binding var groupInfo: GroupInfo - @State private var welcomeText: String = "" + @State var groupProfile: GroupProfile + @State var welcomeText: String @State private var editMode = true @FocusState private var keyboardVisible: Bool @State private var showSaveDialog = false + let maxByteCount = 1200 + var body: some View { VStack { if groupInfo.canEdit { editorView() .modifier(BackButton { - if welcomeText == groupInfo.groupProfile.description || (welcomeText == "" && groupInfo.groupProfile.description == nil) { + if welcomeTextUnchanged() { dismiss() } else { showSaveDialog = true } }) - .confirmationDialog("Save welcome message?", isPresented: $showSaveDialog) { - Button("Save and update group profile") { - save() - dismiss() + .confirmationDialog( + welcomeTextFitsLimit() ? "Save welcome message?" : "Welcome message is too long", + isPresented: $showSaveDialog + ) { + if welcomeTextFitsLimit() { + Button("Save and update group profile") { save() } } Button("Exit without saving") { dismiss() } } @@ -47,14 +50,15 @@ struct GroupWelcomeView: View { } } .onAppear { - welcomeText = groupInfo.groupProfile.description ?? "" - keyboardVisible = true + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + keyboardVisible = true + } } } private func textPreview() -> some View { messageText(welcomeText, parseSimpleXMarkdown(welcomeText), nil, showSecrets: false) - .frame(minHeight: 140, alignment: .topLeading) + .frame(minHeight: 130, alignment: .topLeading) .frame(maxWidth: .infinity, alignment: .leading) } @@ -74,7 +78,7 @@ struct GroupWelcomeView: View { } .padding(.horizontal, -5) .padding(.top, -8) - .frame(height: 140, alignment: .topLeading) + .frame(height: 130, alignment: .topLeading) .frame(maxWidth: .infinity, alignment: .leading) } } else { @@ -93,6 +97,9 @@ struct GroupWelcomeView: View { } .disabled(welcomeText.isEmpty) copyButton() + } footer: { + Text(!welcomeTextFitsLimit() ? "Message too large" : "") + .foregroundColor(.red) } Section { @@ -113,7 +120,15 @@ struct GroupWelcomeView: View { Button("Save and update group profile") { save() } - .disabled(welcomeText == groupInfo.groupProfile.description || (welcomeText == "" && groupInfo.groupProfile.description == nil)) + .disabled(welcomeTextUnchanged() || !welcomeTextFitsLimit()) + } + + private func welcomeTextUnchanged() -> Bool { + welcomeText == groupInfo.groupProfile.description || (welcomeText == "" && groupInfo.groupProfile.description == nil) + } + + private func welcomeTextFitsLimit() -> Bool { + chatJsonLength(welcomeText) <= maxByteCount } private func save() { @@ -123,11 +138,13 @@ struct GroupWelcomeView: View { if welcome?.count == 0 { welcome = nil } - var groupProfileUpdated = groupInfo.groupProfile - groupProfileUpdated.description = welcome - groupInfo = try await apiUpdateGroup(groupId, groupProfileUpdated) - m.updateGroup(groupInfo) - welcomeText = welcome ?? "" + groupProfile.description = welcome + let gInfo = try await apiUpdateGroup(groupInfo.groupId, groupProfile) + await MainActor.run { + groupInfo = gInfo + ChatModel.shared.updateGroup(gInfo) + dismiss() + } } catch let error { logger.error("apiUpdateGroup error: \(responseError(error))") } @@ -137,6 +154,6 @@ struct GroupWelcomeView: View { struct GroupWelcomeView_Previews: PreviewProvider { static var previews: some View { - GroupWelcomeView(groupId: 1, groupInfo: Binding.constant(GroupInfo.sampleData)) + GroupProfileView(groupInfo: Binding.constant(GroupInfo.sampleData), groupProfile: GroupProfile.sampleData) } } diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 186a709ce..8bfc8fec0 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -34,7 +34,7 @@ struct ChatPreviewView: View { HStack(alignment: .top) { chatPreviewTitle() Spacer() - (cItem?.timestampText ?? formatTimestampText(chat.chatInfo.updatedAt)) + (cItem?.timestampText ?? formatTimestampText(chat.chatInfo.chatTs)) .font(.subheadline) .frame(minWidth: 60, alignment: .trailing) .foregroundColor(.secondary) @@ -171,10 +171,21 @@ struct ChatPreviewView: View { } func chatItemPreview(_ cItem: ChatItem) -> Text { - let itemText = cItem.meta.itemDeleted == nil ? cItem.text : NSLocalizedString("marked deleted", comment: "marked deleted chat item preview text") + let itemText = cItem.meta.itemDeleted == nil ? cItem.text : markedDeletedText() let itemFormattedText = cItem.meta.itemDeleted == nil ? cItem.formattedText : nil return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: attachment(), preview: true, showSecrets: false) + // same texts are in markedDeletedText in MarkedDeletedItemView, but it returns LocalizedStringKey; + // can be refactored into a single function if functions calling these are changed to return same type + func markedDeletedText() -> String { + switch cItem.meta.itemDeleted { + case let .moderated(_, byGroupMember): String.localizedStringWithFormat(NSLocalizedString("moderated by %@", comment: "marked deleted chat item preview text"), byGroupMember.displayName) + case .blocked: NSLocalizedString("blocked", comment: "marked deleted chat item preview text") + case .blockedByAdmin: NSLocalizedString("blocked by admin", comment: "marked deleted chat item preview text") + case .deleted, nil: NSLocalizedString("marked deleted", comment: "marked deleted chat item preview text") + } + } + func attachment() -> String? { switch cItem.content.msgContent { case .file: return "doc.fill" diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 6a51c873b..8b5802206 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -61,11 +61,6 @@ 5C7505A527B679EE00BE3227 /* NavLinkPlain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */; }; 5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */; }; 5C764E89279CBCB3000C6508 /* ChatModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E88279CBCB3000C6508 /* ChatModel.swift */; }; - 5C83A1AD2B5EF67D00AE0A4A /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C83A1A82B5EF67D00AE0A4A /* libgmp.a */; }; - 5C83A1AE2B5EF67D00AE0A4A /* libHSsimplex-chat-5.5.0.4-HTW6wkBBAjO2GDtnvnI9O3-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C83A1A92B5EF67D00AE0A4A /* libHSsimplex-chat-5.5.0.4-HTW6wkBBAjO2GDtnvnI9O3-ghc9.6.3.a */; }; - 5C83A1AF2B5EF67D00AE0A4A /* libHSsimplex-chat-5.5.0.4-HTW6wkBBAjO2GDtnvnI9O3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C83A1AA2B5EF67D00AE0A4A /* libHSsimplex-chat-5.5.0.4-HTW6wkBBAjO2GDtnvnI9O3.a */; }; - 5C83A1B02B5EF67D00AE0A4A /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C83A1AB2B5EF67D00AE0A4A /* libffi.a */; }; - 5C83A1B12B5EF67D00AE0A4A /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C83A1AC2B5EF67D00AE0A4A /* libgmpxx.a */; }; 5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 5C8F01CC27A6F0D8007D2C8D /* CodeScanner */; }; 5C93292F29239A170090FFF9 /* ProtocolServersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C93292E29239A170090FFF9 /* ProtocolServersView.swift */; }; 5C93293129239BED0090FFF9 /* ProtocolServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C93293029239BED0090FFF9 /* ProtocolServerView.swift */; }; @@ -142,6 +137,11 @@ 5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407827ADB701007B033A /* EmojiItemView.swift */; }; 5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCE227DE9246000BD591 /* ComposeView.swift */; }; 5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */; }; + 5CEB651B2B65B25500EF2982 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CEB65162B65B25400EF2982 /* libgmpxx.a */; }; + 5CEB651C2B65B25500EF2982 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CEB65172B65B25400EF2982 /* libffi.a */; }; + 5CEB651D2B65B25500EF2982 /* libHSsimplex-chat-5.5.1.0-3edL3RXPYL6hI3kOCWWU9.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CEB65182B65B25400EF2982 /* libHSsimplex-chat-5.5.1.0-3edL3RXPYL6hI3kOCWWU9.a */; }; + 5CEB651E2B65B25500EF2982 /* libHSsimplex-chat-5.5.1.0-3edL3RXPYL6hI3kOCWWU9-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CEB65192B65B25400EF2982 /* libHSsimplex-chat-5.5.1.0-3edL3RXPYL6hI3kOCWWU9-ghc9.6.3.a */; }; + 5CEB651F2B65B25500EF2982 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CEB651A2B65B25500EF2982 /* libgmp.a */; }; 5CEBD7462A5C0A8F00665FE2 /* KeyboardPadding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEBD7452A5C0A8F00665FE2 /* KeyboardPadding.swift */; }; 5CEBD7482A5F115D00665FE2 /* SetDeliveryReceiptsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEBD7472A5F115D00665FE2 /* SetDeliveryReceiptsView.swift */; }; 5CF9371E2B23429500E1D781 /* ConcurrentQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF9371D2B23429500E1D781 /* ConcurrentQueue.swift */; }; @@ -325,11 +325,6 @@ 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavLinkPlain.swift; sourceTree = ""; }; 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoToolbar.swift; sourceTree = ""; }; 5C764E88279CBCB3000C6508 /* ChatModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatModel.swift; sourceTree = ""; }; - 5C83A1A82B5EF67D00AE0A4A /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5C83A1A92B5EF67D00AE0A4A /* libHSsimplex-chat-5.5.0.4-HTW6wkBBAjO2GDtnvnI9O3-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.0.4-HTW6wkBBAjO2GDtnvnI9O3-ghc9.6.3.a"; sourceTree = ""; }; - 5C83A1AA2B5EF67D00AE0A4A /* libHSsimplex-chat-5.5.0.4-HTW6wkBBAjO2GDtnvnI9O3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.0.4-HTW6wkBBAjO2GDtnvnI9O3.a"; sourceTree = ""; }; - 5C83A1AB2B5EF67D00AE0A4A /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5C83A1AC2B5EF67D00AE0A4A /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 5C84FE9129A216C800D95B1A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; 5C84FE9329A2179C00D95B1A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = "nl.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; 5C84FE9429A2179C00D95B1A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -429,6 +424,11 @@ 5CE6C7B42AAB1527007F345C /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = ""; }; 5CEACCE227DE9246000BD591 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = ""; }; 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MsgContentView.swift; sourceTree = ""; }; + 5CEB65162B65B25400EF2982 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5CEB65172B65B25400EF2982 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5CEB65182B65B25400EF2982 /* libHSsimplex-chat-5.5.1.0-3edL3RXPYL6hI3kOCWWU9.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.1.0-3edL3RXPYL6hI3kOCWWU9.a"; sourceTree = ""; }; + 5CEB65192B65B25400EF2982 /* libHSsimplex-chat-5.5.1.0-3edL3RXPYL6hI3kOCWWU9-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.5.1.0-3edL3RXPYL6hI3kOCWWU9-ghc9.6.3.a"; sourceTree = ""; }; + 5CEB651A2B65B25500EF2982 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 5CEBD7452A5C0A8F00665FE2 /* KeyboardPadding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardPadding.swift; sourceTree = ""; }; 5CEBD7472A5F115D00665FE2 /* SetDeliveryReceiptsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetDeliveryReceiptsView.swift; sourceTree = ""; }; 5CF9371D2B23429500E1D781 /* ConcurrentQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcurrentQueue.swift; sourceTree = ""; }; @@ -514,13 +514,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 5CEB651B2B65B25500EF2982 /* libgmpxx.a in Frameworks */, + 5CEB651F2B65B25500EF2982 /* libgmp.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, - 5C83A1B02B5EF67D00AE0A4A /* libffi.a in Frameworks */, - 5C83A1AD2B5EF67D00AE0A4A /* libgmp.a in Frameworks */, - 5C83A1AE2B5EF67D00AE0A4A /* libHSsimplex-chat-5.5.0.4-HTW6wkBBAjO2GDtnvnI9O3-ghc9.6.3.a in Frameworks */, - 5C83A1AF2B5EF67D00AE0A4A /* libHSsimplex-chat-5.5.0.4-HTW6wkBBAjO2GDtnvnI9O3.a in Frameworks */, + 5CEB651D2B65B25500EF2982 /* libHSsimplex-chat-5.5.1.0-3edL3RXPYL6hI3kOCWWU9.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 5C83A1B12B5EF67D00AE0A4A /* libgmpxx.a in Frameworks */, + 5CEB651C2B65B25500EF2982 /* libffi.a in Frameworks */, + 5CEB651E2B65B25500EF2982 /* libHSsimplex-chat-5.5.1.0-3edL3RXPYL6hI3kOCWWU9-ghc9.6.3.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -582,11 +582,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5C83A1AB2B5EF67D00AE0A4A /* libffi.a */, - 5C83A1A82B5EF67D00AE0A4A /* libgmp.a */, - 5C83A1AC2B5EF67D00AE0A4A /* libgmpxx.a */, - 5C83A1A92B5EF67D00AE0A4A /* libHSsimplex-chat-5.5.0.4-HTW6wkBBAjO2GDtnvnI9O3-ghc9.6.3.a */, - 5C83A1AA2B5EF67D00AE0A4A /* libHSsimplex-chat-5.5.0.4-HTW6wkBBAjO2GDtnvnI9O3.a */, + 5CEB65172B65B25400EF2982 /* libffi.a */, + 5CEB651A2B65B25500EF2982 /* libgmp.a */, + 5CEB65162B65B25400EF2982 /* libgmpxx.a */, + 5CEB65192B65B25400EF2982 /* libHSsimplex-chat-5.5.1.0-3edL3RXPYL6hI3kOCWWU9-ghc9.6.3.a */, + 5CEB65182B65B25400EF2982 /* libHSsimplex-chat-5.5.1.0-3edL3RXPYL6hI3kOCWWU9.a */, ); path = Libraries; sourceTree = ""; @@ -1509,7 +1509,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 194; + CURRENT_PROJECT_VERSION = 195; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1531,7 +1531,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 5.5; + MARKETING_VERSION = 5.5.1; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -1552,7 +1552,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 194; + CURRENT_PROJECT_VERSION = 195; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1574,7 +1574,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 5.5; + MARKETING_VERSION = 5.5.1; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -1633,7 +1633,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 194; + CURRENT_PROJECT_VERSION = 195; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1646,7 +1646,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.5; + MARKETING_VERSION = 5.5.1; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1665,7 +1665,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 194; + CURRENT_PROJECT_VERSION = 195; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1678,7 +1678,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 5.5; + MARKETING_VERSION = 5.5.1; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1697,7 +1697,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 194; + CURRENT_PROJECT_VERSION = 195; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -1721,7 +1721,7 @@ "$(inherited)", "$(PROJECT_DIR)/Libraries/sim", ); - MARKETING_VERSION = 5.5; + MARKETING_VERSION = 5.5.1; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -1743,7 +1743,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 194; + CURRENT_PROJECT_VERSION = 195; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -1767,7 +1767,7 @@ "$(inherited)", "$(PROJECT_DIR)/Libraries/sim", ); - MARKETING_VERSION = 5.5; + MARKETING_VERSION = 5.5.1; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; diff --git a/apps/ios/SimpleXChat/API.swift b/apps/ios/SimpleXChat/API.swift index ab069f24c..c0bb29892 100644 --- a/apps/ios/SimpleXChat/API.swift +++ b/apps/ios/SimpleXChat/API.swift @@ -105,6 +105,11 @@ public func parseSimpleXMarkdown(_ s: String) -> [FormattedText]? { return nil } +public func chatJsonLength(_ s: String) -> Int { + var c = s.cString(using: .utf8)! + return Int(chat_json_length(&c)) +} + struct ParsedMarkdown: Decodable { var formattedText: [FormattedText]? } diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index ff61a51d3..198a777f8 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1367,6 +1367,17 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat { } } + public var chatTs: Date { + switch self { + case let .direct(contact): return contact.chatTs ?? contact.updatedAt + case let .group(groupInfo): return groupInfo.chatTs ?? groupInfo.updatedAt + case let .local(noteFolder): return noteFolder.chatTs + case let .contactRequest(contactRequest): return contactRequest.updatedAt + case let .contactConnection(contactConnection): return contactConnection.updatedAt + case .invalidJSON: return .now + } + } + public struct SampleData { public var direct: ChatInfo public var group: ChatInfo @@ -1425,6 +1436,7 @@ public struct Contact: Identifiable, Decodable, NamedChat { public var mergedPreferences: ContactUserPreferences var createdAt: Date var updatedAt: Date + var chatTs: Date? var contactGroupMemberId: Int64? var contactGrpInvSent: Bool @@ -1744,6 +1756,7 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat { public var chatSettings: ChatSettings var createdAt: Date var updatedAt: Date + var chatTs: Date? public var id: ChatId { get { "#\(groupId)" } } public var apiId: Int64 { get { groupId } } @@ -2049,6 +2062,7 @@ public struct NoteFolder: Identifiable, Decodable, NamedChat { public var unread: Bool var createdAt: Date public var updatedAt: Date + var chatTs: Date public var id: ChatId { get { "*\(noteFolderId)" } } public var apiId: Int64 { get { noteFolderId } } @@ -2070,7 +2084,8 @@ public struct NoteFolder: Identifiable, Decodable, NamedChat { favorite: false, unread: false, createdAt: .now, - updatedAt: .now + updatedAt: .now, + chatTs: .now ) } diff --git a/apps/ios/SimpleXChat/SimpleX.h b/apps/ios/SimpleXChat/SimpleX.h index c49d10451..153365424 100644 --- a/apps/ios/SimpleXChat/SimpleX.h +++ b/apps/ios/SimpleXChat/SimpleX.h @@ -25,6 +25,7 @@ extern char *chat_parse_markdown(char *str); extern char *chat_parse_server(char *str); extern char *chat_password_hash(char *pwd, char *salt); extern char *chat_valid_name(char *name); +extern int chat_json_length(char *str); extern char *chat_encrypt_media(chat_ctrl ctl, char *key, char *frame, int len); extern char *chat_decrypt_media(char *key, char *frame, int len); diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt index 8d64ae3c8..7a1299c61 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt @@ -1,11 +1,12 @@ package chat.simplex.app +import android.content.Context import android.content.Intent import android.net.Uri import android.os.* import android.view.WindowManager import androidx.activity.compose.setContent -import androidx.appcompat.app.AppCompatDelegate +import androidx.compose.ui.platform.ClipboardManager import androidx.fragment.app.FragmentActivity import chat.simplex.app.model.NtfManager import chat.simplex.app.model.NtfManager.getUserIdFromIntent @@ -58,6 +59,17 @@ class MainActivity: FragmentActivity() { override fun onResume() { super.onResume() AppLock.recheckAuthState() + withApi { + delay(1000) + if (!isAppOnForeground) return@withApi + /** + * When the app calls [ClipboardManager.shareText] and a user copies text in clipboard, Android denies + * access to clipboard because the app considered in background. + * This will ensure that the app will get the event on resume + * */ + val service = getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager + chatModel.clipboardHasText.value = service.hasPrimaryClip() + } } override fun onPause() { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt index f9c2eac13..e9f28a8ea 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt @@ -97,13 +97,6 @@ class SimplexApp: Application(), LifecycleEventObserver { } Lifecycle.Event.ON_RESUME -> { isAppOnForeground = true - /** - * When the app calls [ClipboardManager.shareText] and a user copies text in clipboard, Android denies - * access to clipboard because the app considered in background. - * This will ensure that the app will get the event on resume - * */ - val service = androidAppContext.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager - chatModel.clipboardHasText.value = service.hasPrimaryClip() if (chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete && chatModel.currentUser.value != null) { SimplexService.showBackgroundServiceNoticeIfNeeded() } @@ -197,10 +190,18 @@ class SimplexApp: Application(), LifecycleEventObserver { } SimplexService.StartReceiver.toggleReceiver(mode == NotificationsMode.SERVICE) CoroutineScope(Dispatchers.Default).launch { - if (mode == NotificationsMode.SERVICE) + if (mode == NotificationsMode.SERVICE) { SimplexService.start() - else + // Sometimes, when we change modes fast from one to another, system destroys the service after start. + // We can wait a little and restart the service, and it will work in 100% of cases + delay(2000) + if (!SimplexService.isServiceStarted && appPrefs.notificationsMode.get() == NotificationsMode.SERVICE) { + Log.i(TAG, "Service tried to start but destroyed by system, repeating once more") + SimplexService.start() + } + } else { SimplexService.safeStopService() + } } if (mode != NotificationsMode.PERIODIC) { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt index dd760e0b1..903f09608 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexService.kt @@ -104,7 +104,7 @@ class SimplexService: Service() { if (wakeLock != null || isStartingService) return val self = this isStartingService = true - withLongRunningApi(slow = 30_000, deadlock = 60_000) { + withLongRunningApi { val chatController = ChatController waitDbMigrationEnds(chatController) try { @@ -262,7 +262,7 @@ class SimplexService: Service() { private const val SHARED_PREFS_SERVICE_STATE = "SIMPLEX_SERVICE_STATE" private const val WORK_NAME_ONCE = "ServiceStartWorkerOnce" - private var isServiceStarted = false + var isServiceStarted = false private var stopAfterStart = false fun scheduleStart(context: Context) { diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt index d360c44b4..1e8fe94bf 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt @@ -12,6 +12,8 @@ import androidx.activity.compose.setContent import androidx.compose.runtime.* import androidx.compose.ui.platform.LocalView import chat.simplex.common.AppScreen +import chat.simplex.common.model.clear +import chat.simplex.common.ui.theme.SimpleXTheme import chat.simplex.common.views.helpers.* import androidx.compose.ui.platform.LocalContext as LocalContext1 import chat.simplex.res.MR diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/CustomTimePicker.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/CustomTimePicker.android.kt new file mode 100644 index 000000000..18f3455e3 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/CustomTimePicker.android.kt @@ -0,0 +1,106 @@ +package chat.simplex.common.views.helpers + +import androidx.compose.foundation.layout.* +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.common.model.CustomTimeUnit +import chat.simplex.common.ui.theme.DEFAULT_PADDING +import com.sd.lib.compose.wheel_picker.* + +@Composable +actual fun CustomTimePicker( + selection: MutableState, + timeUnitsLimits: List +) { + fun getUnitValues(unit: CustomTimeUnit, selectedValue: Int): List { + val unitLimits = timeUnitsLimits.firstOrNull { it.timeUnit == unit } ?: TimeUnitLimits.defaultUnitLimits(unit) + val regularUnitValues = (unitLimits.minValue..unitLimits.maxValue).toList() + return regularUnitValues + if (regularUnitValues.contains(selectedValue)) emptyList() else listOf(selectedValue) + } + + val (unit, duration) = CustomTimeUnit.toTimeUnit(selection.value) + val selectedUnit: MutableState = remember { mutableStateOf(unit) } + val selectedDuration = remember { mutableStateOf(duration) } + val selectedUnitValues = remember { mutableStateOf(getUnitValues(selectedUnit.value, selectedDuration.value)) } + val isTriggered = remember { mutableStateOf(false) } + + LaunchedEffect(selectedUnit.value) { + // on initial composition, if passed selection doesn't fit into picker bounds, so that selectedDuration is bigger than selectedUnit maxValue + // (e.g., for selection = 121 seconds: selectedUnit would be Second, selectedDuration would be 121 > selectedUnit maxValue of 120), + // selectedDuration would've been replaced by maxValue - isTriggered check prevents this by skipping LaunchedEffect on initial composition + if (isTriggered.value) { + val maxValue = timeUnitsLimits.firstOrNull { it.timeUnit == selectedUnit.value }?.maxValue + if (maxValue != null && selectedDuration.value > maxValue) { + selectedDuration.value = maxValue + selectedUnitValues.value = getUnitValues(selectedUnit.value, selectedDuration.value) + } else { + selectedUnitValues.value = getUnitValues(selectedUnit.value, selectedDuration.value) + selection.value = selectedUnit.value.toSeconds * selectedDuration.value + } + } else { + isTriggered.value = true + } + } + + LaunchedEffect(selectedDuration.value) { + selection.value = selectedUnit.value.toSeconds * selectedDuration.value + } + + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = DEFAULT_PADDING), + horizontalArrangement = Arrangement.spacedBy(0.dp) + ) { + Column(Modifier.weight(1f)) { + val durationPickerState = rememberFWheelPickerState(selectedUnitValues.value.indexOf(selectedDuration.value)) + FVerticalWheelPicker( + count = selectedUnitValues.value.count(), + state = durationPickerState, + unfocusedCount = 2, + focus = { + FWheelPickerFocusVertical(dividerColor = MaterialTheme.colors.primary) + } + ) { index -> + Text( + selectedUnitValues.value[index].toString(), + fontSize = 18.sp, + color = MaterialTheme.colors.primary + ) + } + LaunchedEffect(durationPickerState) { + snapshotFlow { durationPickerState.currentIndex } + .collect { + selectedDuration.value = selectedUnitValues.value[it] + } + } + } + Column(Modifier.weight(1f)) { + val unitPickerState = rememberFWheelPickerState(timeUnitsLimits.indexOfFirst { it.timeUnit == selectedUnit.value }) + FVerticalWheelPicker( + count = timeUnitsLimits.count(), + state = unitPickerState, + unfocusedCount = 2, + focus = { + FWheelPickerFocusVertical(dividerColor = MaterialTheme.colors.primary) + } + ) { index -> + Text( + timeUnitsLimits[index].timeUnit.text, + fontSize = 18.sp, + color = MaterialTheme.colors.primary + ) + } + LaunchedEffect(unitPickerState) { + snapshotFlow { unitPickerState.currentIndex } + .collect { + selectedUnit.value = timeUnitsLimits[it].timeUnit + } + } + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c index 5936bd5ff..d0581b433 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c +++ b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c @@ -66,6 +66,7 @@ extern char *chat_parse_markdown(const char *str); extern char *chat_parse_server(const char *str); extern char *chat_password_hash(const char *pwd, const char *salt); extern char *chat_valid_name(const char *name); +extern int chat_json_length(const char *str); extern char *chat_write_file(chat_ctrl ctrl, const char *path, char *ptr, int length); extern char *chat_read_file(const char *path, const char *key, const char *nonce); extern char *chat_encrypt_file(chat_ctrl ctrl, const char *from_path, const char *to_path); @@ -163,6 +164,14 @@ Java_chat_simplex_common_platform_CoreKt_chatValidName(JNIEnv *env, jclass clazz return res; } +JNIEXPORT int JNICALL +Java_chat_simplex_common_platform_CoreKt_chatJsonLength(JNIEnv *env, jclass clazz, jstring str) { + const char *_str = (*env)->GetStringUTFChars(env, str, JNI_FALSE); + int res = chat_json_length(_str); + (*env)->ReleaseStringUTFChars(env, str, _str); + return res; +} + JNIEXPORT jstring JNICALL Java_chat_simplex_common_platform_CoreKt_chatWriteFile(JNIEnv *env, jclass clazz, jlong controller, jstring path, jobject buffer) { const char *_path = (*env)->GetStringUTFChars(env, path, JNI_FALSE); diff --git a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c index f15689285..90504e25c 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c +++ b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c @@ -39,6 +39,7 @@ extern char *chat_parse_markdown(const char *str); extern char *chat_parse_server(const char *str); extern char *chat_password_hash(const char *pwd, const char *salt); extern char *chat_valid_name(const char *name); +extern int chat_json_length(const char *str); extern char *chat_write_file(chat_ctrl ctrl, const char *path, char *ptr, int length); extern char *chat_read_file(const char *path, const char *key, const char *nonce); extern char *chat_encrypt_file(chat_ctrl ctrl, const char *from_path, const char *to_path); @@ -173,6 +174,14 @@ Java_chat_simplex_common_platform_CoreKt_chatValidName(JNIEnv *env, jclass clazz return res; } +JNIEXPORT int JNICALL +Java_chat_simplex_common_platform_CoreKt_chatJsonLength(JNIEnv *env, jclass clazz, jstring str) { + const char *_str = encode_to_utf8_chars(env, str); + int res = chat_json_length(_str); + (*env)->ReleaseStringUTFChars(env, str, _str); + return res; +} + JNIEXPORT jstring JNICALL Java_chat_simplex_common_platform_CoreKt_chatWriteFile(JNIEnv *env, jclass clazz, jlong controller, jstring path, jobject buffer) { const char *_path = encode_to_utf8_chars(env, path); diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt index bfff3bf9f..57959af4c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt @@ -108,6 +108,7 @@ fun MainScreen() { val localUserCreated = chatModel.localUserCreated.value var showInitializationView by remember { mutableStateOf(false) } when { + chatModel.dbMigrationInProgress.value -> DefaultProgressView(stringResource(MR.strings.database_migration_in_progress)) chatModel.chatDbStatus.value == null && showInitializationView -> DefaultProgressView(stringResource(MR.strings.opening_database)) showChatDatabaseError -> { // Prevent showing keyboard on Android when: passcode enabled and database password not saved 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 d44c80e92..c19abdccc 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 @@ -2,6 +2,7 @@ package chat.simplex.common.model import androidx.compose.material.* import androidx.compose.runtime.* +import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.SpanStyle @@ -48,6 +49,7 @@ object ChatModel { val chatDbEncrypted = mutableStateOf(false) val chatDbStatus = mutableStateOf(null) val ctrlInitInProgress = mutableStateOf(false) + val dbMigrationInProgress = mutableStateOf(false) val chats = mutableStateListOf() // map of connections network statuses, key is agent connection id val networkStatuses = mutableStateMapOf() @@ -55,7 +57,7 @@ object ChatModel { // current chat val chatId = mutableStateOf(null) - val chatItems = mutableStateListOf() + val chatItems = mutableStateOf(SnapshotStateList()) // rhId, chatId val deletedChats = mutableStateOf>>(emptyList()) val chatItemStatuses = mutableMapOf() @@ -63,8 +65,6 @@ object ChatModel { val terminalItems = mutableStateOf>(listOf()) val userAddress = mutableStateOf(null) - // Allows to temporary save servers that are being edited on multiple screens - val userSMPServersUnsaved = mutableStateOf<(List)?>(null) val chatItemTTL = mutableStateOf(ChatItemTTL.None) // set when app opened from external intent @@ -269,18 +269,15 @@ object ChatModel { } else { addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf(cItem))) } - Log.d(TAG, "TODOCHAT: addChatItem: adding to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}") withContext(Dispatchers.Main) { // add to current chat if (chatId.value == cInfo.id) { - Log.d(TAG, "TODOCHAT: addChatItem: chatIds are equal, size ${chatItems.size}") // Prevent situation when chat item already in the list received from backend - if (chatItems.none { it.id == cItem.id }) { - if (chatItems.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { - chatItems.add(kotlin.math.max(0, chatItems.lastIndex), cItem) + if (chatItems.value.none { it.id == cItem.id }) { + if (chatItems.value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { + chatItems.add(kotlin.math.max(0, chatItems.value.lastIndex), cItem) } else { chatItems.add(cItem) - Log.d(TAG, "TODOCHAT: addChatItem: added to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}") } } } @@ -307,14 +304,13 @@ object ChatModel { addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf(cItem))) res = true } - Log.d(TAG, "TODOCHAT: upsertChatItem: upserting to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}") return withContext(Dispatchers.Main) { // update current chat if (chatId.value == cInfo.id) { - val itemIndex = chatItems.indexOfFirst { it.id == cItem.id } + val items = chatItems.value + val itemIndex = items.indexOfFirst { it.id == cItem.id } if (itemIndex >= 0) { - chatItems[itemIndex] = cItem - Log.d(TAG, "TODOCHAT: upsertChatItem: updated in chat $chatId from ${cInfo.id} ${cItem.id}, size ${chatItems.size}") + items[itemIndex] = cItem false } else { val status = chatItemStatuses.remove(cItem.id) @@ -324,7 +320,6 @@ object ChatModel { cItem } chatItems.add(ci) - Log.d(TAG, "TODOCHAT: upsertChatItem: added to chat $chatId from ${cInfo.id} ${cItem.id}, size ${chatItems.size}") true } } else { @@ -336,9 +331,10 @@ object ChatModel { suspend fun updateChatItem(cInfo: ChatInfo, cItem: ChatItem, status: CIStatus? = null) { withContext(Dispatchers.Main) { if (chatId.value == cInfo.id) { - val itemIndex = chatItems.indexOfFirst { it.id == cItem.id } + val items = chatItems.value + val itemIndex = items.indexOfFirst { it.id == cItem.id } if (itemIndex >= 0) { - chatItems[itemIndex] = cItem + items[itemIndex] = cItem } } else if (status != null) { chatItemStatuses[cItem.id] = status @@ -362,10 +358,10 @@ object ChatModel { } // remove from current chat if (chatId.value == cInfo.id) { - val itemIndex = chatItems.indexOfFirst { it.id == cItem.id } - if (itemIndex >= 0) { - AudioPlayer.stop(chatItems[itemIndex]) - chatItems.removeAt(itemIndex) + chatItems.removeAll { + val remove = it.id == cItem.id + if (remove) { AudioPlayer.stop(it) } + remove } } } @@ -406,7 +402,7 @@ object ChatModel { } fun removeLiveDummy() { - if (chatItems.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { + if (chatItems.value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { chatItems.removeLast() } } @@ -438,14 +434,14 @@ object ChatModel { var markedRead = 0 if (chatId.value == cInfo.id) { var i = 0 - Log.d(TAG, "TODOCHAT: markItemsReadInCurrentChat: marking read ${cInfo.id}, current chatId ${chatId.value}, size was ${chatItems.size}") - while (i < chatItems.count()) { - val item = chatItems[i] + val items = chatItems.value + while (i < items.size) { + val item = items[i] if (item.meta.itemStatus is CIStatus.RcvNew && (range == null || (range.from <= item.id && item.id <= range.to))) { val newItem = item.withStatus(CIStatus.RcvRead()) - chatItems[i] = newItem + items[i] = newItem if (newItem.meta.itemLive != true && newItem.meta.itemTimed?.ttl != null) { - chatItems[i] = newItem.copy(meta = newItem.meta.copy(itemTimed = newItem.meta.itemTimed.copy( + items[i] = newItem.copy(meta = newItem.meta.copy(itemTimed = newItem.meta.itemTimed.copy( deleteAt = Clock.System.now() + newItem.meta.itemTimed.ttl.toDuration(DurationUnit.SECONDS))) ) } @@ -453,7 +449,6 @@ object ChatModel { } i += 1 } - Log.d(TAG, "TODOCHAT: markItemsReadInCurrentChat: marked read ${cInfo.id}, current chatId ${chatId.value}, size now ${chatItems.size}") } return markedRead } @@ -644,7 +639,8 @@ object ChatModel { } fun addTerminalItem(item: TerminalItem) { - if (terminalItems.value.size >= 500) { + val maxItems = if (appPreferences.developerTools.get()) 500 else 200 + if (terminalItems.value.size >= maxItems) { terminalItems.value = terminalItems.value.subList(1, terminalItems.value.size) } terminalItems.value += item @@ -969,6 +965,16 @@ sealed class ChatInfo: SomeChat, NamedChat { is Group -> groupInfo.chatSettings else -> null } + + val chatTs: Instant + get() = when(this) { + is Direct -> contact.chatTs ?: contact.updatedAt + is Group -> groupInfo.chatTs ?: groupInfo.updatedAt + is Local -> noteFolder.chatTs + is ContactRequest -> contactRequest.updatedAt + is ContactConnection -> contactConnection.updatedAt + is InvalidJSON -> updatedAt + } } @Serializable @@ -1009,6 +1015,7 @@ data class Contact( val mergedPreferences: ContactUserPreferences, override val createdAt: Instant, override val updatedAt: Instant, + val chatTs: Instant?, val contactGroupMemberId: Long? = null, val contactGrpInvSent: Boolean ): SomeChat, NamedChat { @@ -1077,6 +1084,7 @@ data class Contact( mergedPreferences = ContactUserPreferences.sampleData, createdAt = Clock.System.now(), updatedAt = Clock.System.now(), + chatTs = Clock.System.now(), contactGrpInvSent = false ) } @@ -1204,7 +1212,8 @@ data class GroupInfo ( val hostConnCustomUserProfileId: Long? = null, val chatSettings: ChatSettings, override val createdAt: Instant, - override val updatedAt: Instant + override val updatedAt: Instant, + val chatTs: Instant? ): SomeChat, NamedChat { override val chatType get() = ChatType.Group override val id get() = "#$groupId" @@ -1245,7 +1254,8 @@ data class GroupInfo ( hostConnCustomUserProfileId = null, chatSettings = ChatSettings(enableNtfs = MsgFilter.All, sendRcpts = null, favorite = false), createdAt = Clock.System.now(), - updatedAt = Clock.System.now() + updatedAt = Clock.System.now(), + chatTs = Clock.System.now() ) } } @@ -1507,7 +1517,8 @@ class NoteFolder( val favorite: Boolean, val unread: Boolean, override val createdAt: Instant, - override val updatedAt: Instant + override val updatedAt: Instant, + val chatTs: Instant ): SomeChat, NamedChat { override val chatType get() = ChatType.Local override val id get() = "*$noteFolderId" @@ -1530,7 +1541,8 @@ class NoteFolder( favorite = false, unread = false, createdAt = Clock.System.now(), - updatedAt = Clock.System.now() + updatedAt = Clock.System.now(), + chatTs = Clock.System.now() ) } } @@ -1990,6 +2002,46 @@ data class ChatItem ( } } +fun MutableState>.add(index: Int, chatItem: ChatItem) { + value = SnapshotStateList().apply { addAll(value); add(index, chatItem) } +} + +fun MutableState>.add(chatItem: ChatItem) { + value = SnapshotStateList().apply { addAll(value); add(chatItem) } +} + +fun MutableState>.addAll(index: Int, chatItems: List) { + value = SnapshotStateList().apply { addAll(value); addAll(index, chatItems) } +} + +fun MutableState>.addAll(chatItems: List) { + value = SnapshotStateList().apply { addAll(value); addAll(chatItems) } +} + +fun MutableState>.removeAll(block: (ChatItem) -> Boolean) { + value = SnapshotStateList().apply { addAll(value); removeAll(block) } +} + +fun MutableState>.removeAt(index: Int) { + value = SnapshotStateList().apply { addAll(value); removeAt(index) } +} + +fun MutableState>.removeLast() { + value = SnapshotStateList().apply { addAll(value); removeLast() } +} + +fun MutableState>.replaceAll(chatItems: List) { + value = SnapshotStateList().apply { addAll(chatItems) } +} + +fun MutableState>.clear() { + value = SnapshotStateList() +} + +fun State>.asReversed(): MutableList = value.asReversed() + +val State>.size: Int get() = value.size + enum class CIMergeCategory { MemberConnected, RcvGroupEvent, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt index ec81e5441..63fcb90bb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt @@ -28,6 +28,7 @@ external fun chatParseMarkdown(str: String): String external fun chatParseServer(str: String): String external fun chatPasswordHash(pwd: String, salt: String): String external fun chatValidName(name: String): String +external fun chatJsonLength(str: String): Int external fun chatWriteFile(ctrl: ChatCtrl, path: String, buffer: ByteBuffer): String external fun chatReadFile(path: String, key: String, nonce: String): Array external fun chatEncryptFile(ctrl: ChatCtrl, fromPath: String, toPath: String): String @@ -42,7 +43,7 @@ val appPreferences: AppPreferences val chatController: ChatController = ChatController fun initChatControllerAndRunMigrations() { - withLongRunningApi(slow = 30_000, deadlock = 60_000) { + withLongRunningApi { if (appPreferences.chatStopped.get() && appPreferences.storeDBPassphrase.get() && ksDatabasePassword.get() != null) { initChatController(startChat = ::showStartChatAfterRestartAlert) } else { @@ -58,10 +59,23 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat chatModel.ctrlInitInProgress.value = true val dbKey = useKey ?: DatabaseUtils.useDatabaseKey() val confirm = confirmMigrations ?: if (appPreferences.developerTools.get() && appPreferences.confirmDBUpgrades.get()) MigrationConfirmation.Error else MigrationConfirmation.YesUp - val migrated: Array = chatMigrateInit(dbAbsolutePrefixPath, dbKey, confirm.value) - val res: DBMigrationResult = kotlin.runCatching { + var migrated: Array = chatMigrateInit(dbAbsolutePrefixPath, dbKey, MigrationConfirmation.Error.value) + var res: DBMigrationResult = runCatching { json.decodeFromString(migrated[0] as String) }.getOrElse { DBMigrationResult.Unknown(migrated[0] as String) } + val rerunMigration = res is DBMigrationResult.ErrorMigration && when (res.migrationError) { + // we don't allow to run down migrations without confirmation in UI, so currently it won't be YesUpDown + is MigrationError.Upgrade -> confirm == MigrationConfirmation.YesUp || confirm == MigrationConfirmation.YesUpDown + is MigrationError.Downgrade -> confirm == MigrationConfirmation.YesUpDown + is MigrationError.Error -> false + } + if (rerunMigration) { + chatModel.dbMigrationInProgress.value = true + migrated = chatMigrateInit(dbAbsolutePrefixPath, dbKey, confirm.value) + res = runCatching { + json.decodeFromString(migrated[0] as String) + }.getOrElse { DBMigrationResult.Unknown(migrated[0] as String) } + } val ctrl = if (res is DBMigrationResult.OK) { migrated[1] as Long } else null @@ -119,6 +133,7 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat } } finally { chatModel.ctrlInitInProgress.value = false + chatModel.dbMigrationInProgress.value = false } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt index a75ee7590..57c1e578a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt @@ -55,7 +55,7 @@ abstract class NtfManager { } fun openChatAction(userId: Long?, chatId: ChatId) { - withLongRunningApi(slow = 30_000, deadlock = 60_000) { + withLongRunningApi { awaitChatStartedIfNeeded(chatModel) if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) { // TODO include remote host ID in desktop notifications? @@ -70,7 +70,7 @@ abstract class NtfManager { } fun showChatsAction(userId: Long?) { - withLongRunningApi(slow = 30_000, deadlock = 60_000) { + withLongRunningApi { awaitChatStartedIfNeeded(chatModel) if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) { // TODO include remote host ID in desktop notifications? diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt index a46821452..4347623bd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt @@ -324,7 +324,7 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d .fillMaxHeight(), verticalArrangement = Arrangement.SpaceBetween ) { - LaunchedEffect(Unit) { + LaunchedEffect(ciInfo) { if (ciInfo.memberDeliveryStatuses != null) { selection.value = CIInfoTab.Delivery(ciInfo.memberDeliveryStatuses) } 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 2e99d791b..9a92997f8 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 @@ -67,13 +67,13 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: launch { snapshotFlow { chatModel.chatId.value } .distinctUntilChanged() - .onEach { Log.d(TAG, "TODOCHAT: chatId: activeChatId ${activeChat.value?.id} == new chatId $it ${activeChat.value?.id == it} ") } - .filter { it != null && activeChat.value?.id != it } + .filterNotNull() .collect { chatId -> - // Redisplay the whole hierarchy if the chat is different to make going from groups to direct chat working correctly - // Also for situation when chatId changes after clicking in notification, etc - activeChat.value = chatModel.getChat(chatId!!) - Log.d(TAG, "TODOCHAT: chatId: activeChatId became ${activeChat.value?.id}") + if (activeChat.value?.id != chatId) { + // Redisplay the whole hierarchy if the chat is different to make going from groups to direct chat working correctly + // Also for situation when chatId changes after clicking in notification, etc + activeChat.value = chatModel.getChat(chatId) + } markUnreadChatAsRead(activeChat, chatModel) } } @@ -92,12 +92,10 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: } } .distinctUntilChanged() - .onEach { Log.d(TAG, "TODOCHAT: chats: activeChatId ${activeChat.value?.id} == new chatId ${it?.id} ${activeChat.value?.id == it?.id} ") } // Only changed chatInfo is important thing. Other properties can be skipped for reducing recompositions - .filter { it != null && it?.chatInfo != activeChat.value?.chatInfo } + .filter { it != null && it.chatInfo != activeChat.value?.chatInfo } .collect { activeChat.value = it - Log.d(TAG, "TODOCHAT: chats: activeChatId became ${activeChat.value?.id}") } } } @@ -148,7 +146,6 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: }, attachmentOption, attachmentBottomSheetState, - chatModel.chatItems, searchText, useLinkPreviews = useLinkPreviews, linkMode = chatModel.simplexLinkMode.value, @@ -226,19 +223,17 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: loadPrevMessages = { if (chatModel.chatId.value != activeChat.value?.id) return@ChatLayout val c = chatModel.getChat(chatModel.chatId.value ?: return@ChatLayout) - val firstId = chatModel.chatItems.firstOrNull()?.id + val firstId = chatModel.chatItems.value.firstOrNull()?.id if (c != null && firstId != null) { withBGApi { - Log.d(TAG, "TODOCHAT: loadPrevMessages: loading for ${c.id}, current chatId ${ChatModel.chatId.value}, size was ${ChatModel.chatItems.size}") apiLoadPrevMessages(c, chatModel, firstId, searchText.value) - Log.d(TAG, "TODOCHAT: loadPrevMessages: loaded for ${c.id}, current chatId ${ChatModel.chatId.value}, size now ${ChatModel.chatItems.size}") } } }, deleteMessage = { itemId, mode -> withBGApi { val cInfo = chat.chatInfo - val toDeleteItem = chatModel.chatItems.firstOrNull { it.id == itemId } + val toDeleteItem = chatModel.chatItems.value.firstOrNull { it.id == itemId } val toModerate = toDeleteItem?.memberToModerate(chat.chatInfo) val groupInfo = toModerate?.first val groupMember = toModerate?.second @@ -404,12 +399,15 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: setGroupMembers(chatRh, chat.chatInfo.groupInfo, chatModel) } ModalManager.end.closeModals() - ModalManager.end.showModal(endButtons = { + ModalManager.end.showModalCloseable(endButtons = { ShareButton { clipboard.shareText(itemInfoShareText(chatModel, cItem, ciInfo, chatModel.controller.appPrefs.developerTools.get())) } - }) { + }) { close -> ChatItemInfoView(chatModel, cItem, ciInfo, devTools = chatModel.controller.appPrefs.developerTools.get()) + KeyChangeEffect(chatModel.chatId.value) { + close() + } } } } @@ -495,7 +493,6 @@ fun ChatLayout( composeView: (@Composable () -> Unit), attachmentOption: MutableState, attachmentBottomSheetState: ModalBottomSheetState, - chatItems: List, searchValue: State, useLinkPreviews: Boolean, linkMode: SimplexLinkMode, @@ -582,7 +579,7 @@ fun ChatLayout( .padding(contentPadding) ) { ChatItemsList( - chat, unreadCount, composeState, chatItems, searchValue, + chat, unreadCount, composeState, searchValue, useLinkPreviews, linkMode, showMemberInfo, loadPrevMessages, deleteMessage, deleteMessages, receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat, updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember, @@ -647,7 +644,7 @@ fun ChatInfoToolbar( } } - if (chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.allowsFeature(ChatFeature.Calls)) { + if (chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.mergedPreferences.calls.enabled.forUser) { if (activeCall == null) { barButtons.add { if (appPlatform.isAndroid) { @@ -840,7 +837,6 @@ fun BoxWithConstraintsScope.ChatItemsList( chat: Chat, unreadCount: State, composeState: MutableState, - chatItems: List, searchValue: State, useLinkPreviews: Boolean, linkMode: SimplexLinkMode, @@ -869,7 +865,7 @@ fun BoxWithConstraintsScope.ChatItemsList( ) { val listState = rememberLazyListState() val scope = rememberCoroutineScope() - ScrollToBottom(chat.id, listState, chatItems) + ScrollToBottom(chat.id, listState, chatModel.chatItems) var prevSearchEmptiness by rememberSaveable { mutableStateOf(searchValue.value.isEmpty()) } // Scroll to bottom when search value changes from something to nothing and back LaunchedEffect(searchValue.value.isEmpty()) { @@ -886,7 +882,7 @@ fun BoxWithConstraintsScope.ChatItemsList( PreloadItems(listState, ChatPagination.UNTIL_PRELOAD_COUNT, loadPrevMessages) Spacer(Modifier.size(8.dp)) - val reversedChatItems by remember { derivedStateOf { chatItems.reversed().toList() } } + val reversedChatItems by remember { derivedStateOf { chatModel.chatItems.asReversed() } } val maxHeightRounded = with(LocalDensity.current) { maxHeight.roundToPx() } val scrollToItem: (Long) -> Unit = { itemId: Long -> val index = reversedChatItems.indexOfFirst { it.id == itemId } @@ -939,7 +935,7 @@ fun BoxWithConstraintsScope.ChatItemsList( } } val provider = { - providerForGallery(i, chatItems, cItem.id) { indexInReversed -> + providerForGallery(i, chatModel.chatItems.value, cItem.id) { indexInReversed -> scope.launch { listState.scrollToItem( kotlin.math.min(reversedChatItems.lastIndex, indexInReversed + 1), @@ -1062,11 +1058,11 @@ fun BoxWithConstraintsScope.ChatItemsList( } } } - FloatingButtons(chatItems, unreadCount, chat.chatStats.minUnreadItemId, searchValue, markRead, setFloatingButton, listState) + FloatingButtons(chatModel.chatItems, unreadCount, chat.chatStats.minUnreadItemId, searchValue, markRead, setFloatingButton, listState) } @Composable -private fun ScrollToBottom(chatId: ChatId, listState: LazyListState, chatItems: List) { +private fun ScrollToBottom(chatId: ChatId, listState: LazyListState, chatItems: State>) { val scope = rememberCoroutineScope() // Helps to scroll to bottom after moving from Group to Direct chat // and prevents scrolling to bottom on orientation change @@ -1084,7 +1080,7 @@ private fun ScrollToBottom(chatId: ChatId, listState: LazyListState, chatItems: * When the first visible item (from bottom) is visible (even partially) we can autoscroll to 0 item. Or just scrollBy small distance otherwise * */ LaunchedEffect(Unit) { - snapshotFlow { chatItems.lastOrNull()?.id } + snapshotFlow { chatItems.value.lastOrNull()?.id } .distinctUntilChanged() .filter { listState.layoutInfo.visibleItemsInfo.firstOrNull()?.key != it } .collect { @@ -1107,7 +1103,7 @@ private fun ScrollToBottom(chatId: ChatId, listState: LazyListState, chatItems: @Composable fun BoxWithConstraintsScope.FloatingButtons( - chatItems: List, + chatItems: State>, unreadCount: State, minUnreadItemId: Long, searchValue: State, @@ -1141,10 +1137,11 @@ fun BoxWithConstraintsScope.FloatingButtons( val bottomUnreadCount by remember { derivedStateOf { if (unreadCount.value == 0) return@derivedStateOf 0 - val from = chatItems.lastIndex - firstVisibleIndex - lastIndexOfVisibleItems - if (chatItems.size <= from || from < 0) return@derivedStateOf 0 + val items = chatItems.value + val from = items.lastIndex - firstVisibleIndex - lastIndexOfVisibleItems + if (items.size <= from || from < 0) return@derivedStateOf 0 - chatItems.subList(from, chatItems.size).count { it.isRcvNew } + items.subList(from, items.size).count { it.isRcvNew } } } val firstVisibleOffset = (-with(LocalDensity.current) { maxHeight.roundToPx() } * 0.8).toInt() @@ -1190,7 +1187,7 @@ fun BoxWithConstraintsScope.FloatingButtons( painterResource(MR.images.ic_check), onClick = { markRead( - CC.ItemRange(minUnreadItemId, chatItems[chatItems.size - listState.layoutInfo.visibleItemsInfo.lastIndex - 1].id - 1), + CC.ItemRange(minUnreadItemId, chatItems.value[chatItems.size - listState.layoutInfo.visibleItemsInfo.lastIndex - 1].id - 1), bottomUnreadCount ) showDropDown.value = false @@ -1495,7 +1492,6 @@ fun PreviewChatLayout() { composeView = {}, attachmentOption = remember { mutableStateOf(null) }, attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden), - chatItems = chatItems, searchValue, useLinkPreviews = true, linkMode = SimplexLinkMode.DESCRIPTION, @@ -1568,7 +1564,6 @@ fun PreviewGroupChatLayout() { composeView = {}, attachmentOption = remember { mutableStateOf(null) }, attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden), - chatItems = chatItems, searchValue, useLinkPreviews = true, linkMode = SimplexLinkMode.DESCRIPTION, 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 e5982d01d..534185429 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 @@ -583,6 +583,10 @@ fun ComposeView( } fun cancelLinkPreview() { + val pendingLink = pendingLinkUrl.value + if (pendingLink != null) { + cancelledLinks.add(pendingLink) + } val uri = composeState.value.linkPreview?.uri if (uri != null) { cancelledLinks.add(uri) @@ -661,7 +665,7 @@ fun ComposeView( fun editPrevMessage() { if (composeState.value.contextItem != ComposeContextItem.NoContextItem || composeState.value.preview != ComposePreview.NoPreview) return - val lastEditable = chatModel.chatItems.findLast { it.meta.editable } + val lastEditable = chatModel.chatItems.value.findLast { it.meta.editable } if (lastEditable != null) { composeState.value = ComposeState(editingItem = lastEditable, useLinkPreviews = useLinkPreviews) } 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 f1079d2f5..456e2a538 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 @@ -59,14 +59,6 @@ fun SendMsgView( ) { val showCustomDisappearingMessageDialog = remember { mutableStateOf(false) } - if (showCustomDisappearingMessageDialog.value) { - CustomDisappearingMessageDialog( - sendMessage = sendMessage, - setShowDialog = { showCustomDisappearingMessageDialog.value = it }, - customDisappearingMessageTimePref = customDisappearingMessageTimePref - ) - } - Box(Modifier.padding(vertical = 8.dp)) { val cs = composeState.value var progressByTimeout by rememberSaveable { mutableStateOf(false) } @@ -203,6 +195,11 @@ fun SendMsgView( DefaultDropdownMenu(showDropdown) { menuItems.forEach { composable -> composable() } } + CustomDisappearingMessageDialog( + showCustomDisappearingMessageDialog, + sendMessage = sendMessage, + customDisappearingMessageTimePref = customDisappearingMessageTimePref + ) } else { SendMsgButton(icon, sendButtonSize, sendButtonAlpha, sendButtonColor, !sendMsgButtonDisabled, sendMessage) } @@ -220,93 +217,43 @@ expect fun VoiceButtonWithoutPermissionByPlatform() @Composable private fun CustomDisappearingMessageDialog( + showMenu: MutableState, sendMessage: (Int?) -> Unit, - setShowDialog: (Boolean) -> Unit, customDisappearingMessageTimePref: SharedPreference? ) { - val showCustomTimePicker = remember { mutableStateOf(false) } - - if (showCustomTimePicker.value) { - val selectedDisappearingMessageTime = remember { - mutableStateOf(customDisappearingMessageTimePref?.get?.invoke() ?: 300) - } - CustomTimePickerDialog( - selectedDisappearingMessageTime, - title = generalGetString(MR.strings.delete_after), - confirmButtonText = generalGetString(MR.strings.send_disappearing_message_send), - confirmButtonAction = { ttl -> - sendMessage(ttl) - customDisappearingMessageTimePref?.set?.invoke(ttl) - setShowDialog(false) - }, - cancel = { setShowDialog(false) } + DefaultDropdownMenu(showMenu) { + Text( + generalGetString(MR.strings.send_disappearing_message), + Modifier.padding(vertical = DEFAULT_PADDING_HALF, horizontal = DEFAULT_PADDING * 1.5f), + fontSize = 16.sp, + color = MaterialTheme.colors.secondary ) - } else { - @Composable - fun ChoiceButton( - text: String, - onClick: () -> Unit - ) { - TextButton(onClick) { - Text( - text, - fontSize = 18.sp, - color = MaterialTheme.colors.primary - ) - } - } - DefaultDialog(onDismissRequest = { setShowDialog(false) }) { - Surface( - shape = RoundedCornerShape(corner = CornerSize(25.dp)), - contentColor = LocalContentColor.current - ) { - Box( - contentAlignment = Alignment.Center - ) { - Column( - modifier = Modifier.padding(DEFAULT_PADDING), - verticalArrangement = Arrangement.spacedBy(6.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text(" ") // centers title - Text( - generalGetString(MR.strings.send_disappearing_message), - fontSize = 16.sp, - color = MaterialTheme.colors.secondary - ) - Icon( - painterResource(MR.images.ic_close), - generalGetString(MR.strings.icon_descr_close_button), - tint = MaterialTheme.colors.secondary, - modifier = Modifier - .size(25.dp) - .clickable { setShowDialog(false) } - ) - } - ChoiceButton(generalGetString(MR.strings.send_disappearing_message_30_seconds)) { - sendMessage(30) - setShowDialog(false) - } - ChoiceButton(generalGetString(MR.strings.send_disappearing_message_1_minute)) { - sendMessage(60) - setShowDialog(false) - } - ChoiceButton(generalGetString(MR.strings.send_disappearing_message_5_minutes)) { - sendMessage(300) - setShowDialog(false) - } - ChoiceButton(generalGetString(MR.strings.send_disappearing_message_custom_time)) { - showCustomTimePicker.value = true - } - } - } - } + ItemAction(generalGetString(MR.strings.send_disappearing_message_30_seconds)) { + sendMessage(30) + showMenu.value = false + } + ItemAction(generalGetString(MR.strings.send_disappearing_message_1_minute)) { + sendMessage(60) + showMenu.value = false + } + ItemAction(generalGetString(MR.strings.send_disappearing_message_5_minutes)) { + sendMessage(300) + showMenu.value = false + } + ItemAction(generalGetString(MR.strings.send_disappearing_message_custom_time)) { + showMenu.value = false + val selectedDisappearingMessageTime = mutableStateOf(customDisappearingMessageTimePref?.get?.invoke() ?: 300) + showCustomTimePickerDialog( + selectedDisappearingMessageTime, + title = generalGetString(MR.strings.delete_after), + confirmButtonText = generalGetString(MR.strings.send_disappearing_message_send), + confirmButtonAction = { ttl -> + sendMessage(ttl) + customDisappearingMessageTimePref?.set?.invoke(ttl) + }, + cancel = { showMenu.value = false } + ) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt index e4f31748c..6add33d83 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt @@ -54,7 +54,7 @@ fun AddGroupMembersView(rhId: Long?, groupInfo: GroupInfo, creatingGroup: Boolea }, inviteMembers = { allowModifyMembers = false - withLongRunningApi(slow = 30_000, deadlock = 60_000) { + withLongRunningApi(slow = 30_000, deadlock = 120_000) { for (contactId in selectedContacts) { val member = chatModel.controller.apiAddMember(rhId, groupInfo.groupId, contactId, selectedRole.value) if (member != null) { @@ -86,7 +86,7 @@ fun getContactsToAdd(chatModel: ChatModel, search: String): List { .map { it.chatInfo } .filterIsInstance() .map { it.contact } - .filter { it.contactId !in memberContactIds && it.chatViewName.lowercase().contains(s) } + .filter { c -> c.ready && c.active && c.contactId !in memberContactIds && c.chatViewName.lowercase().contains(s) } .sortedBy { it.displayName.lowercase() } .toList() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index 54614d022..6759d5474 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -3,11 +3,9 @@ package chat.simplex.common.views.chat.group import InfoRow import SectionBottomSpacer import SectionDividerSpaced -import SectionItemView import SectionSpacer import SectionTextFooter import SectionView -import TextIconSpaced import androidx.compose.desktop.ui.tooling.preview.Preview import java.net.URI import androidx.compose.foundation.* @@ -74,9 +72,8 @@ fun GroupMemberInfoView( if (chatModel.getContactChat(it) == null) { chatModel.addChat(c) } - chatModel.chatItems.clear() chatModel.chatItemStatuses.clear() - chatModel.chatItems.addAll(c.chatItems) + chatModel.chatItems.replaceAll(c.chatItems) chatModel.chatId.value = c.id closeAll() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt index 9124eed4c..6c2c37503 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt @@ -3,6 +3,7 @@ package chat.simplex.common.views.chat.group import SectionBottomSpacer import SectionDividerSpaced import SectionItemView +import SectionTextFooter import SectionView import TextIconSpaced import androidx.compose.foundation.layout.* @@ -14,6 +15,7 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.AnnotatedString @@ -27,9 +29,13 @@ import chat.simplex.common.views.chat.item.MarkdownText import chat.simplex.common.views.helpers.* import chat.simplex.common.model.ChatModel import chat.simplex.common.model.GroupInfo +import chat.simplex.common.platform.chatJsonLength +import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF import chat.simplex.res.MR import kotlinx.coroutines.delay +private const val maxByteCount = 1200 + @Composable fun GroupWelcomeView(m: ChatModel, rhId: Long?, groupInfo: GroupInfo, close: () -> Unit) { var gInfo by remember { mutableStateOf(groupInfo) } @@ -54,8 +60,11 @@ fun GroupWelcomeView(m: ChatModel, rhId: Long?, groupInfo: GroupInfo, close: () ModalView( close = { - if (welcomeText.value == gInfo.groupProfile.description || (welcomeText.value == "" && gInfo.groupProfile.description == null)) close() - else showUnsavedChangesAlert({ save(close) }, close) + when { + welcomeTextUnchanged(welcomeText, gInfo) -> close() + !welcomeTextFitsLimit(welcomeText) -> showUnsavedChangesTooLongAlert(close) + else -> showUnsavedChangesAlert({ save(close) }, close) + } }, ) { GroupWelcomeLayout( @@ -67,6 +76,14 @@ fun GroupWelcomeView(m: ChatModel, rhId: Long?, groupInfo: GroupInfo, close: () } } +private fun welcomeTextUnchanged(welcomeText: MutableState, groupInfo: GroupInfo): Boolean { + return welcomeText.value == groupInfo.groupProfile.description || (welcomeText.value == "" && groupInfo.groupProfile.description == null) +} + +private fun welcomeTextFitsLimit(welcomeText: MutableState): Boolean { + return chatJsonLength(welcomeText.value) <= maxByteCount +} + @Composable private fun GroupWelcomeLayout( welcomeText: MutableState, @@ -95,6 +112,13 @@ private fun GroupWelcomeLayout( } else { TextPreview(wt.value, linkMode) } + SectionTextFooter( + if (!welcomeTextFitsLimit(wt)) { generalGetString(MR.strings.message_too_large) } else "", + color = if (welcomeTextFitsLimit(wt)) MaterialTheme.colors.secondary else Color.Red + ) + + Spacer(Modifier.size(8.dp)) + ChangeModeButton( editMode.value, click = { @@ -104,10 +128,18 @@ private fun GroupWelcomeLayout( ) val clipboard = LocalClipboardManager.current CopyTextButton { clipboard.setText(AnnotatedString(wt.value)) } - SectionDividerSpaced(maxBottomPadding = false) + + Divider( + Modifier.padding( + start = DEFAULT_PADDING_HALF, + top = 8.dp, + end = DEFAULT_PADDING_HALF, + bottom = 8.dp) + ) + SaveButton( save = save, - disabled = wt.value == groupInfo.groupProfile.description || (wt.value == "" && groupInfo.groupProfile.description == null) + disabled = welcomeTextUnchanged(wt, groupInfo) || !welcomeTextFitsLimit(wt) ) } else { val clipboard = LocalClipboardManager.current @@ -182,3 +214,11 @@ private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) { onDismiss = revert, ) } + +private fun showUnsavedChangesTooLongAlert(revert: () -> Unit) { + AlertManager.shared.showAlertDialogStacked( + title = generalGetString(MR.strings.welcome_message_is_too_long), + confirmText = generalGetString(MR.strings.exit_without_saving), + onConfirm = revert, + ) +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index 549f5f2f5..568f00302 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -103,7 +103,7 @@ fun ChatItemView( setReaction(cInfo, cItem, !r.userReacted, r.reaction) } } - Row(modifier.padding(2.dp)) { + Row(modifier.padding(2.dp), verticalAlignment = Alignment.CenterVertically) { ReactionIcon(r.reaction.text, fontSize = 12.sp) if (r.totalReacted > 1) { Spacer(Modifier.width(4.dp)) @@ -112,7 +112,6 @@ fun ChatItemView( fontSize = 11.5.sp, fontWeight = if (r.userReacted) FontWeight.Bold else FontWeight.Normal, color = if (r.userReacted) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, - modifier = if (appPlatform.isAndroid) Modifier else Modifier.padding(top = 4.dp) ) } } @@ -178,7 +177,8 @@ fun ChatItemView( fun MsgContentItemDropdownMenu() { val saveFileLauncher = rememberSaveFileLauncher(ciFile = cItem.file) when { - cItem.content.msgContent != null -> { + // cItem.id check is a special case for live message chat item which has negative ID while not sent yet + cItem.content.msgContent != null && cItem.id >= 0 -> { DefaultDropdownMenu(showMenu) { if (cInfo.featureEnabled(ChatFeature.Reactions) && cItem.allowAddReaction) { MsgReactionsMenu() @@ -527,8 +527,9 @@ fun DeleteItemAction( val range = chatViewItemsRange(currIndex, prevHidden) if (range != null) { val itemIds: ArrayList = arrayListOf() + val reversedChatItems = chatModel.chatItems.asReversed() for (i in range) { - itemIds.add(chatModel.chatItems.asReversed()[i].id) + itemIds.add(reversedChatItems[i].id) } deleteMessagesAlertDialog(itemIds, generalGetString(MR.strings.delete_message_mark_deleted_warning), deleteMessages = deleteMessages) } else { @@ -651,6 +652,23 @@ fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Colo } } +@Composable +fun ItemAction(text: String, color: Color = Color.Unspecified, onClick: () -> Unit) { + val finalColor = if (color == Color.Unspecified) { + MenuTextColor + } else color + DropdownMenuItem(onClick, contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 1.5f)) { + Text( + text, + modifier = Modifier + .fillMaxWidth() + .weight(1F) + .padding(end = 15.dp), + color = finalColor + ) + } +} + fun cancelFileAlertDialog(fileId: Long, cancelFile: (Long) -> Unit, cancelAction: CancelAction) { AlertManager.shared.showAlertDialog( title = generalGetString(cancelAction.alert.titleId), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt index f7783d682..0e2e8867c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt @@ -91,7 +91,7 @@ private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: MutableState String.format(generalGetString(MR.strings.moderated_item_description), meta.itemDeleted.byGroupMember.displayName) 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 15b1db6c4..2324d62ea 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 @@ -212,18 +212,15 @@ suspend fun openGroupChat(rhId: Long?, groupId: Long, chatModel: ChatModel) { } suspend fun openChat(rhId: Long?, chatInfo: ChatInfo, chatModel: ChatModel) { - Log.d(TAG, "TODOCHAT: openChat: opening ${chatInfo.id}, current chatId ${ChatModel.chatId.value}, size ${ChatModel.chatItems.size}") val chat = chatModel.controller.apiGetChat(rhId, chatInfo.chatType, chatInfo.apiId) if (chat != null) { openLoadedChat(chat, chatModel) - Log.d(TAG, "TODOCHAT: openChat: opened ${chatInfo.id}, current chatId ${ChatModel.chatId.value}, size ${ChatModel.chatItems.size}") } } fun openLoadedChat(chat: Chat, chatModel: ChatModel) { - chatModel.chatItems.clear() chatModel.chatItemStatuses.clear() - chatModel.chatItems.addAll(chat.chatItems) + chatModel.chatItems.replaceAll(chat.chatItems) chatModel.chatId.value = chat.chatInfo.id } @@ -239,8 +236,7 @@ suspend fun apiFindMessages(ch: Chat, chatModel: ChatModel, search: String) { val chatInfo = ch.chatInfo val chat = chatModel.controller.apiGetChat(ch.remoteHostId, chatInfo.chatType, chatInfo.apiId, search = search) ?: return if (chatModel.chatId.value != chat.id) return - chatModel.chatItems.clear() - chatModel.chatItems.addAll(0, chat.chatItems) + chatModel.chatItems.replaceAll(chat.chatItems) } suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt index 08e95f391..1bb5a7899 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt @@ -26,6 +26,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* import chat.simplex.common.model.GroupInfo import chat.simplex.common.platform.chatModel +import chat.simplex.common.views.chat.item.markedDeletedText import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource @@ -170,7 +171,7 @@ fun ChatPreviewView( val (text: CharSequence, inlineTextContent) = when { chatModelDraftChatId == chat.id && chatModelDraft != null -> remember(chatModelDraft) { messageDraft(chatModelDraft) } ci.meta.itemDeleted == null -> ci.text to null - else -> generalGetString(MR.strings.marked_deleted_description) to null + else -> markedDeletedText(ci.meta) to null } val formattedText = when { chatModelDraftChatId == chat.id && chatModelDraft != null -> null @@ -286,7 +287,7 @@ fun ChatPreviewView( Box( contentAlignment = Alignment.TopEnd ) { - val ts = chat.chatItems.lastOrNull()?.timestampText ?: getTimestampText(chat.chatInfo.updatedAt) + val ts = chat.chatItems.lastOrNull()?.timestampText ?: getTimestampText(chat.chatInfo.chatTs) Text( ts, color = MaterialTheme.colors.secondary, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt index 3cfd9e94b..7bd9fbc66 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt @@ -62,7 +62,7 @@ fun DatabaseEncryptionView(m: ChatModel) { initialRandomDBPassphrase, progressIndicator, onConfirmEncrypt = { - withLongRunningApi(slow = 30_000, deadlock = 60_000) { + withLongRunningApi { encryptDatabase(currentKey, newKey, confirmNewKey, initialRandomDBPassphrase, useKeychain, storedKey, progressIndicator) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt index 2d644b297..8680c98d4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt @@ -368,7 +368,7 @@ fun chatArchiveTitle(chatArchiveTime: Instant, chatLastStart: Instant): String { } fun startChat(m: ChatModel, chatLastStart: MutableState, chatDbChanged: MutableState, progressIndicator: MutableState? = null) { - withLongRunningApi(slow = 30_000, deadlock = 60_000) { + withLongRunningApi { try { progressIndicator?.value = true if (chatDbChanged.value) { @@ -581,7 +581,7 @@ private fun importArchive( progressIndicator.value = true val archivePath = saveArchiveFromURI(importedArchiveURI) if (archivePath != null) { - withLongRunningApi(slow = 60_000, deadlock = 180_000) { + withLongRunningApi { try { m.controller.apiDeleteStorage() try { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt index 082d73320..a4cea68ff 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt @@ -22,6 +22,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.compose.painterResource +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow class AlertManager { @@ -128,6 +129,8 @@ class AlertManager { ) { val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { + // Wait before focusing to prevent auto-confirming if a user used Enter key on hardware keyboard + delay(200) focusRequester.requestFocus() } TextButton(onClick = { @@ -195,6 +198,8 @@ class AlertManager { AlertContent(text, hostDevice, extraPadding = true) { val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { + // Wait before focusing to prevent auto-confirming if a user used Enter key on hardware keyboard + delay(200) focusRequester.requestFocus() } Row( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CustomTimePicker.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CustomTimePicker.kt index f13edd618..3c44cbb4d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CustomTimePicker.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/CustomTimePicker.kt @@ -1,116 +1,21 @@ package chat.simplex.common.views.helpers -import androidx.compose.foundation.clickable +import SectionItemView import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.CornerSize -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import dev.icerock.moko.resources.compose.painterResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Dialog -import chat.simplex.common.ui.theme.DEFAULT_PADDING +import androidx.compose.ui.text.style.TextAlign import chat.simplex.common.model.CustomTimeUnit import chat.simplex.common.model.timeText import chat.simplex.res.MR -import com.sd.lib.compose.wheel_picker.* @Composable -fun CustomTimePicker( +expect fun CustomTimePicker( selection: MutableState, timeUnitsLimits: List = TimeUnitLimits.defaultUnitsLimits -) { - fun getUnitValues(unit: CustomTimeUnit, selectedValue: Int): List { - val unitLimits = timeUnitsLimits.firstOrNull { it.timeUnit == unit } ?: TimeUnitLimits.defaultUnitLimits(unit) - val regularUnitValues = (unitLimits.minValue..unitLimits.maxValue).toList() - return regularUnitValues + if (regularUnitValues.contains(selectedValue)) emptyList() else listOf(selectedValue) - } - - val (unit, duration) = CustomTimeUnit.toTimeUnit(selection.value) - val selectedUnit: MutableState = remember { mutableStateOf(unit) } - val selectedDuration = remember { mutableStateOf(duration) } - val selectedUnitValues = remember { mutableStateOf(getUnitValues(selectedUnit.value, selectedDuration.value)) } - val isTriggered = remember { mutableStateOf(false) } - - LaunchedEffect(selectedUnit.value) { - // on initial composition, if passed selection doesn't fit into picker bounds, so that selectedDuration is bigger than selectedUnit maxValue - // (e.g., for selection = 121 seconds: selectedUnit would be Second, selectedDuration would be 121 > selectedUnit maxValue of 120), - // selectedDuration would've been replaced by maxValue - isTriggered check prevents this by skipping LaunchedEffect on initial composition - if (isTriggered.value) { - val maxValue = timeUnitsLimits.firstOrNull { it.timeUnit == selectedUnit.value }?.maxValue - if (maxValue != null && selectedDuration.value > maxValue) { - selectedDuration.value = maxValue - selectedUnitValues.value = getUnitValues(selectedUnit.value, selectedDuration.value) - } else { - selectedUnitValues.value = getUnitValues(selectedUnit.value, selectedDuration.value) - selection.value = selectedUnit.value.toSeconds * selectedDuration.value - } - } else { - isTriggered.value = true - } - } - - LaunchedEffect(selectedDuration.value) { - selection.value = selectedUnit.value.toSeconds * selectedDuration.value - } - - Row( - Modifier - .fillMaxWidth() - .padding(horizontal = DEFAULT_PADDING), - horizontalArrangement = Arrangement.spacedBy(0.dp) - ) { - Column(Modifier.weight(1f)) { - val durationPickerState = rememberFWheelPickerState(selectedUnitValues.value.indexOf(selectedDuration.value)) - FVerticalWheelPicker( - count = selectedUnitValues.value.count(), - state = durationPickerState, - unfocusedCount = 2, - focus = { - FWheelPickerFocusVertical(dividerColor = MaterialTheme.colors.primary) - } - ) { index -> - Text( - selectedUnitValues.value[index].toString(), - fontSize = 18.sp, - color = MaterialTheme.colors.primary - ) - } - LaunchedEffect(durationPickerState) { - snapshotFlow { durationPickerState.currentIndex } - .collect { - selectedDuration.value = selectedUnitValues.value[it] - } - } - } - Column(Modifier.weight(1f)) { - val unitPickerState = rememberFWheelPickerState(timeUnitsLimits.indexOfFirst { it.timeUnit == selectedUnit.value }) - FVerticalWheelPicker( - count = timeUnitsLimits.count(), - state = unitPickerState, - unfocusedCount = 2, - focus = { - FWheelPickerFocusVertical(dividerColor = MaterialTheme.colors.primary) - } - ) { index -> - Text( - timeUnitsLimits[index].timeUnit.text, - fontSize = 18.sp, - color = MaterialTheme.colors.primary - ) - } - LaunchedEffect(unitPickerState) { - snapshotFlow { unitPickerState.currentIndex } - .collect { - selectedUnit.value = timeUnitsLimits[it].timeUnit - } - } - } - } -} +) data class TimeUnitLimits( val timeUnit: CustomTimeUnit, @@ -141,8 +46,7 @@ data class TimeUnitLimits( } } -@Composable -fun CustomTimePickerDialog( +fun showCustomTimePickerDialog( selection: MutableState, timeUnitsLimits: List = TimeUnitLimits.defaultUnitsLimits, title: String, @@ -150,53 +54,26 @@ fun CustomTimePickerDialog( confirmButtonAction: (Int) -> Unit, cancel: () -> Unit ) { - DefaultDialog(onDismissRequest = cancel) { - Surface( - shape = RoundedCornerShape(corner = CornerSize(25.dp)), - contentColor = LocalContentColor.current - ) { - Box( - contentAlignment = Alignment.Center + AlertManager.shared.showAlertDialogButtonsColumn( + title = title, + onDismissRequest = cancel + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CustomTimePicker( + selection, + timeUnitsLimits + ) + SectionItemView({ + AlertManager.shared.hideAlert() + confirmButtonAction(selection.value) + } ) { - Column( - modifier = Modifier.padding(DEFAULT_PADDING), - verticalArrangement = Arrangement.spacedBy(6.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text(" ") // centers title - Text( - title, - fontSize = 16.sp, - color = MaterialTheme.colors.secondary - ) - Icon( - painterResource(MR.images.ic_close), - generalGetString(MR.strings.icon_descr_close_button), - tint = MaterialTheme.colors.secondary, - modifier = Modifier - .size(25.dp) - .clickable { cancel() } - ) - } - - CustomTimePicker( - selection, - timeUnitsLimits - ) - - TextButton(onClick = { confirmButtonAction(selection.value) }) { - Text( - confirmButtonText, - fontSize = 18.sp, - color = MaterialTheme.colors.primary - ) - } - } + Text( + confirmButtonText, + Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + color = MaterialTheme.colors.primary + ) } } } @@ -220,7 +97,6 @@ fun DropdownCustomTimePickerSettingRow( val dropdownSelection: MutableState = remember { mutableStateOf(DropdownSelection.DropdownValue(selection.value)) } val values: MutableState> = remember { mutableStateOf(getValues(selection.value)) } - val showCustomTimePicker = remember { mutableStateOf(false) } fun updateValue(selectedValue: Int?) { values.value = getValues(selectedValue) @@ -247,28 +123,22 @@ fun DropdownCustomTimePickerSettingRow( onSelected = { sel: DropdownSelection -> when (sel) { is DropdownSelection.DropdownValue -> updateValue(sel.value) - DropdownSelection.Custom -> showCustomTimePicker.value = true + DropdownSelection.Custom -> { + val selectedCustomTime = mutableStateOf(selection.value ?: 86400) + showCustomTimePickerDialog( + selectedCustomTime, + timeUnitsLimits = customPickerTimeUnitsLimits, + title = customPickerTitle, + confirmButtonText = customPickerConfirmButtonText, + confirmButtonAction = ::updateValue, + cancel = { + dropdownSelection.value = DropdownSelection.DropdownValue(selection.value) + } + ) + } } } ) - - if (showCustomTimePicker.value) { - val selectedCustomTime = remember { mutableStateOf(selection.value ?: 86400) } - CustomTimePickerDialog( - selectedCustomTime, - timeUnitsLimits = customPickerTimeUnitsLimits, - title = customPickerTitle, - confirmButtonText = customPickerConfirmButtonText, - confirmButtonAction = { time -> - updateValue(time) - showCustomTimePicker.value = false - }, - cancel = { - dropdownSelection.value = DropdownSelection.DropdownValue(selection.value) - showCustomTimePicker.value = false - } - ) - } } private sealed class DropdownSelection { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultProgressBar.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultProgressBar.kt index ec2500ab2..104a01150 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultProgressBar.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultProgressBar.kt @@ -5,6 +5,7 @@ import androidx.compose.material.* import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import chat.simplex.common.ui.theme.DEFAULT_PADDING @@ -20,7 +21,7 @@ fun DefaultProgressView(description: String?) { strokeWidth = 2.5.dp ) if (description != null) { - Text(description) + Text(description, textAlign = TextAlign.Center) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt index f41d21764..e2dd315fb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt @@ -61,10 +61,10 @@ class ModalManager(private val placement: ModalPlacement? = null) { } } - fun showModalCloseable(settings: Boolean = false, showClose: Boolean = true, content: @Composable ModalData.(close: () -> Unit) -> Unit) { + fun showModalCloseable(settings: Boolean = false, showClose: Boolean = true, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.(close: () -> Unit) -> Unit) { val data = ModalData() showCustomModal { close -> - ModalView(close, showClose = showClose, content = { data.content(close) }) + ModalView(close, showClose = showClose, endButtons = endButtons, content = { data.content(close) }) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt index ba0edb98d..1c3540d7f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt @@ -198,16 +198,16 @@ fun SectionItemWithValue( } @Composable -fun SectionTextFooter(text: String) { - SectionTextFooter(AnnotatedString(text)) +fun SectionTextFooter(text: String, color: Color = MaterialTheme.colors.secondary) { + SectionTextFooter(AnnotatedString(text), color = color) } @Composable -fun SectionTextFooter(text: AnnotatedString, textAlign: TextAlign = TextAlign.Start) { +fun SectionTextFooter(text: AnnotatedString, textAlign: TextAlign = TextAlign.Start, color: Color = MaterialTheme.colors.secondary) { Text( text, Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, top = DEFAULT_PADDING_HALF).fillMaxWidth(0.9F), - color = MaterialTheme.colors.secondary, + color = color, lineHeight = 18.sp, fontSize = 14.sp, textAlign = textAlign diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt index 1048b03bc..5a37c860a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt @@ -49,7 +49,7 @@ fun LocalAuthView(m: ChatModel, authRequest: LocalAuthRequest) { } private fun deleteStorageAndRestart(m: ChatModel, password: String, completed: (LAResult) -> Unit) { - withLongRunningApi(slow = 30_000, deadlock = 60_000) { + withLongRunningApi { try { /** Waiting until [initChatController] finishes */ while (m.ctrlInitInProgress.value) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt index 854770489..9ae34eb18 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt @@ -50,7 +50,7 @@ fun SetupDatabasePassphrase(m: ChatModel) { confirmNewKey, progressIndicator, onConfirmEncrypt = { - withLongRunningApi(slow = 30_000, deadlock = 60_000) { + withLongRunningApi { if (m.chatRunning.value == true) { // Stop chat if it's started before doing anything stopChatAsync(m) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt index 7c921d7e8..66b4a0e83 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt @@ -47,10 +47,6 @@ fun NetworkAndServersView( val onionHosts = remember { mutableStateOf(netCfg.onionHosts) } val sessionMode = remember { mutableStateOf(netCfg.sessionMode) } - LaunchedEffect(Unit) { - chatModel.userSMPServersUnsaved.value = null - } - val proxyPort = remember { derivedStateOf { chatModel.controller.appPrefs.networkProxyHostPort.state.value?.split(":")?.lastOrNull()?.toIntOrNull() ?: 9050 } } NetworkAndServersLayout( currentRemoteHost = currentRemoteHost, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServersView.kt index e1668ab9a..ad1648f1e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/ProtocolServersView.kt @@ -28,19 +28,18 @@ import chat.simplex.res.MR @Composable fun ModalData.ProtocolServersView(m: ChatModel, rhId: Long?, serverProtocol: ServerProtocol, close: () -> Unit) { var presetServers by remember(rhId) { mutableStateOf(emptyList()) } - var servers by remember(rhId) { - mutableStateOf(m.userSMPServersUnsaved.value ?: emptyList()) - } + var servers by remember { stateGetOrPut("servers") { emptyList() } } + var serversAlreadyLoaded by remember { stateGetOrPut("serversAlreadyLoaded") { false } } val currServers = remember(rhId) { mutableStateOf(servers) } val testing = rememberSaveable(rhId) { mutableStateOf(false) } - val serversUnchanged = remember { derivedStateOf { servers == currServers.value || testing.value } } - val allServersDisabled = remember { derivedStateOf { servers.all { !it.enabled } } } - val saveDisabled = remember { + val serversUnchanged = remember(servers) { derivedStateOf { servers == currServers.value || testing.value } } + val allServersDisabled = remember { derivedStateOf { servers.none { it.enabled } } } + val saveDisabled = remember(servers) { derivedStateOf { servers.isEmpty() || servers == currServers.value || testing.value || - !servers.all { srv -> + servers.none { srv -> val address = parseServerAddress(srv.server) address != null && uniqueAddress(srv, address, servers) } || @@ -49,8 +48,8 @@ fun ModalData.ProtocolServersView(m: ChatModel, rhId: Long?, serverProtocol: Ser } KeyChangeEffect(rhId) { - m.userSMPServersUnsaved.value = null servers = emptyList() + serversAlreadyLoaded = false } LaunchedEffect(rhId) { @@ -59,8 +58,9 @@ fun ModalData.ProtocolServersView(m: ChatModel, rhId: Long?, serverProtocol: Ser if (res != null) { currServers.value = res.protoServers presetServers = res.presetServers - if (servers.isEmpty()) { + if (servers.isEmpty() && !serversAlreadyLoaded) { servers = currServers.value + serversAlreadyLoaded = true } } } @@ -80,13 +80,11 @@ fun ModalData.ProtocolServersView(m: ChatModel, rhId: Long?, serverProtocol: Ser newServers.add(index, updated) old = updated servers = newServers - m.userSMPServersUnsaved.value = servers }, onDelete = { val newServers = ArrayList(servers) newServers.removeAt(index) servers = newServers - m.userSMPServersUnsaved.value = servers close() }) } @@ -125,7 +123,6 @@ fun ModalData.ProtocolServersView(m: ChatModel, rhId: Long?, serverProtocol: Ser ScanProtocolServer(rhId) { close() servers = servers + it - m.userSMPServersUnsaved.value = servers } } } @@ -150,13 +147,11 @@ fun ModalData.ProtocolServersView(m: ChatModel, rhId: Long?, serverProtocol: Ser testServersJob.value = withLongRunningApi { testServers(testing, servers, m) { servers = it - m.userSMPServersUnsaved.value = servers } } }, resetServers = { - servers = currServers.value ?: emptyList() - m.userSMPServersUnsaved.value = null + servers = currServers.value }, saveSMPServers = { saveServers(rhId, serverProtocol, currServers, servers, m) @@ -355,7 +350,6 @@ private fun saveServers(rhId: Long?, protocol: ServerProtocol, currServers: Muta withBGApi { if (m.controller.setUserProtoServers(rhId, protocol, servers)) { currServers.value = servers - m.userSMPServersUnsaved.value = null } afterSave() } 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 d36eca273..a511e2e13 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -16,6 +16,7 @@ Opening database… + Database migration is in progress.\nIt may take a few minutes. Invalid file path You shared an invalid file path. Report the issue to the app developers. View crashed @@ -1377,9 +1378,11 @@ Welcome message Save welcome message? + Welcome message is too long Save and update group profile Preview Enter welcome message… + Message too large SERVERS diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt index 6e7945d8c..41e87b4a1 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt @@ -14,8 +14,7 @@ import androidx.compose.ui.input.key.* import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.compose.ui.window.* -import chat.simplex.common.model.ChatController -import chat.simplex.common.model.ChatModel +import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.DEFAULT_START_MODAL_WIDTH import chat.simplex.common.ui.theme.SimpleXTheme diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/CustomTimePicker.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/CustomTimePicker.desktop.kt new file mode 100644 index 000000000..03c8e51c5 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/CustomTimePicker.desktop.kt @@ -0,0 +1,80 @@ +package chat.simplex.common.views.helpers + +import androidx.compose.foundation.layout.* +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import chat.simplex.common.model.CustomTimeUnit +import chat.simplex.common.ui.theme.DEFAULT_PADDING + +@Composable +actual fun CustomTimePicker( + selection: MutableState, + timeUnitsLimits: List +) { + val unit = remember { + var res: CustomTimeUnit = CustomTimeUnit.Second + val found = timeUnitsLimits.asReversed().any { + if (selection.value >= it.minValue * it.timeUnit.toSeconds && selection.value <= it.maxValue * it.timeUnit.toSeconds) { + res = it.timeUnit + selection.value = (selection.value / it.timeUnit.toSeconds).coerceIn(it.minValue, it.maxValue) * it.timeUnit.toSeconds + true + } else { + false + } + } + if (!found) { + // If custom interval doesn't fit in any category, set it to 1 second interval + selection.value = 1 + } + mutableStateOf(res) + } + val values = remember(unit.value) { + val limit = timeUnitsLimits.first { it.timeUnit == unit.value } + val res = ArrayList>() + for (i in limit.minValue..limit.maxValue) { + val seconds = i * limit.timeUnit.toSeconds + val desc = i.toString() + res.add(seconds to desc) + } + if (res.none { it.first == selection.value }) { + // Doesn't fit into min..max, put it equal to the closest value + selection.value = selection.value.coerceIn(res.first().first, res.last().first) + //selection.value = res.last { it.first <= selection.value }.first + } + res + } + val units = remember { + val res = ArrayList>() + for (unit in timeUnitsLimits) { + res.add(unit.timeUnit to unit.timeUnit.text) + } + res + } + + Row( + Modifier.padding(bottom = DEFAULT_PADDING), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceEvenly + ) { + ExposedDropDownSetting( + values, + selection, + textColor = MaterialTheme.colors.onBackground, + enabled = remember { mutableStateOf(true) }, + onSelected = { selection.value = it } + ) + Spacer(Modifier.width(DEFAULT_PADDING)) + ExposedDropDownSetting( + units, + unit, + textColor = MaterialTheme.colors.onBackground, + enabled = remember { mutableStateOf(true) }, + onSelected = { + selection.value = selection.value / unit.value.toSeconds * it.toSeconds + unit.value = it + } + ) + } +} diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index 0dafb95b5..ff353d129 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -25,11 +25,11 @@ android.nonTransitiveRClass=true android.enableJetifier=true kotlin.mpp.androidSourceSetLayoutVersion=2 -android.version_name=5.5 -android.version_code=175 +android.version_name=5.5.1 +android.version_code=177 -desktop.version_name=5.5 -desktop.version_code=26 +desktop.version_name=5.5.1 +desktop.version_code=27 kotlin.version=1.8.20 gradle.plugin.version=7.4.2 diff --git a/package.yaml b/package.yaml index 88dbd639d..59d52b6f6 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 5.5.0.4 +version: 5.5.1.0 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/simplex-chat.cabal b/simplex-chat.cabal index d74d57523..cd010a0ee 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 5.5.0.4 +version: 5.5.1.0 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 564db9b42..dcd392629 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -1028,6 +1028,7 @@ processChatCommand' vr = \case when (memberActive membership && isOwner) . void $ sendGroupMessage' user gInfo members XGrpDel deleteGroupLinkIfExists user gInfo deleteMembersConnections user members + updateCIGroupInvitationStatus user gInfo CIGISRejected `catchChatError` \_ -> pure () -- functions below are called in separate transactions to prevent crashes on android -- (possibly, race condition on integrity check?) withStore' $ \db -> deleteGroupConnectionsAndFiles db user gInfo members @@ -1686,17 +1687,9 @@ processChatCommand' vr = \case createMemberConnection db userId fromMember agentConnId (fromJVersionRange peerChatVRange) subMode updateGroupMemberStatus db userId fromMember GSMemAccepted updateGroupMemberStatus db userId membership GSMemAccepted - updateCIGroupInvitationStatus user + updateCIGroupInvitationStatus user g CIGISAccepted `catchChatError` \_ -> pure () pure $ CRUserAcceptedGroupSent user g {membership = membership {memberStatus = GSMemAccepted}} Nothing Nothing -> throwChatError $ CEContactNotActive ct - where - updateCIGroupInvitationStatus user = do - AChatItem _ _ cInfo ChatItem {content, meta = CIMeta {itemId}} <- withStore $ \db -> getChatItemByGroupId db vr user groupId - case (cInfo, content) of - (DirectChat ct, CIRcvGroupInvitation ciGroupInv memRole) -> do - let aciContent = ACIContent SMDRcv $ CIRcvGroupInvitation ciGroupInv {status = CIGISAccepted} memRole - updateDirectChatItemView user ct itemId aciContent False Nothing - _ -> pure () -- prohibited APIMemberRole groupId memberId memRole -> withUser $ \user -> do Group gInfo@GroupInfo {membership} members <- withStore $ \db -> getGroup db vr user groupId if memberId == groupMemberId' membership @@ -2512,6 +2505,14 @@ processChatCommand' vr = \case cReqHashes :: (ConnReqUriHash, ConnReqUriHash) cReqHashes = bimap hash hash cReqSchemas hash = ConnReqUriHash . C.sha256Hash . strEncode + updateCIGroupInvitationStatus user GroupInfo {groupId} newStatus = do + AChatItem _ _ cInfo ChatItem {content, meta = CIMeta {itemId}} <- withStore $ \db -> getChatItemByGroupId db vr user groupId + case (cInfo, content) of + (DirectChat ct, CIRcvGroupInvitation ciGroupInv@CIGroupInvitation {status} memRole) + | status == CIGISPending -> do + let aciContent = ACIContent SMDRcv $ CIRcvGroupInvitation ciGroupInv {status = newStatus} memRole + updateDirectChatItemView user ct itemId aciContent False Nothing + _ -> pure () -- prohibited toggleNtf :: ChatMonad m => User -> GroupMember -> Bool -> m () toggleNtf user m ntfOn = diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 7c74a7325..105dedb32 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -20,6 +20,7 @@ import qualified Data.ByteArray as BA import qualified Data.ByteString.Base64.URL as U import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B +import qualified Data.ByteString.Lazy.Char8 as LB import Data.Functor (($>)) import Data.List (find) import qualified Data.List.NonEmpty as L @@ -94,6 +95,8 @@ foreign export ccall "chat_password_hash" cChatPasswordHash :: CString -> CStrin foreign export ccall "chat_valid_name" cChatValidName :: CString -> IO CString +foreign export ccall "chat_json_length" cChatJsonLength :: CString -> IO CInt + foreign export ccall "chat_encrypt_media" cChatEncryptMedia :: StablePtr ChatController -> CString -> Ptr Word8 -> CInt -> IO CString foreign export ccall "chat_decrypt_media" cChatDecryptMedia :: CString -> Ptr Word8 -> CInt -> IO CString @@ -176,6 +179,10 @@ cChatPasswordHash cPwd cSalt = do cChatValidName :: CString -> IO CString cChatValidName cName = newCString . mkValidName =<< peekCString cName +-- | returns length of JSON encoded string +cChatJsonLength :: CString -> IO CInt +cChatJsonLength s = fromIntegral . subtract 2 . LB.length . J.encode . safeDecodeUtf8 <$> B.packCString s + mobileChatOpts :: String -> ChatOpts mobileChatOpts dbFilePrefix = ChatOpts @@ -264,9 +271,18 @@ chatSendRemoteCmd :: ChatController -> Maybe RemoteHostId -> B.ByteString -> IO chatSendRemoteCmd cc rh s = J.encode . APIResponse Nothing rh <$> runReaderT (execChatCommand rh s) cc chatRecvMsg :: ChatController -> IO JSONByteString -chatRecvMsg ChatController {outputQ} = json <$> atomically (readTBQueue outputQ) +chatRecvMsg ChatController {outputQ} = json <$> readChatResponse where json (corr, remoteHostId, resp) = J.encode APIResponse {corr, remoteHostId, resp} + readChatResponse = do + out@(_, _, cr) <- atomically $ readTBQueue outputQ + if filterEvent cr then pure out else readChatResponse + filterEvent = \case + CRGroupSubscribed {} -> False + CRGroupEmpty {} -> False + CRMemberSubSummary {} -> False + CRPendingSubSummary {} -> False + _ -> True chatRecvMsgWait :: ChatController -> Int -> IO JSONByteString chatRecvMsgWait cc time = fromMaybe "" <$> timeout time (chatRecvMsg cc) diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index 0ed1b30f5..f41d0172d 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -68,6 +68,8 @@ mobileTests = do it "no exception on missing file" testMissingFileEncryptionCApi describe "validate name" $ do it "should convert invalid name to a valid name" testValidNameCApi + describe "JSON length" $ do + it "should compute length of JSON encoded string" testChatJsonLengthCApi noActiveUser :: LB.ByteString noActiveUser = @@ -222,8 +224,6 @@ testChatApi tmp = do chatSendCmd cc "/_start" `shouldReturn` chatStarted chatRecvMsg cc `shouldReturn` networkStatuses chatRecvMsg cc `shouldReturn` userContactSubSummary - chatRecvMsg cc `shouldReturn` memberSubSummary - chatRecvMsgWait cc 10000 `shouldReturn` pendingSubSummary chatRecvMsgWait cc 10000 `shouldReturn` "" chatParseMarkdown "hello" `shouldBe` "{}" chatParseMarkdown "*hello*" `shouldBe` parsedMarkdown @@ -356,6 +356,13 @@ testValidNameCApi _ = do cName2 <- cChatValidName =<< newCString " @'Джон' Доу 👍 " peekCString cName2 `shouldReturn` goodName +testChatJsonLengthCApi :: FilePath -> IO () +testChatJsonLengthCApi _ = do + cInt1 <- cChatJsonLength =<< newCString "Hello!" + cInt1 `shouldBe` 6 + cInt2 <- cChatJsonLength =<< newCString "こんにちは!" + cInt2 `shouldBe` 18 + jDecode :: FromJSON a => String -> IO (Maybe a) jDecode = pure . J.decode . LB.pack diff --git a/website/langs/en.json b/website/langs/en.json index 1bb64c7ef..10db2dd4e 100644 --- a/website/langs/en.json +++ b/website/langs/en.json @@ -250,5 +250,7 @@ "stable-versions-built-by-f-droid-org": "Stable versions built by F-Droid.org", "releases-to-this-repo-are-done-1-2-days-later": "The releases to this repo are done 1-2 days later", "f-droid-page-f-droid-org-repo-section-text": "SimpleX Chat and F-Droid.org repositories sign builds with the different keys. To switch, please export the chat database and re-install the app.", - "jobs": "Join team" + "jobs": "Join team", + "please-enable-javascript": "Please enable JavaScript to see the QR code.", + "please-use-link-in-mobile-app": "Please use the link in the mobile app" } diff --git a/website/src/_includes/contact_page.html b/website/src/_includes/contact_page.html index 6beb148f8..b5f7442a7 100644 --- a/website/src/_includes/contact_page.html +++ b/website/src/_includes/contact_page.html @@ -30,8 +30,12 @@
+ + -
+

{{ "scan-qr-code-from-mobile-app" | i18n({}, lang ) | safe }}

@@ -61,7 +65,11 @@
-

{{ "connect-in-app" | i18n({}, lang ) | safe }}

+

{{ "connect-in-app" | i18n({}, lang ) | safe }}

+ + {{ "open-simplex-app" | i18n({}, lang ) | safe }}
@@ -69,7 +77,7 @@
-
+

{{ "tap-the-connect-button-in-the-app" | i18n({}, lang ) | safe }}

@@ -81,7 +89,7 @@ -