diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 680a7132d..de09853e1 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1017,9 +1017,9 @@ private func sendCommandOkResp(_ cmd: ChatCommand) async throws { throw r } -func apiNewGroup(_ p: GroupProfile) throws -> GroupInfo { +func apiNewGroup(incognito: Bool, groupProfile: GroupProfile) throws -> GroupInfo { let userId = try currentUserId("apiNewGroup") - let r = chatSendCmdSync(.apiNewGroup(userId: userId, groupProfile: p)) + let r = chatSendCmdSync(.apiNewGroup(userId: userId, incognito: incognito, groupProfile: groupProfile)) if case let .groupCreated(_, groupInfo) = r { return groupInfo } throw r } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift index c7ec3ca71..72013877c 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift @@ -17,34 +17,45 @@ struct CIGroupInvitationView: View { var memberRole: GroupMemberRole var chatIncognito: Bool = false @State private var frameWidth: CGFloat = 0 + @State private var inProgress = false + @State private var progressByTimeout = false var body: some View { let action = !chatItem.chatDir.sent && groupInvitation.status == .pending let v = ZStack(alignment: .bottomTrailing) { - VStack(alignment: .leading) { - groupInfoView(action) - .padding(.horizontal, 2) - .padding(.top, 8) - .padding(.bottom, 6) - .overlay(DetermineWidth()) + ZStack { + VStack(alignment: .leading) { + groupInfoView(action) + .padding(.horizontal, 2) + .padding(.top, 8) + .padding(.bottom, 6) + .overlay(DetermineWidth()) - Divider().frame(width: frameWidth) + Divider().frame(width: frameWidth) - if action { - groupInvitationText() - .overlay(DetermineWidth()) - Text(chatIncognito ? "Tap to join incognito" : "Tap to join") - .foregroundColor(chatIncognito ? .indigo : .accentColor) - .font(.callout) - .padding(.trailing, 60) - .overlay(DetermineWidth()) - } else { - groupInvitationText() - .padding(.trailing, 60) - .overlay(DetermineWidth()) + if action { + VStack(alignment: .leading, spacing: 2) { + groupInvitationText() + .overlay(DetermineWidth()) + Text(chatIncognito ? "Tap to join incognito" : "Tap to join") + .foregroundColor(inProgress ? .secondary : chatIncognito ? .indigo : .accentColor) + .font(.callout) + .padding(.trailing, 60) + .overlay(DetermineWidth()) + } + } else { + groupInvitationText() + .padding(.trailing, 60) + .overlay(DetermineWidth()) + } + } + .padding(.bottom, 2) + + if progressByTimeout { + ProgressView().scaleEffect(2) } } - .padding(.bottom, 2) + chatItem.timestampText .font(.caption) .foregroundColor(.secondary) @@ -55,11 +66,24 @@ struct CIGroupInvitationView: View { .cornerRadius(18) .textSelection(.disabled) .onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 } + .onChange(of: inProgress) { inProgress in + if inProgress { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + progressByTimeout = inProgress + } + } else { + progressByTimeout = false + } + } if action { v.onTapGesture { - joinGroup(groupInvitation.groupId) + inProgress = true + joinGroup(groupInvitation.groupId) { + await MainActor.run { inProgress = false } + } } + .disabled(inProgress) } else { v } @@ -67,7 +91,7 @@ struct CIGroupInvitationView: View { private func groupInfoView(_ action: Bool) -> some View { var color: Color - if action { + if action && !inProgress { color = chatIncognito ? .indigo : .accentColor } else { color = Color(uiColor: .tertiaryLabel) diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 21af0ebe1..5679b451a 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -37,6 +37,10 @@ struct ChatView: View { @FocusState private var searchFocussed // opening GroupMemberInfoView on member icon @State private var selectedMember: GroupMember? = nil + // opening GroupLinkView on link button (incognito) + @State private var showGroupLinkSheet: Bool = false + @State private var groupLink: String? + @State private var groupLinkMemberRole: GroupMemberRole = .member var body: some View { if #available(iOS 16.0, *) { @@ -173,9 +177,16 @@ struct ChatView: View { HStack { if groupInfo.canAddMembers { if (chat.chatInfo.incognito) { - Image(systemName: "person.crop.circle.badge.plus") - .foregroundColor(Color(uiColor: .tertiaryLabel)) - .onTapGesture { AlertManager.shared.showAlert(cantInviteIncognitoAlert()) } + groupLinkButton() + .appSheet(isPresented: $showGroupLinkSheet) { + GroupLinkView( + groupId: groupInfo.groupId, + groupLink: $groupLink, + groupLinkMemberRole: $groupLinkMemberRole, + showTitle: true, + creatingGroup: false + ) + } } else { addMembersButton() .appSheet(isPresented: $showAddMembersSheet) { @@ -417,7 +428,26 @@ struct ChatView: View { Image(systemName: "person.crop.circle.badge.plus") } } - + + private func groupLinkButton() -> some View { + Button { + if case let .group(gInfo) = chat.chatInfo { + Task { + do { + if let link = try apiGetGroupLink(gInfo.groupId) { + (groupLink, groupLinkMemberRole) = link + } + } catch let error { + logger.error("ChatView apiGetGroupLink: \(responseError(error))") + } + showGroupLinkSheet = true + } + } + } label: { + Image(systemName: "link.badge.plus") + } + } + private func loadChatItems(_ cInfo: ChatInfo, _ ci: ChatItem, _ proxy: ScrollViewProxy) { if let firstItem = chatModel.reversedChatItems.last, firstItem.id == ci.id { if loadingItems || firstPage { return } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift index 51deced72..3eead5b0a 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift @@ -14,20 +14,28 @@ import PhotosUI struct NativeTextEditor: UIViewRepresentable { @Binding var text: String @Binding var disableEditing: Bool - let height: CGFloat - let font: UIFont + @Binding var height: CGFloat @Binding var focused: Bool let alignment: TextAlignment let onImagesAdded: ([UploadContent]) -> Void + private let minHeight: CGFloat = 37 + + private let defaultHeight: CGFloat = { + let field = CustomUITextField(height: Binding.constant(0)) + field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4) + return min(max(field.sizeThatFits(CGSizeMake(field.frame.size.width, CGFloat.greatestFiniteMagnitude)).height, 37), 360).rounded(.down) + }() + func makeUIView(context: Context) -> UITextView { - let field = CustomUITextField() + let field = CustomUITextField(height: _height) field.text = text - field.font = font field.textAlignment = alignment == .leading ? .left : .right field.autocapitalizationType = .sentences field.setOnTextChangedListener { newText, images in if !disableEditing { + // Speed up the process of updating layout, reduce jumping content on screen + if !isShortEmoji(newText) { updateHeight(field) } text = newText } else { field.text = text @@ -39,24 +47,72 @@ struct NativeTextEditor: UIViewRepresentable { field.setOnFocusChangedListener { focused = $0 } field.delegate = field field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4) + updateFont(field) + updateHeight(field) return field } func updateUIView(_ field: UITextView, context: Context) { field.text = text - field.font = font field.textAlignment = alignment == .leading ? .left : .right + updateFont(field) + updateHeight(field) + } + + private func updateHeight(_ field: UITextView) { + let maxHeight = min(360, field.font!.lineHeight * 12) + // When having emoji in text view and then removing it, sizeThatFits shows previous size (too big for empty text view), so using work around with default size + let newHeight = field.text == "" + ? defaultHeight + : min(max(field.sizeThatFits(CGSizeMake(field.frame.size.width, CGFloat.greatestFiniteMagnitude)).height, minHeight), maxHeight).rounded(.down) + + if field.frame.size.height != newHeight { + field.frame.size = CGSizeMake(field.frame.size.width, newHeight) + (field as! CustomUITextField).invalidateIntrinsicContentHeight(newHeight) + } + } + + private func updateFont(_ field: UITextView) { + field.font = isShortEmoji(field.text) + ? (field.text.count < 4 ? largeEmojiUIFont : mediumEmojiUIFont) + : UIFont.preferredFont(forTextStyle: .body) } } private class CustomUITextField: UITextView, UITextViewDelegate { + var height: Binding + var newHeight: CGFloat = 0 var onTextChanged: (String, [UploadContent]) -> Void = { newText, image in } var onFocusChanged: (Bool) -> Void = { focused in } + init(height: Binding) { + self.height = height + super.init(frame: .zero, textContainer: nil) + } + + required init?(coder: NSCoder) { + fatalError("Not implemented") + } + + // This func here needed because using frame.size.height in intrinsicContentSize while loading a screen with text (for example. when you have a draft), + // produces incorrect height because at that point intrinsicContentSize has old value of frame.size.height even if it was set to new value right before the call + // (who knows why...) + func invalidateIntrinsicContentHeight(_ newHeight: CGFloat) { + self.newHeight = newHeight + invalidateIntrinsicContentSize() + } + + override var intrinsicContentSize: CGSize { + if height.wrappedValue != newHeight { + DispatchQueue.main.asyncAfter(deadline: .now(), execute: { self.height.wrappedValue = self.newHeight }) + } + return CGSizeMake(0, newHeight) + } + func setOnTextChangedListener(onTextChanged: @escaping (String, [UploadContent]) -> Void) { self.onTextChanged = onTextChanged } - + func setOnFocusChangedListener(onFocusChanged: @escaping (Bool) -> Void) { self.onFocusChanged = onFocusChanged } @@ -144,14 +200,14 @@ private class CustomUITextField: UITextView, UITextViewDelegate { struct NativeTextEditor_Previews: PreviewProvider{ static var previews: some View { - return NativeTextEditor( + NativeTextEditor( text: Binding.constant("Hello, world!"), disableEditing: Binding.constant(false), - height: 100, - font: UIFont.preferredFont(forTextStyle: .body), + height: Binding.constant(100), focused: Binding.constant(false), alignment: TextAlignment.leading, onImagesAdded: { _ in } ) + .fixedSize(horizontal: false, vertical: true) } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index 6eed51788..8f7b23c88 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -32,15 +32,12 @@ struct SendMessageView: View { var sendButtonColor = Color.accentColor @State private var teHeight: CGFloat = 42 @State private var teFont: Font = .body - @State private var teUiFont: UIFont = UIFont.preferredFont(forTextStyle: .body) @State private var sendButtonSize: CGFloat = 29 @State private var sendButtonOpacity: CGFloat = 1 @State private var showCustomDisappearingMessageDialogue = false @State private var showCustomTimePicker = false @State private var selectedDisappearingMessageTime: Int? = customDisappearingMessageTimeDefault.get() @State private var progressByTimeout = false - var maxHeight: CGFloat = 360 - var minHeight: CGFloat = 37 @AppStorage(DEFAULT_LIVE_MESSAGE_ALERT_SHOWN) private var liveMessageAlertShown = false var body: some View { @@ -57,30 +54,16 @@ struct SendMessageView: View { .frame(maxWidth: .infinity) } else { let alignment: TextAlignment = isRightToLeft(composeState.message) ? .trailing : .leading - Text(composeState.message) - .lineLimit(10) - .font(teFont) - .multilineTextAlignment(alignment) -// put text on top (after NativeTextEditor) and set color to precisely align it on changes -// .foregroundColor(.red) - .foregroundColor(.clear) - .padding(.horizontal, 10) - .padding(.top, 8) - .padding(.bottom, 6) - .matchedGeometryEffect(id: "te", in: namespace) - .background(GeometryReader(content: updateHeight)) - NativeTextEditor( text: $composeState.message, disableEditing: $composeState.inProgress, - height: teHeight, - font: teUiFont, + height: $teHeight, focused: $keyboardVisible, alignment: alignment, onImagesAdded: onMediaAdded ) .allowsTightening(false) - .frame(height: teHeight) + .fixedSize(horizontal: false, vertical: true) } } @@ -100,11 +83,13 @@ struct SendMessageView: View { .frame(height: teHeight, alignment: .bottom) } } - + .padding(.vertical, 1) + .overlay( RoundedRectangle(cornerSize: CGSize(width: 20, height: 20)) .strokeBorder(.secondary, lineWidth: 0.3, antialiased: true) - .frame(height: teHeight) + ) } + .onChange(of: composeState.message, perform: { text in updateFont(text) }) .onChange(of: composeState.inProgress) { inProgress in if inProgress { DispatchQueue.main.asyncAfter(deadline: .now() + 3) { @@ -415,16 +400,12 @@ struct SendMessageView: View { .padding([.bottom, .trailing], 4) } - private func updateHeight(_ g: GeometryProxy) -> Color { + private func updateFont(_ text: String) { DispatchQueue.main.async { - teHeight = min(max(g.frame(in: .local).size.height, minHeight), maxHeight) - (teFont, teUiFont) = isShortEmoji(composeState.message) - ? composeState.message.count < 4 - ? (largeEmojiFont, largeEmojiUIFont) - : (mediumEmojiFont, mediumEmojiUIFont) - : (.body, UIFont.preferredFont(forTextStyle: .body)) + teFont = isShortEmoji(text) + ? (text.count < 4 ? largeEmojiFont : mediumEmojiFont) + : .body } - return Color.clear } } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 3b9ef347e..dd2392b6d 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -225,9 +225,15 @@ struct GroupChatInfoView: View { private func groupLinkButton() -> some View { NavigationLink { - GroupLinkView(groupId: groupInfo.groupId, groupLink: $groupLink, groupLinkMemberRole: $groupLinkMemberRole) - .navigationBarTitle("Group link") - .navigationBarTitleDisplayMode(.large) + GroupLinkView( + groupId: groupInfo.groupId, + groupLink: $groupLink, + groupLinkMemberRole: $groupLinkMemberRole, + showTitle: false, + creatingGroup: false + ) + .navigationBarTitle("Group link") + .navigationBarTitleDisplayMode(.large) } label: { if groupLink == nil { Label("Create group link", systemImage: "link.badge.plus") diff --git a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift index 781870bf5..bf2179bea 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift @@ -13,6 +13,9 @@ struct GroupLinkView: View { var groupId: Int64 @Binding var groupLink: String? @Binding var groupLinkMemberRole: GroupMemberRole + var showTitle: Bool = false + var creatingGroup: Bool = false + var linkCreatedCb: (() -> Void)? = nil @State private var creatingLink = false @State private var alert: GroupLinkAlert? @@ -29,10 +32,35 @@ struct GroupLinkView: View { } var body: some View { + if creatingGroup { + NavigationView { + groupLinkView() + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button ("Continue") { linkCreatedCb?() } + } + } + } + } else { + groupLinkView() + } + } + + private func groupLinkView() -> some View { List { - Text("You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.") - .listRowBackground(Color.clear) - .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + Group { + if showTitle { + Text("Group link") + .font(.largeTitle) + .bold() + .fixedSize(horizontal: false, vertical: true) + } + Text("You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.") + } + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + Section { if let groupLink = groupLink { Picker("Initial role", selection: $groupLinkMemberRole) { @@ -48,8 +76,10 @@ struct GroupLinkView: View { Label("Share link", systemImage: "square.and.arrow.up") } - Button(role: .destructive) { alert = .deleteLink } label: { - Label("Delete link", systemImage: "trash") + if !creatingGroup { + Button(role: .destructive) { alert = .deleteLink } label: { + Label("Delete link", systemImage: "trash") + } } } else { Button(action: createGroupLink) { diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index 088335d19..971c0e088 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -33,19 +33,32 @@ struct ChatListNavLink: View { @State private var showContactConnectionInfo = false @State private var showInvalidJSON = false @State private var showDeleteContactActionSheet = false + @State private var inProgress = false + @State private var progressByTimeout = false var body: some View { - switch chat.chatInfo { - case let .direct(contact): - contactNavLink(contact) - case let .group(groupInfo): - groupNavLink(groupInfo) - case let .contactRequest(cReq): - contactRequestNavLink(cReq) - case let .contactConnection(cConn): - contactConnectionNavLink(cConn) - case let .invalidJSON(json): - invalidJSONPreview(json) + Group { + switch chat.chatInfo { + case let .direct(contact): + contactNavLink(contact) + case let .group(groupInfo): + groupNavLink(groupInfo) + case let .contactRequest(cReq): + contactRequestNavLink(cReq) + case let .contactConnection(cConn): + contactConnectionNavLink(cConn) + case let .invalidJSON(json): + invalidJSONPreview(json) + } + } + .onChange(of: inProgress) { inProgress in + if inProgress { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + progressByTimeout = inProgress + } + } else { + progressByTimeout = false + } } } @@ -53,7 +66,7 @@ struct ChatListNavLink: View { NavLinkPlain( tag: chat.chatInfo.id, selection: $chatModel.chatId, - label: { ChatPreviewView(chat: chat) } + label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) } ) .swipeActions(edge: .leading, allowsFullSwipe: true) { markReadButton() @@ -101,7 +114,7 @@ struct ChatListNavLink: View { @ViewBuilder private func groupNavLink(_ groupInfo: GroupInfo) -> some View { switch (groupInfo.membership.memberStatus) { case .memInvited: - ChatPreviewView(chat: chat) + ChatPreviewView(chat: chat, progressByTimeout: $progressByTimeout) .frame(height: rowHeights[dynamicTypeSize]) .swipeActions(edge: .trailing, allowsFullSwipe: true) { joinGroupButton() @@ -112,12 +125,16 @@ struct ChatListNavLink: View { .onTapGesture { showJoinGroupDialog = true } .confirmationDialog("Group invitation", isPresented: $showJoinGroupDialog, titleVisibility: .visible) { Button(chat.chatInfo.incognito ? "Join incognito" : "Join group") { - joinGroup(groupInfo.groupId) + inProgress = true + joinGroup(groupInfo.groupId) { + await MainActor.run { inProgress = false } + } } Button("Delete invitation", role: .destructive) { Task { await deleteChat(chat) } } } + .disabled(inProgress) case .memAccepted: - ChatPreviewView(chat: chat) + ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) .frame(height: rowHeights[dynamicTypeSize]) .onTapGesture { AlertManager.shared.showAlert(groupInvitationAcceptedAlert()) @@ -134,7 +151,7 @@ struct ChatListNavLink: View { NavLinkPlain( tag: chat.chatInfo.id, selection: $chatModel.chatId, - label: { ChatPreviewView(chat: chat) }, + label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) }, disabled: !groupInfo.ready ) .frame(height: rowHeights[dynamicTypeSize]) @@ -159,7 +176,10 @@ struct ChatListNavLink: View { private func joinGroupButton() -> some View { Button { - joinGroup(chat.chatInfo.apiId) + inProgress = true + joinGroup(chat.chatInfo.apiId) { + await MainActor.run { inProgress = false } + } } label: { Label("Join", systemImage: chat.chatInfo.incognito ? "theatermasks" : "ipad.and.arrow.forward") } @@ -419,7 +439,7 @@ func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection, ) } -func joinGroup(_ groupId: Int64) { +func joinGroup(_ groupId: Int64, _ onComplete: @escaping () async -> Void) { Task { logger.debug("joinGroup") do { @@ -434,7 +454,9 @@ func joinGroup(_ groupId: Int64) { AlertManager.shared.showAlertMsg(title: "No group!", message: "This group no longer exists.") await deleteGroup() } + await onComplete() } catch let error { + await onComplete() let a = getErrorAlert(error, "Error joining group") AlertManager.shared.showAlertMsg(title: a.title, message: a.message) } diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index b7b7e73dc..71f8baf74 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -12,6 +12,7 @@ import SimpleXChat struct ChatPreviewView: View { @EnvironmentObject var chatModel: ChatModel @ObservedObject var chat: Chat + @Binding var progressByTimeout: Bool @Environment(\.colorScheme) var colorScheme var darkGreen = Color(red: 0, green: 0.5, blue: 0) @@ -252,6 +253,12 @@ struct ChatPreviewView: View { } else { incognitoIcon(chat.chatInfo.incognito) } + case .group: + if progressByTimeout { + ProgressView() + } else { + incognitoIcon(chat.chatInfo.incognito) + } default: incognitoIcon(chat.chatInfo.incognito) } @@ -280,30 +287,30 @@ struct ChatPreviewView_Previews: PreviewProvider { ChatPreviewView(chat: Chat( chatInfo: ChatInfo.sampleData.direct, chatItems: [] - )) + ), progressByTimeout: Binding.constant(false)) ChatPreviewView(chat: Chat( chatInfo: ChatInfo.sampleData.direct, chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))] - )) + ), progressByTimeout: Binding.constant(false)) ChatPreviewView(chat: Chat( chatInfo: ChatInfo.sampleData.direct, chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))], chatStats: ChatStats(unreadCount: 11, minUnreadItemId: 0) - )) + ), progressByTimeout: Binding.constant(false)) ChatPreviewView(chat: Chat( chatInfo: ChatInfo.sampleData.direct, chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now))] - )) + ), progressByTimeout: Binding.constant(false)) ChatPreviewView(chat: Chat( chatInfo: ChatInfo.sampleData.direct, chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))], chatStats: ChatStats(unreadCount: 3, minUnreadItemId: 0) - )) + ), progressByTimeout: Binding.constant(false)) ChatPreviewView(chat: Chat( chatInfo: ChatInfo.sampleData.group, chatItems: [ChatItem.getSample(1, .directSnd, .now, "Lorem ipsum dolor sit amet, d. consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.")], chatStats: ChatStats(unreadCount: 11, minUnreadItemId: 0) - )) + ), progressByTimeout: Binding.constant(false)) } .previewLayout(.fixed(width: 360, height: 78)) } diff --git a/apps/ios/Shared/Views/NewChat/AddGroupView.swift b/apps/ios/Shared/Views/NewChat/AddGroupView.swift index 186a24e99..22bf1c409 100644 --- a/apps/ios/Shared/Views/NewChat/AddGroupView.swift +++ b/apps/ios/Shared/Views/NewChat/AddGroupView.swift @@ -12,6 +12,7 @@ import SimpleXChat struct AddGroupView: View { @EnvironmentObject var m: ChatModel @Environment(\.dismiss) var dismiss: DismissAction + @AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false @State private var chat: Chat? @State private var groupInfo: GroupInfo? @State private var profile = GroupProfile(displayName: "", fullName: "") @@ -21,18 +22,35 @@ struct AddGroupView: View { @State private var showTakePhoto = false @State private var chosenImage: UIImage? = nil @State private var showInvalidNameAlert = false + @State private var groupLink: String? + @State private var groupLinkMemberRole: GroupMemberRole = .member var body: some View { if let chat = chat, let groupInfo = groupInfo { - AddGroupMembersViewCommon( - chat: chat, - groupInfo: groupInfo, - creatingGroup: true, - showFooterCounter: false - ) { _ in - dismiss() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - m.chatId = groupInfo.id + if !groupInfo.membership.memberIncognito { + AddGroupMembersViewCommon( + chat: chat, + groupInfo: groupInfo, + creatingGroup: true, + showFooterCounter: false + ) { _ in + dismiss() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + m.chatId = groupInfo.id + } + } + } else { + GroupLinkView( + groupId: groupInfo.groupId, + groupLink: $groupLink, + groupLinkMemberRole: $groupLinkMemberRole, + showTitle: true, + creatingGroup: true + ) { + dismiss() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + m.chatId = groupInfo.id + } } } } else { @@ -41,77 +59,62 @@ struct AddGroupView: View { } func createGroupView() -> some View { - VStack(alignment: .leading) { - Text("Create secret group") - .font(.largeTitle) - .padding(.vertical, 4) - Text("The group is fully decentralized – it is visible only to the members.") - .padding(.bottom, 4) + List { + Group { + Text("Create secret group") + .font(.largeTitle) + .bold() + .fixedSize(horizontal: false, vertical: true) + .padding(.bottom, 24) + .onTapGesture(perform: hideKeyboard) - HStack { - Image(systemName: "info.circle").foregroundColor(.secondary).font(.footnote) - Spacer().frame(width: 8) - Text("Your chat profile will be sent to group members").font(.footnote) - } - .padding(.bottom) - - ZStack(alignment: .center) { - ZStack(alignment: .topTrailing) { - profileImageView(profile.image) - if profile.image != nil { - Button { - profile.image = nil - } label: { - Image(systemName: "multiply") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 12) + ZStack(alignment: .center) { + ZStack(alignment: .topTrailing) { + ProfileImage(imageStr: profile.image, color: Color(uiColor: .secondarySystemGroupedBackground)) + .aspectRatio(1, contentMode: .fit) + .frame(maxWidth: 128, maxHeight: 128) + if profile.image != nil { + Button { + profile.image = nil + } label: { + Image(systemName: "multiply") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 12) + } } } + + editImageButton { showChooseSource = true } + .buttonStyle(BorderlessButtonStyle()) // otherwise whole "list row" is clickable } - - editImageButton { showChooseSource = true } + .frame(maxWidth: .infinity, alignment: .center) } - .frame(maxWidth: .infinity, alignment: .center) - .padding(.bottom, 4) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) - ZStack(alignment: .topLeading) { - let name = profile.displayName.trimmingCharacters(in: .whitespaces) - if name != mkValidName(name) { - Button { - showInvalidNameAlert = true - } label: { - Image(systemName: "exclamationmark.circle").foregroundColor(.red) - } - } else { - Image(systemName: "exclamationmark.circle").foregroundColor(.clear) + Section { + groupNameTextField() + Button(action: createGroup) { + settingsRow("checkmark", color: .accentColor) { Text("Create group") } } - textField("Enter group name…", text: $profile.displayName) - .focused($focusDisplayName) - .submitLabel(.go) - .onSubmit { - if canCreateProfile() { createGroup() } - } + .disabled(!canCreateProfile()) + IncognitoToggle(incognitoEnabled: $incognitoDefault) + } footer: { + VStack(alignment: .leading, spacing: 4) { + sharedGroupProfileInfo(incognitoDefault) + Text("Fully decentralized – visible only to members.") + } + .frame(maxWidth: .infinity, alignment: .leading) + .onTapGesture(perform: hideKeyboard) } - .padding(.bottom) - - Spacer() - - Button { - createGroup() - } label: { - Text("Create") - Image(systemName: "greaterthan") - } - .disabled(!canCreateProfile()) - .frame(maxWidth: .infinity, alignment: .trailing) } .onAppear() { DispatchQueue.main.asyncAfter(deadline: .now() + 1) { focusDisplayName = true } } - .padding() .confirmationDialog("Group image", isPresented: $showChooseSource, titleVisibility: .visible) { Button("Take picture") { showTakePhoto = true @@ -141,20 +144,48 @@ struct AddGroupView: View { profile.image = nil } } - .contentShape(Rectangle()) - .onTapGesture { hideKeyboard() } + } + + func groupNameTextField() -> some View { + ZStack(alignment: .leading) { + let name = profile.displayName.trimmingCharacters(in: .whitespaces) + if name != mkValidName(name) { + Button { + showInvalidNameAlert = true + } label: { + Image(systemName: "exclamationmark.circle").foregroundColor(.red) + } + } else { + Image(systemName: "pencil").foregroundColor(.secondary) + } + textField("Enter group name…", text: $profile.displayName) + .focused($focusDisplayName) + .submitLabel(.continue) + .onSubmit { + if canCreateProfile() { createGroup() } + } + } } func textField(_ placeholder: LocalizedStringKey, text: Binding) -> some View { TextField(placeholder, text: text) - .padding(.leading, 32) + .padding(.leading, 36) + } + + func sharedGroupProfileInfo(_ incognito: Bool) -> Text { + let name = ChatModel.shared.currentUser?.displayName ?? "" + return Text( + incognito + ? "A new random profile will be shared." + : "Your profile **\(name)** will be shared." + ) } func createGroup() { hideKeyboard() do { profile.displayName = profile.displayName.trimmingCharacters(in: .whitespaces) - let gInfo = try apiNewGroup(profile) + let gInfo = try apiNewGroup(incognito: incognitoDefault, groupProfile: profile) Task { let groupMembers = await apiListMembers(gInfo.groupId) await MainActor.run { diff --git a/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift b/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift index af84f19fe..7c272fb63 100644 --- a/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift +++ b/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift @@ -54,9 +54,11 @@ struct PasteToConnectView: View { IncognitoToggle(incognitoEnabled: $incognitoDefault) } footer: { - sharedProfileInfo(incognitoDefault) - + Text(String("\n\n")) - + Text("You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button.") + VStack(alignment: .leading, spacing: 4) { + sharedProfileInfo(incognitoDefault) + Text("You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button.") + } + .frame(maxWidth: .infinity, alignment: .leading) } } .alert(item: $alert) { a in planAndConnectAlert(a, dismiss: true) } diff --git a/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift b/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift index c55ba1502..9a11eee92 100644 --- a/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift +++ b/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift @@ -38,11 +38,11 @@ struct ScanToConnectView: View { ) .padding(.top) - Group { + VStack(alignment: .leading, spacing: 4) { sharedProfileInfo(incognitoDefault) - + Text(String("\n\n")) - + Text("If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link.") + Text("If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link.") } + .frame(maxWidth: .infinity, alignment: .leading) .font(.footnote) .foregroundColor(.secondary) .padding(.horizontal) diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 5c7220f37..e27067478 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -50,7 +50,7 @@ public enum ChatCommand { case apiVerifyToken(token: DeviceToken, nonce: String, code: String) case apiDeleteToken(token: DeviceToken) case apiGetNtfMessage(nonce: String, encNtfInfo: String) - case apiNewGroup(userId: Int64, groupProfile: GroupProfile) + case apiNewGroup(userId: Int64, incognito: Bool, groupProfile: GroupProfile) case apiAddMember(groupId: Int64, contactId: Int64, memberRole: GroupMemberRole) case apiJoinGroup(groupId: Int64) case apiMemberRole(groupId: Int64, memberId: Int64, memberRole: GroupMemberRole) @@ -175,7 +175,7 @@ public enum ChatCommand { case let .apiVerifyToken(token, nonce, code): return "/_ntf verify \(token.cmdString) \(nonce) \(code)" case let .apiDeleteToken(token): return "/_ntf delete \(token.cmdString)" case let .apiGetNtfMessage(nonce, encNtfInfo): return "/_ntf message \(nonce) \(encNtfInfo)" - case let .apiNewGroup(userId, groupProfile): return "/_group \(userId) \(encodeJSON(groupProfile))" + case let .apiNewGroup(userId, incognito, groupProfile): return "/_group \(userId) incognito=\(onOff(incognito)) \(encodeJSON(groupProfile))" case let .apiAddMember(groupId, contactId, memberRole): return "/_add #\(groupId) \(contactId) \(memberRole)" case let .apiJoinGroup(groupId): return "/_join #\(groupId)" case let .apiMemberRole(groupId, memberId, memberRole): return "/_member role #\(groupId) \(memberId) \(memberRole.rawValue)" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 1abc823c0..b751cb56c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -1166,9 +1166,9 @@ object ChatController { } } - suspend fun apiNewGroup(p: GroupProfile): GroupInfo? { + suspend fun apiNewGroup(incognito: Boolean, groupProfile: GroupProfile): GroupInfo? { val userId = kotlin.runCatching { currentUserId("apiNewGroup") }.getOrElse { return null } - val r = sendCmd(CC.ApiNewGroup(userId, p)) + val r = sendCmd(CC.ApiNewGroup(userId, incognito, groupProfile)) if (r is CR.GroupCreated) return r.groupInfo Log.e(TAG, "apiNewGroup bad response: ${r.responseType} ${r.details}") return null @@ -1889,7 +1889,7 @@ sealed class CC { class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemId: Long, val mode: CIDeleteMode): CC() class ApiDeleteMemberChatItem(val groupId: Long, val groupMemberId: Long, val itemId: Long): CC() class ApiChatItemReaction(val type: ChatType, val id: Long, val itemId: Long, val add: Boolean, val reaction: MsgReaction): CC() - class ApiNewGroup(val userId: Long, val groupProfile: GroupProfile): CC() + class ApiNewGroup(val userId: Long, val incognito: Boolean, val groupProfile: GroupProfile): CC() class ApiAddMember(val groupId: Long, val contactId: Long, val memberRole: GroupMemberRole): CC() class ApiJoinGroup(val groupId: Long): CC() class ApiMemberRole(val groupId: Long, val memberId: Long, val memberRole: GroupMemberRole): CC() @@ -1999,7 +1999,7 @@ sealed class CC { is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} $itemId ${mode.deleteMode}" is ApiDeleteMemberChatItem -> "/_delete member item #$groupId $groupMemberId $itemId" is ApiChatItemReaction -> "/_reaction ${chatRef(type, id)} $itemId ${onOff(add)} ${json.encodeToString(reaction)}" - is ApiNewGroup -> "/_group $userId ${json.encodeToString(groupProfile)}" + is ApiNewGroup -> "/_group $userId incognito=${onOff(incognito)} ${json.encodeToString(groupProfile)}" is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}" is ApiJoinGroup -> "/_join #$groupId" is ApiMemberRole -> "/_member role #$groupId $memberId ${memberRole.memberRole}" 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 40f2b32e5..ac7161044 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 @@ -269,8 +269,11 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: cancelFile = { fileId -> withApi { chatModel.controller.cancelFile(user, fileId) } }, - joinGroup = { groupId -> - withApi { chatModel.controller.apiJoinGroup(groupId) } + joinGroup = { groupId, onComplete -> + withApi { + chatModel.controller.apiJoinGroup(groupId) + onComplete.invoke() + } }, startCall = out@ { media -> withBGApi { @@ -386,6 +389,16 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: } } }, + openGroupLink = { groupInfo -> + hideKeyboard(view) + withApi { + val link = chatModel.controller.apiGetGroupLink(groupInfo.groupId) + ModalManager.end.closeModals() + ModalManager.end.showModalCloseable(true) { + GroupLinkView(chatModel, groupInfo, link?.first, link?.second, onGroupLinkUpdated = null) + } + } + }, markRead = { range, unreadCountAfter -> chatModel.markChatItemsRead(chat.chatInfo, range, unreadCountAfter) ntfManager.cancelNotificationsForChat(chat.id) @@ -431,7 +444,7 @@ fun ChatLayout( deleteMessage: (Long, CIDeleteMode) -> Unit, receiveFile: (Long, Boolean) -> Unit, cancelFile: (Long) -> Unit, - joinGroup: (Long) -> Unit, + joinGroup: (Long, () -> Unit) -> Unit, startCall: (CallMediaType) -> Unit, endCall: () -> Unit, acceptCall: (Contact) -> Unit, @@ -446,6 +459,7 @@ fun ChatLayout( setReaction: (ChatInfo, ChatItem, Boolean, MsgReaction) -> Unit, showItemDetails: (ChatInfo, ChatItem) -> Unit, addMembers: (GroupInfo) -> Unit, + openGroupLink: (GroupInfo) -> Unit, markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit, changeNtfsState: (Boolean, currentValue: MutableState) -> Unit, onSearchValueChanged: (String) -> Unit, @@ -492,7 +506,7 @@ fun ChatLayout( } Scaffold( - topBar = { ChatInfoToolbar(chat, back, info, startCall, endCall, addMembers, changeNtfsState, onSearchValueChanged) }, + topBar = { ChatInfoToolbar(chat, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged) }, bottomBar = composeView, modifier = Modifier.navigationBarsWithImePadding(), floatingActionButton = { floatingButton.value() }, @@ -523,6 +537,7 @@ fun ChatInfoToolbar( startCall: (CallMediaType) -> Unit, endCall: () -> Unit, addMembers: (GroupInfo) -> Unit, + openGroupLink: (GroupInfo) -> Unit, changeNtfsState: (Boolean, currentValue: MutableState) -> Unit, onSearchValueChanged: (String) -> Unit, ) { @@ -604,13 +619,24 @@ fun ChatInfoToolbar( }) } } - } else if (chat.chatInfo is ChatInfo.Group && chat.chatInfo.groupInfo.canAddMembers && !chat.chatInfo.incognito) { - barButtons.add { - IconButton({ - showMenu.value = false - addMembers(chat.chatInfo.groupInfo) - }) { - Icon(painterResource(MR.images.ic_person_add_500), stringResource(MR.strings.icon_descr_add_members), tint = MaterialTheme.colors.primary) + } else if (chat.chatInfo is ChatInfo.Group && chat.chatInfo.groupInfo.canAddMembers) { + if (!chat.chatInfo.incognito) { + barButtons.add { + IconButton({ + showMenu.value = false + addMembers(chat.chatInfo.groupInfo) + }) { + Icon(painterResource(MR.images.ic_person_add_500), stringResource(MR.strings.icon_descr_add_members), tint = MaterialTheme.colors.primary) + } + } + } else { + barButtons.add { + IconButton({ + showMenu.value = false + openGroupLink(chat.chatInfo.groupInfo) + }) { + Icon(painterResource(MR.images.ic_add_link), stringResource(MR.strings.group_link), tint = MaterialTheme.colors.primary) + } } } } @@ -720,7 +746,7 @@ fun BoxWithConstraintsScope.ChatItemsList( deleteMessage: (Long, CIDeleteMode) -> Unit, receiveFile: (Long, Boolean) -> Unit, cancelFile: (Long) -> Unit, - joinGroup: (Long) -> Unit, + joinGroup: (Long, () -> Unit) -> Unit, acceptCall: (Contact) -> Unit, acceptFeature: (Contact, ChatFeature, Int?) -> Unit, openDirectChat: (Long) -> Unit, @@ -872,7 +898,7 @@ fun BoxWithConstraintsScope.ChatItemsList( ) { MemberImage(member) } - ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, getConnectedMemberNames = ::getConnectedMemberNames, developerTools = developerTools) + ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = { _, _ -> }, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, getConnectedMemberNames = ::getConnectedMemberNames, developerTools = developerTools) } } } else { @@ -881,7 +907,7 @@ fun BoxWithConstraintsScope.ChatItemsList( .padding(start = 8.dp + MEMBER_IMAGE_SIZE + 4.dp, end = if (voiceWithTransparentBack) 12.dp else 66.dp) .then(swipeableModifier) ) { - ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, getConnectedMemberNames = ::getConnectedMemberNames, developerTools = developerTools) + ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = { _, _ -> }, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, getConnectedMemberNames = ::getConnectedMemberNames, developerTools = developerTools) } } } @@ -891,7 +917,7 @@ fun BoxWithConstraintsScope.ChatItemsList( .padding(start = if (voiceWithTransparentBack) 12.dp else 104.dp, end = 12.dp) .then(swipeableModifier) ) { - ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools) + ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = { _, _ -> }, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools) } } } else { // direct message @@ -1323,7 +1349,7 @@ fun PreviewChatLayout() { deleteMessage = { _, _ -> }, receiveFile = { _, _ -> }, cancelFile = {}, - joinGroup = {}, + joinGroup = { _, _ -> }, startCall = {}, endCall = {}, acceptCall = { _ -> }, @@ -1338,6 +1364,7 @@ fun PreviewChatLayout() { setReaction = { _, _, _, _ -> }, showItemDetails = { _, _ -> }, addMembers = { _ -> }, + openGroupLink = {}, markRead = { _, _ -> }, changeNtfsState = { _, _ -> }, onSearchValueChanged = {}, @@ -1393,7 +1420,7 @@ fun PreviewGroupChatLayout() { deleteMessage = { _, _ -> }, receiveFile = { _, _ -> }, cancelFile = {}, - joinGroup = {}, + joinGroup = { _, _ -> }, startCall = {}, endCall = {}, acceptCall = { _ -> }, @@ -1408,6 +1435,7 @@ fun PreviewGroupChatLayout() { setReaction = { _, _, _, _ -> }, showItemDetails = { _, _ -> }, addMembers = { _ -> }, + openGroupLink = {}, markRead = { _, _ -> }, changeNtfsState = { _, _ -> }, onSearchValueChanged = {}, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt index 7e1c03130..809c7c2fd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt @@ -23,7 +23,15 @@ import chat.simplex.common.views.newchat.* import chat.simplex.res.MR @Composable -fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: String?, memberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair?) -> Unit) { +fun GroupLinkView( + chatModel: ChatModel, + groupInfo: GroupInfo, + connReqContact: String?, + memberRole: GroupMemberRole?, + onGroupLinkUpdated: ((Pair?) -> Unit)?, + creatingGroup: Boolean = false, + close: (() -> Unit)? = null +) { var groupLink by rememberSaveable { mutableStateOf(connReqContact) } val groupLinkMemberRole = rememberSaveable { mutableStateOf(memberRole) } var creatingLink by rememberSaveable { mutableStateOf(false) } @@ -34,7 +42,7 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St if (link != null) { groupLink = link.first groupLinkMemberRole.value = link.second - onGroupLinkUpdated(link) + onGroupLinkUpdated?.invoke(link) } creatingLink = false } @@ -58,7 +66,7 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St if (link != null) { groupLink = link.first groupLinkMemberRole.value = link.second - onGroupLinkUpdated(link) + onGroupLinkUpdated?.invoke(link) } } } @@ -73,13 +81,15 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St val r = chatModel.controller.apiDeleteGroupLink(groupInfo.groupId) if (r) { groupLink = null - onGroupLinkUpdated(null) + onGroupLinkUpdated?.invoke(null) } } }, destructive = true, ) - } + }, + creatingGroup = creatingGroup, + close = close ) if (creatingLink) { ProgressIndicator() @@ -94,8 +104,19 @@ fun GroupLinkLayout( creatingLink: Boolean, createLink: () -> Unit, updateLink: () -> Unit, - deleteLink: () -> Unit + deleteLink: () -> Unit, + creatingGroup: Boolean = false, + close: (() -> Unit)? = null ) { + @Composable + fun ContinueButton(close: () -> Unit) { + SimpleButton( + stringResource(MR.strings.continue_to_next_step), + icon = painterResource(MR.images.ic_check), + click = close + ) + } + Column( Modifier .verticalScroll(rememberScrollState()), @@ -112,7 +133,16 @@ fun GroupLinkLayout( verticalArrangement = Arrangement.SpaceEvenly ) { if (groupLink == null) { - SimpleButton(stringResource(MR.strings.button_create_group_link), icon = painterResource(MR.images.ic_add_link), disabled = creatingLink, click = createLink) + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = DEFAULT_PADDING, vertical = 10.dp) + ) { + SimpleButton(stringResource(MR.strings.button_create_group_link), icon = painterResource(MR.images.ic_add_link), disabled = creatingLink, click = createLink) + if (creatingGroup && close != null) { + ContinueButton(close) + } + } } else { RoleSelectionRow(groupInfo, groupLinkMemberRole) var initialLaunch by remember { mutableStateOf(true) } @@ -134,12 +164,16 @@ fun GroupLinkLayout( icon = painterResource(MR.images.ic_share), click = { clipboard.shareText(simplexChatLink(groupLink)) } ) - SimpleButton( - stringResource(MR.strings.delete_link), - icon = painterResource(MR.images.ic_delete), - color = Color.Red, - click = deleteLink - ) + if (creatingGroup && close != null) { + ContinueButton(close) + } else { + SimpleButton( + stringResource(MR.strings.delete_link), + icon = painterResource(MR.images.ic_delete), + color = Color.Red, + click = deleteLink + ) + } } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt index 6ee29b830..56dd7a360 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import dev.icerock.moko.resources.compose.stringResource @@ -17,6 +18,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* import chat.simplex.res.MR +import kotlinx.coroutines.delay @Composable fun CIGroupInvitationView( @@ -24,16 +26,26 @@ fun CIGroupInvitationView( groupInvitation: CIGroupInvitation, memberRole: GroupMemberRole, chatIncognito: Boolean = false, - joinGroup: (Long) -> Unit + joinGroup: (Long, () -> Unit) -> Unit ) { val sent = ci.chatDir.sent val action = !sent && groupInvitation.status == CIGroupInvitationStatus.Pending + val inProgress = remember { mutableStateOf(false) } + var progressByTimeout by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(inProgress.value) { + progressByTimeout = if (inProgress.value) { + delay(1000) + inProgress.value + } else { + false + } + } @Composable fun groupInfoView() { val p = groupInvitation.groupProfile val iconColor = - if (action) if (chatIncognito) Indigo else MaterialTheme.colors.primary + if (action && !inProgress.value) if (chatIncognito) Indigo else MaterialTheme.colors.primary else if (isInDarkTheme()) FileDark else FileLight Row( @@ -70,8 +82,9 @@ fun CIGroupInvitationView( val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage Surface( - modifier = if (action) Modifier.clickable(onClick = { - joinGroup(groupInvitation.groupId) + modifier = if (action && !inProgress.value) Modifier.clickable(onClick = { + inProgress.value = true + joinGroup(groupInvitation.groupId) { inProgress.value = false } }) else Modifier, shape = RoundedCornerShape(18.dp), color = if (sent) sentColor else receivedColor, @@ -83,26 +96,45 @@ fun CIGroupInvitationView( .padding(start = 8.dp, end = 12.dp), contentAlignment = Alignment.BottomEnd ) { - Column( - Modifier - .defaultMinSize(minWidth = 220.dp) - .padding(bottom = 4.dp), + Box( + contentAlignment = Alignment.Center ) { - groupInfoView() - Column(Modifier.padding(top = 2.dp, start = 5.dp)) { - Divider(Modifier.fillMaxWidth().padding(bottom = 4.dp)) - if (action) { - groupInvitationText() - Text(stringResource( - if (chatIncognito) MR.strings.group_invitation_tap_to_join_incognito else MR.strings.group_invitation_tap_to_join), - color = if (chatIncognito) Indigo else MaterialTheme.colors.primary) - } else { - Box(Modifier.padding(end = 48.dp)) { + Column( + Modifier + .defaultMinSize(minWidth = 220.dp) + .padding(bottom = 4.dp), + ) { + groupInfoView() + Column(Modifier.padding(top = 2.dp, start = 5.dp)) { + Divider(Modifier.fillMaxWidth().padding(bottom = 4.dp)) + if (action) { groupInvitationText() + Text( + stringResource( + if (chatIncognito) MR.strings.group_invitation_tap_to_join_incognito else MR.strings.group_invitation_tap_to_join + ), + color = if (inProgress.value) + MaterialTheme.colors.secondary + else + if (chatIncognito) Indigo else MaterialTheme.colors.primary + ) + } else { + Box(Modifier.padding(end = 48.dp)) { + groupInvitationText() + } } } } + + if (progressByTimeout) { + CircularProgressIndicator( + Modifier.size(32.dp), + color = if (isInDarkTheme()) FileDark else FileLight, + strokeWidth = 3.dp + ) + } } + Text( ci.timestampText, color = MaterialTheme.colors.secondary, @@ -124,7 +156,7 @@ fun PendingCIGroupInvitationViewPreview() { ci = ChatItem.getGroupInvitationSample(), groupInvitation = CIGroupInvitation.getSample(), memberRole = GroupMemberRole.Admin, - joinGroup = {} + joinGroup = { _, _ -> } ) } } @@ -140,7 +172,7 @@ fun CIGroupInvitationViewAcceptedPreview() { ci = ChatItem.getGroupInvitationSample(), groupInvitation = CIGroupInvitation.getSample(status = CIGroupInvitationStatus.Accepted), memberRole = GroupMemberRole.Admin, - joinGroup = {} + joinGroup = { _, _ -> } ) } } @@ -156,7 +188,7 @@ fun CIGroupInvitationViewLongNamePreview() { status = CIGroupInvitationStatus.Accepted ), memberRole = GroupMemberRole.Admin, - joinGroup = {} + joinGroup = { _, _ -> } ) } } 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 dd07a3fc1..dd9fe4d4a 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 @@ -50,7 +50,7 @@ fun ChatItemView( deleteMessage: (Long, CIDeleteMode) -> Unit, receiveFile: (Long, Boolean) -> Unit, cancelFile: (Long) -> Unit, - joinGroup: (Long) -> Unit, + joinGroup: (Long, () -> Unit) -> Unit, acceptCall: (Contact) -> Unit, scrollToItem: (Long) -> Unit, acceptFeature: (Contact, ChatFeature, Int?) -> Unit, @@ -578,7 +578,7 @@ fun PreviewChatItemView() { deleteMessage = { _, _ -> }, receiveFile = { _, _ -> }, cancelFile = {}, - joinGroup = {}, + joinGroup = { _, _ -> }, acceptCall = { _ -> }, scrollToItem = {}, acceptFeature = { _, _, _ -> }, @@ -609,7 +609,7 @@ fun PreviewChatItemViewDeletedContent() { deleteMessage = { _, _ -> }, receiveFile = { _, _ -> }, cancelFile = {}, - joinGroup = {}, + joinGroup = { _, _ -> }, acceptCall = { _ -> }, scrollToItem = {}, acceptFeature = { _, _, _ -> }, 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 2ff33ead5..bcabb7cfd 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 @@ -12,6 +12,7 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -44,11 +45,22 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { } val selectedChat = remember(chat.id) { derivedStateOf { chat.id == ChatModel.chatId.value } } val showChatPreviews = chatModel.showChatPreviews.value + val inProgress = remember { mutableStateOf(false) } + var progressByTimeout by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(inProgress.value) { + progressByTimeout = if (inProgress.value) { + delay(1000) + inProgress.value + } else { + false + } + } + when (chat.chatInfo) { is ChatInfo.Direct -> { val contactNetworkStatus = chatModel.contactNetworkStatus(chat.chatInfo.contact) ChatListNavLinkLayout( - chatLinkPreview = { ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, stopped, linkMode) }, + chatLinkPreview = { ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, stopped, linkMode, inProgress = false, progressByTimeout = false) }, click = { directChatAction(chat.chatInfo, chatModel) }, dropdownMenuItems = { ContactMenuItems(chat, chatModel, showMenu, showMarkRead) }, showMenu, @@ -58,9 +70,9 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { } is ChatInfo.Group -> ChatListNavLinkLayout( - chatLinkPreview = { ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, stopped, linkMode) }, - click = { groupChatAction(chat.chatInfo.groupInfo, chatModel) }, - dropdownMenuItems = { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, showMarkRead) }, + chatLinkPreview = { ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, stopped, linkMode, inProgress.value, progressByTimeout) }, + click = { if (!inProgress.value) groupChatAction(chat.chatInfo.groupInfo, chatModel, inProgress) }, + dropdownMenuItems = { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, inProgress, showMarkRead) }, showMenu, stopped, selectedChat @@ -110,9 +122,9 @@ fun directChatAction(chatInfo: ChatInfo, chatModel: ChatModel) { withBGApi { openChat(chatInfo, chatModel) } } -fun groupChatAction(groupInfo: GroupInfo, chatModel: ChatModel) { +fun groupChatAction(groupInfo: GroupInfo, chatModel: ChatModel, inProgress: MutableState? = null) { when (groupInfo.membership.memberStatus) { - GroupMemberStatus.MemInvited -> acceptGroupInvitationAlertDialog(groupInfo, chatModel) + GroupMemberStatus.MemInvited -> acceptGroupInvitationAlertDialog(groupInfo, chatModel, inProgress) GroupMemberStatus.MemAccepted -> groupInvitationAcceptedAlert() else -> withBGApi { openChat(ChatInfo.Group(groupInfo), chatModel) } } @@ -193,10 +205,19 @@ fun ContactMenuItems(chat: Chat, chatModel: ChatModel, showMenu: MutableState, showMarkRead: Boolean) { +fun GroupMenuItems( + chat: Chat, + groupInfo: GroupInfo, + chatModel: ChatModel, + showMenu: MutableState, + inProgress: MutableState, + showMarkRead: Boolean +) { when (groupInfo.membership.memberStatus) { GroupMemberStatus.MemInvited -> { - JoinGroupAction(chat, groupInfo, chatModel, showMenu) + if (!inProgress.value) { + JoinGroupAction(chat, groupInfo, chatModel, showMenu, inProgress) + } if (groupInfo.canDelete) { DeleteGroupAction(chat, groupInfo, chatModel, showMenu) } @@ -317,8 +338,20 @@ fun DeleteGroupAction(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, sh } @Composable -fun JoinGroupAction(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState) { - val joinGroup: () -> Unit = { withApi { chatModel.controller.apiJoinGroup(groupInfo.groupId) } } +fun JoinGroupAction( + chat: Chat, + groupInfo: GroupInfo, + chatModel: ChatModel, + showMenu: MutableState, + inProgress: MutableState +) { + val joinGroup: () -> Unit = { + withApi { + inProgress.value = true + chatModel.controller.apiJoinGroup(groupInfo.groupId) + inProgress.value = false + } + } ItemAction( if (chat.chatInfo.incognito) stringResource(MR.strings.join_group_incognito_button) else stringResource(MR.strings.join_group_button), if (chat.chatInfo.incognito) painterResource(MR.images.ic_theater_comedy_filled) else painterResource(MR.images.ic_login), @@ -558,12 +591,18 @@ fun pendingContactAlertDialog(chatInfo: ChatInfo, chatModel: ChatModel) { ) } -fun acceptGroupInvitationAlertDialog(groupInfo: GroupInfo, chatModel: ChatModel) { +fun acceptGroupInvitationAlertDialog(groupInfo: GroupInfo, chatModel: ChatModel, inProgress: MutableState? = null) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.join_group_question), text = generalGetString(MR.strings.you_are_invited_to_group_join_to_connect_with_group_members), confirmText = if (groupInfo.membership.memberIncognito) generalGetString(MR.strings.join_group_incognito_button) else generalGetString(MR.strings.join_group_button), - onConfirm = { withApi { chatModel.controller.apiJoinGroup(groupInfo.groupId) } }, + onConfirm = { + withApi { + inProgress?.value = true + chatModel.controller.apiJoinGroup(groupInfo.groupId) + inProgress?.value = false + } + }, dismissText = generalGetString(MR.strings.delete_verb), onDismiss = { deleteGroup(groupInfo, chatModel) } ) @@ -680,7 +719,9 @@ fun PreviewChatListNavLinkDirect() { null, null, stopped = false, - linkMode = SimplexLinkMode.DESCRIPTION + linkMode = SimplexLinkMode.DESCRIPTION, + inProgress = false, + progressByTimeout = false ) }, click = {}, @@ -721,7 +762,9 @@ fun PreviewChatListNavLinkGroup() { null, null, stopped = false, - linkMode = SimplexLinkMode.DESCRIPTION + linkMode = SimplexLinkMode.DESCRIPTION, + inProgress = false, + progressByTimeout = false ) }, click = {}, 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 a5775d369..d3413e2e0 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 @@ -37,7 +37,9 @@ fun ChatPreviewView( currentUserProfileDisplayName: String?, contactNetworkStatus: NetworkStatus?, stopped: Boolean, - linkMode: SimplexLinkMode + linkMode: SimplexLinkMode, + inProgress: Boolean, + progressByTimeout: Boolean ) { val cInfo = chat.chatInfo @@ -135,7 +137,12 @@ fun ChatPreviewView( } is ChatInfo.Group -> when (cInfo.groupInfo.membership.memberStatus) { - GroupMemberStatus.MemInvited -> chatPreviewTitleText(if (chat.chatInfo.incognito) Indigo else MaterialTheme.colors.primary) + GroupMemberStatus.MemInvited -> chatPreviewTitleText( + if (inProgress) + MaterialTheme.colors.secondary + else + if (chat.chatInfo.incognito) Indigo else MaterialTheme.colors.primary + ) GroupMemberStatus.MemAccepted -> chatPreviewTitleText(MaterialTheme.colors.secondary) else -> chatPreviewTitleText() } @@ -194,6 +201,17 @@ fun ChatPreviewView( } } + @Composable + fun progressView() { + CircularProgressIndicator( + Modifier + .padding(horizontal = 2.dp) + .size(15.dp), + color = MaterialTheme.colors.secondary, + strokeWidth = 1.5.dp + ) + } + @Composable fun chatStatusImage() { if (cInfo is ChatInfo.Direct) { @@ -213,17 +231,17 @@ fun ChatPreviewView( ) else -> - CircularProgressIndicator( - Modifier - .padding(horizontal = 2.dp) - .size(15.dp), - color = MaterialTheme.colors.secondary, - strokeWidth = 1.5.dp - ) + progressView() } } else { IncognitoIcon(chat.chatInfo.incognito) } + } else if (cInfo is ChatInfo.Group) { + if (progressByTimeout) { + progressView() + } else { + IncognitoIcon(chat.chatInfo.incognito) + } } else { IncognitoIcon(chat.chatInfo.incognito) } @@ -351,6 +369,6 @@ fun unreadCountStr(n: Int): String { @Composable fun PreviewChatPreviewView() { SimpleXTheme { - ChatPreviewView(Chat.sampleData, true, null, null, "", contactNetworkStatus = NetworkStatus.Connected(), stopped = false, linkMode = SimplexLinkMode.DESCRIPTION) + ChatPreviewView(Chat.sampleData, true, null, null, "", contactNetworkStatus = NetworkStatus.Connected(), stopped = false, linkMode = SimplexLinkMode.DESCRIPTION, inProgress = false, progressByTimeout = false) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt index be446f608..9b2cedefa 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt @@ -1,5 +1,6 @@ package chat.simplex.common.views.newchat +import SectionTextFooter import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.* import androidx.compose.foundation.layout.* @@ -11,10 +12,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.buildAnnotatedString import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* @@ -22,11 +22,10 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.group.AddGroupMembersView import chat.simplex.common.views.chatlist.setGroupMembers import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.onboarding.ReadableText -import chat.simplex.common.views.usersettings.DeleteImageButton -import chat.simplex.common.views.usersettings.EditImageButton import chat.simplex.common.platform.* import chat.simplex.common.views.* +import chat.simplex.common.views.chat.group.GroupLinkView +import chat.simplex.common.views.usersettings.* import chat.simplex.res.MR import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -35,9 +34,9 @@ import java.net.URI @Composable fun AddGroupView(chatModel: ChatModel, close: () -> Unit) { AddGroupLayout( - createGroup = { groupProfile -> + createGroup = { incognito, groupProfile -> withApi { - val groupInfo = chatModel.controller.apiNewGroup(groupProfile) + val groupInfo = chatModel.controller.apiNewGroup(incognito, groupProfile) if (groupInfo != null) { chatModel.addChat(Chat(chatInfo = ChatInfo.Group(groupInfo), chatItems = listOf())) chatModel.chatItems.clear() @@ -45,24 +44,36 @@ fun AddGroupView(chatModel: ChatModel, close: () -> Unit) { chatModel.chatId.value = groupInfo.id setGroupMembers(groupInfo, chatModel) close.invoke() - ModalManager.end.showModalCloseable(true) { close -> - AddGroupMembersView(groupInfo, true, chatModel, close) + if (!groupInfo.incognito) { + ModalManager.end.showModalCloseable(true) { close -> + AddGroupMembersView(groupInfo, creatingGroup = true, chatModel, close) + } + } else { + ModalManager.end.showModalCloseable(true) { close -> + GroupLinkView(chatModel, groupInfo, connReqContact = null, memberRole = null, onGroupLinkUpdated = null, creatingGroup = true, close) + } } } } }, + incognitoPref = chatModel.controller.appPrefs.incognito, close ) } @Composable -fun AddGroupLayout(createGroup: (GroupProfile) -> Unit, close: () -> Unit) { +fun AddGroupLayout( + createGroup: (Boolean, GroupProfile) -> Unit, + incognitoPref: SharedPreference, + close: () -> Unit +) { val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) val scope = rememberCoroutineScope() val displayName = rememberSaveable { mutableStateOf("") } val chosenImage = rememberSaveable { mutableStateOf(null) } val profileImage = rememberSaveable { mutableStateOf(null) } val focusRequester = remember { FocusRequester() } + val incognito = remember { mutableStateOf(incognitoPref.get()) } ProvideWindowInsets(windowInsetsAnimationsEnabled = true) { ModalBottomSheetLayout( @@ -87,7 +98,6 @@ fun AddGroupLayout(createGroup: (GroupProfile) -> Unit, close: () -> Unit) { .padding(horizontal = DEFAULT_PADDING) ) { AppBarTitle(stringResource(MR.strings.create_secret_group_title)) - ReadableText(MR.strings.group_is_decentralized, TextAlign.Center) Box( Modifier .fillMaxWidth() @@ -118,20 +128,32 @@ fun AddGroupLayout(createGroup: (GroupProfile) -> Unit, close: () -> Unit) { } ProfileNameField(displayName, "", { isValidDisplayName(it.trim()) }, focusRequester) Spacer(Modifier.height(8.dp)) - val enabled = canCreateProfile(displayName.value) - if (enabled) { - CreateGroupButton(MaterialTheme.colors.primary, Modifier - .clickable { - createGroup(GroupProfile( - displayName = displayName.value.trim(), - fullName = "", - image = profileImage.value - )) - } - .padding(8.dp)) - } else { - CreateGroupButton(MaterialTheme.colors.secondary, Modifier.padding(8.dp)) - } + + SettingsActionItem( + painterResource(MR.images.ic_check), + stringResource(MR.strings.create_group_button), + click = { + createGroup(incognito.value, GroupProfile( + displayName = displayName.value.trim(), + fullName = "", + image = profileImage.value + )) + }, + textColor = MaterialTheme.colors.primary, + iconColor = MaterialTheme.colors.primary, + disabled = !canCreateProfile(displayName.value) + ) + + IncognitoToggle(incognitoPref, incognito) { ModalManager.start.showModal { IncognitoView() } } + + SectionTextFooter( + buildAnnotatedString { + append(sharedProfileInfo(chatModel, incognito.value)) + append("\n") + append(annotatedStringResource(MR.strings.group_is_decentralized)) + } + ) + LaunchedEffect(Unit) { delay(300) focusRequester.requestFocus() @@ -142,21 +164,6 @@ fun AddGroupLayout(createGroup: (GroupProfile) -> Unit, close: () -> Unit) { } } -@Composable -fun CreateGroupButton(color: Color, modifier: Modifier) { - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End - ) { - Surface(shape = RoundedCornerShape(20.dp), color = Color.Transparent) { - Row(modifier, verticalAlignment = Alignment.CenterVertically) { - Text(stringResource(MR.strings.create_profile_button), style = MaterialTheme.typography.caption, color = color, fontWeight = FontWeight.Bold) - Icon(painterResource(MR.images.ic_arrow_forward_ios), stringResource(MR.strings.create_profile_button), tint = color) - } - } - } -} - fun canCreateProfile(displayName: String): Boolean = displayName.trim().isNotEmpty() && isValidDisplayName(displayName.trim()) @Preview @@ -164,7 +171,8 @@ fun canCreateProfile(displayName: String): Boolean = displayName.trim().isNotEmp fun PreviewAddGroupLayout() { SimpleXTheme { AddGroupLayout( - createGroup = {}, + createGroup = { _, _ -> }, + incognitoPref = SharedPreference({ false }, {}), close = {} ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt index f7a5a1e86..b142b8e16 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt @@ -3,10 +3,10 @@ package chat.simplex.common.views.newchat import SectionBottomSpacer import SectionTextFooter import androidx.compose.desktop.ui.tooling.preview.Preview -import chat.simplex.common.platform.Log import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material.MaterialTheme import androidx.compose.runtime.* import androidx.compose.ui.Modifier import dev.icerock.moko.resources.compose.painterResource @@ -14,7 +14,6 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.unit.dp -import chat.simplex.common.platform.TAG import chat.simplex.common.model.ChatModel import chat.simplex.common.model.SharedPreference import chat.simplex.common.ui.theme.* @@ -23,7 +22,6 @@ import chat.simplex.common.views.usersettings.IncognitoView import chat.simplex.common.views.usersettings.SettingsActionItem import chat.simplex.res.MR import java.net.URI -import java.net.URISyntaxException @Composable fun PasteToConnectView(chatModel: ChatModel, close: () -> Unit) { @@ -97,6 +95,8 @@ fun PasteToConnectLayout( painterResource(MR.images.ic_link), stringResource(MR.strings.connect_button), click = { connectViaLink(connectionLink.value) }, + textColor = MaterialTheme.colors.primary, + iconColor = MaterialTheme.colors.primary, disabled = connectionLink.value.isEmpty() || connectionLink.value.trim().contains(" ") ) 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 b5ffd6630..aa76a768e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1294,11 +1294,11 @@ Create secret group - The group is fully decentralized – it is visible only to the members. + Fully decentralized – visible only to members. Enter group name: Group full name: Your chat profile will be sent to group members - + Create group Group profile is stored on members\' devices, not on the servers. diff --git a/cabal.project b/cabal.project index 5ab3690b7..9df03bfbc 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: bba7ad349459dc212782517bce68a60299bbb827 + tag: 3a2969c44ad2f7244188fd98ad0df504ead89d14 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index eede7de8f..6a8ea6fc4 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."bba7ad349459dc212782517bce68a60299bbb827" = "0qsiv83zvz2q4g9rhjys57yaj14hvjl0wp71dmckvqayyz7mfqqa"; + "https://github.com/simplex-chat/simplexmq.git"."3a2969c44ad2f7244188fd98ad0df504ead89d14" = "12kdb016bcfgsjpypyr3x0rnf2w4jgyawwygjl5vjsdsa8rm1mqv"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/kazu-yamamoto/http2.git"."804fa283f067bd3fd89b8c5f8d25b3047813a517" = "1j67wp7rfybfx3ryx08z6gqmzj85j51hmzhgx47ihgmgr47sl895"; "https://github.com/simplex-chat/direct-sqlcipher.git"."34309410eb2069b029b8fc1872deb1e0db123294" = "0kwkmhyfsn2lixdlgl15smgr1h5gjk7fky6abzh8rng2h5ymnffd"; diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 52976b475..e5f196a34 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -5832,7 +5832,7 @@ chatCommandP = mcTextP = MCText . safeDecodeUtf8 <$> A.takeByteString msgContentP = "text " *> mcTextP <|> "json " *> jsonP ciDeleteMode = "broadcast" $> CIDMBroadcast <|> "internal" $> CIDMInternal - displayName = safeDecodeUtf8 <$> (quoted "'\"" <|> takeNameTill isSpace) + displayName = safeDecodeUtf8 <$> (quoted "'" <|> takeNameTill isSpace) where takeNameTill p = A.peekChar' >>= \c -> @@ -5947,14 +5947,20 @@ timeItToView s action = do pure a mkValidName :: String -> String -mkValidName = reverse . dropWhile isSpace . fst . foldl' addChar ("", '\NUL') +mkValidName = reverse . dropWhile isSpace . fst3 . foldl' addChar ("", '\NUL', 0 :: Int) where - addChar (r, prev) c = if notProhibited && validChar then (c' : r, c') else (r, prev) + fst3 (x, _, _) = x + addChar (r, prev, punct) c = if validChar then (c' : r, c', punct') else (r, prev, punct) where c' = if isSpace c then ' ' else c + punct' + | isPunctuation c = punct + 1 + | isSpace c = punct + | otherwise = 0 validChar - | prev == '\NUL' || isSpace prev = validFirstChar - | isPunctuation prev = validFirstChar || isSpace c + | c == '\'' = False + | prev == '\NUL' = c > ' ' && c /= '#' && c /= '@' && validFirstChar + | isSpace prev = validFirstChar || (punct == 0 && isPunctuation c) + | isPunctuation prev = validFirstChar || isSpace c || (punct < 3 && isPunctuation c) | otherwise = validFirstChar || isSpace c || isMark c || isPunctuation c validFirstChar = isLetter c || isNumber c || isSymbol c - notProhibited = c `notElem` ("@#'\"`" :: String) diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index d3cdbcf3e..639093d01 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -9,12 +9,14 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE StandaloneDeriving #-} +{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} module Simplex.Chat.Messages.CIContent where import Data.Aeson (FromJSON, ToJSON) import qualified Data.Aeson as J +import qualified Data.Aeson.TH as JQ import Data.Int (Int64) import Data.Text (Text) import Data.Text.Encoding (decodeLatin1, encodeUtf8) @@ -314,11 +316,11 @@ instance ToJSON DBRcvDirectEvent where newtype DBMsgErrorType = DBME MsgErrorType instance FromJSON DBMsgErrorType where - parseJSON v = DBME <$> J.genericParseJSON (singleFieldJSON fstToLower) v + parseJSON v = DBME <$> $(JQ.mkParseJSON (singleFieldJSON fstToLower) ''MsgErrorType) v instance ToJSON DBMsgErrorType where - toJSON (DBME v) = J.genericToJSON (singleFieldJSON fstToLower) v - toEncoding (DBME v) = J.genericToEncoding (singleFieldJSON fstToLower) v + toJSON (DBME v) = $(JQ.mkToJSON (singleFieldJSON fstToLower) ''MsgErrorType) v + toEncoding (DBME v) = $(JQ.mkToEncoding (singleFieldJSON fstToLower) ''MsgErrorType) v data CIGroupInvitation = CIGroupInvitation { groupId :: GroupId, diff --git a/stack.yaml b/stack.yaml index a3a001995..9df6a092d 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,7 +49,7 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: bba7ad349459dc212782517bce68a60299bbb827 + commit: 3a2969c44ad2f7244188fd98ad0df504ead89d14 - github: kazu-yamamoto/http2 commit: 804fa283f067bd3fd89b8c5f8d25b3047813a517 # - ../direct-sqlcipher diff --git a/tests/ValidNames.hs b/tests/ValidNames.hs index 40cda0143..0700d8084 100644 --- a/tests/ValidNames.hs +++ b/tests/ValidNames.hs @@ -14,14 +14,26 @@ testMkValidName = do mkValidName "John Doe" `shouldBe` "John Doe" mkValidName "J.Doe" `shouldBe` "J.Doe" mkValidName "J. Doe" `shouldBe` "J. Doe" - mkValidName "J..Doe" `shouldBe` "J.Doe" - mkValidName "J ..Doe" `shouldBe` "J Doe" - mkValidName "J . . Doe" `shouldBe` "J Doe" + mkValidName "J..Doe" `shouldBe` "J..Doe" + mkValidName "J ..Doe" `shouldBe` "J ..Doe" + mkValidName "J ... Doe" `shouldBe` "J ... Doe" + mkValidName "J .... Doe" `shouldBe` "J ... Doe" + mkValidName "J . . Doe" `shouldBe` "J . Doe" mkValidName "@alice" `shouldBe` "alice" mkValidName "#alice" `shouldBe` "alice" mkValidName " alice" `shouldBe` "alice" mkValidName "alice " `shouldBe` "alice" mkValidName "John Doe" `shouldBe` "John Doe" mkValidName "'John Doe'" `shouldBe` "John Doe" - mkValidName "\"John Doe\"" `shouldBe` "John Doe" - mkValidName "`John Doe`" `shouldBe` "John Doe" + mkValidName "\"John Doe\"" `shouldBe` "John Doe\"" + mkValidName "`John Doe`" `shouldBe` "`John Doe`" + mkValidName "John \"Doe\"" `shouldBe` "John \"Doe\"" + mkValidName "John `Doe`" `shouldBe` "John `Doe`" + mkValidName "alice/bob" `shouldBe` "alice/bob" + mkValidName "alice / bob" `shouldBe` "alice / bob" + mkValidName "alice /// bob" `shouldBe` "alice /// bob" + mkValidName "alice //// bob" `shouldBe` "alice /// bob" + mkValidName "alice >>= bob" `shouldBe` "alice >>= bob" + mkValidName "alice@example.com" `shouldBe` "alice@example.com" + mkValidName "alice <> bob" `shouldBe` "alice <> bob" + mkValidName "alice -> bob" `shouldBe` "alice -> bob"