diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4eef14cf66..10e3ba91e1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -104,7 +104,7 @@ jobs: echo " flags: +openssl" >> cabal.project.local - name: Install AppImage dependencies - if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-22.04' + if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-20.04' run: sudo apt install -y desktop-file-utils - name: Install pkg-config for Mac @@ -156,7 +156,7 @@ jobs: - name: Linux make AppImage id: linux_appimage_build - if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-22.04' + if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'ubuntu-20.04' shell: bash run: | scripts/desktop/make-appimage-linux.sh @@ -170,7 +170,7 @@ jobs: scripts/desktop/build-lib-mac.sh cd apps/multiplatform ./gradlew packageDmg - echo "::set-output name=package_path::$(echo $PWD/release/main/dmg/simplex-*.dmg)" + echo "::set-output name=package_path::$(echo $PWD/release/main/dmg/SimpleX-*.dmg)" - name: Linux upload desktop package to release if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04') diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index f2e67c4aa5..46c36ab197 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -28,6 +28,17 @@ struct ContentView: View { @State private var showWhatsNew = false @State private var showChooseLAMode = false @State private var showSetPasscode = false + @State private var chatListActionSheet: ChatListActionSheet? = nil + + private enum ChatListActionSheet: Identifiable { + case connectViaUrl(action: ConnReqType, link: String) + + var id: String { + switch self { + case .connectViaUrl: return "connectViaUrl \(link)" + } + } + } var body: some View { ZStack { @@ -80,6 +91,11 @@ struct ContentView: View { if case .onboardingComplete = step, chatModel.currentUser != nil { mainView() + .actionSheet(item: $chatListActionSheet) { sheet in + switch sheet { + case let .connectViaUrl(action, link): return connectViaUrlSheet(action, link) + } + } } else { OnboardingView(onboarding: step) } @@ -132,7 +148,9 @@ struct ContentView: View { } } prefShowLANotice = true + connectViaUrl() } + .onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() } .sheet(isPresented: $showWhatsNew) { WhatsNewView() } @@ -265,36 +283,38 @@ struct ContentView: View { secondaryButton: .cancel() ) } -} -func connectViaUrl() { - let m = ChatModel.shared - if let url = m.appOpenUrl { - m.appOpenUrl = nil - AlertManager.shared.showAlert(connectViaUrlAlert(url)) + func connectViaUrl() { + let m = ChatModel.shared + if let url = m.appOpenUrl { + m.appOpenUrl = nil + var path = url.path + logger.debug("ContentView.connectViaUrl path: \(path)") + if (path == "/contact" || path == "/invitation") { + path.removeFirst() + let action: ConnReqType = path == "contact" ? .contact : .invitation + let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)") + chatListActionSheet = .connectViaUrl(action: action, link: link) + } else { + AlertManager.shared.showAlert(Alert(title: Text("Error: URL is invalid"))) + } + } } -} -func connectViaUrlAlert(_ url: URL) -> Alert { - var path = url.path - logger.debug("ChatListView.connectViaUrlAlert path: \(path)") - if (path == "/contact" || path == "/invitation") { - path.removeFirst() - let action: ConnReqType = path == "contact" ? .contact : .invitation - let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)") + private func connectViaUrlSheet(_ action: ConnReqType, _ link: String) -> ActionSheet { let title: LocalizedStringKey - if case .contact = action { title = "Connect via contact link?" } - else { title = "Connect via one-time link?" } - return Alert( + switch action { + case .contact: title = "Connect via contact link" + case .invitation: title = "Connect via one-time link" + } + return ActionSheet( title: Text(title), - message: Text("Your profile will be sent to the contact that you received this link from"), - primaryButton: .default(Text("Connect")) { - connectViaLink(link) - }, - secondaryButton: .cancel() + buttons: [ + .default(Text("Use current profile")) { connectViaLink(link, incognito: false) }, + .default(Text("Use new incognito profile")) { connectViaLink(link, incognito: true) }, + .cancel() + ] ) - } else { - return Alert(title: Text("Error: URL is invalid")) } } diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 3179b4f862..c3331c822a 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -43,9 +43,8 @@ final class ChatModel: ObservableObject { @Published var tokenStatus: NtfTknStatus? @Published var notificationMode = NotificationsMode.off @Published var notificationPreview: NotificationPreviewMode = ntfPreviewModeGroupDefault.get() - @Published var incognito: Bool = incognitoGroupDefault.get() // pending notification actions - @Published var ntfContactRequest: ChatId? + @Published var ntfContactRequest: NTFContactRequest? @Published var ntfCallInvitationAction: (ChatId, NtfCallAction)? // current WebRTC call @Published var callInvitations: Dictionary = [:] @@ -589,6 +588,11 @@ final class ChatModel: ObservableObject { } } +struct NTFContactRequest { + var incognito: Bool + var chatId: String +} + struct UnreadChatItemCounts { var totalBelow: Int var unreadBelow: Int diff --git a/apps/ios/Shared/Model/NtfManager.swift b/apps/ios/Shared/Model/NtfManager.swift index ad41703c91..c7a51a5f1a 100644 --- a/apps/ios/Shared/Model/NtfManager.swift +++ b/apps/ios/Shared/Model/NtfManager.swift @@ -12,6 +12,7 @@ import UIKit import SimpleXChat let ntfActionAcceptContact = "NTF_ACT_ACCEPT_CONTACT" +let ntfActionAcceptContactIncognito = "NTF_ACT_ACCEPT_CONTACT_INCOGNITO" let ntfActionAcceptCall = "NTF_ACT_ACCEPT_CALL" let ntfActionRejectCall = "NTF_ACT_REJECT_CALL" @@ -41,12 +42,13 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { userId != chatModel.currentUser?.userId { changeActiveUser(userId, viewPwd: nil) } - if content.categoryIdentifier == ntfCategoryContactRequest && action == ntfActionAcceptContact, + if content.categoryIdentifier == ntfCategoryContactRequest && (action == ntfActionAcceptContact || action == ntfActionAcceptContactIncognito), let chatId = content.userInfo["chatId"] as? String { + let incognito = action == ntfActionAcceptContactIncognito if case let .contactRequest(contactRequest) = chatModel.getChat(chatId)?.chatInfo { - Task { await acceptContactRequest(contactRequest) } + Task { await acceptContactRequest(incognito: incognito, contactRequest: contactRequest) } } else { - chatModel.ntfContactRequest = chatId + chatModel.ntfContactRequest = NTFContactRequest(incognito: incognito, chatId: chatId) } } else if let (chatId, ntfAction) = ntfCallAction(content, action) { if let invitation = chatModel.callInvitations.removeValue(forKey: chatId) { @@ -134,11 +136,17 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { UNUserNotificationCenter.current().setNotificationCategories([ UNNotificationCategory( identifier: ntfCategoryContactRequest, - actions: [UNNotificationAction( - identifier: ntfActionAcceptContact, - title: NSLocalizedString("Accept", comment: "accept contact request via notification"), - options: .foreground - )], + actions: [ + UNNotificationAction( + identifier: ntfActionAcceptContact, + title: NSLocalizedString("Accept", comment: "accept contact request via notification"), + options: .foreground + ), UNNotificationAction( + identifier: ntfActionAcceptContactIncognito, + title: NSLocalizedString("Accept incognito", comment: "accept contact request via notification"), + options: .foreground + ) + ], intentIdentifiers: [], hiddenPreviewsBodyPlaceholder: NSLocalizedString("New contact request", comment: "notification") ), diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index c99f176b78..62600b2825 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -252,12 +252,6 @@ func setXFTPConfig(_ cfg: XFTPFileConfig?) throws { throw r } -func apiSetIncognito(incognito: Bool) throws { - let r = chatSendCmdSync(.setIncognito(incognito: incognito)) - if case .cmdOk = r { return } - throw r -} - func apiExportArchive(config: ArchiveConfig) async throws { try await sendCommandOkResp(.apiExportArchive(config: config)) } @@ -564,19 +558,25 @@ func apiVerifyGroupMember(_ groupId: Int64, _ groupMemberId: Int64, connectionCo return nil } -func apiAddContact() async -> String? { +func apiAddContact(incognito: Bool) async -> (String, PendingContactConnection)? { guard let userId = ChatModel.shared.currentUser?.userId else { logger.error("apiAddContact: no current user") return nil } - let r = await chatSendCmd(.apiAddContact(userId: userId), bgTask: false) - if case let .invitation(_, connReqInvitation) = r { return connReqInvitation } + let r = await chatSendCmd(.apiAddContact(userId: userId, incognito: incognito), bgTask: false) + if case let .invitation(_, connReqInvitation, connection) = r { return (connReqInvitation, connection) } AlertManager.shared.showAlert(connectionErrorAlert(r)) return nil } -func apiConnect(connReq: String) async -> ConnReqType? { - let (connReqType, alert) = await apiConnect_(connReq: connReq) +func apiSetConnectionIncognito(connId: Int64, incognito: Bool) async throws -> PendingContactConnection? { + let r = await chatSendCmd(.apiSetConnectionIncognito(connId: connId, incognito: incognito)) + if case let .connectionIncognitoUpdated(_, toConnection) = r { return toConnection } + throw r +} + +func apiConnect(incognito: Bool, connReq: String) async -> ConnReqType? { + let (connReqType, alert) = await apiConnect_(incognito: incognito, connReq: connReq) if let alert = alert { AlertManager.shared.showAlert(alert) return nil @@ -585,12 +585,12 @@ func apiConnect(connReq: String) async -> ConnReqType? { } } -func apiConnect_(connReq: String) async -> (ConnReqType?, Alert?) { +func apiConnect_(incognito: Bool, connReq: String) async -> (ConnReqType?, Alert?) { guard let userId = ChatModel.shared.currentUser?.userId else { logger.error("apiConnect: no current user") return (nil, nil) } - let r = await chatSendCmd(.apiConnect(userId: userId, connReq: connReq)) + let r = await chatSendCmd(.apiConnect(userId: userId, incognito: incognito, connReq: connReq)) switch r { case .sentConfirmation: return (.invitation, nil) case .sentInvitation: return (.contact, nil) @@ -766,8 +766,8 @@ func userAddressAutoAccept(_ autoAccept: AutoAccept?) async throws -> UserContac } } -func apiAcceptContactRequest(contactReqId: Int64) async -> Contact? { - let r = await chatSendCmd(.apiAcceptContact(contactReqId: contactReqId)) +func apiAcceptContactRequest(incognito: Bool, contactReqId: Int64) async -> Contact? { + let r = await chatSendCmd(.apiAcceptContact(incognito: incognito, contactReqId: contactReqId)) let am = AlertManager.shared if case let .acceptingContactRequest(_, contact) = r { return contact } @@ -875,8 +875,8 @@ func networkErrorAlert(_ r: ChatResponse) -> Alert? { } } -func acceptContactRequest(_ contactRequest: UserContactRequest) async { - if let contact = await apiAcceptContactRequest(contactReqId: contactRequest.apiId) { +func acceptContactRequest(incognito: Bool, contactRequest: UserContactRequest) async { + if let contact = await apiAcceptContactRequest(incognito: incognito, contactReqId: contactRequest.apiId) { let chat = Chat(chatInfo: ChatInfo.direct(contact: contact), chatItems: []) DispatchQueue.main.async { ChatModel.shared.replaceChat(contactRequest.id, chat) } } @@ -1110,7 +1110,6 @@ func initializeChat(start: Bool, dbKey: String? = nil, refreshInvitations: Bool try apiSetTempFolder(tempFolder: getTempFilesDirectory().path) try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path) try setXFTPConfig(getXFTPCfg()) - try apiSetIncognito(incognito: incognitoGroupDefault.get()) m.chatInitialized = true m.currentUser = try apiGetActiveUser() if m.currentUser == nil { diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 5184808bd7..13e681ae25 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -139,10 +139,10 @@ struct SimpleXApp: App { let chat = chatModel.getChat(id) { loadChat(chat: chat) } - if let chatId = chatModel.ntfContactRequest { + if let ncr = chatModel.ntfContactRequest { chatModel.ntfContactRequest = nil - if case let .contactRequest(contactRequest) = chatModel.getChat(chatId)?.chatInfo { - Task { await acceptContactRequest(contactRequest) } + if case let .contactRequest(contactRequest) = chatModel.getChat(ncr.chatId)?.chatInfo { + Task { await acceptContactRequest(incognito: ncr.incognito, contactRequest: contactRequest) } } } } catch let error { diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index 5bbc64e165..3b0861feb1 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -143,7 +143,12 @@ struct ChatInfoView: View { if let customUserProfile = customUserProfile { Section("Incognito") { - infoRow("Your random profile", customUserProfile.chatViewName) + HStack { + Text("Your random profile") + Spacer() + Text(customUserProfile.chatViewName) + .foregroundStyle(.indigo) + } } } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 0f125ca8f0..75e81790a2 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -57,7 +57,7 @@ struct GroupChatInfoView: View { addOrEditWelcomeMessage() } groupPreferencesButton($groupInfo) - if members.filter { $0.memberCurrent }.count <= SMALL_GROUPS_RCPS_MEM_LIMIT { + if members.filter({ $0.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT { sendReceiptsOption() } else { sendReceiptsOptionDisabled() diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 47a2131d30..6842e93f05 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -19,6 +19,7 @@ struct GroupMemberInfoView: View { @State private var connectionCode: String? = nil @State private var newRole: GroupMemberRole = .member @State private var alert: GroupMemberInfoViewAlert? + @State private var connectToMemberDialog: Bool = false @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @State private var justOpened = true @@ -28,7 +29,6 @@ struct GroupMemberInfoView: View { case switchAddressAlert case abortSwitchAddressAlert case syncConnectionForceAlert - case connectViaMemberAddressAlert(contactLink: String) case connRequestSentAlert(type: ConnReqType) case error(title: LocalizedStringKey, error: LocalizedStringKey) case other(alert: Alert) @@ -40,7 +40,6 @@ struct GroupMemberInfoView: View { case .switchAddressAlert: return "switchAddressAlert" case .abortSwitchAddressAlert: return "abortSwitchAddressAlert" case .syncConnectionForceAlert: return "syncConnectionForceAlert" - case .connectViaMemberAddressAlert: return "connectViaMemberAddressAlert" case .connRequestSentAlert: return "connRequestSentAlert" case let .error(title, _): return "error \(title)" case let .other(alert): return "other \(alert)" @@ -144,7 +143,7 @@ struct GroupMemberInfoView: View { connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil } || connStats.ratchetSyncSendProhibited ) - if connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil } { + if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) { Button("Abort changing address") { alert = .abortSwitchAddressAlert } @@ -203,7 +202,6 @@ struct GroupMemberInfoView: View { case .switchAddressAlert: return switchAddressAlert(switchMemberAddress) case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchMemberAddress) case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncMemberConnection(force: true) }) - case let .connectViaMemberAddressAlert(contactLink): return connectViaMemberAddressAlert(contactLink) case let .connRequestSentAlert(type): return connReqSentAlert(type) case let .error(title, error): return Alert(title: Text(title), message: Text(error)) case let .other(alert): return alert @@ -213,26 +211,19 @@ struct GroupMemberInfoView: View { func connectViaAddressButton(_ contactLink: String) -> some View { Button { - alert = .connectViaMemberAddressAlert(contactLink: contactLink) + connectToMemberDialog = true } label: { Label("Connect", systemImage: "link") } + .confirmationDialog("Connect directly", isPresented: $connectToMemberDialog, titleVisibility: .visible) { + Button("Use current profile") { connectViaAddress(incognito: false, contactLink: contactLink) } + Button("Use new incognito profile") { connectViaAddress(incognito: true, contactLink: contactLink) } + } } - func connectViaMemberAddressAlert(_ contactLink: String) -> Alert { - return Alert( - title: Text("Connect directly?"), - message: Text("Сonnection request will be sent to this group member."), - primaryButton: .default(Text("Connect")) { - connectViaAddress(contactLink) - }, - secondaryButton: .cancel() - ) - } - - func connectViaAddress(_ contactLink: String) { + func connectViaAddress(incognito: Bool, contactLink: String) { Task { - let (connReqType, connectAlert) = await apiConnect_(connReq: contactLink) + let (connReqType, connectAlert) = await apiConnect_(incognito: incognito, connReq: contactLink) if let connReqType = connReqType { alert = .connRequestSentAlert(type: connReqType) } else if let connectAlert = connectAlert { diff --git a/apps/ios/Shared/Views/Chat/ScanCodeView.swift b/apps/ios/Shared/Views/Chat/ScanCodeView.swift index d5b6edf906..09861fa50b 100644 --- a/apps/ios/Shared/Views/Chat/ScanCodeView.swift +++ b/apps/ios/Shared/Views/Chat/ScanCodeView.swift @@ -19,7 +19,7 @@ struct ScanCodeView: View { VStack(alignment: .leading) { CodeScannerView(codeTypes: [.qr], completion: processQRCode) .aspectRatio(1, contentMode: .fit) - .border(.gray) + .cornerRadius(12) Text("Scan security code from your contact's app.") .padding(.top) } diff --git a/apps/ios/Shared/Views/Chat/VerifyCodeView.swift b/apps/ios/Shared/Views/Chat/VerifyCodeView.swift index 21269f8435..75e31c26ed 100644 --- a/apps/ios/Shared/Views/Chat/VerifyCodeView.swift +++ b/apps/ios/Shared/Views/Chat/VerifyCodeView.swift @@ -64,7 +64,7 @@ struct VerifyCodeView: View { HStack { NavigationLink { ScanCodeView(connectionVerified: $connectionVerified, verify: verify) - .navigationBarTitleDisplayMode(.inline) + .navigationBarTitleDisplayMode(.large) .navigationTitle("Scan code") } label: { Label("Scan code", systemImage: "qrcode") diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index 2521c919a1..025c765a72 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -222,9 +222,15 @@ struct ChatListNavLink: View { ContactRequestView(contactRequest: contactRequest, chat: chat) .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button { - Task { await acceptContactRequest(contactRequest) } - } label: { Label("Accept", systemImage: chatModel.incognito ? "theatermasks" : "checkmark") } - .tint(chatModel.incognito ? .indigo : .accentColor) + Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) } + } label: { Label("Accept", systemImage: "checkmark") } + .tint(.accentColor) + Button { + Task { await acceptContactRequest(incognito: true, contactRequest: contactRequest) } + } label: { + Label("Accept incognito", systemImage: "theatermasks") + } + .tint(.indigo) Button { AlertManager.shared.showAlert(rejectContactRequestAlert(contactRequest)) } label: { @@ -234,9 +240,10 @@ struct ChatListNavLink: View { } .frame(height: rowHeights[dynamicTypeSize]) .onTapGesture { showContactRequestDialog = true } - .confirmationDialog("Connection request", isPresented: $showContactRequestDialog, titleVisibility: .visible) { - Button(chatModel.incognito ? "Accept incognito" : "Accept contact") { Task { await acceptContactRequest(contactRequest) } } - Button("Reject contact (sender NOT notified)", role: .destructive) { Task { await rejectContactRequest(contactRequest) } } + .confirmationDialog("Accept connection request?", isPresented: $showContactRequestDialog, titleVisibility: .visible) { + Button("Accept") { Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) } } + Button("Accept incognito") { Task { await acceptContactRequest(incognito: true, contactRequest: contactRequest) } } + Button("Reject (sender NOT notified)", role: .destructive) { Task { await rejectContactRequest(contactRequest) } } } } diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 03dd241087..eb0a5cba68 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -60,8 +60,6 @@ struct ChatListView: View { chatList } } - .onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() } - .onAppear() { connectViaUrl() } .onDisappear() { withAnimation { userPickerVisible = false } } .refreshable { AlertManager.shared.showAlert(Alert( @@ -108,11 +106,6 @@ struct ChatListView: View { } ToolbarItem(placement: .principal) { HStack(spacing: 4) { - if (chatModel.incognito) { - Image(systemName: "theatermasks") - .foregroundColor(.indigo) - .padding(.trailing, 8) - } Text("Chats") .font(.headline) if chatModel.chats.count > 0 { diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index d6cd977b9e..4ddf75a1b3 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -41,11 +41,9 @@ struct ChatPreviewView: View { ZStack(alignment: .topTrailing) { chatMessagePreview(cItem) - if case .direct = chat.chatInfo { - chatStatusImage() - .padding(.top, 24) - .frame(maxWidth: .infinity, alignment: .trailing) - } + chatStatusImage() + .padding(.top, 26) + .frame(maxWidth: .infinity, alignment: .trailing) } .padding(.trailing, 8) @@ -59,12 +57,9 @@ struct ChatPreviewView: View { @ViewBuilder private func chatPreviewImageOverlayIcon() -> some View { if case let .group(groupInfo) = chat.chatInfo { switch (groupInfo.membership.memberStatus) { - case .memLeft: - groupInactiveIcon() - case .memRemoved: - groupInactiveIcon() - case .memGroupDeleted: - groupInactiveIcon() + case .memLeft: groupInactiveIcon() + case .memRemoved: groupInactiveIcon() + case .memGroupDeleted: groupInactiveIcon() default: EmptyView() } } else { @@ -74,7 +69,7 @@ struct ChatPreviewView: View { @ViewBuilder private func groupInactiveIcon() -> some View { Image(systemName: "multiply.circle.fill") - .foregroundColor(.secondary) + .foregroundColor(.secondary.opacity(0.65)) .background(Circle().foregroundColor(Color(uiColor: .systemBackground))) } @@ -198,10 +193,7 @@ struct ChatPreviewView: View { @ViewBuilder private func groupInvitationPreviewText(_ groupInfo: GroupInfo) -> some View { groupInfo.membership.memberIncognito ? chatPreviewInfoText("join as \(groupInfo.membership.memberProfile.displayName)") - : (chatModel.incognito - ? chatPreviewInfoText("join as \(chatModel.currentUser?.profile.displayName ?? "yourself")") - : chatPreviewInfoText("you are invited to group") - ) + : chatPreviewInfoText("you are invited to group") } @ViewBuilder private func chatPreviewInfoText(_ text: LocalizedStringKey) -> some View { @@ -229,7 +221,7 @@ struct ChatPreviewView: View { switch chat.chatInfo { case let .direct(contact): switch (chatModel.contactNetworkStatus(contact)) { - case .connected: EmptyView() + case .connected: incognitoIcon(chat.chatInfo.incognito) case .error: Image(systemName: "exclamationmark.circle") .resizable() @@ -240,11 +232,23 @@ struct ChatPreviewView: View { ProgressView() } default: - EmptyView() + incognitoIcon(chat.chatInfo.incognito) } } } +@ViewBuilder func incognitoIcon(_ incognito: Bool) -> some View { + if incognito { + Image(systemName: "theatermasks") + .resizable() + .scaledToFit() + .frame(width: 22, height: 22) + .foregroundColor(.secondary) + } else { + EmptyView() + } +} + func unreadCountText(_ n: Int) -> Text { Text(n > 999 ? "\(n / 1000)k" : n > 0 ? "\(n)" : "") } diff --git a/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift b/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift index 7d850719d6..3e42d2f207 100644 --- a/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift +++ b/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift @@ -15,6 +15,7 @@ struct ContactConnectionInfo: View { @State var contactConnection: PendingContactConnection @State private var alert: CCInfoAlert? @State private var localAlias = "" + @State private var showIncognitoSheet = false @FocusState private var aliasTextFieldFocused: Bool enum CCInfoAlert: Identifiable { @@ -31,19 +32,14 @@ struct ContactConnectionInfo: View { var body: some View { NavigationView { - List { + let v = List { Group { - Text(contactConnection.initiated ? "You invited your contact" : "You accepted connection") + Text(contactConnection.initiated ? "You invited a contact" : "You accepted connection") .font(.largeTitle) .bold() - .padding(.bottom, 16) + .padding(.bottom) Text(contactConnectionText(contactConnection)) - .padding(.bottom, 16) - - if let connReqInv = contactConnection.connReqInv { - OneTimeLinkProfileText(contactConnection: contactConnection, connReqInvitation: connReqInv) - } } .listRowBackground(Color.clear) .listRowSeparator(.hidden) @@ -65,10 +61,16 @@ struct ContactConnectionInfo: View { if contactConnection.initiated, let connReqInv = contactConnection.connReqInv { - oneTimeLinkSection(contactConnection: contactConnection, connReqInvitation: connReqInv) + QRCode(uri: connReqInv) + incognitoEnabled() + shareLinkButton(connReqInv) + oneTimeLinkLearnMoreButton() } else { + incognitoEnabled() oneTimeLinkLearnMoreButton() } + } footer: { + sharedProfileInfo(contactConnection.incognito) } Section { @@ -80,6 +82,14 @@ struct ContactConnectionInfo: View { } } } + if #available(iOS 16, *) { + v + } else { + // navigationBarHidden is added conditionally, + // because the view jumps in iOS 17 if this is added, + // and on iOS 16+ it is hidden without it. + v.navigationBarHidden(true) + } } .alert(item: $alert) { _alert in switch _alert { @@ -128,6 +138,30 @@ struct ContactConnectionInfo: View { ) : "You will be connected when your contact's device is online, please wait or check later!" } + + @ViewBuilder private func incognitoEnabled() -> some View { + if contactConnection.incognito { + ZStack(alignment: .leading) { + Image(systemName: "theatermasks.fill") + .frame(maxWidth: 24, maxHeight: 24, alignment: .center) + .foregroundColor(Color.indigo) + .font(.system(size: 14)) + HStack(spacing: 6) { + Text("Incognito") + Image(systemName: "info.circle") + .foregroundColor(.accentColor) + .font(.system(size: 14)) + } + .onTapGesture { + showIncognitoSheet = true + } + .padding(.leading, 36) + } + .sheet(isPresented: $showIncognitoSheet) { + IncognitoHelp() + } + } + } } struct ContactConnectionInfo_Previews: PreviewProvider { diff --git a/apps/ios/Shared/Views/ChatList/ContactConnectionView.swift b/apps/ios/Shared/Views/ChatList/ContactConnectionView.swift index 1cd50c606d..d21f347881 100644 --- a/apps/ios/Shared/Views/ChatList/ContactConnectionView.swift +++ b/apps/ios/Shared/Views/ChatList/ContactConnectionView.swift @@ -58,10 +58,14 @@ struct ContactConnectionView: View { } .padding(.bottom, 2) - Text(contactConnection.description) - .frame(alignment: .topLeading) - .padding(.horizontal, 8) - .padding(.bottom, 2) + ZStack(alignment: .topTrailing) { + Text(contactConnection.description) + .frame(maxWidth: .infinity, alignment: .leading) + incognitoIcon(contactConnection.incognito) + .padding(.top, 26) + .frame(maxWidth: .infinity, alignment: .trailing) + } + .padding(.horizontal, 8) Spacer() } diff --git a/apps/ios/Shared/Views/ChatList/ContactRequestView.swift b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift index c40f672877..c5c062a6ec 100644 --- a/apps/ios/Shared/Views/ChatList/ContactRequestView.swift +++ b/apps/ios/Shared/Views/ChatList/ContactRequestView.swift @@ -24,7 +24,7 @@ struct ContactRequestView: View { Text(contactRequest.chatViewName) .font(.title3) .fontWeight(.bold) - .foregroundColor(chatModel.incognito ? .indigo : .accentColor) + .foregroundColor(.accentColor) .padding(.leading, 8) .frame(alignment: .topLeading) Spacer() diff --git a/apps/ios/Shared/Views/NewChat/AddContactView.swift b/apps/ios/Shared/Views/NewChat/AddContactView.swift index 44de3c4987..31b6b64f32 100644 --- a/apps/ios/Shared/Views/NewChat/AddContactView.swift +++ b/apps/ios/Shared/Views/NewChat/AddContactView.swift @@ -12,38 +12,92 @@ import SimpleXChat struct AddContactView: View { @EnvironmentObject private var chatModel: ChatModel - var contactConnection: PendingContactConnection? = nil + @Binding var contactConnection: PendingContactConnection? var connReqInvitation: String + @AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false var body: some View { - List { - OneTimeLinkProfileText(contactConnection: contactConnection, connReqInvitation: connReqInvitation) - .listRowBackground(Color.clear) - .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) - - Section("1-time link") { - oneTimeLinkSection(contactConnection: contactConnection, connReqInvitation: connReqInvitation) + VStack { + List { + Section { + if connReqInvitation != "" { + QRCode(uri: connReqInvitation) + } else { + ProgressView() + .progressViewStyle(.circular) + .scaleEffect(2) + .frame(maxWidth: .infinity) + .padding(.vertical) + } + IncognitoToggle(incognitoEnabled: $incognitoDefault) + .disabled(contactConnection == nil) + shareLinkButton(connReqInvitation) + oneTimeLinkLearnMoreButton() + } header: { + Text("1-time link") + } footer: { + sharedProfileInfo(incognitoDefault) + } } } .onAppear { chatModel.connReqInv = connReqInvitation } + .onChange(of: incognitoDefault) { incognito in + Task { + do { + if let contactConn = contactConnection, + let conn = try await apiSetConnectionIncognito(connId: contactConn.pccConnId, incognito: incognito) { + await MainActor.run { + contactConnection = conn + ChatModel.shared.updateContactConnection(conn) + } + } + } catch { + logger.error("apiSetConnectionIncognito error: \(responseError(error))") + } + } + } } } -@ViewBuilder func oneTimeLinkSection(contactConnection: PendingContactConnection? = nil, connReqInvitation: String) -> some View { - if connReqInvitation != "" { - QRCode(uri: connReqInvitation) - } else { - ProgressView() - .progressViewStyle(.circular) - .scaleEffect(2) - .frame(maxWidth: .infinity) - .padding(.vertical) +struct IncognitoToggle: View { + @Binding var incognitoEnabled: Bool + @State private var showIncognitoSheet = false + + var body: some View { + ZStack(alignment: .leading) { + Image(systemName: incognitoEnabled ? "theatermasks.fill" : "theatermasks") + .frame(maxWidth: 24, maxHeight: 24, alignment: .center) + .foregroundColor(incognitoEnabled ? Color.indigo : .secondary) + .font(.system(size: 14)) + Toggle(isOn: $incognitoEnabled) { + HStack(spacing: 6) { + Text("Incognito") + Image(systemName: "info.circle") + .foregroundColor(.accentColor) + .font(.system(size: 14)) + } + .onTapGesture { + showIncognitoSheet = true + } + } + .padding(.leading, 36) + } + .sheet(isPresented: $showIncognitoSheet) { + IncognitoHelp() + } } - shareLinkButton(connReqInvitation) - oneTimeLinkLearnMoreButton() } -private func shareLinkButton(_ connReqInvitation: String) -> some View { +func sharedProfileInfo(_ 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 shareLinkButton(_ connReqInvitation: String) -> some View { Button { showShareSheet(items: [connReqInvitation]) } label: { @@ -65,26 +119,11 @@ func oneTimeLinkLearnMoreButton() -> some View { } } -struct OneTimeLinkProfileText: View { - @EnvironmentObject private var chatModel: ChatModel - var contactConnection: PendingContactConnection? = nil - var connReqInvitation: String - - var body: some View { - HStack { - if (contactConnection?.incognito ?? chatModel.incognito) { - Image(systemName: "theatermasks").foregroundColor(.indigo) - Text("A random profile will be sent to your contact") - } else { - Image(systemName: "info.circle").foregroundColor(.secondary) - Text("Your chat profile will be sent to your contact") - } - } - } -} - struct AddContactView_Previews: PreviewProvider { static var previews: some View { - AddContactView(connReqInvitation: "https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FFe5ICmvrm4wkrr6X1LTMii-lhBqLeB76%23MCowBQYDK2VuAyEAdhZZsHpuaAk3Hh1q0uNb_6hGTpuwBIrsp2z9U2T0oC0%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAcz6jJk71InuxA0bOX7OUhddfB8Ov7xwQIlIDeXBRZaOntUU4brU5Y3rBzroZBdQJi0FKdtt_D7I%3D%2CMEIwBQYDK2VvAzkA-hDvk1duBi1hlOr08VWSI-Ou4JNNSQjseY69QyKm7Kgg1zZjbpGfyBqSZ2eqys6xtoV4ZtoQUXQ%3D") + AddContactView( + contactConnection: Binding.constant(PendingContactConnection.getSampleData()), + connReqInvitation: "https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FFe5ICmvrm4wkrr6X1LTMii-lhBqLeB76%23MCowBQYDK2VuAyEAdhZZsHpuaAk3Hh1q0uNb_6hGTpuwBIrsp2z9U2T0oC0%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAcz6jJk71InuxA0bOX7OUhddfB8Ov7xwQIlIDeXBRZaOntUU4brU5Y3rBzroZBdQJi0FKdtt_D7I%3D%2CMEIwBQYDK2VvAzkA-hDvk1duBi1hlOr08VWSI-Ou4JNNSQjseY69QyKm7Kgg1zZjbpGfyBqSZ2eqys6xtoV4ZtoQUXQ%3D" + ) } } diff --git a/apps/ios/Shared/Views/NewChat/AddGroupView.swift b/apps/ios/Shared/Views/NewChat/AddGroupView.swift index 247b91a04a..8df37bb560 100644 --- a/apps/ios/Shared/Views/NewChat/AddGroupView.swift +++ b/apps/ios/Shared/Views/NewChat/AddGroupView.swift @@ -47,21 +47,13 @@ struct AddGroupView: View { .padding(.vertical, 4) Text("The group is fully decentralized – it is visible only to the members.") .padding(.bottom, 4) - if (m.incognito) { - HStack { - Image(systemName: "info.circle").foregroundColor(.orange).font(.footnote) - Spacer().frame(width: 8) - Text("Incognito mode is not supported here - your main profile will be sent to group members").font(.footnote) - } - .padding(.bottom) - } else { - 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) + + 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) { diff --git a/apps/ios/Shared/Views/NewChat/CreateLinkView.swift b/apps/ios/Shared/Views/NewChat/CreateLinkView.swift index 71daf88a8c..0b9cfe7a17 100644 --- a/apps/ios/Shared/Views/NewChat/CreateLinkView.swift +++ b/apps/ios/Shared/Views/NewChat/CreateLinkView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import SimpleXChat enum CreateLinkTab { case oneTime @@ -24,6 +25,7 @@ struct CreateLinkView: View { @EnvironmentObject var m: ChatModel @State var selection: CreateLinkTab @State var connReqInvitation: String = "" + @State var contactConnection: PendingContactConnection? = nil @State private var creatingConnReq = false var viaNavLink = false @@ -39,7 +41,7 @@ struct CreateLinkView: View { private func createLinkView() -> some View { TabView(selection: $selection) { - AddContactView(connReqInvitation: connReqInvitation) + AddContactView(contactConnection: $contactConnection, connReqInvitation: connReqInvitation) .tabItem { Label( connReqInvitation == "" @@ -56,7 +58,7 @@ struct CreateLinkView: View { .tag(CreateLinkTab.longTerm) } .onChange(of: selection) { _ in - if case .oneTime = selection, connReqInvitation == "" && !creatingConnReq { + if case .oneTime = selection, connReqInvitation == "", contactConnection == nil && !creatingConnReq { createInvitation() } } @@ -69,12 +71,14 @@ struct CreateLinkView: View { private func createInvitation() { creatingConnReq = true Task { - let connReq = await apiAddContact() - await MainActor.run { - if let connReq = connReq { + if let (connReq, pcc) = await apiAddContact(incognito: incognitoGroupDefault.get()) { + await MainActor.run { connReqInvitation = connReq + contactConnection = pcc m.connReqInv = connReq - } else { + } + } else { + await MainActor.run { creatingConnReq = false } } diff --git a/apps/ios/Shared/Views/NewChat/NewChatButton.swift b/apps/ios/Shared/Views/NewChat/NewChatButton.swift index 3486ab6ef8..a727ad6be0 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatButton.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatButton.swift @@ -10,13 +10,13 @@ import SwiftUI import SimpleXChat enum NewChatAction: Identifiable { - case createLink(link: String) + case createLink(link: String, connection: PendingContactConnection) case connectViaLink case createGroup var id: String { switch self { - case let .createLink(link): return "createLink \(link)" + case let .createLink(link, _): return "createLink \(link)" case .connectViaLink: return "connectViaLink" case .createGroup: return "createGroup" } @@ -41,8 +41,8 @@ struct NewChatButton: View { } .sheet(item: $actionSheet) { sheet in switch sheet { - case let .createLink(link): - CreateLinkView(selection: .oneTime, connReqInvitation: link) + case let .createLink(link, pcc): + CreateLinkView(selection: .oneTime, connReqInvitation: link, contactConnection: pcc) case .connectViaLink: ConnectViaLinkView() case .createGroup: AddGroupView() } @@ -51,8 +51,8 @@ struct NewChatButton: View { func addContactAction() { Task { - if let connReq = await apiAddContact() { - actionSheet = .createLink(link: connReq) + if let (connReq, pcc) = await apiAddContact(incognito: incognitoGroupDefault.get()) { + actionSheet = .createLink(link: connReq, connection: pcc) } } } @@ -63,9 +63,9 @@ enum ConnReqType: Equatable { case invitation } -func connectViaLink(_ connectionLink: String, _ dismiss: DismissAction? = nil) { +func connectViaLink(_ connectionLink: String, dismiss: DismissAction? = nil, incognito: Bool) { Task { - if let connReqType = await apiConnect(connReq: connectionLink) { + if let connReqType = await apiConnect(incognito: incognito, connReq: connectionLink) { DispatchQueue.main.async { dismiss?() AlertManager.shared.showAlert(connReqSentAlert(connReqType)) @@ -100,12 +100,12 @@ func checkCRDataGroup(_ crData: CReqClientData) -> Bool { return crData.type == "group" && crData.groupLinkId != nil } -func groupLinkAlert(_ connectionLink: String) -> Alert { +func groupLinkAlert(_ connectionLink: String, incognito: Bool) -> Alert { return Alert( title: Text("Connect via group link?"), message: Text("You will join a group this link refers to and connect to its group members."), - primaryButton: .default(Text("Connect")) { - connectViaLink(connectionLink) + primaryButton: .default(Text(incognito ? "Connect incognito" : "Connect")) { + connectViaLink(connectionLink, incognito: incognito) }, secondaryButton: .cancel() ) diff --git a/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift b/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift index de390ebad9..3894092e32 100644 --- a/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift +++ b/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift @@ -7,76 +7,77 @@ // import SwiftUI +import SimpleXChat struct PasteToConnectView: View { - @EnvironmentObject var chatModel: ChatModel @Environment(\.dismiss) var dismiss: DismissAction @State private var connectionLink: String = "" + @AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false + @FocusState private var linkEditorFocused: Bool var body: some View { - ScrollView { - VStack(alignment: .leading) { - Text("Connect via link") - .font(.largeTitle) - .bold() - .fixedSize(horizontal: false, vertical: true) - .padding(.vertical) - Text("Paste the link you received into the box below to connect with your contact.") - .padding(.bottom, 4) - if (chatModel.incognito) { - HStack { - Image(systemName: "theatermasks").foregroundColor(.indigo).font(.footnote) - Spacer().frame(width: 8) - Text("A random profile will be sent to the contact that you received this link from").font(.footnote) + List { + Text("Connect via link") + .font(.largeTitle) + .bold() + .fixedSize(horizontal: false, vertical: true) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + .onTapGesture { linkEditorFocused = false } + + Section { + linkEditor() + + Button { + if connectionLink == "" { + connectionLink = UIPasteboard.general.string ?? "" + } else { + connectionLink = "" } - .padding(.bottom) - } else { - HStack { - Image(systemName: "info.circle").foregroundColor(.secondary).font(.footnote) - Spacer().frame(width: 8) - Text("Your profile will be sent to the contact that you received this link from").font(.footnote) + } label: { + if connectionLink == "" { + settingsRow("doc.plaintext") { Text("Paste") } + } else { + settingsRow("multiply") { Text("Clear") } } - .padding(.bottom) + } + + Button { + connect() + } label: { + settingsRow("link") { Text("Connect") } + } + .disabled(connectionLink == "" || connectionLink.trimmingCharacters(in: .whitespaces).firstIndex(of: " ") != nil) + + IncognitoToggle(incognitoEnabled: $incognitoDefault) + } footer: { + sharedProfileInfo(incognitoDefault) + + Text("\n\n") + + Text("You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button.") + } + } + } + + private func linkEditor() -> some View { + ZStack { + Group { + if connectionLink.isEmpty { + TextEditor(text: Binding.constant(NSLocalizedString("Paste the link you received to connect with your contact…", comment: "placeholder"))) + .foregroundColor(.secondary) + .disabled(true) } TextEditor(text: $connectionLink) .onSubmit(connect) .textInputAutocapitalization(.never) .disableAutocorrection(true) - .allowsTightening(false) - .frame(height: 180) - .overlay( - RoundedRectangle(cornerRadius: 10) - .strokeBorder(.secondary, lineWidth: 0.3, antialiased: true) - ) - - HStack(spacing: 20) { - if connectionLink == "" { - Button { - connectionLink = UIPasteboard.general.string ?? "" - } label: { - Label("Paste", systemImage: "doc.plaintext") - } - } else { - Button { - connectionLink = "" - } label: { - Label("Clear", systemImage: "multiply") - } - - } - Spacer() - Button(action: connect, label: { - Label("Connect", systemImage: "link") - }) - .disabled(connectionLink == "" || connectionLink.trimmingCharacters(in: .whitespaces).firstIndex(of: " ") != nil) - } - .frame(height: 48) - .padding(.bottom) - - Text("You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button.") + .focused($linkEditorFocused) } - .padding() - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .allowsTightening(false) + .padding(.horizontal, -5) + .padding(.top, -8) + .frame(height: 180, alignment: .topLeading) + .frame(maxWidth: .infinity, alignment: .leading) } } @@ -85,9 +86,9 @@ struct PasteToConnectView: View { if let crData = parseLinkQueryData(link), checkCRDataGroup(crData) { dismiss() - AlertManager.shared.showAlert(groupLinkAlert(link)) + AlertManager.shared.showAlert(groupLinkAlert(link, incognito: incognitoDefault)) } else { - connectViaLink(link, dismiss) + connectViaLink(link, dismiss: dismiss, incognito: incognitoDefault) } } } diff --git a/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift b/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift index 2213dff203..cff94ef2aa 100644 --- a/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift +++ b/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift @@ -7,11 +7,12 @@ // import SwiftUI +import SimpleXChat import CodeScanner struct ScanToConnectView: View { - @EnvironmentObject var chatModel: ChatModel @Environment(\.dismiss) var dismiss: DismissAction + @AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false var body: some View { ScrollView { @@ -19,34 +20,35 @@ struct ScanToConnectView: View { Text("Scan QR code") .font(.largeTitle) .bold() + .fixedSize(horizontal: false, vertical: true) .padding(.vertical) - if (chatModel.incognito) { - HStack { - Image(systemName: "theatermasks").foregroundColor(.indigo).font(.footnote) - Spacer().frame(width: 8) - Text("A random profile will be sent to your contact").font(.footnote) - } - .padding(.bottom) - } else { - HStack { - Image(systemName: "info.circle").foregroundColor(.secondary).font(.footnote) - Spacer().frame(width: 8) - Text("Your chat profile will be sent to your contact").font(.footnote) - } - .padding(.bottom) + + CodeScannerView(codeTypes: [.qr], completion: processQRCode) + .aspectRatio(1, contentMode: .fit) + .cornerRadius(12) + + IncognitoToggle(incognitoEnabled: $incognitoDefault) + .padding(.horizontal) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(uiColor: .systemBackground)) + ) + .padding(.top) + + Group { + sharedProfileInfo(incognitoDefault) + + Text("\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.") } - ZStack { - CodeScannerView(codeTypes: [.qr], completion: processQRCode) - .aspectRatio(1, contentMode: .fit) - .border(.gray) - } - .padding(.bottom) - Text("If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link.") - .padding(.bottom) + .font(.footnote) + .foregroundColor(.secondary) + .padding(.horizontal) } .padding() .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } + .background(Color(.systemGroupedBackground)) } func processQRCode(_ resp: Result) { @@ -55,9 +57,9 @@ struct ScanToConnectView: View { if let crData = parseLinkQueryData(r.string), checkCRDataGroup(crData) { dismiss() - AlertManager.shared.showAlert(groupLinkAlert(r.string)) + AlertManager.shared.showAlert(groupLinkAlert(r.string, incognito: incognitoDefault)) } else { - Task { connectViaLink(r.string, dismiss) } + Task { connectViaLink(r.string, dismiss: dismiss, incognito: incognitoDefault) } } case let .failure(e): logger.error("ConnectContactView.processQRCode QR code error: \(e.localizedDescription)") diff --git a/apps/ios/Shared/Views/UserSettings/IncognitoHelp.swift b/apps/ios/Shared/Views/UserSettings/IncognitoHelp.swift index 3652dec054..20dadb7954 100644 --- a/apps/ios/Shared/Views/UserSettings/IncognitoHelp.swift +++ b/apps/ios/Shared/Views/UserSettings/IncognitoHelp.swift @@ -18,10 +18,9 @@ struct IncognitoHelp: View { ScrollView { VStack(alignment: .leading) { Group { - Text("Incognito mode protects the privacy of your main profile name and image — for each new contact a new random profile is created.") + Text("Incognito mode protects your privacy by using a new random profile for each contact.") Text("It allows having many anonymous connections without any shared data between them in a single chat profile.") Text("When you share an incognito profile with somebody, this profile will be used for the groups they invite you to.") - Text("To find the profile used for an incognito connection, tap the contact or group name on top of the chat.") } .padding(.bottom) } diff --git a/apps/ios/Shared/Views/UserSettings/ScanProtocolServer.swift b/apps/ios/Shared/Views/UserSettings/ScanProtocolServer.swift index c0ad4e3e18..ffdbd1b07e 100644 --- a/apps/ios/Shared/Views/UserSettings/ScanProtocolServer.swift +++ b/apps/ios/Shared/Views/UserSettings/ScanProtocolServer.swift @@ -21,11 +21,10 @@ struct ScanProtocolServer: View { .font(.largeTitle) .bold() .padding(.vertical) - ZStack { - CodeScannerView(codeTypes: [.qr], completion: processQRCode) - .aspectRatio(1, contentMode: .fit) - .border(.gray) - } + CodeScannerView(codeTypes: [.qr], completion: processQRCode) + .aspectRatio(1, contentMode: .fit) + .cornerRadius(12) + .padding(.top) } .padding() .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 7ca18692a8..72cfaaac48 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -131,7 +131,6 @@ struct SettingsView: View { @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var sceneDelegate: SceneDelegate @Binding var showSettings: Bool - @State private var settingsSheet: SettingsSheet? var body: some View { ZStack { @@ -161,8 +160,6 @@ struct SettingsView: View { settingsRow("person.crop.rectangle.stack") { Text("Your chat profiles") } } - incognitoRow() - NavigationLink { UserAddressView(shareViaProfile: chatModel.currentUser!.addressShared) .navigationTitle("SimpleX address") @@ -298,39 +295,6 @@ struct SettingsView: View { } .navigationTitle("Your settings") } - .sheet(item: $settingsSheet) { sheet in - switch sheet { - case .incognitoInfo: IncognitoHelp() - } - } - } - - @ViewBuilder private func incognitoRow() -> some View { - ZStack(alignment: .leading) { - Image(systemName: chatModel.incognito ? "theatermasks.fill" : "theatermasks") - .frame(maxWidth: 24, maxHeight: 24, alignment: .center) - .foregroundColor(chatModel.incognito ? Color.indigo : .secondary) - Toggle(isOn: $chatModel.incognito) { - HStack(spacing: 6) { - Text("Incognito") - Image(systemName: "info.circle") - .foregroundColor(.accentColor) - .font(.system(size: 14)) - } - .onTapGesture { - settingsSheet = .incognitoInfo - } - } - .onChange(of: chatModel.incognito) { incognito in - incognitoGroupDefault.set(incognito) - do { - try apiSetIncognito(incognito: incognito) - } catch { - logger.error("apiSetIncognito: cannot set incognito \(responseError(error))") - } - } - .padding(.leading, indent) - } } private func chatDatabaseRow() -> some View { @@ -351,12 +315,6 @@ struct SettingsView: View { } } - private enum SettingsSheet: Identifiable { - case incognitoInfo - - var id: SettingsSheet { get { self } } - } - private enum NotificationAlert { case enable case error(LocalizedStringKey, String) diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index 43ba3ab323..95050cae83 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -219,7 +219,6 @@ func startChat() -> DBMigrationResult? { let justStarted = try apiStartChat() chatStarted = true if justStarted { - try apiSetIncognito(incognito: incognitoGroupDefault.get()) chatLastStartGroupDefault.set(Date.now) Task { await receiveMessages() } } @@ -352,12 +351,6 @@ func setXFTPConfig(_ cfg: XFTPFileConfig?) throws { throw r } -func apiSetIncognito(incognito: Bool) throws { - let r = sendSimpleXCmd(.setIncognito(incognito: incognito)) - if case .cmdOk = r { return } - throw r -} - func apiGetNtfMessage(nonce: String, encNtfInfo: String) -> NtfMessages? { guard apiGetActiveUser() != nil else { logger.debug("no active user") diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index e789420d07..9af88bd9f9 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -24,6 +24,11 @@ 5C00168128C4FE760094D739 /* KeyChain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C00168028C4FE760094D739 /* KeyChain.swift */; }; 5C029EA82837DBB3004A9677 /* CICallItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C029EA72837DBB3004A9677 /* CICallItemView.swift */; }; 5C029EAA283942EA004A9677 /* CallController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C029EA9283942EA004A9677 /* CallController.swift */; }; + 5C0403922A7EAA41006ACFE8 /* libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C04038D2A7EAA41006ACFE8 /* libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5-ghc8.10.7.a */; }; + 5C0403932A7EAA41006ACFE8 /* libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C04038E2A7EAA41006ACFE8 /* libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5.a */; }; + 5C0403942A7EAA41006ACFE8 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C04038F2A7EAA41006ACFE8 /* libffi.a */; }; + 5C0403952A7EAA41006ACFE8 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0403902A7EAA41006ACFE8 /* libgmp.a */; }; + 5C0403962A7EAA41006ACFE8 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0403912A7EAA41006ACFE8 /* libgmpxx.a */; }; 5C05DF532840AA1D00C683F9 /* CallSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C05DF522840AA1D00C683F9 /* CallSettings.swift */; }; 5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */; }; 5C10D88828EED12E00E58BF0 /* ContactConnectionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C10D88728EED12E00E58BF0 /* ContactConnectionInfo.swift */; }; @@ -137,11 +142,6 @@ 5CE2BA97284537A800EC33A6 /* dummy.m in Sources */ = {isa = PBXBuildFile; fileRef = 5CE2BA96284537A800EC33A6 /* dummy.m */; }; 5CE2BA9D284555F500EC33A6 /* SimpleX NSE.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 5CDCAD452818589900503DA2 /* SimpleX NSE.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 5CE2BAA62845617C00EC33A6 /* SimpleXChat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; platformFilter = ios; }; - 5CE41C6C2A780D9D00FBE3A4 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE41C672A780D9D00FBE3A4 /* libffi.a */; }; - 5CE41C6D2A780D9D00FBE3A4 /* libHSsimplex-chat-5.3.0.1-7tz186yL9rbJf5HgVWwp8l-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE41C682A780D9D00FBE3A4 /* libHSsimplex-chat-5.3.0.1-7tz186yL9rbJf5HgVWwp8l-ghc8.10.7.a */; }; - 5CE41C6E2A780D9D00FBE3A4 /* libHSsimplex-chat-5.3.0.1-7tz186yL9rbJf5HgVWwp8l.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE41C692A780D9D00FBE3A4 /* libHSsimplex-chat-5.3.0.1-7tz186yL9rbJf5HgVWwp8l.a */; }; - 5CE41C6F2A780D9D00FBE3A4 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE41C6A2A780D9D00FBE3A4 /* libgmp.a */; }; - 5CE41C702A780D9D00FBE3A4 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE41C6B2A780D9D00FBE3A4 /* libgmpxx.a */; }; 5CE4407227ADB1D0007B033A /* Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407127ADB1D0007B033A /* Emoji.swift */; }; 5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407827ADB701007B033A /* EmojiItemView.swift */; }; 5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCE227DE9246000BD591 /* ComposeView.swift */; }; @@ -263,6 +263,11 @@ 5C00168028C4FE760094D739 /* KeyChain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyChain.swift; sourceTree = ""; }; 5C029EA72837DBB3004A9677 /* CICallItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CICallItemView.swift; sourceTree = ""; }; 5C029EA9283942EA004A9677 /* CallController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallController.swift; sourceTree = ""; }; + 5C04038D2A7EAA41006ACFE8 /* libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5-ghc8.10.7.a"; sourceTree = ""; }; + 5C04038E2A7EAA41006ACFE8 /* libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5.a"; sourceTree = ""; }; + 5C04038F2A7EAA41006ACFE8 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5C0403902A7EAA41006ACFE8 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5C0403912A7EAA41006ACFE8 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 5C05DF522840AA1D00C683F9 /* CallSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallSettings.swift; sourceTree = ""; }; 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatPreviewView.swift; sourceTree = ""; }; 5C10D88728EED12E00E58BF0 /* ContactConnectionInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactConnectionInfo.swift; sourceTree = ""; }; @@ -415,11 +420,6 @@ 5CE2BA78284530CC00EC33A6 /* SimpleXChat.docc */ = {isa = PBXFileReference; lastKnownFileType = folder.documentationcatalog; path = SimpleXChat.docc; sourceTree = ""; }; 5CE2BA8A2845332200EC33A6 /* SimpleX.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SimpleX.h; sourceTree = ""; }; 5CE2BA96284537A800EC33A6 /* dummy.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = dummy.m; sourceTree = ""; }; - 5CE41C672A780D9D00FBE3A4 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5CE41C682A780D9D00FBE3A4 /* libHSsimplex-chat-5.3.0.1-7tz186yL9rbJf5HgVWwp8l-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.3.0.1-7tz186yL9rbJf5HgVWwp8l-ghc8.10.7.a"; sourceTree = ""; }; - 5CE41C692A780D9D00FBE3A4 /* libHSsimplex-chat-5.3.0.1-7tz186yL9rbJf5HgVWwp8l.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.3.0.1-7tz186yL9rbJf5HgVWwp8l.a"; sourceTree = ""; }; - 5CE41C6A2A780D9D00FBE3A4 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5CE41C6B2A780D9D00FBE3A4 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 5CE4407127ADB1D0007B033A /* Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emoji.swift; sourceTree = ""; }; 5CE4407827ADB701007B033A /* EmojiItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiItemView.swift; sourceTree = ""; }; 5CEACCE227DE9246000BD591 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = ""; }; @@ -501,13 +501,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5CE41C6E2A780D9D00FBE3A4 /* libHSsimplex-chat-5.3.0.1-7tz186yL9rbJf5HgVWwp8l.a in Frameworks */, + 5C0403932A7EAA41006ACFE8 /* libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5.a in Frameworks */, + 5C0403922A7EAA41006ACFE8 /* libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5-ghc8.10.7.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, - 5CE41C6C2A780D9D00FBE3A4 /* libffi.a in Frameworks */, - 5CE41C6D2A780D9D00FBE3A4 /* libHSsimplex-chat-5.3.0.1-7tz186yL9rbJf5HgVWwp8l-ghc8.10.7.a in Frameworks */, - 5CE41C6F2A780D9D00FBE3A4 /* libgmp.a in Frameworks */, + 5C0403942A7EAA41006ACFE8 /* libffi.a in Frameworks */, + 5C0403952A7EAA41006ACFE8 /* libgmp.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, - 5CE41C702A780D9D00FBE3A4 /* libgmpxx.a in Frameworks */, + 5C0403962A7EAA41006ACFE8 /* libgmpxx.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -568,11 +568,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5CE41C672A780D9D00FBE3A4 /* libffi.a */, - 5CE41C6A2A780D9D00FBE3A4 /* libgmp.a */, - 5CE41C6B2A780D9D00FBE3A4 /* libgmpxx.a */, - 5CE41C682A780D9D00FBE3A4 /* libHSsimplex-chat-5.3.0.1-7tz186yL9rbJf5HgVWwp8l-ghc8.10.7.a */, - 5CE41C692A780D9D00FBE3A4 /* libHSsimplex-chat-5.3.0.1-7tz186yL9rbJf5HgVWwp8l.a */, + 5C04038F2A7EAA41006ACFE8 /* libffi.a */, + 5C0403902A7EAA41006ACFE8 /* libgmp.a */, + 5C0403912A7EAA41006ACFE8 /* libgmpxx.a */, + 5C04038D2A7EAA41006ACFE8 /* libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5-ghc8.10.7.a */, + 5C04038E2A7EAA41006ACFE8 /* libHSsimplex-chat-5.3.0.2-57EsBXX08D1H5qwhz1zMA5.a */, ); path = Libraries; sourceTree = ""; @@ -1478,7 +1478,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 163; + CURRENT_PROJECT_VERSION = 164; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1520,7 +1520,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 163; + CURRENT_PROJECT_VERSION = 164; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1600,7 +1600,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 163; + CURRENT_PROJECT_VERSION = 164; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1632,7 +1632,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 163; + CURRENT_PROJECT_VERSION = 164; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1664,7 +1664,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 163; + CURRENT_PROJECT_VERSION = 164; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -1688,7 +1688,7 @@ "$(inherited)", "$(PROJECT_DIR)/Libraries/sim", ); - MARKETING_VERSION = 5.2; + MARKETING_VERSION = 5.2.2; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -1710,7 +1710,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 163; + CURRENT_PROJECT_VERSION = 164; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -1734,7 +1734,7 @@ "$(inherited)", "$(PROJECT_DIR)/Libraries/sim", ); - MARKETING_VERSION = 5.2; + MARKETING_VERSION = 5.2.2; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index a33f6496d7..3a64a0bc7c 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -32,7 +32,6 @@ public enum ChatCommand { case setTempFolder(tempFolder: String) case setFilesFolder(filesFolder: String) case apiSetXFTPConfig(config: XFTPFileConfig?) - case setIncognito(incognito: Bool) case apiExportArchive(config: ArchiveConfig) case apiImportArchive(config: ArchiveConfig) case apiDeleteStorage @@ -83,8 +82,9 @@ public enum ChatCommand { case apiGetGroupMemberCode(groupId: Int64, groupMemberId: Int64) case apiVerifyContact(contactId: Int64, connectionCode: String?) case apiVerifyGroupMember(groupId: Int64, groupMemberId: Int64, connectionCode: String?) - case apiAddContact(userId: Int64) - case apiConnect(userId: Int64, connReq: String) + case apiAddContact(userId: Int64, incognito: Bool) + case apiSetConnectionIncognito(connId: Int64, incognito: Bool) + case apiConnect(userId: Int64, incognito: Bool, connReq: String) case apiDeleteChat(type: ChatType, id: Int64) case apiClearChat(type: ChatType, id: Int64) case apiListContacts(userId: Int64) @@ -97,7 +97,7 @@ public enum ChatCommand { case apiShowMyAddress(userId: Int64) case apiSetProfileAddress(userId: Int64, on: Bool) case apiAddressAutoAccept(userId: Int64, autoAccept: AutoAccept?) - case apiAcceptContact(contactReqId: Int64) + case apiAcceptContact(incognito: Bool, contactReqId: Int64) case apiRejectContact(contactReqId: Int64) // WebRTC calls case apiSendCallInvitation(contact: Contact, callType: CallType) @@ -148,7 +148,6 @@ public enum ChatCommand { } else { return "/_xftp off" } - case let .setIncognito(incognito): return "/incognito \(onOff(incognito))" case let .apiExportArchive(cfg): return "/_db export \(encodeJSON(cfg))" case let .apiImportArchive(cfg): return "/_db import \(encodeJSON(cfg))" case .apiDeleteStorage: return "/_db delete" @@ -213,8 +212,9 @@ public enum ChatCommand { case let .apiVerifyContact(contactId, .none): return "/_verify code @\(contactId)" case let .apiVerifyGroupMember(groupId, groupMemberId, .some(connectionCode)): return "/_verify code #\(groupId) \(groupMemberId) \(connectionCode)" case let .apiVerifyGroupMember(groupId, groupMemberId, .none): return "/_verify code #\(groupId) \(groupMemberId)" - case let .apiAddContact(userId): return "/_connect \(userId)" - case let .apiConnect(userId, connReq): return "/_connect \(userId) \(connReq)" + case let .apiAddContact(userId, incognito): return "/_connect \(userId) incognito=\(onOff(incognito))" + case let .apiSetConnectionIncognito(connId, incognito): return "/_set incognito :\(connId) \(onOff(incognito))" + case let .apiConnect(userId, incognito, connReq): return "/_connect \(userId) incognito=\(onOff(incognito)) \(connReq)" case let .apiDeleteChat(type, id): return "/_delete \(ref(type, id))" case let .apiClearChat(type, id): return "/_clear chat \(ref(type, id))" case let .apiListContacts(userId): return "/_contacts \(userId)" @@ -227,7 +227,7 @@ public enum ChatCommand { case let .apiShowMyAddress(userId): return "/_show_address \(userId)" case let .apiSetProfileAddress(userId, on): return "/_profile_address \(userId) \(onOff(on))" case let .apiAddressAutoAccept(userId, autoAccept): return "/_auto_accept \(userId) \(AutoAccept.cmdString(autoAccept))" - case let .apiAcceptContact(contactReqId): return "/_accept \(contactReqId)" + case let .apiAcceptContact(incognito, contactReqId): return "/_accept incognito=\(onOff(incognito)) \(contactReqId)" case let .apiRejectContact(contactReqId): return "/_reject \(contactReqId)" case let .apiSendCallInvitation(contact, callType): return "/_call invite @\(contact.apiId) \(encodeJSON(callType))" case let .apiRejectCall(contact): return "/_call reject @\(contact.apiId)" @@ -274,7 +274,6 @@ public enum ChatCommand { case .setTempFolder: return "setTempFolder" case .setFilesFolder: return "setFilesFolder" case .apiSetXFTPConfig: return "apiSetXFTPConfig" - case .setIncognito: return "setIncognito" case .apiExportArchive: return "apiExportArchive" case .apiImportArchive: return "apiImportArchive" case .apiDeleteStorage: return "apiDeleteStorage" @@ -326,6 +325,7 @@ public enum ChatCommand { case .apiVerifyContact: return "apiVerifyContact" case .apiVerifyGroupMember: return "apiVerifyGroupMember" case .apiAddContact: return "apiAddContact" + case .apiSetConnectionIncognito: return "apiSetConnectionIncognito" case .apiConnect: return "apiConnect" case .apiDeleteChat: return "apiDeleteChat" case .apiClearChat: return "apiClearChat" @@ -448,7 +448,8 @@ public enum ChatResponse: Decodable, Error { case contactCode(user: User, contact: Contact, connectionCode: String) case groupMemberCode(user: User, groupInfo: GroupInfo, member: GroupMember, connectionCode: String) case connectionVerified(user: User, verified: Bool, expectedCode: String) - case invitation(user: User, connReqInvitation: String) + case invitation(user: User, connReqInvitation: String, connection: PendingContactConnection) + case connectionIncognitoUpdated(user: User, toConnection: PendingContactConnection) case sentConfirmation(user: User) case sentInvitation(user: User) case contactAlreadyExists(user: User, contact: Contact) @@ -582,6 +583,7 @@ public enum ChatResponse: Decodable, Error { case .groupMemberCode: return "groupMemberCode" case .connectionVerified: return "connectionVerified" case .invitation: return "invitation" + case .connectionIncognitoUpdated: return "connectionIncognitoUpdated" case .sentConfirmation: return "sentConfirmation" case .sentInvitation: return "sentInvitation" case .contactAlreadyExists: return "contactAlreadyExists" @@ -713,7 +715,8 @@ public enum ChatResponse: Decodable, Error { case let .contactCode(u, contact, connectionCode): return withUser(u, "contact: \(String(describing: contact))\nconnectionCode: \(connectionCode)") case let .groupMemberCode(u, groupInfo, member, connectionCode): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionCode: \(connectionCode)") case let .connectionVerified(u, verified, expectedCode): return withUser(u, "verified: \(verified)\nconnectionCode: \(expectedCode)") - case let .invitation(u, connReqInvitation): return withUser(u, connReqInvitation) + case let .invitation(u, connReqInvitation, _): return withUser(u, connReqInvitation) + case let .connectionIncognitoUpdated(u, toConnection): return withUser(u, String(describing: toConnection)) case .sentConfirmation: return noDetails case .sentInvitation: return noDetails case let .contactAlreadyExists(u, contact): return withUser(u, String(describing: contact)) @@ -1449,6 +1452,7 @@ public enum ChatErrorType: Decodable { case serverProtocol case agentCommandError(message: String) case invalidFileDescription(message: String) + case connectionIncognitoChangeProhibited case internalError(message: String) case exception(message: String) } diff --git a/apps/ios/SimpleXChat/AppGroup.swift b/apps/ios/SimpleXChat/AppGroup.swift index 65907d89f3..335ba06183 100644 --- a/apps/ios/SimpleXChat/AppGroup.swift +++ b/apps/ios/SimpleXChat/AppGroup.swift @@ -29,7 +29,7 @@ let GROUP_DEFAULT_NETWORK_ENABLE_KEEP_ALIVE = "networkEnableKeepAlive" let GROUP_DEFAULT_NETWORK_TCP_KEEP_IDLE = "networkTCPKeepIdle" let GROUP_DEFAULT_NETWORK_TCP_KEEP_INTVL = "networkTCPKeepIntvl" let GROUP_DEFAULT_NETWORK_TCP_KEEP_CNT = "networkTCPKeepCnt" -let GROUP_DEFAULT_INCOGNITO = "incognito" +public let GROUP_DEFAULT_INCOGNITO = "incognito" let GROUP_DEFAULT_STORE_DB_PASSPHRASE = "storeDBPassphrase" let GROUP_DEFAULT_INITIAL_RANDOM_DB_PASSPHRASE = "initialRandomDBPassphrase" public let GROUP_DEFAULT_CONFIRM_DB_UPGRADES = "confirmDBUpgrades" diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/model/NtfManager.android.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/model/NtfManager.android.kt index 95d2520f56..95fc7b6d64 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/model/NtfManager.android.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/model/NtfManager.android.kt @@ -118,6 +118,7 @@ object NtfManager { val actionPendingIntent: PendingIntent = PendingIntent.getBroadcast(SimplexApp.context, 0, actionIntent, flags) val actionButton = when (action) { NotificationAction.ACCEPT_CONTACT_REQUEST -> generalGetString(MR.strings.accept) + NotificationAction.ACCEPT_CONTACT_REQUEST_INCOGNITO -> generalGetString(MR.strings.accept_contact_incognito_button) } builder.addAction(0, actionButton, actionPendingIntent) } @@ -260,7 +261,8 @@ object NtfManager { val chatId = intent?.getStringExtra(ChatIdKey) ?: return val m = SimplexApp.context.chatModel when (intent.action) { - NotificationAction.ACCEPT_CONTACT_REQUEST.name -> ntfManager.acceptContactRequestAction(userId, chatId) + NotificationAction.ACCEPT_CONTACT_REQUEST.name -> ntfManager.acceptContactRequestAction(userId, incognito = false, chatId) + NotificationAction.ACCEPT_CONTACT_REQUEST_INCOGNITO.name -> ntfManager.acceptContactRequestAction(userId, incognito = true, chatId) RejectCallAction -> { val invitation = m.callInvitations[chatId] if (invitation != null) { diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.android.kt index ee825730ca..d0cad31210 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.android.kt @@ -13,7 +13,8 @@ actual fun ScanToConnectView(chatModel: ChatModel, close: () -> Unit) { cameraPermissionState.launchPermissionRequest() } ConnectContactLayout( - chatModelIncognito = chatModel.incognito.value, - close + chatModel = chatModel, + incognitoPref = chatModel.controller.appPrefs.incognito, + close = close ) } 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 7a036fffdb..94bf9d1929 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 @@ -80,7 +80,6 @@ object ChatModel { } val performLA by lazy { mutableStateOf(ChatController.appPrefs.performLA.get()) } val showAdvertiseLAUnavailableAlert = mutableStateOf(false) - val incognito by lazy { mutableStateOf(ChatController.appPrefs.incognito.get()) } // current WebRTC call val callManager = CallManager(this) 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 57a9077bbc..14838e2de0 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 @@ -331,7 +331,6 @@ object ChatController { if (justStarted) { chatModel.currentUser.value = user chatModel.userCreated.value = true - apiSetIncognito(chatModel.incognito.value) getUserChatData() appPrefs.chatLastStart.set(Clock.System.now()) chatModel.chatRunning.value = true @@ -546,12 +545,6 @@ object ChatController { throw Error("apiSetXFTPConfig bad response: ${r.responseType} ${r.details}") } - suspend fun apiSetIncognito(incognito: Boolean) { - val r = sendCmd(CC.SetIncognito(incognito)) - if (r is CR.CmdOk) return - throw Exception("failed to set incognito: ${r.responseType} ${r.details}") - } - suspend fun apiExportArchive(config: ArchiveConfig) { val r = sendCmd(CC.ApiExportArchive(config)) if (r is CR.CmdOk) return @@ -819,14 +812,14 @@ object ChatController { - suspend fun apiAddContact(): String? { + suspend fun apiAddContact(incognito: Boolean): Pair? { val userId = chatModel.currentUser.value?.userId ?: run { Log.e(TAG, "apiAddContact: no current user") return null } - val r = sendCmd(CC.APIAddContact(userId)) + val r = sendCmd(CC.APIAddContact(userId, incognito)) return when (r) { - is CR.Invitation -> r.connReqInvitation + is CR.Invitation -> r.connReqInvitation to r.connection else -> { if (!(networkErrorAlert(r))) { apiErrorAlert("apiAddContact", generalGetString(MR.strings.connection_error), r) @@ -836,12 +829,19 @@ object ChatController { } } - suspend fun apiConnect(connReq: String): Boolean { + suspend fun apiSetConnectionIncognito(connId: Long, incognito: Boolean): PendingContactConnection? { + val r = sendCmd(CC.ApiSetConnectionIncognito(connId, incognito)) + if (r is CR.ConnectionIncognitoUpdated) return r.toConnection + Log.e(TAG, "apiSetConnectionIncognito bad response: ${r.responseType} ${r.details}") + return null + } + + suspend fun apiConnect(incognito: Boolean, connReq: String): Boolean { val userId = chatModel.currentUser.value?.userId ?: run { Log.e(TAG, "apiConnect: no current user") return false } - val r = sendCmd(CC.APIConnect(userId, connReq)) + val r = sendCmd(CC.APIConnect(userId, incognito, connReq)) when { r is CR.SentConfirmation || r is CR.SentInvitation -> return true r is CR.ContactAlreadyExists -> { @@ -998,8 +998,8 @@ object ChatController { return null } - suspend fun apiAcceptContactRequest(contactReqId: Long): Contact? { - val r = sendCmd(CC.ApiAcceptContact(contactReqId)) + suspend fun apiAcceptContactRequest(incognito: Boolean, contactReqId: Long): Contact? { + val r = sendCmd(CC.ApiAcceptContact(incognito, contactReqId)) return when { r is CR.AcceptingContactRequest -> r.contact r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent @@ -1805,7 +1805,6 @@ sealed class CC { class SetTempFolder(val tempFolder: String): CC() class SetFilesFolder(val filesFolder: String): CC() class ApiSetXFTPConfig(val config: XFTPFileConfig?): CC() - class SetIncognito(val incognito: Boolean): CC() class ApiExportArchive(val config: ArchiveConfig): CC() class ApiImportArchive(val config: ArchiveConfig): CC() class ApiDeleteStorage: CC() @@ -1850,8 +1849,9 @@ sealed class CC { class APIGetGroupMemberCode(val groupId: Long, val groupMemberId: Long): CC() class APIVerifyContact(val contactId: Long, val connectionCode: String?): CC() class APIVerifyGroupMember(val groupId: Long, val groupMemberId: Long, val connectionCode: String?): CC() - class APIAddContact(val userId: Long): CC() - class APIConnect(val userId: Long, val connReq: String): CC() + class APIAddContact(val userId: Long, val incognito: Boolean): CC() + class ApiSetConnectionIncognito(val connId: Long, val incognito: Boolean): CC() + class APIConnect(val userId: Long, val incognito: Boolean, val connReq: String): CC() class ApiDeleteChat(val type: ChatType, val id: Long): CC() class ApiClearChat(val type: ChatType, val id: Long): CC() class ApiListContacts(val userId: Long): CC() @@ -1872,7 +1872,7 @@ sealed class CC { class ApiSendCallExtraInfo(val contact: Contact, val extraInfo: WebRTCExtraInfo): CC() class ApiEndCall(val contact: Contact): CC() class ApiCallStatus(val contact: Contact, val callStatus: WebRTCCallStatus): CC() - class ApiAcceptContact(val contactReqId: Long): CC() + class ApiAcceptContact(val incognito: Boolean, val contactReqId: Long): CC() class ApiRejectContact(val contactReqId: Long): CC() class ApiChatRead(val type: ChatType, val id: Long, val range: ItemRange): CC() class ApiChatUnread(val type: ChatType, val id: Long, val unreadChat: Boolean): CC() @@ -1908,7 +1908,6 @@ sealed class CC { is SetTempFolder -> "/_temp_folder $tempFolder" is SetFilesFolder -> "/_files_folder $filesFolder" is ApiSetXFTPConfig -> if (config != null) "/_xftp on ${json.encodeToString(config)}" else "/_xftp off" - is SetIncognito -> "/incognito ${onOff(incognito)}" is ApiExportArchive -> "/_db export ${json.encodeToString(config)}" is ApiImportArchive -> "/_db import ${json.encodeToString(config)}" is ApiDeleteStorage -> "/_db delete" @@ -1956,8 +1955,9 @@ sealed class CC { is APIGetGroupMemberCode -> "/_get code #$groupId $groupMemberId" is APIVerifyContact -> "/_verify code @$contactId" + if (connectionCode != null) " $connectionCode" else "" is APIVerifyGroupMember -> "/_verify code #$groupId $groupMemberId" + if (connectionCode != null) " $connectionCode" else "" - is APIAddContact -> "/_connect $userId" - is APIConnect -> "/_connect $userId $connReq" + is APIAddContact -> "/_connect $userId incognito=${onOff(incognito)}" + is ApiSetConnectionIncognito -> "/_set incognito :$connId ${onOff(incognito)}" + is APIConnect -> "/_connect $userId incognito=${onOff(incognito)} $connReq" is ApiDeleteChat -> "/_delete ${chatRef(type, id)}" is ApiClearChat -> "/_clear chat ${chatRef(type, id)}" is ApiListContacts -> "/_contacts $userId" @@ -1971,7 +1971,7 @@ sealed class CC { is ApiShowMyAddress -> "/_show_address $userId" is ApiSetProfileAddress -> "/_profile_address $userId ${onOff(on)}" is ApiAddressAutoAccept -> "/_auto_accept $userId ${AutoAccept.cmdString(autoAccept)}" - is ApiAcceptContact -> "/_accept $contactReqId" + is ApiAcceptContact -> "/_accept incognito=${onOff(incognito)} $contactReqId" is ApiRejectContact -> "/_reject $contactReqId" is ApiSendCallInvitation -> "/_call invite @${contact.apiId} ${json.encodeToString(callType)}" is ApiRejectCall -> "/_call reject @${contact.apiId}" @@ -2006,7 +2006,6 @@ sealed class CC { is SetTempFolder -> "setTempFolder" is SetFilesFolder -> "setFilesFolder" is ApiSetXFTPConfig -> "apiSetXFTPConfig" - is SetIncognito -> "setIncognito" is ApiExportArchive -> "apiExportArchive" is ApiImportArchive -> "apiImportArchive" is ApiDeleteStorage -> "apiDeleteStorage" @@ -2052,6 +2051,7 @@ sealed class CC { is APIVerifyContact -> "apiVerifyContact" is APIVerifyGroupMember -> "apiVerifyGroupMember" is APIAddContact -> "apiAddContact" + is ApiSetConnectionIncognito -> "apiSetConnectionIncognito" is APIConnect -> "apiConnect" is ApiDeleteChat -> "apiDeleteChat" is ApiClearChat -> "apiClearChat" @@ -3249,7 +3249,8 @@ sealed class CR { @Serializable @SerialName("contactCode") class ContactCode(val user: User, val contact: Contact, val connectionCode: String): CR() @Serializable @SerialName("groupMemberCode") class GroupMemberCode(val user: User, val groupInfo: GroupInfo, val member: GroupMember, val connectionCode: String): CR() @Serializable @SerialName("connectionVerified") class ConnectionVerified(val user: User, val verified: Boolean, val expectedCode: String): CR() - @Serializable @SerialName("invitation") class Invitation(val user: User, val connReqInvitation: String): CR() + @Serializable @SerialName("invitation") class Invitation(val user: User, val connReqInvitation: String, val connection: PendingContactConnection): CR() + @Serializable @SerialName("connectionIncognitoUpdated") class ConnectionIncognitoUpdated(val user: User, val toConnection: PendingContactConnection): CR() @Serializable @SerialName("sentConfirmation") class SentConfirmation(val user: User): CR() @Serializable @SerialName("sentInvitation") class SentInvitation(val user: User): CR() @Serializable @SerialName("contactAlreadyExists") class ContactAlreadyExists(val user: User, val contact: Contact): CR() @@ -3378,6 +3379,7 @@ sealed class CR { is GroupMemberCode -> "groupMemberCode" is ConnectionVerified -> "connectionVerified" is Invitation -> "invitation" + is ConnectionIncognitoUpdated -> "connectionIncognitoUpdated" is SentConfirmation -> "sentConfirmation" is SentInvitation -> "sentInvitation" is ContactAlreadyExists -> "contactAlreadyExists" @@ -3503,6 +3505,7 @@ sealed class CR { is GroupMemberCode -> withUser(user, "groupInfo: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionCode: $connectionCode") is ConnectionVerified -> withUser(user, "verified: $verified\nconnectionCode: $expectedCode") is Invitation -> withUser(user, connReqInvitation) + is ConnectionIncognitoUpdated -> withUser(user, json.encodeToString(toConnection)) is SentConfirmation -> withUser(user, noDetails()) is SentInvitation -> withUser(user, noDetails()) is ContactAlreadyExists -> withUser(user, json.encodeToString(contact)) @@ -3822,6 +3825,7 @@ sealed class ChatErrorType { is ServerProtocol -> "serverProtocol" is AgentCommandError -> "agentCommandError" is InvalidFileDescription -> "invalidFileDescription" + is ConnectionIncognitoChangeProhibited -> "connectionIncognitoChangeProhibited" is InternalError -> "internalError" is CEException -> "exception $message" } @@ -3895,6 +3899,7 @@ sealed class ChatErrorType { @Serializable @SerialName("serverProtocol") object ServerProtocol: ChatErrorType() @Serializable @SerialName("agentCommandError") class AgentCommandError(val message: String): ChatErrorType() @Serializable @SerialName("invalidFileDescription") class InvalidFileDescription(val message: String): ChatErrorType() + @Serializable @SerialName("connectionIncognitoChangeProhibited") object ConnectionIncognitoChangeProhibited: ChatErrorType() @Serializable @SerialName("internalError") class InternalError(val message: String): ChatErrorType() @Serializable @SerialName("exception") class CEException(val message: String): ChatErrorType() } 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 0e4a67f221..cc23915834 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 @@ -10,7 +10,8 @@ import chat.simplex.res.MR import kotlinx.coroutines.delay enum class NotificationAction { - ACCEPT_CONTACT_REQUEST + ACCEPT_CONTACT_REQUEST, + ACCEPT_CONTACT_REQUEST_INCOGNITO } lateinit var ntfManager: NtfManager @@ -29,7 +30,10 @@ abstract class NtfManager { displayName = cInfo.displayName, msgText = generalGetString(MR.strings.notification_new_contact_request), image = cInfo.image, - listOf(NotificationAction.ACCEPT_CONTACT_REQUEST to { acceptContactRequestAction(user.userId, cInfo.id) }) + listOf( + NotificationAction.ACCEPT_CONTACT_REQUEST to { acceptContactRequestAction(user.userId, incognito = false, cInfo.id) }, + NotificationAction.ACCEPT_CONTACT_REQUEST_INCOGNITO to { acceptContactRequestAction(user.userId, incognito = true, cInfo.id) } + ) ) fun notifyMessageReceived(user: User, cInfo: ChatInfo, cItem: ChatItem) { @@ -37,7 +41,7 @@ abstract class NtfManager { displayNotification(user = user, chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem)) } - fun acceptContactRequestAction(userId: Long?, chatId: ChatId) { + fun acceptContactRequestAction(userId: Long?, incognito: Boolean, chatId: ChatId) { val isCurrentUser = ChatModel.currentUser.value?.userId == userId val cInfo: ChatInfo.ContactRequest? = if (isCurrentUser) { (ChatModel.getChat(chatId)?.chatInfo as? ChatInfo.ContactRequest) ?: return @@ -45,7 +49,7 @@ abstract class NtfManager { null } val apiId = chatId.replace("<@", "").toLongOrNull() ?: return - acceptContactRequest(apiId, cInfo, isCurrentUser, ChatModel) + acceptContactRequest(incognito, apiId, cInfo, isCurrentUser, ChatModel) cancelNotificationsForChat(chatId) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index 85ab5c2ea3..3a44b9e420 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -5,6 +5,7 @@ import InfoRowEllipsis import SectionBottomSpacer import SectionDividerSpaced import SectionItemView +import SectionItemViewSpaceBetween import SectionSpacer import SectionTextFooter import SectionView @@ -271,7 +272,10 @@ fun ChatInfoLayout( SectionSpacer() if (customUserProfile != null) { SectionView(generalGetString(MR.strings.incognito).uppercase()) { - InfoRow(generalGetString(MR.strings.incognito_random_profile), customUserProfile.chatViewName) + SectionItemViewSpaceBetween { + Text(generalGetString(MR.strings.incognito_random_profile)) + Text(customUserProfile.chatViewName, color = Indigo) + } } SectionDividerSpaced() } 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 ce7765bcd1..63cf729ed7 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 @@ -127,7 +127,6 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) { searchText, useLinkPreviews = useLinkPreviews, linkMode = chatModel.simplexLinkMode.value, - chatModelIncognito = chatModel.incognito.value, back = { hideKeyboard(view) AudioPlayer.stop() @@ -379,7 +378,6 @@ fun ChatLayout( searchValue: State, useLinkPreviews: Boolean, linkMode: SimplexLinkMode, - chatModelIncognito: Boolean, back: () -> Unit, info: () -> Unit, showMemberInfo: (GroupInfo, GroupMember) -> Unit, @@ -465,7 +463,7 @@ fun ChatLayout( ) { ChatItemsList( chat, unreadCount, composeState, chatItems, searchValue, - useLinkPreviews, linkMode, chatModelIncognito, showMemberInfo, loadPrevMessages, deleteMessage, + useLinkPreviews, linkMode, showMemberInfo, loadPrevMessages, deleteMessage, receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember, setReaction, showItemDetails, markRead, setFloatingButton, onComposed, @@ -634,7 +632,6 @@ fun BoxWithConstraintsScope.ChatItemsList( searchValue: State, useLinkPreviews: Boolean, linkMode: SimplexLinkMode, - chatModelIncognito: Boolean, showMemberInfo: (GroupInfo, GroupMember) -> Unit, loadPrevMessages: (ChatInfo) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, @@ -1184,7 +1181,6 @@ fun PreviewChatLayout() { searchValue, useLinkPreviews = true, linkMode = SimplexLinkMode.DESCRIPTION, - chatModelIncognito = false, back = {}, info = {}, showMemberInfo = { _, _ -> }, @@ -1252,7 +1248,6 @@ fun PreviewGroupChatLayout() { searchValue, useLinkPreviews = true, linkMode = SimplexLinkMode.DESCRIPTION, - chatModelIncognito = false, back = {}, info = {}, showMemberInfo = { _, _ -> }, 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 e9a9854456..1b8310e18d 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 @@ -20,17 +20,16 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextOverflow import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.ChatInfoToolbarTitle import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.newchat.InfoAboutIncognito import chat.simplex.common.views.usersettings.SettingsActionItem import chat.simplex.common.model.GroupInfo import chat.simplex.common.platform.* -import chat.simplex.common.views.chat.group.GroupPreferencesView import chat.simplex.res.MR @Composable @@ -41,7 +40,6 @@ fun AddGroupMembersView(groupInfo: GroupInfo, creatingGroup: Boolean = false, ch val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) } BackHandler(onBack = close) AddGroupMembersLayout( - chatModel.incognito.value, groupInfo = groupInfo, creatingGroup = creatingGroup, contactsToAdd = getContactsToAdd(chatModel, searchText.value.text), @@ -92,7 +90,6 @@ fun getContactsToAdd(chatModel: ChatModel, search: String): List { @Composable fun AddGroupMembersLayout( - chatModelIncognito: Boolean, groupInfo: GroupInfo, creatingGroup: Boolean, contactsToAdd: List, @@ -107,19 +104,31 @@ fun AddGroupMembersLayout( removeContact: (Long) -> Unit, close: () -> Unit, ) { + @Composable fun profileText() { + Row( + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + painterResource(MR.images.ic_info), + null, + tint = MaterialTheme.colors.secondary, + modifier = Modifier.padding(end = 10.dp).size(20.dp) + ) + Text(generalGetString(MR.strings.group_main_profile_sent), textAlign = TextAlign.Center, style = MaterialTheme.typography.body2) + } + } + Column( Modifier .fillMaxWidth() .verticalScroll(rememberScrollState()), ) { AppBarTitle(stringResource(MR.strings.button_add_members)) - InfoAboutIncognito( - chatModelIncognito, - false, - generalGetString(MR.strings.group_unsupported_incognito_main_profile_sent), - generalGetString(MR.strings.group_main_profile_sent), - true - ) + profileText() Spacer(Modifier.size(DEFAULT_PADDING)) Row( Modifier.fillMaxWidth(), @@ -350,7 +359,6 @@ fun showProhibitedToInviteIncognitoAlertDialog() { fun PreviewAddGroupMembersLayout() { SimpleXTheme { AddGroupMembersLayout( - chatModelIncognito = false, groupInfo = GroupInfo.sampleData, creatingGroup = false, contactsToAdd = listOf(Contact.sampleData, Contact.sampleData, Contact.sampleData), 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 f730c6ef21..a3e5d5af18 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,6 +3,7 @@ package chat.simplex.common.views.chat.group import InfoRow import SectionBottomSpacer import SectionDividerSpaced +import SectionItemView import SectionSpacer import SectionTextFooter import SectionView @@ -445,19 +446,42 @@ private fun updateMemberRoleDialog( } fun connectViaMemberAddressAlert(connReqUri: String) { - AlertManager.shared.showAlertDialog( + AlertManager.shared.showAlertDialogButtonsColumn( title = generalGetString(MR.strings.connect_via_member_address_alert_title), - text = generalGetString(MR.strings.connect_via_member_address_alert_desc), - confirmText = generalGetString(MR.strings.connect_via_link_verb), - onConfirm = { - val uri = URI(connReqUri) - withUriAction(uri) { linkType -> - withApi { - Log.d(TAG, "connectViaUri: connecting") - connectViaUri(chatModel, linkType, uri) + text = AnnotatedString(generalGetString(MR.strings.connect_via_member_address_alert_desc)), + buttons = { + Column { + SectionItemView({ + AlertManager.shared.hideAlert() + val uri = URI(connReqUri) + withUriAction(uri) { linkType -> + withApi { + Log.d(TAG, "connectViaUri: connecting") + connectViaUri(chatModel, linkType, uri, incognito = false) + } + } + }) { + Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + SectionItemView({ + AlertManager.shared.hideAlert() + val uri = URI(connReqUri) + withUriAction(uri) { linkType -> + withApi { + Log.d(TAG, "connectViaUri: connecting incognito") + connectViaUri(chatModel, linkType, uri, incognito = true) + } + } + }) { + Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } } - }, + } ) } 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 0be9ef12d8..f924737490 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 @@ -1,5 +1,6 @@ package chat.simplex.common.views.chatlist +import SectionItemView import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.material.* @@ -13,9 +14,12 @@ 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.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* +import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.chat.group.deleteGroupDialog @@ -23,9 +27,7 @@ import chat.simplex.common.views.chat.group.leaveGroupDialog import chat.simplex.common.views.chat.item.InvalidJSONView import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.newchat.ContactConnectionInfoView -import chat.simplex.common.platform.appPlatform -import chat.simplex.common.platform.ntfManager +import chat.simplex.common.views.newchat.* import chat.simplex.res.MR import kotlinx.coroutines.delay import kotlinx.datetime.Clock @@ -46,7 +48,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { is ChatInfo.Direct -> { val contactNetworkStatus = chatModel.contactNetworkStatus(chat.chatInfo.contact) ChatListNavLinkLayout( - chatLinkPreview = { ChatPreviewView(chat, chatModel.draft.value, chatModel.draftChatId.value, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, stopped, linkMode) }, + chatLinkPreview = { ChatPreviewView(chat, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, stopped, linkMode) }, click = { directChatAction(chat.chatInfo, chatModel) }, dropdownMenuItems = { ContactMenuItems(chat, chatModel, showMenu, showMarkRead) }, showMenu, @@ -55,7 +57,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { } is ChatInfo.Group -> ChatListNavLinkLayout( - chatLinkPreview = { ChatPreviewView(chat, chatModel.draft.value, chatModel.draftChatId.value, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, null, stopped, linkMode) }, + chatLinkPreview = { ChatPreviewView(chat, 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) }, showMenu, @@ -63,7 +65,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { ) is ChatInfo.ContactRequest -> ChatListNavLinkLayout( - chatLinkPreview = { ContactRequestView(chatModel.incognito.value, chat.chatInfo) }, + chatLinkPreview = { ContactRequestView(chat.chatInfo) }, click = { contactRequestAlertDialog(chat.chatInfo, chatModel) }, dropdownMenuItems = { ContactRequestMenuItems(chat.chatInfo, chatModel, showMenu) }, showMenu, @@ -320,11 +322,20 @@ fun LeaveGroupAction(groupInfo: GroupInfo, chatModel: ChatModel, showMenu: Mutab @Composable fun ContactRequestMenuItems(chatInfo: ChatInfo.ContactRequest, chatModel: ChatModel, showMenu: MutableState) { ItemAction( - if (chatModel.incognito.value) stringResource(MR.strings.accept_contact_incognito_button) else stringResource(MR.strings.accept_contact_button), - if (chatModel.incognito.value) painterResource(MR.images.ic_theater_comedy_filled) else painterResource(MR.images.ic_check), - color = if (chatModel.incognito.value) Indigo else MaterialTheme.colors.onBackground, + stringResource(MR.strings.accept_contact_button), + painterResource(MR.images.ic_check), + color = MaterialTheme.colors.onBackground, onClick = { - acceptContactRequest(chatInfo.apiId, chatInfo, true, chatModel) + acceptContactRequest(incognito = false, chatInfo.apiId, chatInfo, true, chatModel) + showMenu.value = false + } + ) + ItemAction( + stringResource(MR.strings.accept_contact_incognito_button), + painterResource(MR.images.ic_theater_comedy), + color = MaterialTheme.colors.onBackground, + onClick = { + acceptContactRequest(incognito = true, chatInfo.apiId, chatInfo, true, chatModel) showMenu.value = false } ) @@ -430,19 +441,37 @@ fun markChatUnread(chat: Chat, chatModel: ChatModel) { } fun contactRequestAlertDialog(contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) { - AlertManager.shared.showAlertDialog( + AlertManager.shared.showAlertDialogButtonsColumn( title = generalGetString(MR.strings.accept_connection_request__question), - text = generalGetString(MR.strings.if_you_choose_to_reject_the_sender_will_not_be_notified), - confirmText = if (chatModel.incognito.value) generalGetString(MR.strings.accept_contact_incognito_button) else generalGetString(MR.strings.accept_contact_button), - onConfirm = { acceptContactRequest(contactRequest.apiId, contactRequest, true, chatModel) }, - dismissText = generalGetString(MR.strings.reject_contact_button), - onDismiss = { rejectContactRequest(contactRequest, chatModel) } + text = AnnotatedString(generalGetString(MR.strings.if_you_choose_to_reject_the_sender_will_not_be_notified)), + buttons = { + Column { + SectionItemView({ + AlertManager.shared.hideAlert() + acceptContactRequest(incognito = false, contactRequest.apiId, contactRequest, true, chatModel) + }) { + Text(generalGetString(MR.strings.accept_contact_button), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + SectionItemView({ + AlertManager.shared.hideAlert() + acceptContactRequest(incognito = true, contactRequest.apiId, contactRequest, true, chatModel) + }) { + Text(generalGetString(MR.strings.accept_contact_incognito_button), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + SectionItemView({ + AlertManager.shared.hideAlert() + rejectContactRequest(contactRequest, chatModel) + }) { + Text(generalGetString(MR.strings.reject_contact_button), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) + } + } + } ) } -fun acceptContactRequest(apiId: Long, contactRequest: ChatInfo.ContactRequest?, isCurrentUser: Boolean, chatModel: ChatModel) { +fun acceptContactRequest(incognito: Boolean, apiId: Long, contactRequest: ChatInfo.ContactRequest?, isCurrentUser: Boolean, chatModel: ChatModel) { withApi { - val contact = chatModel.controller.apiAcceptContactRequest(apiId) + val contact = chatModel.controller.apiAcceptContactRequest(incognito, apiId) if (contact != null && isCurrentUser && contactRequest != null) { val chat = Chat(ChatInfo.Direct(contact), listOf()) chatModel.replaceChat(contactRequest.id, chat) @@ -457,38 +486,6 @@ fun rejectContactRequest(contactRequest: ChatInfo.ContactRequest, chatModel: Cha } } -fun contactConnectionAlertDialog(connection: PendingContactConnection, chatModel: ChatModel) { - AlertManager.shared.showAlertDialogButtons( - title = generalGetString( - if (connection.initiated) MR.strings.you_invited_your_contact - else MR.strings.you_accepted_connection - ), - text = generalGetString( - if (connection.viaContactUri) MR.strings.you_will_be_connected_when_your_connection_request_is_accepted - else MR.strings.you_will_be_connected_when_your_contacts_device_is_online - ), - buttons = { - Row( - Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 2.dp), - horizontalArrangement = Arrangement.Center, - ) { - TextButton(onClick = { - AlertManager.shared.hideAlert() - deleteContactConnectionAlert(connection, chatModel) {} - }) { - Text(stringResource(MR.strings.delete_verb)) - } - Spacer(Modifier.padding(horizontal = 4.dp)) - TextButton(onClick = { AlertManager.shared.hideAlert() }) { - Text(stringResource(MR.strings.ok)) - } - } - } - ) -} - fun deleteContactConnectionAlert(connection: PendingContactConnection, chatModel: ChatModel, onSuccess: () -> Unit) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.delete_pending_connection__question), @@ -662,7 +659,6 @@ fun PreviewChatListNavLinkDirect() { ), null, null, - false, null, null, stopped = false, @@ -702,7 +698,6 @@ fun PreviewChatListNavLinkGroup() { ), null, null, - false, null, null, stopped = false, @@ -727,7 +722,7 @@ fun PreviewChatListNavLinkContactRequest() { SimpleXTheme { ChatListNavLinkLayout( chatLinkPreview = { - ContactRequestView(false, ChatInfo.ContactRequest.sampleData) + ContactRequestView(ChatInfo.ContactRequest.sampleData) }, click = {}, dropdownMenuItems = null, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 43b06a2159..e2c316046b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -1,5 +1,6 @@ package chat.simplex.common.views.chatlist +import SectionItemView import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* @@ -13,9 +14,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.* import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.AnnotatedString 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.* import chat.simplex.common.SettingsViewState import chat.simplex.common.model.* @@ -221,14 +224,6 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, user }, title = { Row(verticalAlignment = Alignment.CenterVertically) { - if (chatModel.incognito.value) { - Icon( - painterResource(MR.images.ic_theater_comedy_filled), - stringResource(MR.strings.incognito), - tint = Indigo, - modifier = Modifier.padding(10.dp).size(26.dp) - ) - } Text( stringResource(MR.strings.your_chats), color = MaterialTheme.colors.onBackground, @@ -317,17 +312,37 @@ fun connectIfOpenedViaUri(uri: URI, chatModel: ChatModel) { ConnectionLinkType.INVITATION -> generalGetString(MR.strings.connect_via_invitation_link) ConnectionLinkType.GROUP -> generalGetString(MR.strings.connect_via_group_link) } - AlertManager.shared.showAlertDialog( + AlertManager.shared.showAlertDialogButtonsColumn( title = title, text = if (linkType == ConnectionLinkType.GROUP) - generalGetString(MR.strings.you_will_join_group) + AnnotatedString(generalGetString(MR.strings.you_will_join_group)) else - generalGetString(MR.strings.profile_will_be_sent_to_contact_sending_link), - confirmText = generalGetString(MR.strings.connect_via_link_verb), - onConfirm = { - withApi { - Log.d(TAG, "connectIfOpenedViaUri: connecting") - connectViaUri(chatModel, linkType, uri) + AnnotatedString(generalGetString(MR.strings.profile_will_be_sent_to_contact_sending_link)), + buttons = { + Column { + SectionItemView({ + AlertManager.shared.hideAlert() + withApi { + Log.d(TAG, "connectIfOpenedViaUri: connecting") + connectViaUri(chatModel, linkType, uri, incognito = false) + } + }) { + Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + SectionItemView({ + AlertManager.shared.hideAlert() + withApi { + Log.d(TAG, "connectIfOpenedViaUri: connecting incognito") + connectViaUri(chatModel, linkType, uri, incognito = true) + } + }) { + Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } } } ) 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 04e0724c27..bd030438e9 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 @@ -33,7 +33,6 @@ fun ChatPreviewView( chat: Chat, chatModelDraft: ComposeState?, chatModelDraftChatId: ChatId?, - chatModelIncognito: Boolean, currentUserProfileDisplayName: String?, contactNetworkStatus: NetworkStatus?, stopped: Boolean, @@ -138,7 +137,7 @@ fun ChatPreviewView( } @Composable - fun chatPreviewText(chatModelIncognito: Boolean) { + fun chatPreviewText() { val ci = chat.chatItems.lastOrNull() if (ci != null) { val (text: CharSequence, inlineTextContent) = when { @@ -175,7 +174,7 @@ fun ChatPreviewView( } is ChatInfo.Group -> when (cInfo.groupInfo.membership.memberStatus) { - GroupMemberStatus.MemInvited -> Text(groupInvitationPreviewText(chatModelIncognito, currentUserProfileDisplayName, cInfo.groupInfo)) + GroupMemberStatus.MemInvited -> Text(groupInvitationPreviewText(currentUserProfileDisplayName, cInfo.groupInfo)) GroupMemberStatus.MemAccepted -> Text(stringResource(MR.strings.group_connection_pending), color = MaterialTheme.colors.secondary) else -> {} } @@ -184,6 +183,37 @@ fun ChatPreviewView( } } + @Composable + fun chatStatusImage() { + if (cInfo is ChatInfo.Direct) { + val descr = contactNetworkStatus?.statusString + when (contactNetworkStatus) { + is NetworkStatus.Connected -> + IncognitoIcon(chat.chatInfo.incognito) + + is NetworkStatus.Error -> + Icon( + painterResource(MR.images.ic_error), + contentDescription = descr, + tint = MaterialTheme.colors.secondary, + modifier = Modifier + .size(19.dp) + ) + + else -> + CircularProgressIndicator( + Modifier + .padding(horizontal = 2.dp) + .size(15.dp), + color = MaterialTheme.colors.secondary, + strokeWidth = 1.5.dp + ) + } + } else { + IncognitoIcon(chat.chatInfo.incognito) + } + } + Row { Box(contentAlignment = Alignment.BottomEnd) { ChatInfoImage(cInfo, size = 72.dp) @@ -199,14 +229,14 @@ fun ChatPreviewView( chatPreviewTitle() val height = with(LocalDensity.current) { 46.sp.toDp() } Row(Modifier.heightIn(min = height)) { - chatPreviewText(chatModelIncognito) + chatPreviewText() } } - val ts = chat.chatItems.lastOrNull()?.timestampText ?: getTimestampText(chat.chatInfo.updatedAt) Box( contentAlignment = Alignment.TopEnd ) { + val ts = chat.chatItems.lastOrNull()?.timestampText ?: getTimestampText(chat.chatInfo.updatedAt) Text( ts, color = MaterialTheme.colors.secondary, @@ -262,24 +292,33 @@ fun ChatPreviewView( ) } } - if (cInfo is ChatInfo.Direct) { - Box( - Modifier.padding(top = 52.dp), - contentAlignment = Alignment.Center - ) { - ChatStatusImage(contactNetworkStatus) - } + Box( + Modifier.padding(top = 50.dp), + contentAlignment = Alignment.Center + ) { + chatStatusImage() } } } } @Composable -private fun groupInvitationPreviewText(chatModelIncognito: Boolean, currentUserProfileDisplayName: String?, groupInfo: GroupInfo): String { +fun IncognitoIcon(incognito: Boolean) { + if (incognito) { + Icon( + painterResource(MR.images.ic_theater_comedy), + contentDescription = null, + tint = MaterialTheme.colors.secondary, + modifier = Modifier + .size(21.dp) + ) + } +} + +@Composable +private fun groupInvitationPreviewText(currentUserProfileDisplayName: String?, groupInfo: GroupInfo): String { return if (groupInfo.membership.memberIncognito) String.format(stringResource(MR.strings.group_preview_join_as), groupInfo.membership.memberProfile.displayName) - else if (chatModelIncognito) - String.format(stringResource(MR.strings.group_preview_join_as), currentUserProfileDisplayName ?: "") else stringResource(MR.strings.group_preview_you_are_invited) } @@ -289,28 +328,6 @@ fun unreadCountStr(n: Int): String { return if (n < 1000) "$n" else "${n / 1000}" + stringResource(MR.strings.thousand_abbreviation) } -@Composable -fun ChatStatusImage(s: NetworkStatus?) { - val descr = s?.statusString - if (s is NetworkStatus.Error) { - Icon( - painterResource(MR.images.ic_error), - contentDescription = descr, - tint = MaterialTheme.colors.secondary, - modifier = Modifier - .size(19.dp) - ) - } else if (s !is NetworkStatus.Connected) { - CircularProgressIndicator( - Modifier - .padding(horizontal = 2.dp) - .size(15.dp), - color = MaterialTheme.colors.secondary, - strokeWidth = 1.5.dp - ) - } -} - @Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, @@ -319,6 +336,6 @@ fun ChatStatusImage(s: NetworkStatus?) { @Composable fun PreviewChatPreviewView() { SimpleXTheme { - ChatPreviewView(Chat.sampleData, null, null, false, "", contactNetworkStatus = NetworkStatus.Connected(), stopped = false, linkMode = SimplexLinkMode.DESCRIPTION) + ChatPreviewView(Chat.sampleData, null, null, "", contactNetworkStatus = NetworkStatus.Connected(), stopped = false, linkMode = SimplexLinkMode.DESCRIPTION) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactConnectionView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactConnectionView.kt index 7931034de3..99d6c5db15 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactConnectionView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactConnectionView.kt @@ -39,16 +39,22 @@ fun ContactConnectionView(contactConnection: PendingContactConnection) { val height = with(LocalDensity.current) { 46.sp.toDp() } Text(contactConnection.description, Modifier.heightIn(min = height), maxLines = 2, color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight) } - val ts = getTimestampText(contactConnection.updatedAt) - Column( - Modifier.fillMaxHeight(), + Box( + contentAlignment = Alignment.TopEnd ) { + val ts = getTimestampText(contactConnection.updatedAt) Text( ts, color = MaterialTheme.colors.secondary, style = MaterialTheme.typography.body2, modifier = Modifier.padding(bottom = 5.dp) ) + Box( + Modifier.padding(top = 50.dp), + contentAlignment = Alignment.Center + ) { + IncognitoIcon(contactConnection.incognito) + } } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactRequestView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactRequestView.kt index 6ec03ad7f5..8debcce98c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactRequestView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactRequestView.kt @@ -18,7 +18,7 @@ import chat.simplex.common.model.getTimestampText import chat.simplex.res.MR @Composable -fun ContactRequestView(chatModelIncognito: Boolean, contactRequest: ChatInfo.ContactRequest) { +fun ContactRequestView(contactRequest: ChatInfo.ContactRequest) { Row { ChatInfoImage(contactRequest, size = 72.dp) Column( @@ -32,7 +32,7 @@ fun ContactRequestView(chatModelIncognito: Boolean, contactRequest: ChatInfo.Con overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.h3, fontWeight = FontWeight.Bold, - color = if (chatModelIncognito) Indigo else MaterialTheme.colors.primary + color = MaterialTheme.colors.primary ) val height = with(LocalDensity.current) { 46.sp.toDp() } Text(stringResource(MR.strings.contact_wants_to_connect_with_you), Modifier.heightIn(min = height), maxLines = 2, color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt index 2d0ed7eb53..8b65b2b5bc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt @@ -121,14 +121,6 @@ private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableState color = MaterialTheme.colors.onBackground, fontWeight = FontWeight.SemiBold, ) - if (chatModel.incognito.value) { - Icon( - painterResource(MR.images.ic_theater_comedy_filled), - stringResource(MR.strings.incognito), - tint = Indigo, - modifier = Modifier.padding(10.dp).size(26.dp) - ) - } } }, onTitleClick = null, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt index 7ebbb7e5cb..45accccc59 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt @@ -1,7 +1,10 @@ package chat.simplex.common.views.helpers +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.border import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.* @@ -48,19 +51,20 @@ fun TextEditor( Modifier .fillMaxWidth() .padding(contentPadding) - .heightIn(min = 52.dp), - // .border(border = BorderStroke(1.dp, strokeColor), shape = RoundedCornerShape(26.dp)), + .heightIn(min = 52.dp) + .border(border = BorderStroke(1.dp, strokeColor), shape = RoundedCornerShape(14.dp)), contentAlignment = Alignment.Center, ) { - val modifier = modifier + val textFieldModifier = modifier .fillMaxWidth() .navigationBarsWithImePadding() .onFocusChanged { focused = it.isFocused } + .padding(10.dp) BasicTextField( value = value.value, onValueChange = { value.value = it }, - modifier = if (focusRequester == null) modifier else modifier.focusRequester(focusRequester), + modifier = if (focusRequester == null) textFieldModifier else textFieldModifier.focusRequester(focusRequester), textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground, lineHeight = 22.sp), keyboardOptions = KeyboardOptions( capitalization = KeyboardCapitalization.None, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactView.kt index 2ee2145259..8542ea52a4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddContactView.kt @@ -1,7 +1,7 @@ package chat.simplex.common.views.newchat import SectionBottomSpacer -import SectionSpacer +import SectionTextFooter import SectionView import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.layout.* @@ -14,20 +14,26 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalClipboardManager import dev.icerock.moko.resources.compose.stringResource -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import chat.simplex.common.model.* import chat.simplex.common.platform.shareText import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.usersettings.SettingsActionItem +import chat.simplex.common.views.usersettings.* import chat.simplex.res.MR @Composable -fun AddContactView(connReqInvitation: String, connIncognito: Boolean) { +fun AddContactView( + chatModel: ChatModel, + connReqInvitation: String, + contactConnection: MutableState +) { val clipboard = LocalClipboardManager.current AddContactLayout( + chatModel = chatModel, + incognitoPref = chatModel.controller.appPrefs.incognito, connReq = connReqInvitation, - connIncognito = connIncognito, + contactConnection = contactConnection, share = { clipboard.shareText(connReqInvitation) }, learnMore = { ModalManager.center.showModal { @@ -45,57 +51,63 @@ fun AddContactView(connReqInvitation: String, connIncognito: Boolean) { } @Composable -fun AddContactLayout(connReq: String, connIncognito: Boolean, share: () -> Unit, learnMore: () -> Unit) { +fun AddContactLayout( + chatModel: ChatModel, + incognitoPref: SharedPreference, + connReq: String, + contactConnection: MutableState, + share: () -> Unit, + learnMore: () -> Unit +) { + val incognito = remember { mutableStateOf(incognitoPref.get()) } + + LaunchedEffect(incognito.value) { + withApi { + val contactConnVal = contactConnection.value + if (contactConnVal != null) { + chatModel.controller.apiSetConnectionIncognito(contactConnVal.pccConnId, incognito.value)?.let { + contactConnection.value = it + chatModel.updateContactConnection(it) + } + } + } + } + Column( Modifier .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.SpaceBetween, ) { AppBarTitle(stringResource(MR.strings.add_contact)) - OneTimeLinkProfileText(connIncognito) - SectionSpacer() SectionView(stringResource(MR.strings.one_time_link_short).uppercase()) { - OneTimeLinkSection(connReq, share, learnMore) + if (connReq.isNotEmpty()) { + QRCode( + connReq, Modifier + .padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF) + .aspectRatio(1f) + ) + } else { + CircularProgressIndicator( + Modifier + .size(36.dp) + .padding(4.dp) + .align(Alignment.CenterHorizontally), + color = MaterialTheme.colors.secondary, + strokeWidth = 3.dp + ) + } + + IncognitoToggle(incognitoPref, incognito) { ModalManager.start.showModal { IncognitoView() } } + ShareLinkButton(share) + OneTimeLinkLearnMoreButton(learnMore) } + SectionTextFooter(sharedProfileInfo(chatModel, incognito.value)) + SectionBottomSpacer() } } -@Composable -fun OneTimeLinkProfileText(connIncognito: Boolean) { - Row(Modifier.padding(horizontal = DEFAULT_PADDING)) { - InfoAboutIncognito( - connIncognito, - true, - generalGetString(MR.strings.incognito_random_profile_description), - generalGetString(MR.strings.your_profile_will_be_sent) - ) - } -} - -@Composable -fun ColumnScope.OneTimeLinkSection(connReq: String, share: () -> Unit, learnMore: () -> Unit) { - if (connReq.isNotEmpty()) { - QRCode( - connReq, Modifier - .padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF) - .aspectRatio(1f) - ) - } else { - CircularProgressIndicator( - Modifier - .size(36.dp) - .padding(4.dp) - .align(Alignment.CenterHorizontally), - color = MaterialTheme.colors.secondary, - strokeWidth = 3.dp - ) - } - ShareLinkButton(share) - OneTimeLinkLearnMoreButton(learnMore) -} - @Composable fun ShareLinkButton(onClick: () -> Unit) { SettingsActionItem( @@ -117,39 +129,38 @@ fun OneTimeLinkLearnMoreButton(onClick: () -> Unit) { } @Composable -fun InfoAboutIncognito(chatModelIncognito: Boolean, supportedIncognito: Boolean = true, onText: String, offText: String, centered: Boolean = false) { - if (chatModelIncognito) { - Row( - Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = if (centered) Arrangement.Center else Arrangement.Start - ) { - Icon( - if (supportedIncognito) painterResource(MR.images.ic_theater_comedy_filled) else painterResource(MR.images.ic_info), - stringResource(MR.strings.incognito), - tint = if (supportedIncognito) Indigo else WarningOrange, - modifier = Modifier.padding(end = 10.dp).size(20.dp) - ) - Text(onText, textAlign = if (centered) TextAlign.Center else TextAlign.Left, style = MaterialTheme.typography.body2) - } +fun IncognitoToggle( + incognitoPref: SharedPreference, + incognito: MutableState, + onClickInfo: () -> Unit +) { + SettingsActionItemWithContent( + icon = if (incognito.value) painterResource(MR.images.ic_theater_comedy_filled) else painterResource(MR.images.ic_theater_comedy), + text = null, + click = onClickInfo, + iconColor = if (incognito.value) Indigo else MaterialTheme.colors.secondary, + extraPadding = false + ) { + SharedPreferenceToggleWithIcon( + stringResource(MR.strings.incognito), + painterResource(MR.images.ic_info), + stopped = false, + onClickInfo = onClickInfo, + preference = incognitoPref, + preferenceState = incognito + ) + } +} + +fun sharedProfileInfo( + chatModel: ChatModel, + incognito: Boolean +): String { + val name = chatModel.currentUser.value?.displayName ?: "" + return if (incognito) { + generalGetString(MR.strings.connect__a_new_random_profile_will_be_shared) } else { - Row( - Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = if (centered) Arrangement.Center else Arrangement.Start - ) { - Icon( - painterResource(MR.images.ic_info), - stringResource(MR.strings.incognito), - tint = MaterialTheme.colors.secondary, - modifier = Modifier.padding(end = 10.dp).size(20.dp) - ) - Text(offText, textAlign = if (centered) TextAlign.Center else TextAlign.Left, style = MaterialTheme.typography.body2) - } + String.format(generalGetString(MR.strings.connect__your_profile_will_be_shared), name) } } @@ -162,8 +173,10 @@ fun InfoAboutIncognito(chatModelIncognito: Boolean, supportedIncognito: Boolean fun PreviewAddContactView() { SimpleXTheme { AddContactLayout( + chatModel = ChatModel, + incognitoPref = SharedPreference({ false }, {}), connReq = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D", - connIncognito = false, + contactConnection = mutableStateOf(PendingContactConnection.getSampleData()), share = {}, learnMore = {}, ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt index 2b971282b3..fe62a7d9da 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt @@ -2,16 +2,18 @@ package chat.simplex.common.views.newchat import SectionBottomSpacer import SectionDividerSpaced +import SectionTextFooter import SectionView import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.* import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.material.* import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.unit.dp import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import chat.simplex.common.model.* @@ -19,10 +21,10 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.LocalAliasEditor import chat.simplex.common.views.chatlist.deleteContactConnectionAlert import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.usersettings.SettingsActionItem import chat.simplex.common.model.ChatModel import chat.simplex.common.model.PendingContactConnection import chat.simplex.common.platform.shareText +import chat.simplex.common.views.usersettings.* import chat.simplex.res.MR @Composable @@ -49,10 +51,10 @@ fun ContactConnectionInfoView( } val clipboard = LocalClipboardManager.current ContactConnectionInfoLayout( + chatModel = chatModel, connReq = connReqInvitation, - contactConnection, - connIncognito = contactConnection.incognito, - focusAlias, + contactConnection = contactConnection, + focusAlias = focusAlias, deleteConnection = { deleteContactConnectionAlert(contactConnection, chatModel, close) }, onLocalAliasChanged = { setContactAlias(contactConnection, it, chatModel) }, share = { if (connReqInvitation != null) clipboard.shareText(connReqInvitation) }, @@ -73,22 +75,43 @@ fun ContactConnectionInfoView( @Composable private fun ContactConnectionInfoLayout( + chatModel: ChatModel, connReq: String?, contactConnection: PendingContactConnection, - connIncognito: Boolean, focusAlias: Boolean, deleteConnection: () -> Unit, onLocalAliasChanged: (String) -> Unit, share: () -> Unit, learnMore: () -> Unit, ) { + @Composable fun incognitoEnabled() { + if (contactConnection.incognito) { + SettingsActionItemWithContent( + icon = painterResource(MR.images.ic_theater_comedy_filled), + text = null, + click = { ModalManager.start.showModal { IncognitoView() } }, + iconColor = Indigo, + extraPadding = false + ) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(MR.strings.incognito), Modifier.padding(end = 4.dp)) + Icon( + painterResource(MR.images.ic_info), + null, + tint = MaterialTheme.colors.primary + ) + } + } + } + } + Column( Modifier .verticalScroll(rememberScrollState()), ) { AppBarTitle( stringResource( - if (contactConnection.initiated) MR.strings.you_invited_your_contact + if (contactConnection.initiated) MR.strings.you_invited_a_contact else MR.strings.you_accepted_connection ) ) @@ -101,7 +124,6 @@ private fun ContactConnectionInfoLayout( ), Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING) ) - OneTimeLinkProfileText(connIncognito) if (contactConnection.groupLinkId == null) { LocalAliasEditor(contactConnection.localAlias, center = false, leadingIcon = true, focus = focusAlias, updateValue = onLocalAliasChanged) @@ -109,11 +131,20 @@ private fun ContactConnectionInfoLayout( SectionView { if (!connReq.isNullOrEmpty() && contactConnection.initiated) { - OneTimeLinkSection(connReq, share, learnMore) + QRCode( + connReq, Modifier + .padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF) + .aspectRatio(1f) + ) + incognitoEnabled() + ShareLinkButton(share) + OneTimeLinkLearnMoreButton(learnMore) } else { + incognitoEnabled() OneTimeLinkLearnMoreButton(learnMore) } } + SectionTextFooter(sharedProfileInfo(chatModel, contactConnection.incognito)) SectionDividerSpaced(maxBottomPadding = false) @@ -149,9 +180,9 @@ private fun setContactAlias(contactConnection: PendingContactConnection, localAl private fun PreviewContactConnectionInfoView() { SimpleXTheme { ContactConnectionInfoLayout( + chatModel = ChatModel, connReq = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D", - PendingContactConnection.getSampleData(), - connIncognito = false, + contactConnection = PendingContactConnection.getSampleData(), focusAlias = false, deleteConnection = {}, onLocalAliasChanged = {}, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/CreateLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/CreateLinkView.kt index 53f7bdcc73..08afbd4c6c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/CreateLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/CreateLinkView.kt @@ -10,6 +10,7 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.sp import chat.simplex.common.model.ChatModel +import chat.simplex.common.model.PendingContactConnection import chat.simplex.common.views.helpers.ModalManager import chat.simplex.common.views.helpers.withApi import chat.simplex.common.views.usersettings.UserAddressView @@ -23,10 +24,16 @@ enum class CreateLinkTab { fun CreateLinkView(m: ChatModel, initialSelection: CreateLinkTab) { val selection = remember { mutableStateOf(initialSelection) } val connReqInvitation = rememberSaveable { m.connReqInv } + val contactConnection: MutableState = rememberSaveable { mutableStateOf(null) } val creatingConnReq = rememberSaveable { mutableStateOf(false) } LaunchedEffect(selection.value) { - if (selection.value == CreateLinkTab.ONE_TIME && connReqInvitation.value.isNullOrEmpty() && !creatingConnReq.value) { - createInvitation(m, creatingConnReq, connReqInvitation) + if ( + selection.value == CreateLinkTab.ONE_TIME + && connReqInvitation.value.isNullOrEmpty() + && contactConnection.value == null + && !creatingConnReq.value + ) { + createInvitation(m, creatingConnReq, connReqInvitation, contactConnection) } } /** When [AddContactView] is open, we don't need to drop [chatModel.connReqInv]. @@ -42,9 +49,12 @@ fun CreateLinkView(m: ChatModel, initialSelection: CreateLinkTab) { } val tabTitles = CreateLinkTab.values().map { when { - it == CreateLinkTab.ONE_TIME && connReqInvitation.value.isNullOrEmpty() -> stringResource(MR.strings.create_one_time_link) - it == CreateLinkTab.ONE_TIME -> stringResource(MR.strings.one_time_link) - it == CreateLinkTab.LONG_TERM -> stringResource(MR.strings.your_simplex_contact_address) + it == CreateLinkTab.ONE_TIME && connReqInvitation.value.isNullOrEmpty() && contactConnection.value == null -> + stringResource(MR.strings.create_one_time_link) + it == CreateLinkTab.ONE_TIME -> + stringResource(MR.strings.one_time_link) + it == CreateLinkTab.LONG_TERM -> + stringResource(MR.strings.your_simplex_contact_address) else -> "" } } @@ -56,7 +66,7 @@ fun CreateLinkView(m: ChatModel, initialSelection: CreateLinkTab) { Column(Modifier.weight(1f)) { when (selection.value) { CreateLinkTab.ONE_TIME -> { - AddContactView(connReqInvitation.value ?: "", m.incognito.value) + AddContactView(m, connReqInvitation.value ?: "", contactConnection) } CreateLinkTab.LONG_TERM -> { UserAddressView(m, viaCreateLinkView = true, close = {}) @@ -89,12 +99,18 @@ fun CreateLinkView(m: ChatModel, initialSelection: CreateLinkTab) { } } -private fun createInvitation(m: ChatModel, creatingConnReq: MutableState, connReqInvitation: MutableState) { +private fun createInvitation( + m: ChatModel, + creatingConnReq: MutableState, + connReqInvitation: MutableState, + contactConnection: MutableState +) { creatingConnReq.value = true withApi { - val connReq = m.controller.apiAddContact() - if (connReq != null) { - connReqInvitation.value = connReq + val r = m.controller.apiAddContact(incognito = m.controller.appPrefs.incognito.get()) + if (r != null) { + connReqInvitation.value = r.first + contactConnection.value = r.second } else { creatingConnReq.value = false } 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 ba8b8df548..a0d0da69ac 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 @@ -1,22 +1,26 @@ 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.Text import androidx.compose.runtime.* import androidx.compose.ui.Modifier import dev.icerock.moko.resources.compose.painterResource 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.* import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.usersettings.IncognitoView +import chat.simplex.common.views.usersettings.SettingsActionItem import chat.simplex.res.MR import java.net.URI @@ -25,85 +29,98 @@ fun PasteToConnectView(chatModel: ChatModel, close: () -> Unit) { val connectionLink = remember { mutableStateOf("") } val clipboard = LocalClipboardManager.current PasteToConnectLayout( - chatModel.incognito.value, + chatModel = chatModel, + incognitoPref = chatModel.controller.appPrefs.incognito, connectionLink = connectionLink, pasteFromClipboard = { connectionLink.value = clipboard.getText()?.text ?: return@PasteToConnectLayout }, - connectViaLink = { connReqUri -> - try { - val uri = URI(connReqUri) - withUriAction(uri) { linkType -> - val action = suspend { - Log.d(TAG, "connectViaUri: connecting") - if (connectViaUri(chatModel, linkType, uri)) { - close() - } - } - if (linkType == ConnectionLinkType.GROUP) { - AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.connect_via_group_link), - text = generalGetString(MR.strings.you_will_join_group), - confirmText = generalGetString(MR.strings.connect_via_link_verb), - onConfirm = { withApi { action() } } - ) - } else action() - } - } catch (e: RuntimeException) { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.invalid_connection_link), - text = generalGetString(MR.strings.this_string_is_not_a_connection_link) - ) - } - }, + close = close ) } @Composable fun PasteToConnectLayout( - chatModelIncognito: Boolean, + chatModel: ChatModel, + incognitoPref: SharedPreference, connectionLink: MutableState, pasteFromClipboard: () -> Unit, - connectViaLink: (String) -> Unit, + close: () -> Unit ) { + val incognito = remember { mutableStateOf(incognitoPref.get()) } + + fun connectViaLink(connReqUri: String) { + try { + val uri = URI(connReqUri) + withUriAction(uri) { linkType -> + val action = suspend { + Log.d(TAG, "connectViaUri: connecting") + if (connectViaUri(chatModel, linkType, uri, incognito = incognito.value)) { + close() + } + } + if (linkType == ConnectionLinkType.GROUP) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.connect_via_group_link), + text = generalGetString(MR.strings.you_will_join_group), + confirmText = if (incognito.value) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), + onConfirm = { withApi { action() } } + ) + } else action() + } + } catch (e: RuntimeException) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.invalid_connection_link), + text = generalGetString(MR.strings.this_string_is_not_a_connection_link) + ) + } + } + Column( Modifier.verticalScroll(rememberScrollState()).padding(horizontal = DEFAULT_PADDING), verticalArrangement = Arrangement.SpaceBetween, ) { AppBarTitle(stringResource(MR.strings.connect_via_link), false) - Text(stringResource(MR.strings.paste_connection_link_below_to_connect)) - - InfoAboutIncognito( - chatModelIncognito, - true, - generalGetString(MR.strings.incognito_random_profile_from_contact_description), - generalGetString(MR.strings.profile_will_be_sent_to_contact_sending_link) - ) Box(Modifier.padding(top = DEFAULT_PADDING, bottom = 6.dp)) { - TextEditor(connectionLink, Modifier.height(180.dp), contentPadding = PaddingValues()) + TextEditor( + connectionLink, + Modifier.height(180.dp), + contentPadding = PaddingValues(), + placeholder = stringResource(MR.strings.paste_the_link_you_received_to_connect_with_your_contact) + ) } - Row( - Modifier.fillMaxWidth().padding(bottom = 6.dp), - horizontalArrangement = Arrangement.Start, - ) { - if (connectionLink.value == "") { - SimpleButton(text = stringResource(MR.strings.paste_button), icon = painterResource(MR.images.ic_content_paste)) { - pasteFromClipboard() - } - } else { - SimpleButton(text = stringResource(MR.strings.clear_verb), icon = painterResource(MR.images.ic_close)) { - connectionLink.value = "" - } - } - Spacer(Modifier.weight(1f).fillMaxWidth()) - SimpleButton(text = stringResource(MR.strings.connect_button), icon = painterResource(MR.images.ic_link)) { - connectViaLink(connectionLink.value) - } + if (connectionLink.value == "") { + SettingsActionItem( + painterResource(MR.images.ic_content_paste), + stringResource(MR.strings.paste_button), + click = pasteFromClipboard, + ) + } else { + SettingsActionItem( + painterResource(MR.images.ic_close), + stringResource(MR.strings.clear_verb), + click = { connectionLink.value = "" }, + ) } - Text(annotatedStringResource(MR.strings.you_can_also_connect_by_clicking_the_link)) + SettingsActionItem( + painterResource(MR.images.ic_link), + stringResource(MR.strings.connect_button), + click = { connectViaLink(connectionLink.value) }, + ) + + IncognitoToggle(incognitoPref, incognito) { ModalManager.start.showModal { IncognitoView() } } + + SectionTextFooter( + buildAnnotatedString { + append(sharedProfileInfo(chatModel, incognito.value)) + append("\n\n") + append(annotatedStringResource(MR.strings.you_can_also_connect_by_clicking_the_link)) + } + ) + SectionBottomSpacer() } } @@ -117,17 +134,11 @@ fun PasteToConnectLayout( fun PreviewPasteToConnectTextbox() { SimpleXTheme { PasteToConnectLayout( - chatModelIncognito = false, + chatModel = ChatModel, + incognitoPref = SharedPreference({ false }, {}), connectionLink = remember { mutableStateOf("") }, pasteFromClipboard = {}, - connectViaLink = { link -> - try { - println(link) - // withApi { chatModel.controller.apiConnect(link) } - } catch (e: Exception) { - e.printStackTrace() - } - }, + close = {} ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt index 5e43a34197..a848d3777b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/QRCode.kt @@ -37,7 +37,7 @@ fun QRCode( bitmap = qr, contentDescription = stringResource(MR.strings.image_descr_qr_code), Modifier - .widthIn(max = 500.dp) + .widthIn(max = 360.dp) .then(modifier) .clickable { scope.launch { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt index b6bcf96749..e3fa922755 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.kt @@ -1,23 +1,23 @@ 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.* -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.text.buildAnnotatedString import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp +import chat.simplex.common.model.* import chat.simplex.common.platform.TAG -import chat.simplex.common.model.ChatModel -import chat.simplex.common.model.json -import chat.simplex.common.ui.theme.DEFAULT_PADDING -import chat.simplex.common.ui.theme.SimpleXTheme +import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.usersettings.* import chat.simplex.res.MR import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -26,36 +26,6 @@ import java.net.URI @Composable expect fun ScanToConnectView(chatModel: ChatModel, close: () -> Unit) -@Composable -fun QRCodeScanner(close: () -> Unit) { - QRCodeScanner { connReqUri -> - try { - val uri = URI(connReqUri) - withUriAction(uri) { linkType -> - val action = suspend { - Log.d(TAG, "connectViaUri: connecting") - if (connectViaUri(ChatModel, linkType, uri)) { - close() - } - } - if (linkType == ConnectionLinkType.GROUP) { - AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.connect_via_group_link), - text = generalGetString(MR.strings.you_will_join_group), - confirmText = generalGetString(MR.strings.connect_via_link_verb), - onConfirm = { withApi { action() } } - ) - } else action() - } - } catch (e: RuntimeException) { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.invalid_QR_code), - text = generalGetString(MR.strings.this_QR_code_is_not_a_link) - ) - } - } -} - enum class ConnectionLinkType { CONTACT, INVITATION, GROUP } @@ -93,8 +63,8 @@ fun withUriAction(uri: URI, run: suspend (ConnectionLinkType) -> Unit) { } } -suspend fun connectViaUri(chatModel: ChatModel, action: ConnectionLinkType, uri: URI): Boolean { - val r = chatModel.controller.apiConnect(uri.toString()) +suspend fun connectViaUri(chatModel: ChatModel, action: ConnectionLinkType, uri: URI, incognito: Boolean): Boolean { + val r = chatModel.controller.apiConnect(incognito, uri.toString()) if (r) { AlertManager.shared.showAlertMsg( title = generalGetString(MR.strings.connection_request_sent), @@ -110,28 +80,65 @@ suspend fun connectViaUri(chatModel: ChatModel, action: ConnectionLinkType, uri: } @Composable -fun ConnectContactLayout(chatModelIncognito: Boolean, close: () -> Unit) { +fun ConnectContactLayout( + chatModel: ChatModel, + incognitoPref: SharedPreference, + close: () -> Unit +) { + val incognito = remember { mutableStateOf(incognitoPref.get()) } + + @Composable + fun QRCodeScanner(close: () -> Unit) { + QRCodeScanner { connReqUri -> + try { + val uri = URI(connReqUri) + withUriAction(uri) { linkType -> + val action = suspend { + Log.d(TAG, "connectViaUri: connecting") + if (connectViaUri(ChatModel, linkType, uri, incognito = incognito.value)) { + close() + } + } + if (linkType == ConnectionLinkType.GROUP) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.connect_via_group_link), + text = generalGetString(MR.strings.you_will_join_group), + confirmText = if (incognito.value) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), + onConfirm = { withApi { action() } } + ) + } else action() + } + } catch (e: RuntimeException) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.invalid_QR_code), + text = generalGetString(MR.strings.this_QR_code_is_not_a_link) + ) + } + } + } + Column( Modifier.verticalScroll(rememberScrollState()).padding(horizontal = DEFAULT_PADDING), - verticalArrangement = Arrangement.spacedBy(12.dp) + verticalArrangement = Arrangement.SpaceBetween ) { AppBarTitle(stringResource(MR.strings.scan_QR_code), false) - InfoAboutIncognito( - chatModelIncognito, - true, - generalGetString(MR.strings.incognito_random_profile_description), - generalGetString(MR.strings.your_profile_will_be_sent) - ) Box( Modifier .fillMaxWidth() .aspectRatio(ratio = 1F) .padding(bottom = 12.dp) ) { QRCodeScanner(close) } - Text( - annotatedStringResource(MR.strings.if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link), - lineHeight = 22.sp + + IncognitoToggle(incognitoPref, incognito) { ModalManager.start.showModal { IncognitoView() } } + + SectionTextFooter( + buildAnnotatedString { + append(sharedProfileInfo(chatModel, incognito.value)) + append("\n\n") + append(annotatedStringResource(MR.strings.if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link)) + } ) + SectionBottomSpacer() } } @@ -150,7 +157,8 @@ fun URI.getQueryParameter(param: String): String? { fun PreviewConnectContactLayout() { SimpleXTheme { ConnectContactLayout( - chatModelIncognito = false, + chatModel = ChatModel, + incognitoPref = SharedPreference({ false }, {}), close = {}, ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/IncognitoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/IncognitoView.kt index 4728c0f2b7..e264172f9c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/IncognitoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/IncognitoView.kt @@ -31,7 +31,6 @@ fun IncognitoLayout() { Text(generalGetString(MR.strings.incognito_info_protects)) Text(generalGetString(MR.strings.incognito_info_allows)) Text(generalGetString(MR.strings.incognito_info_share)) - Text(generalGetString(MR.strings.incognito_info_find)) SectionBottomSpacer() } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index 0120e35700..e81746f99f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -37,16 +37,12 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, drawerSt val user = chatModel.currentUser.value val stopped = chatModel.chatRunning.value == false - MaintainIncognitoState(chatModel) - if (user != null) { val requireAuth = remember { chatModel.controller.appPrefs.performLA.state } SettingsLayout( profile = user.profile, stopped, chatModel.chatDbEncrypted.value == true, - chatModel.incognito, - chatModel.controller.appPrefs.incognito, user.displayName, setPerformLA = setPerformLA, showModal = { modalView -> { ModalManager.start.showModal { modalView(chatModel) } } }, @@ -118,8 +114,6 @@ fun SettingsLayout( profile: LocalProfile, stopped: Boolean, encrypted: Boolean, - incognito: MutableState, - incognitoPref: SharedPreference, userDisplayName: String, setPerformLA: (Boolean) -> Unit, showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit), @@ -155,7 +149,6 @@ fun SettingsLayout( } val profileHidden = rememberSaveable { mutableStateOf(false) } SettingsActionItem(painterResource(MR.images.ic_manage_accounts), stringResource(MR.strings.your_chat_profiles), { withAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) { showSettingsModalWithSearch { it, search -> UserProfilesView(it, search, profileHidden) } } }, disabled = stopped, extraPadding = true) - SettingsIncognitoActionItem(incognitoPref, incognito, stopped) { showModal { IncognitoView() }() } SettingsActionItem(painterResource(MR.images.ic_qr_code), stringResource(MR.strings.your_simplex_contact_address), showCustomModal { it, close -> UserAddressView(it, shareViaProfile = it.currentUser.value!!.addressShared, close = close) }, disabled = stopped, extraPadding = true) ChatPreferencesItem(showCustomModal, stopped = stopped) } @@ -212,43 +205,6 @@ expect fun SettingsSectionApp( withAuth: (title: String, desc: String, block: () -> Unit) -> Unit ) -@Composable -fun SettingsIncognitoActionItem( - incognitoPref: SharedPreference, - incognito: MutableState, - stopped: Boolean, - onClickInfo: () -> Unit, -) { - SettingsPreferenceItemWithInfo( - if (incognito.value) painterResource(MR.images.ic_theater_comedy_filled) else painterResource(MR.images.ic_theater_comedy), - if (incognito.value) Indigo else MaterialTheme.colors.secondary, - stringResource(MR.strings.incognito), - stopped, - onClickInfo, - incognitoPref, - incognito - ) -} - -@Composable -fun MaintainIncognitoState(chatModel: ChatModel) { - // Cache previous value and once it changes in background, update it via API - var cachedIncognito by remember { mutableStateOf(chatModel.incognito.value) } - LaunchedEffect(chatModel.incognito.value) { - // Don't do anything if nothing changed - if (cachedIncognito == chatModel.incognito.value) return@LaunchedEffect - try { - chatModel.controller.apiSetIncognito(chatModel.incognito.value) - } catch (e: Exception) { - // Rollback the state - chatModel.controller.appPrefs.incognito.set(cachedIncognito) - // Crash the app - throw e - } - cachedIncognito = chatModel.incognito.value - } -} - @Composable private fun DatabaseItem(encrypted: Boolean, openDatabaseView: () -> Unit, stopped: Boolean) { SectionItemViewWithIcon(openDatabaseView) { Row( @@ -453,21 +409,6 @@ fun SettingsPreferenceItem( } } -@Composable -fun SettingsPreferenceItemWithInfo( - icon: Painter, - iconTint: Color, - text: String, - stopped: Boolean, - onClickInfo: () -> Unit, - pref: SharedPreference, - prefState: MutableState? = null -) { - SettingsActionItemWithContent(icon, null, click = if (stopped) null else onClickInfo, iconColor = iconTint, extraPadding = true,) { - SharedPreferenceToggleWithIcon(text, painterResource(MR.images.ic_info), stopped, onClickInfo, pref, prefState) - } -} - @Composable fun PreferenceToggle( text: String, @@ -523,8 +464,6 @@ fun PreviewSettingsLayout() { profile = LocalProfile.sampleData, stopped = false, encrypted = false, - incognito = remember { mutableStateOf(false) }, - incognitoPref = SharedPreference({ false }, {}), userDisplayName = "Alice", setPerformLA = { _ -> }, showModal = { {} }, 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 753e89a1d4..c060470b15 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -7,9 +7,12 @@ Connect via contact link? Connect via invitation link? Connect via group link? + Use current profile + Use new incognito profile Your profile will be sent to the contact that you received this link from. You will join a group this link refers to and connect to its group members. Connect + Connect incognito Opening database… @@ -443,7 +446,7 @@ - You invited your contact + You invited a contact You accepted connection Delete pending connection? The contact you shared this link with will NOT be able to connect! @@ -489,11 +492,13 @@ Your chat profile will be sent\nto your contact scan QR code in the video call, or your contact can share an invitation link.]]> Share 1-time link - Paste the link you received into the box below to connect with your contact. - Your chat profile will be sent to your contact + Paste the link you received to connect with your contact… Learn more About SimpleX address + A new random profile will be shared. + Your profile %1$s will be shared. + To connect, your contact can scan QR code or use the link in the app. If you can\'t meet in person, show QR code in a video call, or share the link. @@ -1247,7 +1252,6 @@ The group is fully decentralized – it is visible only to the members. Group display name: Group full name: - Incognito mode is not supported here - your main profile will be sent to group members Your chat profile will be sent to group members @@ -1301,13 +1305,10 @@ Incognito Your random profile - A random profile will be sent to your contact - A random profile will be sent to the contact that you received this link from - Incognito mode protects the privacy of your main profile name and image — for each new contact a new random profile is created. + Incognito mode protects your privacy by using a new random profile for each contact. It allows having many anonymous connections without any shared data between them in a single chat profile. When you share an incognito profile with somebody, this profile will be used for the groups they invite you to. - To find the profile used for an incognito connection, tap the contact or group name on top of the chat. System diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml index 9576c1ff92..9f415446eb 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml @@ -201,7 +201,7 @@ Označit jako nepřečteno Ztlumit Zrušit ztlumení - Pozvali jste kontakt + Pozvali jste kontakt Kontakt, se kterým jste tento odkaz sdíleli, se NEBUDE moci připojit! Připojení, které jste přijali, bude zrušeno! help diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml index 159dd1e5dc..9a9395e306 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml @@ -292,7 +292,7 @@ Stummschalten Stummschaltung aufheben - Sie haben Ihren Kontakt eingeladen + Sie haben Ihren Kontakt eingeladen Sie haben die Verbindung akzeptiert Ausstehende Verbindung löschen? Der Kontakt, mit dem Sie diesen Link geteilt haben, kann sich NICHT verbinden! diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml index 6e5b82e74e..40fae87821 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml @@ -914,7 +914,7 @@ Tu rol es observador Comprobar código de seguridad Has aceptado la conexión - Has invitado a tu contacto + Has invitado a tu contacto Te conectarás al grupo cuando el dispositivo anfitrión esté en línea, por favor espera o compruébalo más tarde. Tu configuración Servidores SMP diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml index d4e5df7055..5256965f64 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml @@ -1232,7 +1232,7 @@ sinut on kutsuttu ryhmään Poista mykistys Hyväksyit yhteyden - Kutsuit kontaktisi + Kutsuit kontaktisi releellä Varoitus: saatat menettää joitain tietoja! SimpleX Osoite diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml index 1462e04f61..2d225895c1 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml @@ -221,7 +221,7 @@ Marquer comme lu Marquer non lu Définir le nom du contact - Vous avez invité votre contact + Vous avez invité votre contact Vous avez accepté la connexion Supprimer la connexion en attente \? La connexion que vous avez acceptée sera annulée ! diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml index 4dc63182f0..62e7f12e33 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -568,7 +568,7 @@ Indirizzo di SimpleX Squadra di SimpleX Hai accettato la connessione - Hai invitato il contatto + Hai invitato il contatto Il tuo profilo di chat verrà inviato \nal tuo contatto Il tuo contatto deve essere in linea per completare la connessione. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml index e165588a6b..fd429a672e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml @@ -1182,7 +1182,7 @@ ממתין לסרטון ממתין לקובץ הודעות קוליות אסורות! - הזמנת את איש הקשר שלך + הזמנת את איש הקשר שלך באפשרותכם לשתף את הכתובת שלכם כקישור או כקוד QR – כל אחד יכול להתחבר אליכם. כאשר אנשים מבקשים להתחבר, באפשרותך לקבל או לדחות זאת. פתח באפליקציה.]]> diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml index c3f5dbfec2..0601bc5666 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml @@ -715,7 +715,7 @@ SimpleX Chatをご利用いただきありがとうございます! カメラ 繋がりを承認しました - 連絡先に招待を送りました + 連絡先に招待を送りました 承認ずみの接続がキャンセルされます! あなたからリンクを受けた連絡先が接続できなくなります! SimpleXアドレス diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml index 4167a47847..d557dc6221 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml @@ -682,7 +682,7 @@ De door u geaccepteerde verbinding wordt geannuleerd! Het contact met wie je deze link hebt gedeeld, kan GEEN verbinding maken! Je hebt de verbinding geaccepteerd - Je hebt je contactpersoon uitgenodigd + Je hebt je contactpersoon uitgenodigd Uw contactpersoon moet online zijn om de verbinding te voltooien. \nU kunt deze verbinding verbreken en het contact verwijderen (en later proberen met een nieuwe link). QR code diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml index 3364d15f14..e7423abdfc 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml @@ -257,7 +257,7 @@ Wyłącz wyciszenie chce się z Tobą połączyć! Zaakceptowałeś połączenie - Zaprosiłeś swój kontakt + Zaprosiłeś swój kontakt Twój kontakt musi być online, aby połączenie zostało zakończone. \nMożesz anulować to połączenie i usunąć kontakt (i spróbować później z nowym linkiem). Połącz diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml index 0f3b415469..06ee46ac52 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml @@ -473,7 +473,7 @@ Recebendo mensagens… Aruivo grande! Marcado como lido - Você convidou seu contato + Você convidou seu contato Código QR inválido Mais Você será conectado ao grupo quando o dispositivo do host do grupo estiver online, por favor aguarde ou verifique mais tarde! diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml index da755b65ef..b0dad03aa2 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml @@ -294,7 +294,7 @@ Без звука Уведомлять - Вы пригласили Ваш контакт + Вы пригласили Ваш контакт Вы приняли приглашение соединиться Удалить ожидаемое соединение? Контакт, которому Вы отправили эту ссылку, не сможет соединиться! diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml index f2f7fd70c7..c669e03bcf 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml @@ -1126,7 +1126,7 @@ เพื่อเริ่มแชทใหม่ วิดีโอ เปิดเสียง - คุณได้เชิญผู้ติดต่อของคุณ + คุณได้เชิญผู้ติดต่อของคุณ คุณยอมรับการเชื่อมต่อ ต้องการเชื่อมต่อกับคุณ! ผู้ติดต่อของคุณจะต้องออนไลน์เพื่อให้การเชื่อมต่อเสร็จสมบูรณ์ diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml index 161d641b3b..51e676a12c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml @@ -226,7 +226,7 @@ Відкрийте в мобільному додатку, потім торкніться Підключіть в додатку.]]> Вимкнути звук Увімкнути звук - Ви запросили свого контакта + Ви запросили свого контакта Контакт, якому ви надали це посилання, НЕ зможе підключитися! заповнювач зображення профілю QR-код diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml index 21f154caac..133e5041ff 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml @@ -902,7 +902,7 @@ 语音消息禁止发送! 您需要允许您的联系人发送语音消息才能发送它们。 扫描二维码 - 您邀请了您的联系人 + 您邀请了您的联系人 想要与您连接! 您的联系人需要在线才能完成连接。 \n您可以取消此连接并删除联系人(稍后尝试使用新链接)。 diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml index 714e8fb8f5..da61b8bb45 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml @@ -533,7 +533,7 @@ 連接到 SimpleX Chat 開發人員提出任何問題並同意更新。]]> 開啟新的對話 設定聯絡人名稱 - 你已邀請了你的聯絡人 + 你已邀請了你的聯絡人 你接受了連接 刪除等待中的連接? 當聯絡人發現此連結後,嘗試點擊的聯絡人將無法連接! diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.desktop.kt index e485ee479d..20675dc74e 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDialog.desktop.kt @@ -1,7 +1,13 @@ package chat.simplex.common.views.helpers +import androidx.compose.foundation.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface import androidx.compose.runtime.* +import androidx.compose.ui.Modifier import androidx.compose.ui.input.key.* +import androidx.compose.ui.unit.dp import androidx.compose.ui.window.* import chat.simplex.common.DialogParams import chat.simplex.res.MR @@ -21,6 +27,8 @@ actual fun DefaultDialog( ) { Dialog( undecorated = true, + transparent = true, + resizable = false, title = "", onCloseRequest = onDismissRequest, onPreviewKeyEvent = { event -> @@ -29,7 +37,12 @@ actual fun DefaultDialog( } else false } ) { - content() + Surface( + Modifier + .border(border = BorderStroke(1.dp, MaterialTheme.colors.secondary.copy(alpha = 0.3F)), shape = RoundedCornerShape(8)) + ) { + content() + } } } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.desktop.kt index 66f2d5c6d2..f202318f16 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/newchat/ScanToConnectView.desktop.kt @@ -6,7 +6,8 @@ import chat.simplex.common.model.ChatModel @Composable actual fun ScanToConnectView(chatModel: ChatModel, close: () -> Unit) { ConnectContactLayout( - chatModelIncognito = chatModel.incognito.value, + chatModel = chatModel, + incognitoPref = chatModel.controller.appPrefs.incognito, close = close ) } diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index 07e2de4eaf..0c3aed6a88 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -25,10 +25,11 @@ android.nonTransitiveRClass=true android.enableJetifier=true kotlin.mpp.androidSourceSetLayoutVersion=2 -android.version_name=5.3-beta.2 -android.version_code=140 +android.version_name=5.3-beta.3 +android.version_code=141 -desktop.version_name=1.0.1 +desktop.version_name=1.1.0 +desktop.version_code=3 kotlin.version=1.8.20 gradle.plugin.version=7.4.2 diff --git a/apps/simplex-broadcast-bot/Main.hs b/apps/simplex-broadcast-bot/Main.hs index 966971633f..15bb743b56 100644 --- a/apps/simplex-broadcast-bot/Main.hs +++ b/apps/simplex-broadcast-bot/Main.hs @@ -1,76 +1,11 @@ -{-# LANGUAGE DuplicateRecordFields #-} -{-# LANGUAGE GADTs #-} -{-# LANGUAGE LambdaCase #-} -{-# LANGUAGE NamedFieldPuns #-} -{-# LANGUAGE OverloadedStrings #-} - module Main where -import Control.Concurrent (forkIO) -import Control.Concurrent.Async -import Control.Concurrent.STM -import Control.Monad.Reader -import qualified Data.Text as T -import Options -import Simplex.Chat.Bot -import Simplex.Chat.Controller +import Broadcast.Bot +import Broadcast.Options import Simplex.Chat.Core -import Simplex.Chat.Messages -import Simplex.Chat.Messages.CIContent -import Simplex.Chat.Options -import Simplex.Chat.Protocol (MsgContent (..)) import Simplex.Chat.Terminal (terminalChatConfig) -import Simplex.Chat.Types -import System.Directory (getAppUserDataDirectory) main :: IO () main = do opts <- welcomeGetOpts simplexChatCore terminalChatConfig (mkChatOpts opts) Nothing $ broadcastBot opts - -welcomeGetOpts :: IO BroadcastBotOpts -welcomeGetOpts = do - appDir <- getAppUserDataDirectory "simplex" - opts@BroadcastBotOpts {coreOptions = CoreChatOpts {dbFilePrefix}} <- getBroadcastBotOpts appDir "simplex_status_bot" - putStrLn $ "SimpleX Chat Bot v" ++ versionNumber - putStrLn $ "db: " <> dbFilePrefix <> "_chat.db, " <> dbFilePrefix <> "_agent.db" - pure opts - -broadcastBot :: BroadcastBotOpts -> User -> ChatController -> IO () -broadcastBot BroadcastBotOpts {publishers, welcomeMessage, prohibitedMessage} _user cc = do - initializeBotAddress cc - race_ (forever $ void getLine) . forever $ do - (_, resp) <- atomically . readTBQueue $ outputQ cc - case resp of - CRContactConnected _ ct _ -> do - contactConnected ct - sendMessage cc ct welcomeMessage - CRNewChatItem _ (AChatItem _ SMDRcv (DirectChat ct) ci@ChatItem {content = CIRcvMsgContent mc}) - | publisher `elem` publishers -> - if allowContent mc - then do - sendChatCmd cc "/contacts" >>= \case - CRContactsList _ cts -> void . forkIO $ do - let cts' = filter broadcastTo cts - forM_ cts' $ \ct' -> sendComposedMessage cc ct' Nothing mc - sendReply $ "Forwarded to " <> show (length cts') <> " contact(s)" - r -> putStrLn $ "Error getting contacts list: " <> show r - else sendReply "!1 Message is not supported!" - | otherwise -> do - sendReply prohibitedMessage - deleteMessage cc ct $ chatItemId' ci - where - sendReply = sendComposedMessage cc ct (Just $ chatItemId' ci) . textMsgContent - publisher = Publisher {contactId = contactId' ct, localDisplayName = localDisplayName' ct} - allowContent = \case - MCText _ -> True - MCLink {} -> True - MCImage {} -> True - _ -> False - broadcastTo ct'@Contact {activeConn = conn@Connection {connStatus}} = - (connStatus == ConnSndReady || connStatus == ConnReady) - && not (connDisabled conn) - && contactId' ct' /= contactId' ct - _ -> pure () - where - contactConnected ct = putStrLn $ T.unpack (localDisplayName' ct) <> " connected" diff --git a/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs b/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs new file mode 100644 index 0000000000..3a1be2ae08 --- /dev/null +++ b/apps/simplex-broadcast-bot/src/Broadcast/Bot.hs @@ -0,0 +1,71 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE GADTs #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module Broadcast.Bot where + +import Control.Concurrent (forkIO) +import Control.Concurrent.Async +import Control.Concurrent.STM +import Control.Monad.Reader +import qualified Data.Text as T +import Broadcast.Options +import Simplex.Chat.Bot +import Simplex.Chat.Bot.KnownContacts +import Simplex.Chat.Controller +import Simplex.Chat.Core +import Simplex.Chat.Messages +import Simplex.Chat.Messages.CIContent +import Simplex.Chat.Options +import Simplex.Chat.Protocol (MsgContent (..)) +import Simplex.Chat.Types +import System.Directory (getAppUserDataDirectory) + +welcomeGetOpts :: IO BroadcastBotOpts +welcomeGetOpts = do + appDir <- getAppUserDataDirectory "simplex" + opts@BroadcastBotOpts {coreOptions = CoreChatOpts {dbFilePrefix}} <- getBroadcastBotOpts appDir "simplex_status_bot" + putStrLn $ "SimpleX Chat Bot v" ++ versionNumber + putStrLn $ "db: " <> dbFilePrefix <> "_chat.db, " <> dbFilePrefix <> "_agent.db" + pure opts + +broadcastBot :: BroadcastBotOpts -> User -> ChatController -> IO () +broadcastBot BroadcastBotOpts {publishers, welcomeMessage, prohibitedMessage} _user cc = do + initializeBotAddress cc + race_ (forever $ void getLine) . forever $ do + (_, resp) <- atomically . readTBQueue $ outputQ cc + case resp of + CRContactConnected _ ct _ -> do + contactConnected ct + sendMessage cc ct welcomeMessage + CRNewChatItem _ (AChatItem _ SMDRcv (DirectChat ct) ci@ChatItem {content = CIRcvMsgContent mc}) + | publisher `elem` publishers -> + if allowContent mc + then do + sendChatCmd cc ListContacts >>= \case + CRContactsList _ cts -> void . forkIO $ do + let cts' = filter broadcastTo cts + forM_ cts' $ \ct' -> sendComposedMessage cc ct' Nothing mc + sendReply $ "Forwarded to " <> show (length cts') <> " contact(s)" + r -> putStrLn $ "Error getting contacts list: " <> show r + else sendReply "!1 Message is not supported!" + | otherwise -> do + sendReply prohibitedMessage + deleteMessage cc ct $ chatItemId' ci + where + sendReply = sendComposedMessage cc ct (Just $ chatItemId' ci) . textMsgContent + publisher = KnownContact {contactId = contactId' ct, localDisplayName = localDisplayName' ct} + allowContent = \case + MCText _ -> True + MCLink {} -> True + MCImage {} -> True + _ -> False + broadcastTo ct'@Contact {activeConn = conn@Connection {connStatus}} = + (connStatus == ConnSndReady || connStatus == ConnReady) + && not (connDisabled conn) + && contactId' ct' /= contactId' ct + _ -> pure () + where + contactConnected ct = putStrLn $ T.unpack (localDisplayName' ct) <> " connected" diff --git a/apps/simplex-broadcast-bot/Options.hs b/apps/simplex-broadcast-bot/src/Broadcast/Options.hs similarity index 69% rename from apps/simplex-broadcast-bot/Options.hs rename to apps/simplex-broadcast-bot/src/Broadcast/Options.hs index 994884760d..76b349a499 100644 --- a/apps/simplex-broadcast-bot/Options.hs +++ b/apps/simplex-broadcast-bot/src/Broadcast/Options.hs @@ -4,48 +4,33 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} -module Options where +module Broadcast.Options where -import qualified Data.Attoparsec.ByteString.Char8 as A -import Data.Int (Int64) import Data.Maybe (fromMaybe) -import Data.Text (Text) -import qualified Data.Text as T -import Data.Text.Encoding (encodeUtf8) import Options.Applicative +import Simplex.Chat.Bot.KnownContacts import Simplex.Chat.Controller (updateStr, versionNumber, versionString) import Simplex.Chat.Options (ChatOpts (..), CoreChatOpts, coreChatOptsP) -import Simplex.Messaging.Parsers (parseAll) -import Simplex.Messaging.Util (safeDecodeUtf8) - -data Publisher = Publisher - { contactId :: Int64, - localDisplayName :: Text - } - deriving (Eq) data BroadcastBotOpts = BroadcastBotOpts { coreOptions :: CoreChatOpts, - publishers :: [Publisher], + publishers :: [KnownContact], welcomeMessage :: String, prohibitedMessage :: String } -defaultWelcomeMessage :: [Publisher] -> String -defaultWelcomeMessage ps = "Hello! I am a broadcast bot.\nI broadcast messages to all connected users from " <> publisherNames ps <> "." +defaultWelcomeMessage :: [KnownContact] -> String +defaultWelcomeMessage ps = "Hello! I am a broadcast bot.\nI broadcast messages to all connected users from " <> knownContactNames ps <> "." -defaultProhibitedMessage :: [Publisher] -> String -defaultProhibitedMessage ps = "Sorry, only these users can broadcast messages: " <> publisherNames ps <> ". Your message is deleted." - -publisherNames :: [Publisher] -> String -publisherNames = T.unpack . T.intercalate ", " . map (("@" <>) . localDisplayName) +defaultProhibitedMessage :: [KnownContact] -> String +defaultProhibitedMessage ps = "Sorry, only these users can broadcast messages: " <> knownContactNames ps <> ". Your message is deleted." broadcastBotOpts :: FilePath -> FilePath -> Parser BroadcastBotOpts broadcastBotOpts appDir defaultDbFileName = do coreOptions <- coreChatOptsP appDir defaultDbFileName publishers <- option - parsePublishers + parseKnownContacts ( long "publishers" <> metavar "PUBLISHERS" <> help "Comma-separated list of publishers in the format CONTACT_ID:DISPLAY_NAME whose messages will be broadcasted" @@ -74,17 +59,6 @@ broadcastBotOpts appDir defaultDbFileName = do prohibitedMessage = fromMaybe (defaultProhibitedMessage publishers) prohibitedMessage_ } -parsePublishers :: ReadM [Publisher] -parsePublishers = eitherReader $ parseAll publishersP . encodeUtf8 . T.pack - -publishersP :: A.Parser [Publisher] -publishersP = publisherP `A.sepBy1` A.char ',' - where - publisherP = do - contactId <- A.decimal <* A.char ':' - localDisplayName <- safeDecodeUtf8 <$> A.takeTill (A.inClass ", ") - pure Publisher {contactId, localDisplayName} - getBroadcastBotOpts :: FilePath -> FilePath -> IO BroadcastBotOpts getBroadcastBotOpts appDir defaultDbFileName = execParser $ diff --git a/apps/simplex-chat/Main.hs b/apps/simplex-chat/Main.hs index b16bbd8ee2..8dd02623e2 100644 --- a/apps/simplex-chat/Main.hs +++ b/apps/simplex-chat/Main.hs @@ -28,7 +28,7 @@ main = do t <- withTerminal pure simplexChatTerminal terminalChatConfig opts t else simplexChatCore terminalChatConfig opts Nothing $ \user cc -> do - r <- sendChatCmd cc chatCmd + r <- sendChatCmdStr cc chatCmd ts <- getCurrentTime tz <- getCurrentTimeZone putStrLn $ serializeChatResponse (Just user) ts tz r diff --git a/apps/simplex-directory-service/Main.hs b/apps/simplex-directory-service/Main.hs new file mode 100644 index 0000000000..434e42d851 --- /dev/null +++ b/apps/simplex-directory-service/Main.hs @@ -0,0 +1,15 @@ +{-# LANGUAGE NamedFieldPuns #-} + +module Main where + +import Directory.Options +import Directory.Service +import Directory.Store +import Simplex.Chat.Core +import Simplex.Chat.Terminal (terminalChatConfig) + +main :: IO () +main = do + opts@DirectoryOpts {directoryLog} <- welcomeGetOpts + st <- restoreDirectoryStore directoryLog + simplexChatCore terminalChatConfig (mkChatOpts opts) Nothing $ directoryService st opts diff --git a/apps/simplex-directory-service/README.md b/apps/simplex-directory-service/README.md new file mode 100644 index 0000000000..b64e018adb --- /dev/null +++ b/apps/simplex-directory-service/README.md @@ -0,0 +1,5 @@ +# SimpleX Directory Service + +The service is currently a chat bot that allows to register and search for groups. + +Superusers are configured via CLI options. diff --git a/apps/simplex-directory-service/src/Directory/Events.hs b/apps/simplex-directory-service/src/Directory/Events.hs new file mode 100644 index 0000000000..8ab6bea805 --- /dev/null +++ b/apps/simplex-directory-service/src/Directory/Events.hs @@ -0,0 +1,155 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE GADTs #-} +{-# LANGUAGE KindSignatures #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE StandaloneDeriving #-} + +module Directory.Events + ( DirectoryEvent (..), + DirectoryCmd (..), + ADirectoryCmd (..), + DirectoryRole (..), + SDirectoryRole (..), + crDirectoryEvent, + ) +where + +import Control.Applicative ((<|>)) +import Data.Attoparsec.Text (Parser) +import qualified Data.Attoparsec.Text as A +import Data.Text (Text) +import qualified Data.Text as T +import Directory.Store +import Simplex.Chat.Controller +import Simplex.Chat.Messages +import Simplex.Chat.Messages.CIContent +import Simplex.Chat.Protocol (MsgContent (..)) +import Simplex.Chat.Types +import Data.Char (isSpace) +import Data.Either (fromRight) + +data DirectoryEvent + = DEContactConnected Contact + | DEGroupInvitation {contact :: Contact, groupInfo :: GroupInfo, fromMemberRole :: GroupMemberRole, memberRole :: GroupMemberRole} + | DEServiceJoinedGroup {contactId :: ContactId, groupInfo :: GroupInfo, hostMember :: GroupMember} + | DEGroupUpdated {contactId :: ContactId, fromGroup :: GroupInfo, toGroup :: GroupInfo} + | DEContactRoleChanged GroupInfo ContactId GroupMemberRole -- contactId here is the contact whose role changed + | DEServiceRoleChanged GroupInfo GroupMemberRole + | DEContactRemovedFromGroup ContactId GroupInfo + | DEContactLeftGroup ContactId GroupInfo + | DEServiceRemovedFromGroup GroupInfo + | DEGroupDeleted GroupInfo + | DEUnsupportedMessage Contact ChatItemId + | DEItemEditIgnored Contact + | DEItemDeleteIgnored Contact + | DEContactCommand Contact ChatItemId ADirectoryCmd + deriving (Show) + +crDirectoryEvent :: ChatResponse -> Maybe DirectoryEvent +crDirectoryEvent = \case + CRContactConnected {contact} -> Just $ DEContactConnected contact + CRReceivedGroupInvitation {contact, groupInfo, fromMemberRole, memberRole} -> Just $ DEGroupInvitation {contact, groupInfo, fromMemberRole, memberRole} + CRUserJoinedGroup {groupInfo, hostMember} -> (\contactId -> DEServiceJoinedGroup {contactId, groupInfo, hostMember}) <$> memberContactId hostMember + CRGroupUpdated {fromGroup, toGroup, member_} -> (\contactId -> DEGroupUpdated {contactId, fromGroup, toGroup}) <$> (memberContactId =<< member_) + CRMemberRole {groupInfo, member, toRole} + | groupMemberId' member == groupMemberId' (membership groupInfo) -> Just $ DEServiceRoleChanged groupInfo toRole + | otherwise -> (\ctId -> DEContactRoleChanged groupInfo ctId toRole) <$> memberContactId member + CRDeletedMember {groupInfo, deletedMember} -> (`DEContactRemovedFromGroup` groupInfo) <$> memberContactId deletedMember + CRLeftMember {groupInfo, member} -> (`DEContactLeftGroup` groupInfo) <$> memberContactId member + CRDeletedMemberUser {groupInfo} -> Just $ DEServiceRemovedFromGroup groupInfo + CRGroupDeleted {groupInfo} -> Just $ DEGroupDeleted groupInfo + CRChatItemUpdated {chatItem = AChatItem _ SMDRcv (DirectChat ct) _} -> Just $ DEItemEditIgnored ct + CRChatItemDeleted {deletedChatItem = AChatItem _ SMDRcv (DirectChat ct) _, byUser = False} -> Just $ DEItemDeleteIgnored ct + CRNewChatItem {chatItem = AChatItem _ SMDRcv (DirectChat ct) ci@ChatItem {content = CIRcvMsgContent mc, meta = CIMeta {itemLive}}} -> + Just $ case (mc, itemLive) of + (MCText t, Nothing) -> DEContactCommand ct ciId $ fromRight err $ A.parseOnly directoryCmdP $ T.dropWhileEnd isSpace t + _ -> DEUnsupportedMessage ct ciId + where + ciId = chatItemId' ci + err = ADC SDRUser DCUnknownCommand + _ -> Nothing + +data DirectoryRole = DRUser | DRSuperUser + +data SDirectoryRole (r :: DirectoryRole) where + SDRUser :: SDirectoryRole 'DRUser + SDRSuperUser :: SDirectoryRole 'DRSuperUser + +deriving instance Show (SDirectoryRole r) + +data DirectoryCmdTag (r :: DirectoryRole) where + DCHelp_ :: DirectoryCmdTag 'DRUser + DCConfirmDuplicateGroup_ :: DirectoryCmdTag 'DRUser + DCListUserGroups_ :: DirectoryCmdTag 'DRUser + DCDeleteGroup_ :: DirectoryCmdTag 'DRUser + DCApproveGroup_ :: DirectoryCmdTag 'DRSuperUser + DCRejectGroup_ :: DirectoryCmdTag 'DRSuperUser + DCSuspendGroup_ :: DirectoryCmdTag 'DRSuperUser + DCResumeGroup_ :: DirectoryCmdTag 'DRSuperUser + DCListLastGroups_ :: DirectoryCmdTag 'DRSuperUser + +deriving instance Show (DirectoryCmdTag r) + +data ADirectoryCmdTag = forall r. ADCT (SDirectoryRole r) (DirectoryCmdTag r) + +data DirectoryCmd (r :: DirectoryRole) where + DCHelp :: DirectoryCmd 'DRUser + DCSearchGroup :: Text -> DirectoryCmd 'DRUser + DCConfirmDuplicateGroup :: UserGroupRegId -> GroupName -> DirectoryCmd 'DRUser + DCListUserGroups :: DirectoryCmd 'DRUser + DCDeleteGroup :: UserGroupRegId -> GroupName -> DirectoryCmd 'DRUser + DCApproveGroup :: {groupId :: GroupId, displayName :: GroupName, groupApprovalId :: GroupApprovalId} -> DirectoryCmd 'DRSuperUser + DCRejectGroup :: GroupId -> GroupName -> DirectoryCmd 'DRSuperUser + DCSuspendGroup :: GroupId -> GroupName -> DirectoryCmd 'DRSuperUser + DCResumeGroup :: GroupId -> GroupName -> DirectoryCmd 'DRSuperUser + DCListLastGroups :: Int -> DirectoryCmd 'DRSuperUser + DCUnknownCommand :: DirectoryCmd 'DRUser + DCCommandError :: DirectoryCmdTag r -> DirectoryCmd r + +deriving instance Show (DirectoryCmd r) + +data ADirectoryCmd = forall r. ADC (SDirectoryRole r) (DirectoryCmd r) + +deriving instance Show ADirectoryCmd + +directoryCmdP :: Parser ADirectoryCmd +directoryCmdP = + (A.char '/' *> cmdStrP) <|> (ADC SDRUser . DCSearchGroup <$> A.takeText) + where + cmdStrP = + (tagP >>= \(ADCT u t) -> ADC u <$> (cmdP t <|> pure (DCCommandError t))) + <|> pure (ADC SDRUser DCUnknownCommand) + tagP = A.takeTill (== ' ') >>= \case + "help" -> u DCHelp_ + "h" -> u DCHelp_ + "confirm" -> u DCConfirmDuplicateGroup_ + "list" -> u DCListUserGroups_ + "delete" -> u DCDeleteGroup_ + "approve" -> su DCApproveGroup_ + "reject" -> su DCRejectGroup_ + "suspend" -> su DCSuspendGroup_ + "resume" -> su DCResumeGroup_ + "last" -> su DCListLastGroups_ + _ -> fail "bad command tag" + where + u = pure . ADCT SDRUser + su = pure . ADCT SDRSuperUser + cmdP :: DirectoryCmdTag r -> Parser (DirectoryCmd r) + cmdP = \case + DCHelp_ -> pure DCHelp + DCConfirmDuplicateGroup_ -> gc DCConfirmDuplicateGroup + DCListUserGroups_ -> pure DCListUserGroups + DCDeleteGroup_ -> gc DCDeleteGroup + DCApproveGroup_ -> do + (groupId, displayName) <- gc (,) + groupApprovalId <- A.space *> A.decimal + pure $ DCApproveGroup {groupId, displayName, groupApprovalId} + DCRejectGroup_ -> gc DCRejectGroup + DCSuspendGroup_ -> gc DCSuspendGroup + DCResumeGroup_ -> gc DCResumeGroup + DCListLastGroups_ -> DCListLastGroups <$> (A.space *> A.decimal <|> pure 10) + where + gc f = f <$> (A.space *> A.decimal <* A.char ':') <*> A.takeTill (== ' ') diff --git a/apps/simplex-directory-service/src/Directory/Options.hs b/apps/simplex-directory-service/src/Directory/Options.hs new file mode 100644 index 0000000000..1f06afe116 --- /dev/null +++ b/apps/simplex-directory-service/src/Directory/Options.hs @@ -0,0 +1,84 @@ +{-# LANGUAGE ApplicativeDo #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} + +module Directory.Options + ( DirectoryOpts (..), + getDirectoryOpts, + mkChatOpts, + ) +where + +import Options.Applicative +import Simplex.Chat.Bot.KnownContacts +import Simplex.Chat.Controller (updateStr, versionNumber, versionString) +import Simplex.Chat.Options (ChatOpts (..), CoreChatOpts, coreChatOptsP) + +data DirectoryOpts = DirectoryOpts + { coreOptions :: CoreChatOpts, + superUsers :: [KnownContact], + directoryLog :: Maybe FilePath, + serviceName :: String, + testing :: Bool + } + +directoryOpts :: FilePath -> FilePath -> Parser DirectoryOpts +directoryOpts appDir defaultDbFileName = do + coreOptions <- coreChatOptsP appDir defaultDbFileName + superUsers <- + option + parseKnownContacts + ( long "super-users" + <> metavar "SUPER_USERS" + <> help "Comma-separated list of super-users in the format CONTACT_ID:DISPLAY_NAME who will be allowed to manage the directory" + ) + directoryLog <- + Just <$> + strOption + ( long "directory-file" + <> metavar "DIRECTORY_FILE" + <> help "Append only log for directory state" + ) + serviceName <- + strOption + ( long "service-name" + <> metavar "SERVICE_NAME" + <> help "The display name of the directory service bot, without *'s and spaces (SimpleX-Directory)" + <> value "SimpleX-Directory" + ) + pure + DirectoryOpts + { coreOptions, + superUsers, + directoryLog, + serviceName, + testing = False + } + +getDirectoryOpts :: FilePath -> FilePath -> IO DirectoryOpts +getDirectoryOpts appDir defaultDbFileName = + execParser $ + info + (helper <*> versionOption <*> directoryOpts appDir defaultDbFileName) + (header versionStr <> fullDesc <> progDesc "Start SimpleX Directory Service with DB_FILE, DIRECTORY_FILE and SUPER_USERS options") + where + versionStr = versionString versionNumber + versionOption = infoOption versionAndUpdate (long "version" <> short 'v' <> help "Show version") + versionAndUpdate = versionStr <> "\n" <> updateStr + +mkChatOpts :: DirectoryOpts -> ChatOpts +mkChatOpts DirectoryOpts {coreOptions} = + ChatOpts + { coreOptions, + chatCmd = "", + chatCmdDelay = 3, + chatServerPort = Nothing, + optFilesFolder = Nothing, + showReactions = False, + allowInstantFiles = True, + autoAcceptFileSize = 0, + muteNotifications = True, + maintenance = False + } diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs new file mode 100644 index 0000000000..570aa57817 --- /dev/null +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -0,0 +1,539 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE GADTs #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE MultiWayIf #-} + +module Directory.Service + ( welcomeGetOpts, + directoryService, + ) +where + +import Control.Concurrent (forkIO) +import Control.Concurrent.Async +import Control.Concurrent.STM +import Control.Monad.Reader +import qualified Data.ByteString.Char8 as B +import Data.Maybe (fromMaybe, maybeToList) +import qualified Data.Set as S +import Data.Text (Text) +import qualified Data.Text as T +import Directory.Events +import Directory.Options +import Directory.Store +import Simplex.Chat.Bot +import Simplex.Chat.Bot.KnownContacts +import Simplex.Chat.Controller +import Simplex.Chat.Core +import Simplex.Chat.Messages +-- import Simplex.Chat.Messages.CIContent +import Simplex.Chat.Options +import Simplex.Chat.Protocol (MsgContent (..)) +import Simplex.Chat.Types +import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Util (safeDecodeUtf8, tshow, ($>>=), (<$$>)) +import System.Directory (getAppUserDataDirectory) + +data GroupProfileUpdate = GPNoServiceLink | GPServiceLinkAdded | GPServiceLinkRemoved | GPHasServiceLink | GPServiceLinkError + +data DuplicateGroup + = DGUnique -- display name or full name is unique + | DGRegistered -- the group with the same names is registered, additional confirmation is required + | DGReserved -- the group with the same names is listed, the registration is not allowed + +data GroupRolesStatus + = GRSOk + | GRSServiceNotAdmin + | GRSContactNotOwner + | GRSBadRoles + deriving (Eq) + +welcomeGetOpts :: IO DirectoryOpts +welcomeGetOpts = do + appDir <- getAppUserDataDirectory "simplex" + opts@DirectoryOpts {coreOptions = CoreChatOpts {dbFilePrefix}, testing} <- getDirectoryOpts appDir "simplex_directory_service" + unless testing $ do + putStrLn $ "SimpleX Directory Service Bot v" ++ versionNumber + putStrLn $ "db: " <> dbFilePrefix <> "_chat.db, " <> dbFilePrefix <> "_agent.db" + pure opts + +directoryService :: DirectoryStore -> DirectoryOpts -> User -> ChatController -> IO () +directoryService st DirectoryOpts {superUsers, serviceName, testing} User {userId} cc = do + initializeBotAddress' (not testing) cc + race_ (forever $ void getLine) . forever $ do + (_, resp) <- atomically . readTBQueue $ outputQ cc + forM_ (crDirectoryEvent resp) $ \case + DEContactConnected ct -> deContactConnected ct + DEGroupInvitation {contact = ct, groupInfo = g, fromMemberRole, memberRole} -> deGroupInvitation ct g fromMemberRole memberRole + DEServiceJoinedGroup ctId g owner -> deServiceJoinedGroup ctId g owner + DEGroupUpdated {contactId, fromGroup, toGroup} -> deGroupUpdated contactId fromGroup toGroup + DEContactRoleChanged g ctId role -> deContactRoleChanged g ctId role + DEServiceRoleChanged g role -> deServiceRoleChanged g role + DEContactRemovedFromGroup ctId g -> deContactRemovedFromGroup ctId g + DEContactLeftGroup ctId g -> deContactLeftGroup ctId g + DEServiceRemovedFromGroup g -> deServiceRemovedFromGroup g + DEGroupDeleted _g -> pure () + DEUnsupportedMessage _ct _ciId -> pure () + DEItemEditIgnored _ct -> pure () + DEItemDeleteIgnored _ct -> pure () + DEContactCommand ct ciId aCmd -> case aCmd of + ADC SDRUser cmd -> deUserCommand ct ciId cmd + ADC SDRSuperUser cmd -> deSuperUserCommand ct ciId cmd + where + withSuperUsers action = void . forkIO $ forM_ superUsers $ \KnownContact {contactId} -> action contactId + notifySuperUsers s = withSuperUsers $ \contactId -> sendMessage' cc contactId s + notifyOwner GroupReg {dbContactId} = sendMessage' cc dbContactId + ctId `isOwner` GroupReg {dbContactId} = ctId == dbContactId + withGroupReg GroupInfo {groupId, localDisplayName} err action = do + atomically (getGroupReg st groupId) >>= \case + Just gr -> action gr + Nothing -> putStrLn $ T.unpack $ "Error: " <> err <> ", group: " <> localDisplayName <> ", can't find group registration ID " <> tshow groupId + groupInfoText GroupProfile {displayName = n, fullName = fn, description = d} = + n <> (if n == fn || T.null fn then "" else " (" <> fn <> ")") <> maybe "" ("\nWelcome message:\n" <>) d + userGroupReference gr GroupInfo {groupProfile = GroupProfile {displayName}} = userGroupReference' gr displayName + userGroupReference' GroupReg {userGroupRegId} displayName = groupReference' userGroupRegId displayName + groupReference GroupInfo {groupId, groupProfile = GroupProfile {displayName}} = groupReference' groupId displayName + groupReference' groupId displayName = "ID " <> show groupId <> " (" <> T.unpack displayName <> ")" + groupAlreadyListed GroupInfo {groupProfile = GroupProfile {displayName, fullName}} = + T.unpack $ "The group " <> displayName <> " (" <> fullName <> ") is already listed in the directory, please choose another name." + + getGroups :: Text -> IO (Maybe [(GroupInfo, GroupSummary)]) + getGroups search = + sendChatCmd cc (APIListGroups userId Nothing $ Just $ T.unpack search) >>= \case + CRGroupsList {groups} -> pure $ Just groups + _ -> pure Nothing + + getDuplicateGroup :: GroupInfo -> IO (Maybe DuplicateGroup) + getDuplicateGroup GroupInfo {groupId, groupProfile = GroupProfile {displayName, fullName}} = + getGroups fullName >>= mapM duplicateGroup + where + sameGroup (GroupInfo {groupId = gId, groupProfile = GroupProfile {displayName = n, fullName = fn}}, _) = + gId /= groupId && n == displayName && fn == fullName + duplicateGroup [] = pure DGUnique + duplicateGroup groups = do + let gs = filter sameGroup groups + if null gs + then pure DGUnique + else do + (lgs, rgs) <- atomically $ (,) <$> readTVar (listedGroups st) <*> readTVar (reservedGroups st) + let reserved = any (\(GroupInfo {groupId = gId}, _) -> gId `S.member` lgs || gId `S.member` rgs) gs + pure $ if reserved then DGReserved else DGRegistered + + processInvitation :: Contact -> GroupInfo -> IO () + processInvitation ct g@GroupInfo {groupId, groupProfile = GroupProfile {displayName}} = do + void $ addGroupReg st ct g GRSProposed + r <- sendChatCmd cc $ APIJoinGroup groupId + sendMessage cc ct $ T.unpack $ case r of + CRUserAcceptedGroupSent {} -> "Joining the group " <> displayName <> "…" + _ -> "Error joining group " <> displayName <> ", please re-send the invitation!" + + deContactConnected :: Contact -> IO () + deContactConnected ct = do + unless testing $ putStrLn $ T.unpack (localDisplayName' ct) <> " connected" + sendMessage cc ct $ + "Welcome to " <> serviceName <> " service!\n\ + \Send a search string to find groups or */help* to learn how to add groups to directory.\n\n\ + \For example, send _privacy_ to find groups about privacy." + + deGroupInvitation :: Contact -> GroupInfo -> GroupMemberRole -> GroupMemberRole -> IO () + deGroupInvitation ct g@GroupInfo {groupProfile = GroupProfile {displayName, fullName}} fromMemberRole memberRole = do + case badRolesMsg $ groupRolesStatus fromMemberRole memberRole of + Just msg -> sendMessage cc ct msg + Nothing -> getDuplicateGroup g >>= \case + Just DGUnique -> processInvitation ct g + Just DGRegistered -> askConfirmation + Just DGReserved -> sendMessage cc ct $ groupAlreadyListed g + Nothing -> sendMessage cc ct "Error: getDuplicateGroup. Please notify the developers." + where + askConfirmation = do + ugrId <- addGroupReg st ct g GRSPendingConfirmation + sendMessage cc ct $ T.unpack $ "The group " <> displayName <> " (" <> fullName <> ") is already submitted to the directory.\nTo confirm the registration, please send:" + sendMessage cc ct $ "/confirm " <> show ugrId <> ":" <> T.unpack displayName + + badRolesMsg :: GroupRolesStatus -> Maybe String + badRolesMsg = \case + GRSOk -> Nothing + GRSServiceNotAdmin -> Just "You must have a group *owner* role to register the group" + GRSContactNotOwner -> Just "You must grant directory service *admin* role to register the group" + GRSBadRoles -> Just "You must have a group *owner* role and you must grant directory service *admin* role to register the group" + + getGroupRolesStatus :: GroupInfo -> GroupReg -> IO (Maybe GroupRolesStatus) + getGroupRolesStatus GroupInfo {membership = GroupMember {memberRole = serviceRole}} gr = + rStatus <$$> getGroupMember gr + where + rStatus GroupMember {memberRole} = groupRolesStatus memberRole serviceRole + + groupRolesStatus :: GroupMemberRole -> GroupMemberRole -> GroupRolesStatus + groupRolesStatus contactRole serviceRole = case (contactRole, serviceRole) of + (GROwner, GRAdmin) -> GRSOk + (_, GRAdmin) -> GRSServiceNotAdmin + (GROwner, _) -> GRSContactNotOwner + _ -> GRSBadRoles + + getGroupMember :: GroupReg -> IO (Maybe GroupMember) + getGroupMember GroupReg {dbGroupId, dbOwnerMemberId} = + readTVarIO dbOwnerMemberId + $>>= \mId -> resp <$> sendChatCmd cc (APIGroupMemberInfo dbGroupId mId) + where + resp = \case + CRGroupMemberInfo {member} -> Just member + _ -> Nothing + + deServiceJoinedGroup :: ContactId -> GroupInfo -> GroupMember -> IO () + deServiceJoinedGroup ctId g owner = + withGroupReg g "joined group" $ \gr -> + when (ctId `isOwner` gr) $ do + setGroupRegOwner st gr owner + let GroupInfo {groupId, groupProfile = GroupProfile {displayName}} = g + notifyOwner gr $ T.unpack $ "Joined the group " <> displayName <> ", creating the link…" + sendChatCmd cc (APICreateGroupLink groupId GRMember) >>= \case + CRGroupLinkCreated {connReqContact} -> do + setGroupStatus st gr GRSPendingUpdate + notifyOwner gr + "Created the public link to join the group via this directory service that is always online.\n\n\ + \Please add it to the group welcome message.\n\ + \For example, add:" + notifyOwner gr $ "Link to join the group " <> T.unpack displayName <> ": " <> B.unpack (strEncode connReqContact) + CRChatCmdError _ (ChatError e) -> case e of + CEGroupUserRole {} -> notifyOwner gr "Failed creating group link, as service is no longer an admin." + CEGroupMemberUserRemoved -> notifyOwner gr "Failed creating group link, as service is removed from the group." + CEGroupNotJoined _ -> notifyOwner gr $ unexpectedError "group not joined" + CEGroupMemberNotActive -> notifyOwner gr $ unexpectedError "service membership is not active" + _ -> notifyOwner gr $ unexpectedError "can't create group link" + _ -> notifyOwner gr $ unexpectedError "can't create group link" + + deGroupUpdated :: ContactId -> GroupInfo -> GroupInfo -> IO () + deGroupUpdated ctId fromGroup toGroup = + unless (sameProfile p p') $ do + withGroupReg toGroup "group updated" $ \gr -> do + let userGroupRef = userGroupReference gr toGroup + readTVarIO (groupRegStatus gr) >>= \case + GRSPendingConfirmation -> pure () + GRSProposed -> pure () + GRSPendingUpdate -> groupProfileUpdate >>= \case + GPNoServiceLink -> + when (ctId `isOwner` gr) $ notifyOwner gr $ "The profile updated for " <> userGroupRef <> ", but the group link is not added to the welcome message." + GPServiceLinkAdded + | ctId `isOwner` gr -> groupLinkAdded gr + | otherwise -> notifyOwner gr "The group link is added by another group member, your registration will not be processed.\n\nPlease update the group profile yourself." + GPServiceLinkRemoved -> when (ctId `isOwner` gr) $ notifyOwner gr $ "The group link of " <> userGroupRef <> " is removed from the welcome message, please add it." + GPHasServiceLink -> when (ctId `isOwner` gr) $ groupLinkAdded gr + GPServiceLinkError -> do + when (ctId `isOwner` gr) $ notifyOwner gr $ "Error: " <> serviceName <> " has no group link for " <> userGroupRef <> ". Please report the error to the developers." + putStrLn $ "Error: no group link for " <> userGroupRef + GRSPendingApproval n -> processProfileChange gr $ n + 1 + GRSActive -> processProfileChange gr 1 + GRSSuspended -> processProfileChange gr 1 + GRSSuspendedBadRoles -> processProfileChange gr 1 + GRSRemoved -> pure () + where + isInfix l d_ = l `T.isInfixOf` fromMaybe "" d_ + GroupInfo {groupId, groupProfile = p} = fromGroup + GroupInfo {groupProfile = p'} = toGroup + sameProfile + GroupProfile {displayName = n, fullName = fn, image = i, description = d} + GroupProfile {displayName = n', fullName = fn', image = i', description = d'} = + n == n' && fn == fn' && i == i' && d == d' + groupLinkAdded gr = do + getDuplicateGroup toGroup >>= \case + Nothing -> notifyOwner gr "Error: getDuplicateGroup. Please notify the developers." + Just DGReserved -> notifyOwner gr $ groupAlreadyListed toGroup + _ -> do + let gaId = 1 + setGroupStatus st gr $ GRSPendingApproval gaId + notifyOwner gr $ "Thank you! The group link for " <> userGroupReference gr toGroup <> " is added to the welcome message.\nYou will be notified once the group is added to the directory - it may take up to 24 hours." + checkRolesSendToApprove gr gaId + processProfileChange gr n' = do + setGroupStatus st gr GRSPendingUpdate + let userGroupRef = userGroupReference gr toGroup + groupRef = groupReference toGroup + groupProfileUpdate >>= \case + GPNoServiceLink -> do + notifyOwner gr $ "The group profile is updated " <> userGroupRef <> ", but no link is added to the welcome message.\n\nThe group will remain hidden from the directory until the group link is added and the group is re-approved." + GPServiceLinkRemoved -> do + notifyOwner gr $ "The group link for " <> userGroupRef <> " is removed from the welcome message.\n\nThe group is hidden from the directory until the group link is added and the group is re-approved." + notifySuperUsers $ "The group link is removed from " <> groupRef <> ", de-listed." + GPServiceLinkAdded -> do + setGroupStatus st gr $ GRSPendingApproval n' + notifyOwner gr $ "The group link is added to " <> userGroupRef <> "!\nIt is hidden from the directory until approved." + notifySuperUsers $ "The group link is added to " <> groupRef <> "." + checkRolesSendToApprove gr n' + GPHasServiceLink -> do + setGroupStatus st gr $ GRSPendingApproval n' + notifyOwner gr $ "The group " <> userGroupRef <> " is updated!\nIt is hidden from the directory until approved." + notifySuperUsers $ "The group " <> groupRef <> " is updated." + checkRolesSendToApprove gr n' + GPServiceLinkError -> putStrLn $ "Error: no group link for " <> groupRef <> " pending approval." + groupProfileUpdate = profileUpdate <$> sendChatCmd cc (APIGetGroupLink groupId) + where + profileUpdate = \case + CRGroupLink {connReqContact} -> + let groupLink = safeDecodeUtf8 $ strEncode connReqContact + hadLinkBefore = groupLink `isInfix` description p + hasLinkNow = groupLink `isInfix` description p' + in if + | hadLinkBefore && hasLinkNow -> GPHasServiceLink + | hadLinkBefore -> GPServiceLinkRemoved + | hasLinkNow -> GPServiceLinkAdded + | otherwise -> GPNoServiceLink + _ -> GPServiceLinkError + checkRolesSendToApprove gr gaId = do + (badRolesMsg <$$> getGroupRolesStatus toGroup gr) >>= \case + Nothing -> notifyOwner gr "Error: getGroupRolesStatus. Please notify the developers." + Just (Just msg) -> notifyOwner gr msg + Just Nothing -> sendToApprove toGroup gr gaId + + sendToApprove :: GroupInfo -> GroupReg -> GroupApprovalId -> IO () + sendToApprove GroupInfo {groupProfile = p@GroupProfile {displayName, image = image'}} GroupReg {dbGroupId, dbContactId} gaId = do + ct_ <- getContact cc dbContactId + let text = maybe ("The group ID " <> tshow dbGroupId <> " submitted: ") (\c -> localDisplayName' c <> " submitted the group ID " <> tshow dbGroupId <> ": ") ct_ + <> groupInfoText p <> "\n\nTo approve send:" + msg = maybe (MCText text) (\image -> MCImage {text, image}) image' + withSuperUsers $ \cId -> do + sendComposedMessage' cc cId Nothing msg + sendMessage' cc cId $ "/approve " <> show dbGroupId <> ":" <> T.unpack displayName <> " " <> show gaId + + deContactRoleChanged :: GroupInfo -> ContactId -> GroupMemberRole -> IO () + deContactRoleChanged g@GroupInfo {membership = GroupMember {memberRole = serviceRole}} ctId contactRole = + withGroupReg g "contact role changed" $ \gr -> do + let userGroupRef = userGroupReference gr g + uCtRole = "Your role in the group " <> userGroupRef <> " is changed to " <> ctRole + when (ctId `isOwner` gr) $ do + readTVarIO (groupRegStatus gr) >>= \case + GRSSuspendedBadRoles -> when (rStatus == GRSOk) $ do + setGroupStatus st gr GRSActive + notifyOwner gr $ uCtRole <> ".\n\nThe group is listed in the directory again." + notifySuperUsers $ "The group " <> groupRef <> " is listed " <> suCtRole + GRSPendingApproval gaId -> when (rStatus == GRSOk) $ do + sendToApprove g gr gaId + notifyOwner gr $ uCtRole <> ".\n\nThe group is submitted for approval." + GRSActive -> when (rStatus /= GRSOk) $ do + setGroupStatus st gr GRSSuspendedBadRoles + notifyOwner gr $ uCtRole <> ".\n\nThe group is no longer listed in the directory." + notifySuperUsers $ "The group " <> groupRef <> " is de-listed " <> suCtRole + _ -> pure () + where + rStatus = groupRolesStatus contactRole serviceRole + groupRef = groupReference g + ctRole = "*" <> B.unpack (strEncode contactRole) <> "*" + suCtRole = "(user role is set to " <> ctRole <> ")." + + deServiceRoleChanged :: GroupInfo -> GroupMemberRole -> IO () + deServiceRoleChanged g serviceRole = do + withGroupReg g "service role changed" $ \gr -> do + let userGroupRef = userGroupReference gr g + uSrvRole = serviceName <> " role in the group " <> userGroupRef <> " is changed to " <> srvRole + readTVarIO (groupRegStatus gr) >>= \case + GRSSuspendedBadRoles -> when (serviceRole == GRAdmin) $ + whenContactIsOwner gr $ do + setGroupStatus st gr GRSActive + notifyOwner gr $ uSrvRole <> ".\n\nThe group is listed in the directory again." + notifySuperUsers $ "The group " <> groupRef <> " is listed " <> suSrvRole + GRSPendingApproval gaId -> when (serviceRole == GRAdmin) $ + whenContactIsOwner gr $ do + sendToApprove g gr gaId + notifyOwner gr $ uSrvRole <> ".\n\nThe group is submitted for approval." + GRSActive -> when (serviceRole /= GRAdmin) $ do + setGroupStatus st gr GRSSuspendedBadRoles + notifyOwner gr $ uSrvRole <> ".\n\nThe group is no longer listed in the directory." + notifySuperUsers $ "The group " <> groupRef <> " is de-listed " <> suSrvRole + _ -> pure () + where + groupRef = groupReference g + srvRole = "*" <> B.unpack (strEncode serviceRole) <> "*" + suSrvRole = "(" <> serviceName <> " role is changed to " <> srvRole <> ")." + whenContactIsOwner gr action = + getGroupMember gr >>= + mapM_ (\cm@GroupMember {memberRole} -> when (memberRole == GROwner && memberActive cm) action) + + deContactRemovedFromGroup :: ContactId -> GroupInfo -> IO () + deContactRemovedFromGroup ctId g = + withGroupReg g "contact removed" $ \gr -> do + when (ctId `isOwner` gr) $ do + setGroupStatus st gr GRSRemoved + notifyOwner gr $ "You are removed from the group " <> userGroupReference gr g <> ".\n\nThe group is no longer listed in the directory." + notifySuperUsers $ "The group " <> groupReference g <> " is de-listed (group owner is removed)." + + deContactLeftGroup :: ContactId -> GroupInfo -> IO () + deContactLeftGroup ctId g = + withGroupReg g "contact left" $ \gr -> do + when (ctId `isOwner` gr) $ do + setGroupStatus st gr GRSRemoved + notifyOwner gr $ "You left the group " <> userGroupReference gr g <> ".\n\nThe group is no longer listed in the directory." + notifySuperUsers $ "The group " <> groupReference g <> " is de-listed (group owner left)." + + deServiceRemovedFromGroup :: GroupInfo -> IO () + deServiceRemovedFromGroup g = + withGroupReg g "service removed" $ \gr -> do + setGroupStatus st gr GRSRemoved + notifyOwner gr $ serviceName <> " is removed from the group " <> userGroupReference gr g <> ".\n\nThe group is no longer listed in the directory." + notifySuperUsers $ "The group " <> groupReference g <> " is de-listed (directory service is removed)." + + deUserCommand :: Contact -> ChatItemId -> DirectoryCmd 'DRUser -> IO () + deUserCommand ct ciId = \case + DCHelp -> + sendMessage cc ct $ + "You must be the owner to add the group to the directory:\n\ + \1. Invite " <> serviceName <> " bot to your group as *admin*.\n\ + \2. " <> serviceName <> " bot will create a public group link for the new members to join even when you are offline.\n\ + \3. You will then need to add this link to the group welcome message.\n\ + \4. Once the link is added, service admins will approve the group (it can take up to 24 hours), and everybody will be able to find it in directory.\n\n\ + \Start from inviting the bot to your group as admin - it will guide you through the process" + DCSearchGroup s -> + getGroups s >>= \case + Just groups -> + atomically (filterListedGroups st groups) >>= \case + [] -> sendReply "No groups found" + gs -> do + sendReply $ "Found " <> show (length gs) <> " group(s)" <> if length gs > 10 then ", sending 10." else "" + void . forkIO $ forM_ (take 10 gs) $ + \(GroupInfo {groupProfile = p@GroupProfile {image = image_}}, GroupSummary {currentMembers}) -> do + let membersStr = tshow currentMembers <> " members" + text = groupInfoText p <> "\n" <> membersStr + msg = maybe (MCText text) (\image -> MCImage {text, image}) image_ + sendComposedMessage cc ct Nothing msg + Nothing -> sendReply "Error: getGroups. Please notify the developers." + DCConfirmDuplicateGroup ugrId gName -> + atomically (getUserGroupReg st (contactId' ct) ugrId) >>= \case + Nothing -> sendReply $ "Group ID " <> show ugrId <> " not found" + Just GroupReg {dbGroupId, groupRegStatus} -> do + getGroup cc dbGroupId >>= \case + Nothing -> sendReply $ "Group ID " <> show ugrId <> " not found" + Just g@GroupInfo {groupProfile = GroupProfile {displayName}} + | displayName == gName -> + readTVarIO groupRegStatus >>= \case + GRSPendingConfirmation -> do + getDuplicateGroup g >>= \case + Nothing -> sendMessage cc ct "Error: getDuplicateGroup. Please notify the developers." + Just DGReserved -> sendMessage cc ct $ groupAlreadyListed g + _ -> processInvitation ct g + _ -> sendReply $ "Error: the group ID " <> show ugrId <> " (" <> T.unpack displayName <> ") is not pending confirmation." + | otherwise -> sendReply $ "Group ID " <> show ugrId <> " has the display name " <> T.unpack displayName + DCListUserGroups -> + atomically (getUserGroupRegs st $ contactId' ct) >>= \grs -> do + sendReply $ show (length grs) <> " registered group(s)" + void . forkIO $ forM_ (reverse grs) $ \gr@GroupReg {userGroupRegId} -> + sendGroupInfo ct gr userGroupRegId Nothing + DCDeleteGroup _ugrId _gName -> pure () + DCUnknownCommand -> sendReply "Unknown command" + DCCommandError tag -> sendReply $ "Command error: " <> show tag + where + sendReply = sendComposedMessage cc ct (Just ciId) . textMsgContent + + deSuperUserCommand :: Contact -> ChatItemId -> DirectoryCmd 'DRSuperUser -> IO () + deSuperUserCommand ct ciId cmd + | superUser `elem` superUsers = case cmd of + DCApproveGroup {groupId, displayName = n, groupApprovalId} -> do + getGroupAndReg groupId n >>= \case + Nothing -> sendReply $ "The group " <> groupRef <> " not found (getGroupAndReg)." + Just (g, gr) -> + readTVarIO (groupRegStatus gr) >>= \case + GRSPendingApproval gaId + | gaId == groupApprovalId -> do + getDuplicateGroup g >>= \case + Nothing -> sendReply "Error: getDuplicateGroup. Please notify the developers." + Just DGReserved -> sendReply $ "The group " <> groupRef <> " is already listed in the directory." + _ -> do + getGroupRolesStatus g gr >>= \case + Just GRSOk -> do + setGroupStatus st gr GRSActive + sendReply "Group approved!" + notifyOwner gr $ "The group " <> userGroupReference' gr n <> " is approved and listed in directory!\nPlease note: if you change the group profile it will be hidden from directory until it is re-approved." + Just GRSServiceNotAdmin -> replyNotApproved serviceNotAdmin + Just GRSContactNotOwner -> replyNotApproved "user is not an owner." + Just GRSBadRoles -> replyNotApproved $ "user is not an owner, " <> serviceNotAdmin + Nothing -> sendReply "Error: getGroupRolesStatus. Please notify the developers." + where + replyNotApproved reason = sendReply $ "Group is not approved: " <> reason + serviceNotAdmin = serviceName <> " is not an admin." + | otherwise -> sendReply "Incorrect approval code" + _ -> sendReply $ "Error: the group " <> groupRef <> " is not pending approval." + where + groupRef = groupReference' groupId n + DCRejectGroup _gaId _gName -> pure () + DCSuspendGroup groupId gName -> do + let groupRef = groupReference' groupId gName + getGroupAndReg groupId gName >>= \case + Nothing -> sendReply $ "The group " <> groupRef <> " not found (getGroupAndReg)." + Just (_, gr) -> + readTVarIO (groupRegStatus gr) >>= \case + GRSActive -> do + setGroupStatus st gr GRSSuspended + notifyOwner gr $ "The group " <> userGroupReference' gr gName <> " is suspended and hidden from directory. Please contact the administrators." + sendReply "Group suspended!" + _ -> sendReply $ "The group " <> groupRef <> " is not active, can't be suspended." + DCResumeGroup groupId gName -> do + let groupRef = groupReference' groupId gName + getGroupAndReg groupId gName >>= \case + Nothing -> sendReply $ "The group " <> groupRef <> " not found (getGroupAndReg)." + Just (_, gr) -> + readTVarIO (groupRegStatus gr) >>= \case + GRSSuspended -> do + setGroupStatus st gr GRSActive + notifyOwner gr $ "The group " <> userGroupReference' gr gName <> " is listed in the directory again!" + sendReply "Group listing resumed!" + _ -> sendReply $ "The group " <> groupRef <> " is not suspended, can't be resumed." + DCListLastGroups count -> + readTVarIO (groupRegs st) >>= \grs -> do + sendReply $ show (length grs) <> " registered group(s)" <> (if length grs > count then ", showing the last " <> show count else "") + void . forkIO $ forM_ (reverse $ take count grs) $ \gr@GroupReg {dbGroupId, dbContactId} -> do + ct_ <- getContact cc dbContactId + let ownerStr = "Owner: " <> maybe "getContact error" localDisplayName' ct_ + sendGroupInfo ct gr dbGroupId $ Just ownerStr + DCCommandError tag -> sendReply $ "Command error: " <> show tag + | otherwise = sendReply "You are not allowed to use this command" + where + superUser = KnownContact {contactId = contactId' ct, localDisplayName = localDisplayName' ct} + sendReply = sendComposedMessage cc ct (Just ciId) . textMsgContent + + getGroupAndReg :: GroupId -> GroupName -> IO (Maybe (GroupInfo, GroupReg)) + getGroupAndReg gId gName = + getGroup cc gId + $>>= \g@GroupInfo {groupProfile = GroupProfile {displayName}} -> + if displayName == gName + then atomically (getGroupReg st gId) + $>>= \gr -> pure $ Just (g, gr) + else pure Nothing + + sendGroupInfo :: Contact -> GroupReg -> GroupId -> Maybe Text -> IO () + sendGroupInfo ct gr@GroupReg {dbGroupId} useGroupId ownerStr_ = do + grStatus <- readTVarIO $ groupRegStatus gr + let statusStr = "Status: " <> groupRegStatusText grStatus + getGroupAndSummary cc dbGroupId >>= \case + Just (GroupInfo {groupProfile = p@GroupProfile {image = image_}}, GroupSummary {currentMembers}) -> do + let membersStr = tshow currentMembers <> " members" + text = T.unlines $ [tshow useGroupId <> ". " <> groupInfoText p] <> maybeToList ownerStr_ <> [membersStr, statusStr] + msg = maybe (MCText text) (\image -> MCImage {text, image}) image_ + sendComposedMessage cc ct Nothing msg + Nothing -> do + let text = T.unlines $ [tshow useGroupId <> ". Error: getGroup. Please notify the developers."] <> maybeToList ownerStr_ <> [statusStr] + sendComposedMessage cc ct Nothing $ MCText text + +getContact :: ChatController -> ContactId -> IO (Maybe Contact) +getContact cc ctId = resp <$> sendChatCmd cc (APIGetChat (ChatRef CTDirect ctId) (CPLast 0) Nothing) + where + resp :: ChatResponse -> Maybe Contact + resp = \case + CRApiChat _ (AChat SCTDirect Chat {chatInfo = DirectChat ct}) -> Just ct + _ -> Nothing + +getGroup :: ChatController -> GroupId -> IO (Maybe GroupInfo) +getGroup cc gId = resp <$> sendChatCmd cc (APIGroupInfo gId) + where + resp :: ChatResponse -> Maybe GroupInfo + resp = \case + CRGroupInfo {groupInfo} -> Just groupInfo + _ -> Nothing + +getGroupAndSummary :: ChatController -> GroupId -> IO (Maybe (GroupInfo, GroupSummary)) +getGroupAndSummary cc gId = resp <$> sendChatCmd cc (APIGroupInfo gId) + where + resp = \case + CRGroupInfo {groupInfo, groupSummary} -> Just (groupInfo, groupSummary) + _ -> Nothing + +unexpectedError :: String -> String +unexpectedError err = "Unexpected error: " <> err <> ", please notify the developers." diff --git a/apps/simplex-directory-service/src/Directory/Store.hs b/apps/simplex-directory-service/src/Directory/Store.hs new file mode 100644 index 0000000000..5082cab2ce --- /dev/null +++ b/apps/simplex-directory-service/src/Directory/Store.hs @@ -0,0 +1,328 @@ +{-# LANGUAGE BangPatterns #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module Directory.Store + ( DirectoryStore (..), + GroupReg (..), + GroupRegStatus (..), + UserGroupRegId, + GroupApprovalId, + restoreDirectoryStore, + addGroupReg, + setGroupStatus, + setGroupRegOwner, + getGroupReg, + getUserGroupReg, + getUserGroupRegs, + filterListedGroups, + groupRegStatusText, + ) +where + +import Control.Concurrent.STM +import Control.Monad +import qualified Data.Attoparsec.ByteString.Char8 as A +import Data.ByteString.Char8 (ByteString) +import qualified Data.ByteString.Char8 as B +import Data.Composition ((.:)) +import Data.Int (Int64) +import Data.List (find, foldl', sortOn) +import Data.Map (Map) +import qualified Data.Map.Strict as M +import Data.Maybe (isJust) +import Data.Set (Set) +import qualified Data.Set as S +import Data.Text (Text) +import Simplex.Chat.Types +import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Util (ifM) +import System.IO (Handle, IOMode (..), openFile, BufferMode (..), hSetBuffering) +import System.Directory (renameFile, doesFileExist) + +data DirectoryStore = DirectoryStore + { groupRegs :: TVar [GroupReg], + listedGroups :: TVar (Set GroupId), + reservedGroups :: TVar (Set GroupId), + directoryLogFile :: Maybe Handle + } + +data GroupReg = GroupReg + { dbGroupId :: GroupId, + userGroupRegId :: UserGroupRegId, + dbContactId :: ContactId, + dbOwnerMemberId :: TVar (Maybe GroupMemberId), + groupRegStatus :: TVar GroupRegStatus + } + +data GroupRegData = GroupRegData + { dbGroupId_ :: GroupId, + userGroupRegId_ :: UserGroupRegId, + dbContactId_ :: ContactId, + dbOwnerMemberId_ :: Maybe GroupMemberId, + groupRegStatus_ :: GroupRegStatus + } + +type UserGroupRegId = Int64 + +type GroupApprovalId = Int64 + +data GroupRegStatus + = GRSPendingConfirmation + | GRSProposed + | GRSPendingUpdate + | GRSPendingApproval GroupApprovalId + | GRSActive + | GRSSuspended + | GRSSuspendedBadRoles + | GRSRemoved + +data DirectoryStatus = DSListed | DSReserved | DSRegistered + +groupRegStatusText :: GroupRegStatus -> Text +groupRegStatusText = \case + GRSPendingConfirmation -> "pending confirmation (duplicate names)" + GRSProposed -> "proposed" + GRSPendingUpdate -> "pending profile update" + GRSPendingApproval _ -> "pending admin approval" + GRSActive -> "active" + GRSSuspended -> "suspended by admin" + GRSSuspendedBadRoles -> "suspended because roles changed" + GRSRemoved -> "removed" + +grDirectoryStatus :: GroupRegStatus -> DirectoryStatus +grDirectoryStatus = \case + GRSActive -> DSListed + GRSSuspended -> DSReserved + GRSSuspendedBadRoles -> DSReserved + _ -> DSRegistered + +addGroupReg :: DirectoryStore -> Contact -> GroupInfo -> GroupRegStatus -> IO UserGroupRegId +addGroupReg st ct GroupInfo {groupId} grStatus = do + grData <- atomically addGroupReg_ + logGCreate st grData + pure $ userGroupRegId_ grData + where + addGroupReg_ = do + let grData = GroupRegData {dbGroupId_ = groupId, userGroupRegId_ = 1, dbContactId_ = ctId, dbOwnerMemberId_ = Nothing, groupRegStatus_ = grStatus} + gr <- dataToGroupReg grData + stateTVar (groupRegs st) $ \grs -> + let ugrId = 1 + foldl' maxUgrId 0 grs + grData' = grData {userGroupRegId_ = ugrId} + gr' = gr {userGroupRegId = ugrId} + in (grData', gr' : grs) + ctId = contactId' ct + maxUgrId mx GroupReg {dbContactId, userGroupRegId} + | dbContactId == ctId && userGroupRegId > mx = userGroupRegId + | otherwise = mx + +setGroupStatus :: DirectoryStore -> GroupReg -> GroupRegStatus -> IO () +setGroupStatus st gr grStatus = do + logGUpdateStatus st (dbGroupId gr) grStatus + atomically $ do + writeTVar (groupRegStatus gr) grStatus + updateListing st $ dbGroupId gr + where + updateListing = case grDirectoryStatus grStatus of + DSListed -> listGroup + DSReserved -> reserveGroup + DSRegistered -> unlistGroup + +setGroupRegOwner :: DirectoryStore -> GroupReg -> GroupMember -> IO () +setGroupRegOwner st gr owner = do + let memberId = groupMemberId' owner + logGUpdateOwner st (dbGroupId gr) memberId + atomically $ writeTVar (dbOwnerMemberId gr) (Just memberId) + +getGroupReg :: DirectoryStore -> GroupId -> STM (Maybe GroupReg) +getGroupReg st gId = find ((gId ==) . dbGroupId) <$> readTVar (groupRegs st) + +getUserGroupReg :: DirectoryStore -> ContactId -> UserGroupRegId -> STM (Maybe GroupReg) +getUserGroupReg st ctId ugrId = find (\r -> ctId == dbContactId r && ugrId == userGroupRegId r) <$> readTVar (groupRegs st) + +getUserGroupRegs :: DirectoryStore -> ContactId -> STM [GroupReg] +getUserGroupRegs st ctId = filter ((ctId ==) . dbContactId) <$> readTVar (groupRegs st) + +filterListedGroups :: DirectoryStore -> [(GroupInfo, GroupSummary)] -> STM [(GroupInfo, GroupSummary)] +filterListedGroups st gs = do + lgs <- readTVar $ listedGroups st + pure $ filter (\(GroupInfo {groupId}, _) -> groupId `S.member` lgs) gs + +listGroup :: DirectoryStore -> GroupId -> STM () +listGroup st gId = do + modifyTVar' (listedGroups st) $ S.insert gId + modifyTVar' (reservedGroups st) $ S.delete gId + +reserveGroup :: DirectoryStore -> GroupId -> STM () +reserveGroup st gId = do + modifyTVar' (listedGroups st) $ S.delete gId + modifyTVar' (reservedGroups st) $ S.insert gId + +unlistGroup :: DirectoryStore -> GroupId -> STM () +unlistGroup st gId = do + modifyTVar' (listedGroups st) $ S.delete gId + modifyTVar' (reservedGroups st) $ S.delete gId + +data DirectoryLogRecord + = GRCreate GroupRegData + | GRUpdateStatus GroupId GroupRegStatus + | GRUpdateOwner GroupId GroupMemberId + +data DLRTag = GRCreate_ | GRUpdateStatus_ | GRUpdateOwner_ + +logDLR :: DirectoryStore -> DirectoryLogRecord -> IO () +logDLR st r = forM_ (directoryLogFile st) $ \h -> B.hPutStrLn h (strEncode r) + +logGCreate :: DirectoryStore -> GroupRegData -> IO () +logGCreate st = logDLR st . GRCreate + +logGUpdateStatus :: DirectoryStore -> GroupId -> GroupRegStatus -> IO () +logGUpdateStatus st = logDLR st .: GRUpdateStatus + +logGUpdateOwner :: DirectoryStore -> GroupId -> GroupMemberId -> IO () +logGUpdateOwner st = logDLR st .: GRUpdateOwner + +instance StrEncoding DLRTag where + strEncode = \case + GRCreate_ -> "GCREATE" + GRUpdateStatus_ -> "GSTATUS" + GRUpdateOwner_ -> "GOWNER" + strP = + A.takeTill (== ' ') >>= \case + "GCREATE" -> pure GRCreate_ + "GSTATUS" -> pure GRUpdateStatus_ + "GOWNER" -> pure GRUpdateOwner_ + _ -> fail "invalid DLRTag" + +instance StrEncoding DirectoryLogRecord where + strEncode = \case + GRCreate gr -> strEncode (GRCreate_, gr) + GRUpdateStatus gId grStatus -> strEncode (GRUpdateStatus_, gId, grStatus) + GRUpdateOwner gId grOwnerId -> strEncode (GRUpdateOwner_, gId, grOwnerId) + strP = + strP >>= \case + GRCreate_ -> GRCreate <$> (A.space *> strP) + GRUpdateStatus_ -> GRUpdateStatus <$> (A.space *> A.decimal) <*> (A.space *> strP) + GRUpdateOwner_ -> GRUpdateOwner <$> (A.space *> A.decimal) <*> (A.space *> A.decimal) + +instance StrEncoding GroupRegData where + strEncode GroupRegData {dbGroupId_, userGroupRegId_, dbContactId_, dbOwnerMemberId_, groupRegStatus_} = + B.unwords + [ "group_id=" <> strEncode dbGroupId_, + "user_group_id=" <> strEncode userGroupRegId_, + "contact_id=" <> strEncode dbContactId_, + "owner_member_id=" <> strEncode dbOwnerMemberId_, + "status=" <> strEncode groupRegStatus_ + ] + strP = do + dbGroupId_ <- "group_id=" *> strP_ + userGroupRegId_ <- "user_group_id=" *> strP_ + dbContactId_ <- "contact_id=" *> strP_ + dbOwnerMemberId_ <- "owner_member_id=" *> strP_ + groupRegStatus_ <- "status=" *> strP + pure GroupRegData {dbGroupId_, userGroupRegId_, dbContactId_, dbOwnerMemberId_, groupRegStatus_} + +instance StrEncoding GroupRegStatus where + strEncode = \case + GRSPendingConfirmation -> "pending_confirmation" + GRSProposed -> "proposed" + GRSPendingUpdate -> "pending_update" + GRSPendingApproval gaId -> "pending_approval:" <> strEncode gaId + GRSActive -> "active" + GRSSuspended -> "suspended" + GRSSuspendedBadRoles -> "suspended_bad_roles" + GRSRemoved -> "removed" + strP = + A.takeTill (\c -> c == ' ' || c == ':') >>= \case + "pending_confirmation" -> pure GRSPendingConfirmation + "proposed" -> pure GRSProposed + "pending_update" -> pure GRSPendingUpdate + "pending_approval" -> GRSPendingApproval <$> (A.char ':' *> A.decimal) + "active" -> pure GRSActive + "suspended" -> pure GRSSuspended + "suspended_bad_roles" -> pure GRSSuspendedBadRoles + "removed" -> pure GRSRemoved + _ -> fail "invalid GroupRegStatus" + +dataToGroupReg :: GroupRegData -> STM GroupReg +dataToGroupReg GroupRegData {dbGroupId_, userGroupRegId_, dbContactId_, dbOwnerMemberId_, groupRegStatus_} = do + dbOwnerMemberId <- newTVar dbOwnerMemberId_ + groupRegStatus <- newTVar groupRegStatus_ + pure + GroupReg + { dbGroupId = dbGroupId_, + userGroupRegId = userGroupRegId_, + dbContactId = dbContactId_, + dbOwnerMemberId, + groupRegStatus + } + +restoreDirectoryStore :: Maybe FilePath -> IO DirectoryStore +restoreDirectoryStore = \case + Just f -> ifM (doesFileExist f) (restore f) (newFile f >>= new . Just) + Nothing -> new Nothing + where + new = atomically . newDirectoryStore + newFile f = do + h <- openFile f WriteMode + hSetBuffering h LineBuffering + pure h + restore f = do + grs <- readDirectoryData f + renameFile f (f <> ".bak") + h <- writeDirectoryData f grs -- compact + atomically $ mkDirectoryStore h grs + +emptyStoreData :: ([GroupReg], Set GroupId, Set GroupId) +emptyStoreData = ([], S.empty, S.empty) + +newDirectoryStore :: Maybe Handle -> STM DirectoryStore +newDirectoryStore = (`mkDirectoryStore_` emptyStoreData) + +mkDirectoryStore :: Handle -> [GroupRegData] -> STM DirectoryStore +mkDirectoryStore h groups = + foldM addGroupRegData emptyStoreData groups >>= mkDirectoryStore_ (Just h) + where + addGroupRegData (!grs, !listed, !reserved) gr@GroupRegData {dbGroupId_ = gId} = do + gr' <- dataToGroupReg gr + let grs' = gr' : grs + pure $ case grDirectoryStatus $ groupRegStatus_ gr of + DSListed -> (grs', S.insert gId listed, reserved) + DSReserved -> (grs', listed, S.insert gId reserved) + DSRegistered -> (grs', listed, reserved) + +mkDirectoryStore_ :: Maybe Handle -> ([GroupReg], Set GroupId, Set GroupId) -> STM DirectoryStore +mkDirectoryStore_ h (grs, listed, reserved) = do + groupRegs <- newTVar grs + listedGroups <- newTVar listed + reservedGroups <- newTVar reserved + pure DirectoryStore {groupRegs, listedGroups, reservedGroups, directoryLogFile = h} + +readDirectoryData :: FilePath -> IO [GroupRegData] +readDirectoryData f = + sortOn dbGroupId_ . M.elems + <$> (foldM processDLR M.empty . B.lines =<< B.readFile f) + where + processDLR :: Map GroupId GroupRegData -> ByteString -> IO (Map GroupId GroupRegData) + processDLR m l = case strDecode l of + Left e -> m <$ putStrLn ("Error parsing log record: " <> e <> ", " <> B.unpack (B.take 80 l)) + Right r -> case r of + GRCreate gr@GroupRegData {dbGroupId_ = gId} -> do + when (isJust $ M.lookup gId m) $ + putStrLn $ "Warning: duplicate group with ID " <> show gId <> ", group replaced." + pure $ M.insert gId gr m + GRUpdateStatus gId groupRegStatus_ -> case M.lookup gId m of + Just gr -> pure $ M.insert gId gr {groupRegStatus_} m + Nothing -> m <$ putStrLn ("Warning: no group with ID " <> show gId <>", status update ignored.") + GRUpdateOwner gId grOwnerId -> case M.lookup gId m of + Just gr -> pure $ M.insert gId gr {dbOwnerMemberId_ = Just grOwnerId} m + Nothing -> m <$ putStrLn ("Warning: no group with ID " <> show gId <>", owner update ignored.") + +writeDirectoryData :: FilePath -> [GroupRegData] -> IO Handle +writeDirectoryData f grs = do + h <- openFile f WriteMode + hSetBuffering h LineBuffering + forM_ grs $ B.hPutStrLn h . strEncode . GRCreate + pure h diff --git a/package.yaml b/package.yaml index 80749cdc03..1d4a88fca6 100644 --- a/package.yaml +++ b/package.yaml @@ -10,6 +10,7 @@ copyright: 2020-22 simplex.chat category: Web, System, Services, Cryptography extra-source-files: - README.md + - cabal.project dependencies: - aeson == 2.0.* @@ -91,8 +92,16 @@ executables: - -threaded simplex-broadcast-bot: - source-dirs: apps/simplex-broadcast-bot - main: Main.hs + source-dirs: apps/simplex-broadcast-bot/src + main: ../Main.hs + dependencies: + - simplex-chat + ghc-options: + - -threaded + + simplex-directory-service: + source-dirs: apps/simplex-directory-service/src + main: ../Main.hs dependencies: - simplex-chat ghc-options: @@ -100,7 +109,10 @@ executables: tests: simplex-chat-test: - source-dirs: tests + source-dirs: + - tests + - apps/simplex-broadcast-bot/src + - apps/simplex-directory-service/src main: Test.hs dependencies: - simplex-chat diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 2c7d84ab75..7945becde1 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -28,6 +28,7 @@ library Simplex.Chat Simplex.Chat.Archive Simplex.Chat.Bot + Simplex.Chat.Bot.KnownContacts Simplex.Chat.Call Simplex.Chat.Controller Simplex.Chat.Core @@ -275,12 +276,13 @@ executable simplex-bot-advanced cpp-options: -DswiftJSON executable simplex-broadcast-bot - main-is: Main.hs + main-is: ../Main.hs other-modules: - Options + Broadcast.Bot + Broadcast.Options Paths_simplex_chat hs-source-dirs: - apps/simplex-broadcast-bot + apps/simplex-broadcast-bot/src ghc-options: -Wall -Wcompat -Werror=incomplete-patterns -Wredundant-constraints -Wincomplete-record-updates -Wincomplete-uni-patterns -Wunused-type-patterns -threaded build-depends: aeson ==2.0.* @@ -375,10 +377,65 @@ executable simplex-chat if flag(swift) cpp-options: -DswiftJSON +executable simplex-directory-service + main-is: ../Main.hs + other-modules: + Directory.Events + Directory.Options + Directory.Service + Directory.Store + Paths_simplex_chat + hs-source-dirs: + apps/simplex-directory-service/src + ghc-options: -Wall -Wcompat -Werror=incomplete-patterns -Wredundant-constraints -Wincomplete-record-updates -Wincomplete-uni-patterns -Wunused-type-patterns -threaded + build-depends: + aeson ==2.0.* + , ansi-terminal >=0.10 && <0.12 + , async ==2.2.* + , attoparsec ==0.14.* + , base >=4.7 && <5 + , base64-bytestring >=1.0 && <1.3 + , bytestring ==0.10.* + , composition ==1.0.* + , constraints >=0.12 && <0.14 + , containers ==0.6.* + , cryptonite >=0.27 && <0.30 + , direct-sqlcipher ==2.3.* + , directory ==1.3.* + , email-validate ==2.3.* + , exceptions ==0.10.* + , filepath ==1.4.* + , http-types ==0.12.* + , memory ==0.15.* + , mtl ==2.2.* + , network >=3.1.2.7 && <3.2 + , optparse-applicative >=0.15 && <0.17 + , process ==1.6.* + , random >=1.1 && <1.3 + , record-hasfield ==1.0.* + , simple-logger ==0.1.* + , simplex-chat + , simplexmq >=5.0 + , socks ==0.6.* + , sqlcipher-simple ==0.4.* + , stm ==2.5.* + , template-haskell ==2.16.* + , terminal ==0.2.* + , text ==1.2.* + , time ==1.9.* + , unliftio ==0.2.* + , unliftio-core ==0.2.* + , zip ==1.7.* + default-language: Haskell2010 + if flag(swift) + cpp-options: -DswiftJSON + test-suite simplex-chat-test type: exitcode-stdio-1.0 main-is: Test.hs other-modules: + Bots.BroadcastTests + Bots.DirectoryTests ChatClient ChatTests ChatTests.Direct @@ -392,9 +449,17 @@ test-suite simplex-chat-test SchemaDump ViewTests WebRTCTests + Broadcast.Bot + Broadcast.Options + Directory.Events + Directory.Options + Directory.Service + Directory.Store Paths_simplex_chat hs-source-dirs: tests + apps/simplex-broadcast-bot/src + apps/simplex-directory-service/src ghc-options: -Wall -Wcompat -Werror=incomplete-patterns -Wredundant-constraints -Wincomplete-record-updates -Wincomplete-uni-patterns -Wunused-type-patterns -threaded build-depends: aeson ==2.0.* diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 79745f198b..fb9e72c0b2 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -193,7 +193,6 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen rcvFiles <- newTVarIO M.empty currentCalls <- atomically TM.empty filesFolder <- newTVarIO optFilesFolder - incognitoMode <- newTVarIO False chatStoreChanged <- newTVarIO False expireCIThreads <- newTVarIO M.empty expireCIFlags <- newTVarIO M.empty @@ -202,7 +201,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen showLiveItems <- newTVarIO False userXFTPFileConfig <- newTVarIO $ xftpFileConfig cfg tempDirectory <- newTVarIO tempDir - pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, chatStoreChanged, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, currentCalls, config, sendNotification, incognitoMode, filesFolder, expireCIThreads, expireCIFlags, cleanupManagerAsync, timedItemThreads, showLiveItems, userXFTPFileConfig, tempDirectory, logFilePath = logFile} + pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, chatStoreChanged, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, currentCalls, config, sendNotification, filesFolder, expireCIThreads, expireCIFlags, cleanupManagerAsync, timedItemThreads, showLiveItems, userXFTPFileConfig, tempDirectory, logFilePath = logFile} where configServers :: DefaultAgentServers configServers = @@ -332,7 +331,13 @@ execChatCommand s = do u <- readTVarIO =<< asks currentUser case parseChatCommand s of Left e -> pure $ chatCmdError u e - Right cmd -> either (CRChatCmdError u) id <$> runExceptT (processChatCommand cmd) + Right cmd -> execChatCommand_ u cmd + +execChatCommand' :: ChatMonad' m => ChatCommand -> m ChatResponse +execChatCommand' cmd = asks currentUser >>= readTVarIO >>= (`execChatCommand_` cmd) + +execChatCommand_ :: ChatMonad' m => Maybe User -> ChatCommand -> m ChatResponse +execChatCommand_ u cmd = either (CRChatCmdError u) id <$> runExceptT (processChatCommand cmd) parseChatCommand :: ByteString -> Either String ChatCommand parseChatCommand = A.parseOnly chatCommandP . B.dropWhileEnd isSpace @@ -473,9 +478,6 @@ processChatCommand = \case APISetXFTPConfig cfg -> do asks userXFTPFileConfig >>= atomically . (`writeTVar` cfg) ok_ - SetIncognito onOff -> do - asks incognitoMode >>= atomically . (`writeTVar` onOff) - ok_ APIExportArchive cfg -> checkChatStopped $ exportArchive cfg >> ok_ ExportArchive -> do ts <- liftIO getCurrentTime @@ -930,10 +932,9 @@ processChatCommand = \case pure $ CRChatCleared user (AChatInfo SCTGroup $ GroupChat gInfo) CTContactConnection -> pure $ chatCmdError (Just user) "not supported" CTContactRequest -> pure $ chatCmdError (Just user) "not supported" - APIAcceptContact connReqId -> withUser $ \_ -> withChatLock "acceptContact" $ do + APIAcceptContact incognito connReqId -> withUser $ \_ -> withChatLock "acceptContact" $ do (user, cReq) <- withStore $ \db -> getContactRequest' db connReqId -- [incognito] generate profile to send, create connection with incognito profile - incognito <- readTVarIO =<< asks incognitoMode incognitoProfile <- if incognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing ct <- acceptContactRequest user cReq incognitoProfile pure $ CRAcceptingContactRequest user ct @@ -1138,6 +1139,9 @@ processChatCommand = \case incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) connectionStats <- withAgent (`getConnectionServers` contactConnId ct) pure $ CRContactInfo user ct connectionStats (fmap fromLocalProfile incognitoProfile) + APIGroupInfo gId -> withUser $ \user -> do + (g, s) <- withStore $ \db -> (,) <$> getGroupInfo db user gId <*> liftIO (getGroupSummary db user gId) + pure $ CRGroupInfo user g s APIGroupMemberInfo gId gMemberId -> withUser $ \user -> do (g, m) <- withStore $ \db -> (,) <$> getGroupInfo db user gId <*> getGroupMember db user gId gMemberId connectionStats <- mapM (withAgent . flip getConnectionServers) (memberConnId m) @@ -1224,6 +1228,9 @@ processChatCommand = \case SetShowMessages cName ntfOn -> updateChatSettings cName (\cs -> cs {enableNtfs = ntfOn}) SetSendReceipts cName rcptsOn_ -> updateChatSettings cName (\cs -> cs {sendRcpts = rcptsOn_}) ContactInfo cName -> withContactName cName APIContactInfo + ShowGroupInfo gName -> withUser $ \user -> do + groupId <- withStore $ \db -> getGroupIdByName db user gName + processChatCommand $ APIGroupInfo groupId GroupMemberInfo gName mName -> withMemberName gName mName APIGroupMemberInfo SwitchContact cName -> withContactName cName APISwitchContact SwitchGroupMember gName mName -> withMemberName gName mName APISwitchGroupMember @@ -1239,32 +1246,45 @@ processChatCommand = \case EnableGroupMember gName mName -> withMemberName gName mName $ \gId mId -> APIEnableGroupMember gId mId ChatHelp section -> pure $ CRChatHelp section Welcome -> withUser $ pure . CRWelcome - APIAddContact userId -> withUserId userId $ \user -> withChatLock "addContact" . procCmd $ do + APIAddContact userId incognito -> withUserId userId $ \user -> withChatLock "addContact" . procCmd $ do -- [incognito] generate profile for connection - incognito <- readTVarIO =<< asks incognitoMode incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnNew incognitoProfile toView $ CRNewContactConnection user conn - pure $ CRInvitation user cReq - AddContact -> withUser $ \User {userId} -> - processChatCommand $ APIAddContact userId - APIConnect userId (Just (ACR SCMInvitation cReq)) -> withUserId userId $ \user -> withChatLock "connect" . procCmd $ do + pure $ CRInvitation user cReq conn + AddContact incognito -> withUser $ \User {userId} -> + processChatCommand $ APIAddContact userId incognito + APISetConnectionIncognito connId incognito -> withUser $ \user@User {userId} -> do + conn'_ <- withStore $ \db -> do + conn@PendingContactConnection {pccConnStatus, customUserProfileId} <- getPendingContactConnection db userId connId + case (pccConnStatus, customUserProfileId, incognito) of + (ConnNew, Nothing, True) -> liftIO $ do + incognitoProfile <- generateRandomProfile + pId <- createIncognitoProfile db user incognitoProfile + Just <$> updatePCCIncognito db user conn (Just pId) + (ConnNew, Just pId, False) -> liftIO $ do + deletePCCIncognitoProfile db user pId + Just <$> updatePCCIncognito db user conn Nothing + _ -> pure Nothing + case conn'_ of + Just conn' -> pure $ CRConnectionIncognitoUpdated user conn' + Nothing -> throwChatError CEConnectionIncognitoChangeProhibited + APIConnect userId incognito (Just (ACR SCMInvitation cReq)) -> withUserId userId $ \user -> withChatLock "connect" . procCmd $ do -- [incognito] generate profile to send - incognito <- readTVarIO =<< asks incognitoMode incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing let profileToSend = userProfileToSend user incognitoProfile Nothing connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq . directMessage $ XInfo profileToSend conn <- withStore' $ \db -> createDirectConnection db user connId cReq ConnJoined $ incognitoProfile $> profileToSend toView $ CRNewContactConnection user conn pure $ CRSentConfirmation user - APIConnect userId (Just (ACR SCMContact cReq)) -> withUserId userId (`connectViaContact` cReq) - APIConnect _ Nothing -> throwChatError CEInvalidConnReq - Connect cReqUri -> withUser $ \User {userId} -> - processChatCommand $ APIConnect userId cReqUri - ConnectSimplex -> withUser $ \user -> + APIConnect userId incognito (Just (ACR SCMContact cReq)) -> withUserId userId $ \user -> connectViaContact user incognito cReq + APIConnect _ _ Nothing -> throwChatError CEInvalidConnReq + Connect incognito cReqUri -> withUser $ \User {userId} -> + processChatCommand $ APIConnect userId incognito cReqUri + ConnectSimplex incognito -> withUser $ \user -> -- [incognito] generate profile to send - connectViaContact user adminContactReq + connectViaContact user incognito adminContactReq DeleteContact cName -> withContactName cName $ APIDeleteChat . ChatRef CTDirect ClearContact cName -> withContactName cName $ APIClearChat . ChatRef CTDirect APIListContacts userId -> withUserId userId $ \user -> @@ -1308,9 +1328,9 @@ processChatCommand = \case pure $ CRUserContactLinkUpdated user contactLink AddressAutoAccept autoAccept_ -> withUser $ \User {userId} -> processChatCommand $ APIAddressAutoAccept userId autoAccept_ - AcceptContact cName -> withUser $ \User {userId} -> do + AcceptContact incognito cName -> withUser $ \User {userId} -> do connReqId <- withStore $ \db -> getContactRequestIdByName db userId cName - processChatCommand $ APIAcceptContact connReqId + processChatCommand $ APIAcceptContact incognito connReqId RejectContact cName -> withUser $ \User {userId} -> do connReqId <- withStore $ \db -> getContactRequestIdByName db userId cName processChatCommand $ APIRejectContact connReqId @@ -1486,8 +1506,11 @@ processChatCommand = \case ListMembers gName -> withUser $ \user -> do groupId <- withStore $ \db -> getGroupIdByName db user gName processChatCommand $ APIListMembers groupId - ListGroups -> withUser $ \user -> - CRGroupsList user <$> withStore' (`getUserGroupDetails` user) + APIListGroups userId contactId_ search_ -> withUserId userId $ \user -> + CRGroupsList user <$> withStore' (\db -> getUserGroupsWithSummary db user contactId_ search_) + ListGroups cName_ search_ -> withUser $ \user@User {userId} -> do + ct_ <- forM cName_ $ \cName -> withStore $ \db -> getContactByName db user cName + processChatCommand $ APIListGroups userId (contactId' <$> ct_) search_ APIUpdateGroupProfile groupId p' -> withUser $ \user -> do g <- withStore $ \db -> getGroup db user groupId runUpdateGroupProfile user g p' @@ -1497,6 +1520,8 @@ processChatCommand = \case CRGroupProfile user <$> withStore (\db -> getGroupInfoByName db user gName) UpdateGroupDescription gName description -> updateGroupProfileByName gName $ \p -> p {description} + ShowGroupDescription gName -> withUser $ \user -> + CRGroupDescription user <$> withStore (\db -> getGroupInfoByName db user gName) APICreateGroupLink groupId mRole -> withUser $ \user -> withChatLock "createGroupLink" $ do gInfo <- withStore $ \db -> getGroupInfo db user groupId assertUserGroupRole gInfo GRAdmin @@ -1754,8 +1779,8 @@ processChatCommand = \case CTDirect -> withStore $ \db -> getDirectChatItemIdByText' db user cId msg CTGroup -> withStore $ \db -> getGroupChatItemIdByText' db user cId msg _ -> throwChatError $ CECommandError "not supported" - connectViaContact :: User -> ConnectionRequestUri 'CMContact -> m ChatResponse - connectViaContact user@User {userId} cReq@(CRContactUri ConnReqUriData {crClientData}) = withChatLock "connectViaContact" $ do + connectViaContact :: User -> IncognitoEnabled -> ConnectionRequestUri 'CMContact -> m ChatResponse + connectViaContact user@User {userId} incognito cReq@(CRContactUri ConnReqUriData {crClientData}) = withChatLock "connectViaContact" $ do let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq withStore' (\db -> getConnReqContactXContactId db user cReqHash) >>= \case (Just contact, _) -> pure $ CRContactAlreadyExists user contact @@ -1763,11 +1788,6 @@ processChatCommand = \case let randomXContactId = XContactId <$> drgRandomBytes 16 xContactId <- maybe randomXContactId pure xContactId_ -- [incognito] generate profile to send - -- if user makes a contact request using main profile, then turns on incognito mode and repeats the request, - -- an incognito profile will be sent even though the address holder will have user's main profile received as well; - -- we ignore this edge case as we already allow profile updates on repeat contact requests; - -- alternatively we can re-send the main profile even if incognito mode is enabled - incognito <- readTVarIO =<< asks incognitoMode incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing let profileToSend = userProfileToSend user incognitoProfile Nothing connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq $ directMessage (XContact profileToSend $ Just xContactId) @@ -2534,7 +2554,7 @@ expireChatItems user@User {userId} ttl sync = do createdAtCutoff = addUTCTime (-43200 :: NominalDiffTime) currentTs contacts <- withStoreCtx' (Just "expireChatItems, getUserContacts") (`getUserContacts` user) loop contacts $ processContact expirationDate - groups <- withStoreCtx' (Just "expireChatItems, getUserGroupDetails") (`getUserGroupDetails` user) + groups <- withStoreCtx' (Just "expireChatItems, getUserGroupDetails") (\db -> getUserGroupDetails db user Nothing Nothing) loop groups $ processGroup expirationDate createdAtCutoff where loop :: [a] -> (a -> m ()) -> m () @@ -3436,7 +3456,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do setActive $ ActiveG g showToast ("#" <> g) $ "member " <> c <> " is connected" - probeMatchingContacts :: Contact -> Bool -> m () + probeMatchingContacts :: Contact -> IncognitoEnabled -> m () probeMatchingContacts ct connectedIncognito = do gVar <- asks idsDrg (probe, probeId) <- withStore $ \db -> createSentProbe db gVar userId ct @@ -3954,7 +3974,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do ci <- saveRcvChatItem user (CDDirectRcv ct) msg msgMeta content withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci) toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci) - toView $ CRReceivedGroupInvitation user gInfo ct memRole + toView $ CRReceivedGroupInvitation {user, groupInfo = gInfo, contact = ct, fromMemberRole = fromRole, memberRole = memRole} whenContactNtfs user ct $ showToast ("#" <> localDisplayName <> " " <> c <> "> ") "invited you to join the group" where @@ -4830,13 +4850,13 @@ createInternalChatItem user cd content itemTs_ = do ci <- liftIO $ mkChatItem cd ciId content Nothing Nothing Nothing Nothing False itemTs createdAt toView $ CRNewChatItem user (AChatItem (chatTypeI @c) (msgDirection @d) (toChatInfo cd) ci) -getCreateActiveUser :: SQLiteStore -> IO User -getCreateActiveUser st = do +getCreateActiveUser :: SQLiteStore -> Bool -> IO User +getCreateActiveUser st testView = do user <- withTransaction st getUsers >>= \case [] -> newUser users -> maybe (selectUser users) pure (find activeUser users) - putStrLn $ "Current user: " <> userStr user + unless testView $ putStrLn $ "Current user: " <> userStr user pure user where newUser :: IO User @@ -5033,7 +5053,7 @@ chatCommandP = "/_unread chat " *> (APIChatUnread <$> chatRefP <* A.space <*> onOffP), "/_delete " *> (APIDeleteChat <$> chatRefP), "/_clear chat " *> (APIClearChat <$> chatRefP), - "/_accept " *> (APIAcceptContact <$> A.decimal), + "/_accept" *> (APIAcceptContact <$> incognitoOnOffP <* A.space <*> A.decimal), "/_reject " *> (APIRejectContact <$> A.decimal), "/_call invite @" *> (APISendCallInvitation <$> A.decimal <* A.space <*> jsonP), "/call " *> char_ '@' *> (SendCallInvitation <$> displayName <*> pure defaultCallType), @@ -5081,8 +5101,10 @@ chatCommandP = "/reconnect" $> ReconnectAllServers, "/_settings " *> (APISetChatSettings <$> chatRefP <* A.space <*> jsonP), "/_info #" *> (APIGroupMemberInfo <$> A.decimal <* A.space <*> A.decimal), + "/_info #" *> (APIGroupInfo <$> A.decimal), "/_info @" *> (APIContactInfo <$> A.decimal), ("/info #" <|> "/i #") *> (GroupMemberInfo <$> displayName <* A.space <* char_ '@' <*> displayName), + ("/info #" <|> "/i #") *> (ShowGroupInfo <$> displayName), ("/info " <|> "/i ") *> char_ '@' *> (ContactInfo <$> displayName), "/_switch #" *> (APISwitchGroupMember <$> A.decimal <* A.space <*> A.decimal), "/_switch @" *> (APISwitchContact <$> A.decimal), @@ -5112,6 +5134,7 @@ chatCommandP = ("/help groups" <|> "/help group" <|> "/hg") $> ChatHelp HSGroups, ("/help contacts" <|> "/help contact" <|> "/hc") $> ChatHelp HSContacts, ("/help address" <|> "/ha") $> ChatHelp HSMyAddress, + "/help incognito" $> ChatHelp HSIncognito, ("/help messages" <|> "/hm") $> ChatHelp HSMessages, ("/help settings" <|> "/hs") $> ChatHelp HSSettings, ("/help db" <|> "/hd") $> ChatHelp HSDatabase, @@ -5128,11 +5151,15 @@ chatCommandP = "/clear #" *> (ClearGroup <$> displayName), "/clear " *> char_ '@' *> (ClearContact <$> displayName), ("/members " <|> "/ms ") *> char_ '#' *> (ListMembers <$> displayName), - ("/groups" <|> "/gs") $> ListGroups, + "/_groups" *> (APIListGroups <$> A.decimal <*> optional (" @" *> A.decimal) <*> optional (A.space *> stringP)), + ("/groups" <|> "/gs") *> (ListGroups <$> optional (" @" *> displayName) <*> optional (A.space *> stringP)), "/_group_profile #" *> (APIUpdateGroupProfile <$> A.decimal <* A.space <*> jsonP), ("/group_profile " <|> "/gp ") *> char_ '#' *> (UpdateGroupNames <$> displayName <* A.space <*> groupProfile), ("/group_profile " <|> "/gp ") *> char_ '#' *> (ShowGroupProfile <$> displayName), "/group_descr " *> char_ '#' *> (UpdateGroupDescription <$> displayName <*> optional (A.space *> msgTextP)), + "/set welcome " *> char_ '#' *> (UpdateGroupDescription <$> displayName <* A.space <*> (Just <$> msgTextP)), + "/delete welcome " *> char_ '#' *> (UpdateGroupDescription <$> displayName <*> pure Nothing), + "/show welcome " *> char_ '#' *> (ShowGroupDescription <$> displayName), "/_create link #" *> (APICreateGroupLink <$> A.decimal <*> (memberRole <|> pure GRMember)), "/_set link role #" *> (APIGroupLinkMemberRole <$> A.decimal <*> memberRole), "/_delete link #" *> (APIDeleteGroupLink <$> A.decimal), @@ -5145,10 +5172,11 @@ chatCommandP = (">#" <|> "> #") *> (SendGroupMessageQuote <$> displayName <* A.space <* char_ '@' <*> (Just <$> displayName) <* A.space <*> quotedMsg <*> msgTextP), "/_contacts " *> (APIListContacts <$> A.decimal), "/contacts" $> ListContacts, - "/_connect " *> (APIConnect <$> A.decimal <* A.space <*> ((Just <$> strP) <|> A.takeByteString $> Nothing)), - "/_connect " *> (APIAddContact <$> A.decimal), - ("/connect " <|> "/c ") *> (Connect <$> ((Just <$> strP) <|> A.takeByteString $> Nothing)), - ("/connect" <|> "/c") $> AddContact, + "/_connect " *> (APIConnect <$> A.decimal <*> incognitoOnOffP <* A.space <*> ((Just <$> strP) <|> A.takeByteString $> Nothing)), + "/_connect " *> (APIAddContact <$> A.decimal <*> incognitoOnOffP), + "/_set incognito :" *> (APISetConnectionIncognito <$> A.decimal <* A.space <*> onOffP), + ("/connect" <|> "/c") *> (Connect <$> incognitoP <* A.space <*> ((Just <$> strP) <|> A.takeByteString $> Nothing)), + ("/connect" <|> "/c") *> (AddContact <$> incognitoP), SendMessage <$> chatNameP <* A.space <*> msgTextP, "/live " *> (SendLiveMessage <$> chatNameP <*> (A.space *> msgTextP <|> pure "")), (">@" <|> "> @") *> sendMsgQuote (AMsgDirection SMDRcv), @@ -5174,7 +5202,7 @@ chatCommandP = "/_set_file_to_receive " *> (SetFileToReceive <$> A.decimal), ("/fcancel " <|> "/fc ") *> (CancelFile <$> A.decimal), ("/fstatus " <|> "/fs ") *> (FileStatus <$> A.decimal), - "/simplex" $> ConnectSimplex, + "/simplex" *> (ConnectSimplex <$> incognitoP), "/_address " *> (APICreateMyAddress <$> A.decimal), ("/address" <|> "/ad") $> CreateMyAddress, "/_delete_address " *> (APIDeleteMyAddress <$> A.decimal), @@ -5185,7 +5213,7 @@ chatCommandP = ("/profile_address " <|> "/pa ") *> (SetProfileAddress <$> onOffP), "/_auto_accept " *> (APIAddressAutoAccept <$> A.decimal <* A.space <*> autoAcceptP), "/auto_accept " *> (AddressAutoAccept <$> autoAcceptP), - ("/accept " <|> "/ac ") *> char_ '@' *> (AcceptContact <$> displayName), + ("/accept" <|> "/ac") *> (AcceptContact <$> incognitoP <* A.space <* char_ '@' <*> displayName), ("/reject " <|> "/rc ") *> char_ '@' *> (RejectContact <$> displayName), ("/markdown" <|> "/m") $> ChatHelp HSMarkdown, ("/welcome" <|> "/w") $> Welcome, @@ -5207,7 +5235,7 @@ chatCommandP = "/set disappear #" *> (SetGroupTimedMessages <$> displayName <*> (A.space *> timedTTLOnOffP)), "/set disappear @" *> (SetContactTimedMessages <$> displayName <*> optional (A.space *> timedMessagesEnabledP)), "/set disappear " *> (SetUserTimedMessages <$> (("yes" $> True) <|> ("no" $> False))), - "/incognito " *> (SetIncognito <$> onOffP), + ("/incognito" <* optional (A.space *> onOffP)) $> ChatHelp HSIncognito, ("/quit" <|> "/q" <|> "/exit") $> QuitChat, ("/version" <|> "/v") $> ShowVersion, "/debug locks" $> DebugLocks, @@ -5216,6 +5244,8 @@ chatCommandP = ] where choice = A.choice . map (\p -> p <* A.takeWhile (== ' ') <* A.endOfInput) + incognitoP = (A.space *> ("incognito" <|> "i")) $> True <|> pure False + incognitoOnOffP = (A.space *> "incognito=" *> onOffP) <|> pure False imagePrefix = (<>) <$> "data:" <*> ("image/png;base64," <|> "image/jpg;base64,") imageP = safeDecodeUtf8 <$> ((<>) <$> imagePrefix <*> (B64.encode <$> base64P)) chatTypeP = A.char '@' $> CTDirect <|> A.char '#' $> CTGroup <|> A.char ':' $> CTContactConnection diff --git a/src/Simplex/Chat/Bot.hs b/src/Simplex/Chat/Bot.hs index 05a755fd87..234963b44c 100644 --- a/src/Simplex/Chat/Bot.hs +++ b/src/Simplex/Chat/Bot.hs @@ -9,9 +9,7 @@ module Simplex.Chat.Bot where import Control.Concurrent.Async import Control.Concurrent.STM import Control.Monad.Reader -import qualified Data.Aeson as J import qualified Data.ByteString.Char8 as B -import qualified Data.ByteString.Lazy.Char8 as LB import qualified Data.Text as T import Simplex.Chat.Controller import Simplex.Chat.Core @@ -19,9 +17,8 @@ import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent import Simplex.Chat.Protocol (MsgContent (..)) import Simplex.Chat.Store -import Simplex.Chat.Types (Contact (..), IsContact (..), User (..)) +import Simplex.Chat.Types (Contact (..), ContactId, IsContact (..), User (..)) import Simplex.Messaging.Encoding.String (strEncode) -import Simplex.Messaging.Util (safeDecodeUtf8) import System.Exit (exitFailure) chatBotRepl :: String -> (Contact -> String -> IO String) -> User -> ChatController -> IO () @@ -32,49 +29,58 @@ chatBotRepl welcome answer _user cc = do case resp of CRContactConnected _ contact _ -> do contactConnected contact - void $ sendMsg contact welcome + void $ sendMessage cc contact welcome CRNewChatItem _ (AChatItem _ SMDRcv (DirectChat contact) ChatItem {content = mc@CIRcvMsgContent {}}) -> do let msg = T.unpack $ ciContentToText mc - void $ sendMsg contact =<< answer contact msg + void $ sendMessage cc contact =<< answer contact msg _ -> pure () where - sendMsg Contact {contactId} msg = sendChatCmd cc $ "/_send @" <> show contactId <> " text " <> msg contactConnected Contact {localDisplayName} = putStrLn $ T.unpack localDisplayName <> " connected" initializeBotAddress :: ChatController -> IO () -initializeBotAddress cc = do - sendChatCmd cc "/show_address" >>= \case +initializeBotAddress = initializeBotAddress' True + +initializeBotAddress' :: Bool -> ChatController -> IO () +initializeBotAddress' logAddress cc = do + sendChatCmd cc ShowMyAddress >>= \case CRUserContactLink _ UserContactLink {connReqContact} -> showBotAddress connReqContact CRChatCmdError _ (ChatErrorStore SEUserContactLinkNotFound) -> do - putStrLn "No bot address, creating..." - sendChatCmd cc "/address" >>= \case + when logAddress $ putStrLn "No bot address, creating..." + sendChatCmd cc CreateMyAddress >>= \case CRUserContactLinkCreated _ uri -> showBotAddress uri _ -> putStrLn "can't create bot address" >> exitFailure _ -> putStrLn "unexpected response" >> exitFailure where showBotAddress uri = do - putStrLn $ "Bot's contact address is: " <> B.unpack (strEncode uri) - void $ sendChatCmd cc "/auto_accept on" + when logAddress $ putStrLn $ "Bot's contact address is: " <> B.unpack (strEncode uri) + void $ sendChatCmd cc $ AddressAutoAccept $ Just AutoAccept {acceptIncognito = False, autoReply = Nothing} sendMessage :: ChatController -> Contact -> String -> IO () sendMessage cc ct = sendComposedMessage cc ct Nothing . textMsgContent +sendMessage' :: ChatController -> ContactId -> String -> IO () +sendMessage' cc ctId = sendComposedMessage' cc ctId Nothing . textMsgContent + sendComposedMessage :: ChatController -> Contact -> Maybe ChatItemId -> MsgContent -> IO () -sendComposedMessage cc ct quotedItemId msgContent = do +sendComposedMessage cc = sendComposedMessage' cc . contactId' + +sendComposedMessage' :: ChatController -> ContactId -> Maybe ChatItemId -> MsgContent -> IO () +sendComposedMessage' cc ctId quotedItemId msgContent = do let cm = ComposedMessage {filePath = Nothing, quotedItemId, msgContent} - sendChatCmd cc ("/_send @" <> show (contactId' ct) <> " json " <> jsonEncode cm) >>= \case - CRNewChatItem {} -> printLog cc CLLInfo $ "sent message to " <> contactInfo ct + sendChatCmd cc (APISendMessage (ChatRef CTDirect ctId) False Nothing cm) >>= \case + CRNewChatItem {} -> printLog cc CLLInfo $ "sent message to contact ID " <> show ctId r -> putStrLn $ "unexpected send message response: " <> show r - where - jsonEncode = T.unpack . safeDecodeUtf8 . LB.toStrict . J.encode deleteMessage :: ChatController -> Contact -> ChatItemId -> IO () deleteMessage cc ct chatItemId = do - let cmd = "/_delete item @" <> show (contactId' ct) <> " " <> show chatItemId <> " internal" + let cmd = APIDeleteChatItem (contactRef ct) chatItemId CIDMInternal sendChatCmd cc cmd >>= \case CRChatItemDeleted {} -> printLog cc CLLInfo $ "deleted message from " <> contactInfo ct r -> putStrLn $ "unexpected delete message response: " <> show r +contactRef :: Contact -> ChatRef +contactRef = ChatRef CTDirect . contactId' + textMsgContent :: String -> MsgContent textMsgContent = MCText . T.pack diff --git a/src/Simplex/Chat/Bot/KnownContacts.hs b/src/Simplex/Chat/Bot/KnownContacts.hs new file mode 100644 index 0000000000..c079b994a6 --- /dev/null +++ b/src/Simplex/Chat/Bot/KnownContacts.hs @@ -0,0 +1,33 @@ +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module Simplex.Chat.Bot.KnownContacts where + +import qualified Data.Attoparsec.ByteString.Char8 as A +import Data.Int (Int64) +import Data.Text (Text) +import Data.Text.Encoding (encodeUtf8) +import qualified Data.Text as T +import Options.Applicative +import Simplex.Messaging.Parsers (parseAll) +import Simplex.Messaging.Util (safeDecodeUtf8) + +data KnownContact = KnownContact + { contactId :: Int64, + localDisplayName :: Text + } + deriving (Eq) + +knownContactNames :: [KnownContact] -> String +knownContactNames = T.unpack . T.intercalate ", " . map (("@" <>) . localDisplayName) + +parseKnownContacts :: ReadM [KnownContact] +parseKnownContacts = eitherReader $ parseAll knownContactsP . encodeUtf8 . T.pack + +knownContactsP :: A.Parser [KnownContact] +knownContactsP = contactP `A.sepBy1` A.char ',' + where + contactP = do + contactId <- A.decimal <* A.char ':' + localDisplayName <- safeDecodeUtf8 <$> A.takeTill (A.inClass ", ") + pure KnownContact {contactId, localDisplayName} diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 90f90fdcb6..a02eb0b73b 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -176,7 +176,6 @@ data ChatController = ChatController currentCalls :: TMap ContactId Call, config :: ChatConfig, filesFolder :: TVar (Maybe FilePath), -- path to files folder for mobile apps, - incognitoMode :: TVar Bool, expireCIThreads :: TMap UserId (Maybe (Async ())), expireCIFlags :: TMap UserId Bool, cleanupManagerAsync :: TVar (Maybe (Async ())), @@ -187,7 +186,7 @@ data ChatController = ChatController logFilePath :: Maybe FilePath } -data HelpSection = HSMain | HSFiles | HSGroups | HSContacts | HSMyAddress | HSMarkdown | HSMessages | HSSettings | HSDatabase +data HelpSection = HSMain | HSFiles | HSGroups | HSContacts | HSMyAddress | HSIncognito | HSMarkdown | HSMessages | HSSettings | HSDatabase deriving (Show, Generic) instance ToJSON HelpSection where @@ -223,7 +222,6 @@ data ChatCommand | SetTempFolder FilePath | SetFilesFolder FilePath | APISetXFTPConfig (Maybe XFTPFileConfig) - | SetIncognito Bool | APIExportArchive ArchiveConfig | ExportArchive | APIImportArchive ArchiveConfig @@ -244,7 +242,7 @@ data ChatCommand | APIChatUnread ChatRef Bool | APIDeleteChat ChatRef | APIClearChat ChatRef - | APIAcceptContact Int64 + | APIAcceptContact IncognitoEnabled Int64 | APIRejectContact Int64 | APISendCallInvitation ContactId CallType | SendCallInvitation ContactName CallType @@ -291,6 +289,7 @@ data ChatCommand | ReconnectAllServers | APISetChatSettings ChatRef ChatSettings | APIContactInfo ContactId + | APIGroupInfo GroupId | APIGroupMemberInfo GroupId GroupMemberId | APISwitchContact ContactId | APISwitchGroupMember GroupId GroupMemberId @@ -307,6 +306,7 @@ data ChatCommand | SetShowMessages ChatName Bool | SetSendReceipts ChatName (Maybe Bool) | ContactInfo ContactName + | ShowGroupInfo GroupName | GroupMemberInfo GroupName ContactName | SwitchContact ContactName | SwitchGroupMember GroupName ContactName @@ -322,11 +322,12 @@ data ChatCommand | EnableGroupMember GroupName ContactName | ChatHelp HelpSection | Welcome - | APIAddContact UserId - | AddContact - | APIConnect UserId (Maybe AConnectionRequestUri) - | Connect (Maybe AConnectionRequestUri) - | ConnectSimplex -- UserId (not used in UI) + | APIAddContact UserId IncognitoEnabled + | AddContact IncognitoEnabled + | APISetConnectionIncognito Int64 IncognitoEnabled + | APIConnect UserId IncognitoEnabled (Maybe AConnectionRequestUri) + | Connect IncognitoEnabled (Maybe AConnectionRequestUri) + | ConnectSimplex IncognitoEnabled -- UserId (not used in UI) | DeleteContact ContactName | ClearContact ContactName | APIListContacts UserId @@ -341,7 +342,7 @@ data ChatCommand | SetProfileAddress Bool | APIAddressAutoAccept UserId (Maybe AutoAccept) | AddressAutoAccept (Maybe AutoAccept) - | AcceptContact ContactName + | AcceptContact IncognitoEnabled ContactName | RejectContact ContactName | SendMessage ChatName Text | SendLiveMessage ChatName Text @@ -362,10 +363,12 @@ data ChatCommand | DeleteGroup GroupName | ClearGroup GroupName | ListMembers GroupName - | ListGroups -- UserId (not used in UI) + | APIListGroups UserId (Maybe ContactId) (Maybe String) + | ListGroups (Maybe ContactName) (Maybe String) | UpdateGroupNames GroupName GroupProfile | ShowGroupProfile GroupName | UpdateGroupDescription GroupName (Maybe Text) + | ShowGroupDescription GroupName | CreateGroupLink GroupName GroupMemberRole | GroupLinkMemberRole GroupName GroupMemberRole | DeleteGroupLink GroupName @@ -422,6 +425,7 @@ data ChatResponse | CRChatItemTTL {user :: User, chatItemTTL :: Maybe Int64} | CRNetworkConfig {networkConfig :: NetworkConfig} | CRContactInfo {user :: User, contact :: Contact, connectionStats :: ConnectionStats, customUserProfile :: Maybe Profile} + | CRGroupInfo {user :: User, groupInfo :: GroupInfo, groupSummary :: GroupSummary} | CRGroupMemberInfo {user :: User, groupInfo :: GroupInfo, member :: GroupMember, connectionStats_ :: Maybe ConnectionStats} | CRContactSwitchStarted {user :: User, contact :: Contact, connectionStats :: ConnectionStats} | CRGroupMemberSwitchStarted {user :: User, groupInfo :: GroupInfo, member :: GroupMember, connectionStats :: ConnectionStats} @@ -459,7 +463,7 @@ data ChatResponse | CRContactRequestRejected {user :: User, contactRequest :: UserContactRequest} | CRUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact} | CRUserDeletedMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} - | CRGroupsList {user :: User, groups :: [GroupInfo]} + | CRGroupsList {user :: User, groups :: [(GroupInfo, GroupSummary)]} | CRSentGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, member :: GroupMember} | CRFileTransferStatus User (FileTransfer, [Integer]) -- TODO refactor this type to FileTransferStatus | CRFileTransferStatusXFTP User AChatItem @@ -467,7 +471,8 @@ data ChatResponse | CRUserProfileNoChange {user :: User} | CRUserPrivacy {user :: User, updatedUser :: User} | CRVersionInfo {versionInfo :: CoreVersionInfo, chatMigrations :: [UpMigration], agentMigrations :: [UpMigration]} - | CRInvitation {user :: User, connReqInvitation :: ConnReqInvitation} + | CRInvitation {user :: User, connReqInvitation :: ConnReqInvitation, connection :: PendingContactConnection} + | CRConnectionIncognitoUpdated {user :: User, toConnection :: PendingContactConnection} | CRSentConfirmation {user :: User} | CRSentInvitation {user :: User, customUserProfile :: Maybe Profile} | CRContactUpdated {user :: User, fromContact :: Contact, toContact :: Contact} @@ -518,7 +523,7 @@ data ChatResponse | CRHostConnected {protocol :: AProtocolType, transportHost :: TransportHost} | CRHostDisconnected {protocol :: AProtocolType, transportHost :: TransportHost} | CRGroupInvitation {user :: User, groupInfo :: GroupInfo} - | CRReceivedGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, memberRole :: GroupMemberRole} + | CRReceivedGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, fromMemberRole :: GroupMemberRole, memberRole :: GroupMemberRole} | CRUserJoinedGroup {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember} | CRJoinedGroupMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRJoinedGroupMemberConnecting {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember, member :: GroupMember} @@ -533,6 +538,7 @@ data ChatResponse | CRGroupDeleted {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRGroupUpdated {user :: User, fromGroup :: GroupInfo, toGroup :: GroupInfo, member_ :: Maybe GroupMember} | CRGroupProfile {user :: User, groupInfo :: GroupInfo} + | CRGroupDescription {user :: User, groupInfo :: GroupInfo} -- only used in CLI | CRGroupLinkCreated {user :: User, groupInfo :: GroupInfo, connReqContact :: ConnReqContact, memberRole :: GroupMemberRole} | CRGroupLink {user :: User, groupInfo :: GroupInfo, connReqContact :: ConnReqContact, memberRole :: GroupMemberRole} | CRGroupLinkDeleted {user :: User, groupInfo :: GroupInfo} @@ -876,6 +882,7 @@ data ChatErrorType | CEServerProtocol {serverProtocol :: AProtocolType} | CEAgentCommandError {message :: String} | CEInvalidFileDescription {message :: String} + | CEConnectionIncognitoChangeProhibited | CEInternalError {message :: String} | CEException {message :: String} deriving (Show, Exception, Generic) diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs index e23dbc5a96..4af161ab41 100644 --- a/src/Simplex/Chat/Core.hs +++ b/src/Simplex/Chat/Core.hs @@ -15,7 +15,7 @@ import System.Exit (exitFailure) import UnliftIO.Async simplexChatCore :: ChatConfig -> ChatOpts -> Maybe (Notification -> IO ()) -> (User -> ChatController -> IO ()) -> IO () -simplexChatCore cfg@ChatConfig {confirmMigrations} opts@ChatOpts {coreOptions = CoreChatOpts {dbFilePrefix, dbKey, logAgent}} sendToast chat = +simplexChatCore cfg@ChatConfig {confirmMigrations, testView} opts@ChatOpts {coreOptions = CoreChatOpts {dbFilePrefix, dbKey, logAgent}} sendToast chat = case logAgent of Just level -> do setLogLevel level @@ -27,7 +27,7 @@ simplexChatCore cfg@ChatConfig {confirmMigrations} opts@ChatOpts {coreOptions = putStrLn $ "Error opening database: " <> show e exitFailure run db@ChatDatabase {chatStore} = do - u <- getCreateActiveUser chatStore + u <- getCreateActiveUser chatStore testView cc <- newChatController db (Just u) cfg opts sendToast runSimplexChat opts u cc chat @@ -39,5 +39,8 @@ runSimplexChat ChatOpts {maintenance} u cc chat a2 <- async $ chat u cc waitEither_ a1 a2 -sendChatCmd :: ChatController -> String -> IO ChatResponse -sendChatCmd cc s = runReaderT (execChatCommand . encodeUtf8 $ T.pack s) cc +sendChatCmdStr :: ChatController -> String -> IO ChatResponse +sendChatCmdStr cc s = runReaderT (execChatCommand . encodeUtf8 $ T.pack s) cc + +sendChatCmd :: ChatController -> ChatCommand -> IO ChatResponse +sendChatCmd cc cmd = runReaderT (execChatCommand' cmd) cc diff --git a/src/Simplex/Chat/Help.hs b/src/Simplex/Chat/Help.hs index c83e81a9ef..c2a5720633 100644 --- a/src/Simplex/Chat/Help.hs +++ b/src/Simplex/Chat/Help.hs @@ -8,6 +8,7 @@ module Simplex.Chat.Help groupsHelpInfo, contactsHelpInfo, myAddressHelpInfo, + incognitoHelpInfo, messagesHelpInfo, markdownInfo, settingsInfo, @@ -48,7 +49,7 @@ chatWelcome user = "Welcome " <> green userName <> "!", "Thank you for installing SimpleX Chat!", "", - "Connect to SimpleX Chat lead developer for any questions - just type " <> highlight "/simplex", + "Connect to SimpleX Chat developers for any questions - just type " <> highlight "/simplex", "", "Follow our updates:", "> Reddit: https://www.reddit.com/r/SimpleXChat/", @@ -213,6 +214,26 @@ myAddressHelpInfo = "The commands may be abbreviated: " <> listHighlight ["/ad", "/da", "/sa", "/ac", "/rc"] ] +incognitoHelpInfo :: [StyledString] +incognitoHelpInfo = + map + styleMarkdown + [ markdown (colored Red) "/incognito" <> " command is deprecated, use commands below instead.", + "", + "Incognito mode protects the privacy of your main profile — you can choose to create a new random profile for each new contact.", + "It allows having many anonymous connections without any shared data between them in a single chat profile.", + "When you share an incognito profile with somebody, this profile will be used for the groups they invite you to.", + "", + green "Incognito commands:", + indent <> highlight "/connect incognito " <> " - create new invitation link using incognito profile", + indent <> highlight "/connect incognito " <> " - accept invitation using incognito profile", + indent <> highlight "/accept incognito " <> " - accept contact request using incognito profile", + indent <> highlight "/simplex incognito " <> " - connect to SimpleX Chat developers using incognito profile", + "", + "The commands may be abbreviated: " <> listHighlight ["/c i", "/c i ", "/ac i "], + "To find the profile used for an incognito connection, use " <> highlight "/info " <> "." + ] + messagesHelpInfo :: [StyledString] messagesHelpInfo = map @@ -269,7 +290,6 @@ settingsInfo = map styleMarkdown [ green "Chat settings:", - indent <> highlight "/incognito on/off " <> " - enable/disable incognito mode", indent <> highlight "/network " <> " - show / set network access options", indent <> highlight "/smp " <> " - show / set configured SMP servers", indent <> highlight "/xftp " <> " - show / set configured XFTP servers", @@ -285,12 +305,12 @@ databaseHelpInfo :: [StyledString] databaseHelpInfo = map styleMarkdown - [ green "Database export:", - indent <> highlight "/db export " <> " - create database export file that can be imported in mobile apps", - indent <> highlight "/files_folder " <> " - set files folder path to include app files in the exported archive", - "", - green "Database encryption:", - indent <> highlight "/db encrypt " <> " - encrypt chat database with key/passphrase", - indent <> highlight "/db key " <> " - change the key of the encrypted app database", - indent <> highlight "/db decrypt " <> " - decrypt chat database" - ] + [ green "Database export:", + indent <> highlight "/db export " <> " - create database export file that can be imported in mobile apps", + indent <> highlight "/files_folder " <> " - set files folder path to include app files in the exported archive", + "", + green "Database encryption:", + indent <> highlight "/db encrypt " <> " - encrypt chat database with key/passphrase", + indent <> highlight "/db key " <> " - change the key of the encrypted app database", + indent <> highlight "/db decrypt " <> " - decrypt chat database" + ] diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 944527ae48..9c18350b85 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -17,6 +17,7 @@ module Simplex.Chat.Store.Direct getPendingContactConnection, deletePendingContactConnection, createDirectConnection, + createIncognitoProfile, createConnReqConnection, getProfileById, getConnReqContactXContactId, @@ -33,6 +34,8 @@ module Simplex.Chat.Store.Direct updateContactUserPreferences, updateContactAlias, updateContactConnectionAlias, + updatePCCIncognito, + deletePCCIncognitoProfile, updateContactUsed, updateContactUnreadChat, updateGroupUnreadChat, @@ -171,6 +174,11 @@ createDirectConnection db User {userId} acId cReq pccConnStatus incognitoProfile pccConnId <- insertedRowId db pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = False, viaUserContactLink = Nothing, groupLinkId = Nothing, customUserProfileId, connReqInv = Just cReq, localAlias = "", createdAt, updatedAt = createdAt} +createIncognitoProfile :: DB.Connection -> User -> Profile -> IO Int64 +createIncognitoProfile db User {userId} p = do + createdAt <- getCurrentTime + createIncognitoProfile_ db userId createdAt p + createIncognitoProfile_ :: DB.Connection -> UserId -> UTCTime -> Profile -> IO Int64 createIncognitoProfile_ db userId createdAt Profile {displayName, fullName, image} = do DB.execute @@ -307,7 +315,30 @@ updateContactConnectionAlias db userId conn localAlias = do WHERE user_id = ? AND connection_id = ? |] (localAlias, updatedAt, userId, pccConnId conn) - pure (conn :: PendingContactConnection) {localAlias} + pure (conn :: PendingContactConnection) {localAlias, updatedAt} + +updatePCCIncognito :: DB.Connection -> User -> PendingContactConnection -> Maybe ProfileId -> IO PendingContactConnection +updatePCCIncognito db User {userId} conn customUserProfileId = do + updatedAt <- getCurrentTime + DB.execute + db + [sql| + UPDATE connections + SET custom_user_profile_id = ?, updated_at = ? + WHERE user_id = ? AND connection_id = ? + |] + (customUserProfileId, updatedAt, userId, pccConnId conn) + pure (conn :: PendingContactConnection) {customUserProfileId, updatedAt} + +deletePCCIncognitoProfile :: DB.Connection -> User -> ProfileId -> IO () +deletePCCIncognitoProfile db User {userId} profileId = + DB.execute + db + [sql| + DELETE FROM contact_profiles + WHERE user_id = ? AND contact_profile_id = ? AND incognito = 1 + |] + (userId, profileId) updateContactUsed :: DB.Connection -> User -> Contact -> IO () updateContactUsed db User {userId} Contact {contactId} = do diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index c3c62d52da..274d6edc6f 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -45,6 +45,8 @@ module Simplex.Chat.Store.Groups deleteGroup, getUserGroups, getUserGroupDetails, + getUserGroupsWithSummary, + getGroupSummary, getContactGroupPreferences, checkContactHasGroups, getGroupInvitation, @@ -448,8 +450,8 @@ getUserGroups db user@User {userId} = do groupIds <- map fromOnly <$> DB.query db "SELECT group_id FROM groups WHERE user_id = ?" (Only userId) rights <$> mapM (runExceptT . getGroup db user) groupIds -getUserGroupDetails :: DB.Connection -> User -> IO [GroupInfo] -getUserGroupDetails db User {userId, userContactId} = +getUserGroupDetails :: DB.Connection -> User -> Maybe ContactId -> Maybe String -> IO [GroupInfo] +getUserGroupDetails db User {userId, userContactId} _contactId_ search_ = map (toGroupInfo userContactId) <$> DB.query db @@ -462,8 +464,35 @@ getUserGroupDetails db User {userId, userContactId} = JOIN group_members mu USING (group_id) JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) WHERE g.user_id = ? AND mu.contact_id = ? + AND (gp.display_name LIKE '%' || ? || '%' OR gp.full_name LIKE '%' || ? || '%' OR gp.description LIKE '%' || ? || '%') |] - (userId, userContactId) + (userId, userContactId, search, search, search) + where + search = fromMaybe "" search_ + +getUserGroupsWithSummary :: DB.Connection -> User -> Maybe ContactId -> Maybe String -> IO [(GroupInfo, GroupSummary)] +getUserGroupsWithSummary db user _contactId_ search_ = + getUserGroupDetails db user _contactId_ search_ + >>= mapM (\g@GroupInfo {groupId} -> (g,) <$> getGroupSummary db user groupId) + +-- the statuses on non-current members should match memberCurrent' function +getGroupSummary :: DB.Connection -> User -> GroupId -> IO GroupSummary +getGroupSummary db User {userId} groupId = do + currentMembers_ <- maybeFirstRow fromOnly $ + DB.query + db + [sql| + SELECT count (m.group_member_id) + FROM groups g + JOIN group_members m USING (group_id) + WHERE g.user_id = ? + AND g.group_id = ? + AND m.member_status != ? + AND m.member_status != ? + AND m.member_status != ? + |] + (userId, groupId, GSMemRemoved, GSMemLeft, GSMemInvited) + pure GroupSummary {currentMembers = fromMaybe 0 currentMembers_} getContactGroupPreferences :: DB.Connection -> User -> Contact -> IO [FullGroupPreferences] getContactGroupPreferences db User {userId} Contact {contactId} = do diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index ddbe665d71..4577712f0e 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -397,14 +397,14 @@ data UserContactLink = UserContactLink instance ToJSON UserContactLink where toEncoding = J.genericToEncoding J.defaultOptions data AutoAccept = AutoAccept - { acceptIncognito :: Bool, + { acceptIncognito :: IncognitoEnabled, autoReply :: Maybe MsgContent } deriving (Show, Generic) instance ToJSON AutoAccept where toEncoding = J.genericToEncoding J.defaultOptions -toUserContactLink :: (ConnReqContact, Bool, Bool, Maybe MsgContent) -> UserContactLink +toUserContactLink :: (ConnReqContact, Bool, IncognitoEnabled, Maybe MsgContent) -> UserContactLink toUserContactLink (connReq, autoAccept, acceptIncognito, autoReply) = UserContactLink connReq $ if autoAccept then Just AutoAccept {acceptIncognito, autoReply} else Nothing @@ -452,9 +452,6 @@ updateUserAddressAutoAccept db user@User {userId} autoAccept = do Just AutoAccept {acceptIncognito, autoReply} -> (True, acceptIncognito, autoReply) _ -> (False, False, Nothing) - - - getProtocolServers :: forall p. ProtocolTypeI p => DB.Connection -> User -> IO [ServerCfg p] getProtocolServers db User {userId} = map toServerCfg diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 9e4d3c0e02..ad3116695d 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -203,7 +203,7 @@ createContact_ db userId connId Profile {displayName, fullName, image, contactLi pure $ Right (ldn, contactId, profileId) deleteUnusedIncognitoProfileById_ :: DB.Connection -> User -> ProfileId -> IO () -deleteUnusedIncognitoProfileById_ db User {userId} profile_id = +deleteUnusedIncognitoProfileById_ db User {userId} profileId = DB.executeNamed db [sql| @@ -218,7 +218,7 @@ deleteUnusedIncognitoProfileById_ db User {userId} profile_id = WHERE user_id = :user_id AND member_profile_id = :profile_id LIMIT 1 ) |] - [":user_id" := userId, ":profile_id" := profile_id] + [":user_id" := userId, ":profile_id" := profileId] type ContactRow = (ContactId, ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Bool) :. (Maybe Bool, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime) diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 9d9791f1a9..ac71ce6122 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -184,7 +184,9 @@ contactConn = activeConn contactConnId :: Contact -> ConnId contactConnId = aConnId . contactConn -contactConnIncognito :: Contact -> Bool +type IncognitoEnabled = Bool + +contactConnIncognito :: Contact -> IncognitoEnabled contactConnIncognito = connIncognito . contactConn contactDirect :: Contact -> Bool @@ -318,6 +320,13 @@ instance ToJSON GroupInfo where toEncoding = J.genericToEncoding J.defaultOption groupName' :: GroupInfo -> GroupName groupName' GroupInfo {localDisplayName = g} = g +data GroupSummary = GroupSummary + { currentMembers :: Int + } + deriving (Show, Generic) + +instance ToJSON GroupSummary where toEncoding = J.genericToEncoding J.defaultOptions + data ContactOrGroup = CGContact Contact | CGGroup Group contactAndGroupIds :: ContactOrGroup -> (Maybe ContactId, Maybe GroupId) @@ -595,7 +604,7 @@ memberConnId GroupMember {activeConn} = aConnId <$> activeConn groupMemberId' :: GroupMember -> GroupMemberId groupMemberId' GroupMember {groupMemberId} = groupMemberId -memberIncognito :: GroupMember -> Bool +memberIncognito :: GroupMember -> IncognitoEnabled memberIncognito GroupMember {memberProfile, memberContactProfileId} = localProfileId memberProfile /= memberContactProfileId memberSecurityCode :: GroupMember -> Maybe SecurityCode @@ -784,6 +793,7 @@ memberActive m = case memberStatus m of memberCurrent :: GroupMember -> Bool memberCurrent = memberCurrent' . memberStatus +-- update getGroupSummary if this is changed memberCurrent' :: GroupMemberStatus -> Bool memberCurrent' = \case GSMemRemoved -> False diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index d6e443401d..bd273dec2b 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -79,6 +79,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRChatItemTTL u ttl -> ttyUser u $ viewChatItemTTL ttl CRNetworkConfig cfg -> viewNetworkConfig cfg CRContactInfo u ct cStats customUserProfile -> ttyUser u $ viewContactInfo ct cStats customUserProfile + CRGroupInfo u g s -> ttyUser u $ viewGroupInfo g s CRGroupMemberInfo u g m cStats -> ttyUser u $ viewGroupMemberInfo g m cStats CRContactSwitchStarted {} -> ["switch started"] CRGroupMemberSwitchStarted {} -> ["switch started"] @@ -115,6 +116,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView HSGroups -> groupsHelpInfo HSContacts -> contactsHelpInfo HSMyAddress -> myAddressHelpInfo + HSIncognito -> incognitoHelpInfo HSMessages -> messagesHelpInfo HSMarkdown -> markdownInfo HSSettings -> settingsInfo @@ -138,7 +140,8 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRUserProfileNoChange u -> ttyUser u ["user profile did not change"] CRUserPrivacy u u' -> ttyUserPrefix u $ viewUserPrivacy u u' CRVersionInfo info _ _ -> viewVersionInfo logLevel info - CRInvitation u cReq -> ttyUser u $ viewConnReqInvitation cReq + CRInvitation u cReq _ -> ttyUser u $ viewConnReqInvitation cReq + CRConnectionIncognitoUpdated u c -> ttyUser u $ viewConnectionIncognitoUpdated c CRSentConfirmation u -> ttyUser u ["confirmation sent!"] CRSentInvitation u customUserProfile -> ttyUser u $ viewSentInvitation customUserProfile testView CRContactDeleted u c -> ttyUser u [ttyContact' c <> ": contact is deleted"] @@ -200,7 +203,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView addressSS UserContactSubStatus {userContactError} = maybe ("Your address is active! To show: " <> highlight' "/sa") (\e -> "User address error: " <> sShow e <> ", to delete your address: " <> highlight' "/da") userContactError (groupLinkErrors, groupLinksSubscribed) = partition (isJust . userContactError) groupLinks CRGroupInvitation u g -> ttyUser u [groupInvitation' g] - CRReceivedGroupInvitation u g c role -> ttyUser u $ viewReceivedGroupInvitation g c role + CRReceivedGroupInvitation {user = u, groupInfo = g, contact = c, memberRole = r} -> ttyUser u $ viewReceivedGroupInvitation g c r CRUserJoinedGroup u g _ -> ttyUser u $ viewUserJoinedGroup g CRJoinedGroupMember u g m -> ttyUser u $ viewJoinedGroupMember g m CRHostConnected p h -> [plain $ "connected to " <> viewHostEvent p h] @@ -217,6 +220,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRGroupDeleted u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " deleted the group", "use " <> highlight ("/d #" <> groupName' g) <> " to delete the local copy of the group"] CRGroupUpdated u g g' m -> ttyUser u $ viewGroupUpdated g g' m CRGroupProfile u g -> ttyUser u $ viewGroupProfile g + CRGroupDescription u g -> ttyUser u $ viewGroupDescription g CRGroupLinkCreated u g cReq mRole -> ttyUser u $ groupLink_ "Group link is created!" g cReq mRole CRGroupLink u g cReq mRole -> ttyUser u $ groupLink_ "Group link:" g cReq mRole CRGroupLinkDeleted u g -> ttyUser u $ viewGroupLinkDeleted g @@ -810,12 +814,12 @@ viewContactConnected ct@Contact {localDisplayName} userIncognitoProfile testView Nothing -> [ttyFullContact ct <> ": contact is connected"] -viewGroupsList :: [GroupInfo] -> [StyledString] +viewGroupsList :: [(GroupInfo, GroupSummary)] -> [StyledString] viewGroupsList [] = ["you have no groups!", "to create: " <> highlight' "/g "] viewGroupsList gs = map groupSS $ sortOn ldn_ gs where - ldn_ = T.toLower . (localDisplayName :: GroupInfo -> GroupName) - groupSS g@GroupInfo {localDisplayName = ldn, groupProfile = GroupProfile {fullName}, membership, chatSettings} = + ldn_ = T.toLower . (localDisplayName :: GroupInfo -> GroupName) . fst + groupSS (g@GroupInfo {localDisplayName = ldn, groupProfile = GroupProfile {fullName}, membership, chatSettings}, GroupSummary {currentMembers}) = case memberStatus membership of GSMemInvited -> groupInvitation' g s -> membershipIncognito g <> ttyGroup ldn <> optFullName ldn fullName <> viewMemberStatus s @@ -825,9 +829,10 @@ viewGroupsList gs = map groupSS $ sortOn ldn_ gs GSMemLeft -> delete "you left" GSMemGroupDeleted -> delete "group deleted" _ - | enableNtfs chatSettings -> "" - | otherwise -> " (muted, you can " <> highlight ("/unmute #" <> ldn) <> ")" + | enableNtfs chatSettings -> " (" <> memberCount <> ")" + | otherwise -> " (" <> memberCount <> ", muted, you can " <> highlight ("/unmute #" <> ldn) <> ")" delete reason = " (" <> reason <> ", delete local copy: " <> highlight ("/d #" <> ldn) <> ")" + memberCount = sShow currentMembers <> " member" <> if currentMembers == 1 then "" else "s" groupInvitation' :: GroupInfo -> StyledString groupInvitation' GroupInfo {localDisplayName = ldn, groupProfile = GroupProfile {fullName}, membership = membership@GroupMember {memberProfile}} = @@ -934,6 +939,12 @@ viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, conta <> ["alias: " <> plain localAlias | localAlias /= ""] <> [viewConnectionVerified (contactSecurityCode ct)] +viewGroupInfo :: GroupInfo -> GroupSummary -> [StyledString] +viewGroupInfo GroupInfo {groupId} s = + [ "group ID: " <> sShow groupId, + "current members: " <> sShow (currentMembers s) + ] + viewGroupMemberInfo :: GroupInfo -> GroupMember -> Maybe ConnectionStats -> [StyledString] viewGroupMemberInfo GroupInfo {groupId} m@GroupMember {groupMemberId, memberProfile = LocalProfile {localAlias}} stats = [ "group ID: " <> sShow groupId, @@ -1135,6 +1146,10 @@ viewGroupProfile g@GroupInfo {groupProfile = GroupProfile {description, image, g where pref = getGroupPreference f . mergeGroupPreferences +viewGroupDescription :: GroupInfo -> [StyledString] +viewGroupDescription GroupInfo {groupProfile = GroupProfile {description}} = + maybe ["No welcome message!"] ((bold' "Welcome message:" :) . map plain . T.lines) description + bold' :: String -> StyledString bold' = styled Bold @@ -1148,6 +1163,11 @@ viewConnectionAliasUpdated PendingContactConnection {pccConnId, localAlias} | localAlias == "" = ["connection " <> sShow pccConnId <> " alias removed"] | otherwise = ["connection " <> sShow pccConnId <> " alias updated: " <> plain localAlias] +viewConnectionIncognitoUpdated :: PendingContactConnection -> [StyledString] +viewConnectionIncognitoUpdated PendingContactConnection {pccConnId, customUserProfileId} + | isJust customUserProfileId = ["connection " <> sShow pccConnId <> " changed to incognito"] + | otherwise = ["connection " <> sShow pccConnId <> " changed to non incognito"] + viewContactUpdated :: Contact -> Contact -> [StyledString] viewContactUpdated Contact {localDisplayName = n, profile = LocalProfile {fullName, contactLink}} @@ -1539,6 +1559,7 @@ viewChatError logLevel = \case CECommandError e -> ["bad chat command: " <> plain e] CEAgentCommandError e -> ["agent command error: " <> plain e] CEInvalidFileDescription e -> ["invalid file description: " <> plain e] + CEConnectionIncognitoChangeProhibited -> ["incognito mode change prohibited"] CEInternalError e -> ["internal chat error: " <> plain e] CEException e -> ["exception: " <> plain e] -- e -> ["chat error: " <> sShow e] diff --git a/tests/Bots/BroadcastTests.hs b/tests/Bots/BroadcastTests.hs new file mode 100644 index 0000000000..69ec10a7ab --- /dev/null +++ b/tests/Bots/BroadcastTests.hs @@ -0,0 +1,76 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module Bots.BroadcastTests where + +import Broadcast.Bot +import Broadcast.Options +import ChatClient +import ChatTests.Utils +import Control.Concurrent (forkIO, killThread, threadDelay) +import Control.Exception (bracket) +import Simplex.Chat.Bot.KnownContacts +import Simplex.Chat.Core +import Simplex.Chat.Options (ChatOpts (..), CoreChatOpts (..)) +import Simplex.Chat.Types (Profile (..)) +import System.FilePath (()) +import Test.Hspec + +broadcastBotTests :: SpecWith FilePath +broadcastBotTests = do + it "should broadcast message" testBroadcastMessages + +withBroadcastBot :: BroadcastBotOpts -> IO () -> IO () +withBroadcastBot opts test = + bracket (forkIO bot) killThread (\_ -> threadDelay 500000 >> test) + where + bot = simplexChatCore testCfg (mkChatOpts opts) Nothing $ broadcastBot opts + +broadcastBotProfile :: Profile +broadcastBotProfile = Profile {displayName = "broadcast_bot", fullName = "Broadcast Bot", image = Nothing, contactLink = Nothing, preferences = Nothing} + +mkBotOpts :: FilePath -> [KnownContact] -> BroadcastBotOpts +mkBotOpts tmp publishers = + BroadcastBotOpts + { coreOptions = (coreOptions (testOpts :: ChatOpts)) {dbFilePrefix = tmp botDbPrefix}, + publishers, + welcomeMessage = defaultWelcomeMessage publishers, + prohibitedMessage = defaultWelcomeMessage publishers + } + +botDbPrefix :: FilePath +botDbPrefix = "broadcast_bot" + +testBroadcastMessages :: HasCallStack => FilePath -> IO () +testBroadcastMessages tmp = do + botLink <- + withNewTestChat tmp botDbPrefix broadcastBotProfile $ \bc_bot -> + withNewTestChat tmp "alice" aliceProfile $ \alice -> do + connectUsers bc_bot alice + bc_bot ##> "/ad" + getContactLink bc_bot True + let botOpts = mkBotOpts tmp [KnownContact 2 "alice"] + withBroadcastBot botOpts $ + withTestChat tmp "alice" $ \alice -> + withNewTestChat tmp "bob" bobProfile $ \bob -> + withNewTestChat tmp "cath" cathProfile $ \cath -> do + alice <## "1 contacts connected (use /cs for the list)" + bob `connectVia` botLink + bob #> "@broadcast_bot hello" + bob <# "broadcast_bot> > hello" + bob <## " Hello! I am a broadcast bot." + bob <## "I broadcast messages to all connected users from @alice." + cath `connectVia` botLink + alice #> "@broadcast_bot hello all!" + bob <# "broadcast_bot> hello all!" + cath <# "broadcast_bot> hello all!" + alice <# "broadcast_bot> > hello all!" + alice <## " Forwarded to 2 contact(s)" + where + cc `connectVia` botLink = do + cc ##> ("/c " <> botLink) + cc <## "connection request sent!" + cc <## "broadcast_bot (Broadcast Bot): contact is connected" + cc <# "broadcast_bot> Hello! I am a broadcast bot." + cc <## "I broadcast messages to all connected users from @alice." diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs new file mode 100644 index 0000000000..e074587ad5 --- /dev/null +++ b/tests/Bots/DirectoryTests.hs @@ -0,0 +1,882 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PostfixOperators #-} + +module Bots.DirectoryTests where + +import ChatClient +import ChatTests.Utils +import Control.Concurrent (forkIO, killThread, threadDelay) +import Control.Exception (finally) +import Control.Monad (forM_) +import Directory.Options +import Directory.Service +import Directory.Store +import Simplex.Chat.Bot.KnownContacts +import Simplex.Chat.Core +import Simplex.Chat.Options (ChatOpts (..), CoreChatOpts (..)) +import Simplex.Chat.Types (GroupMemberRole (..), Profile (..)) +import System.FilePath (()) +import Test.Hspec +import GHC.IO.Handle (hClose) + +directoryServiceTests :: SpecWith FilePath +directoryServiceTests = do + it "should register group" testDirectoryService + it "should suspend and resume group" testSuspendResume + describe "de-listing the group" $ do + it "should de-list if owner leaves the group" testDelistedOwnerLeaves + it "should de-list if owner is removed from the group" testDelistedOwnerRemoved + it "should NOT de-list if another member leaves the group" testNotDelistedMemberLeaves + it "should NOT de-list if another member is removed from the group" testNotDelistedMemberRemoved + it "should de-list if service is removed from the group" testDelistedServiceRemoved + it "should de-list/re-list when service/owner roles change" testDelistedRoleChanges + it "should NOT de-list if another member role changes" testNotDelistedMemberRoleChanged + it "should NOT send to approval if roles are incorrect" testNotSentApprovalBadRoles + it "should NOT allow approving if roles are incorrect" testNotApprovedBadRoles + describe "should require re-approval if profile is changed by" $ do + it "the registration owner" testRegOwnerChangedProfile + it "another owner" testAnotherOwnerChangedProfile + describe "should require profile update if group link is removed by " $ do + it "the registration owner" testRegOwnerRemovedLink + it "another owner" testAnotherOwnerRemovedLink + describe "duplicate groups (same display name and full name)" $ do + it "should ask for confirmation if a duplicate group is submitted" testDuplicateAskConfirmation + it "should prohibit registration if a duplicate group is listed" testDuplicateProhibitRegistration + it "should prohibit confirmation if a duplicate group is listed" testDuplicateProhibitConfirmation + it "should prohibit when profile is updated and not send for approval" testDuplicateProhibitWhenUpdated + it "should prohibit approval if a duplicate group is listed" testDuplicateProhibitApproval + describe "list groups" $ do + it "should list user's groups" testListUserGroups + describe "store log" $ do + it "should restore directory service state" testRestoreDirectory + +directoryProfile :: Profile +directoryProfile = Profile {displayName = "SimpleX-Directory", fullName = "", image = Nothing, contactLink = Nothing, preferences = Nothing} + +mkDirectoryOpts :: FilePath -> [KnownContact] -> DirectoryOpts +mkDirectoryOpts tmp superUsers = + DirectoryOpts + { coreOptions = (coreOptions (testOpts :: ChatOpts)) {dbFilePrefix = tmp serviceDbPrefix}, + superUsers, + directoryLog = Just $ tmp "directory_service.log", + serviceName = "SimpleX-Directory", + testing = True + } + +serviceDbPrefix :: FilePath +serviceDbPrefix = "directory_service" + +testDirectoryService :: HasCallStack => FilePath -> IO () +testDirectoryService tmp = + withDirectoryService tmp $ \superUser dsLink -> + withNewTestChat tmp "bob" bobProfile $ \bob -> + withNewTestChat tmp "cath" cathProfile $ \cath -> do + bob `connectVia` dsLink + bob #> "@SimpleX-Directory privacy" + bob <# "SimpleX-Directory> > privacy" + bob <## " No groups found" + -- putStrLn "*** create a group" + bob ##> "/g PSA Privacy, Security & Anonymity" + bob <## "group #PSA (Privacy, Security & Anonymity) is created" + bob <## "to add members use /a PSA or /create link #PSA" + bob ##> "/a PSA SimpleX-Directory member" + bob <## "invitation to join the group #PSA sent to SimpleX-Directory" + bob <# "SimpleX-Directory> You must grant directory service admin role to register the group" + bob ##> "/mr PSA SimpleX-Directory admin" + -- putStrLn "*** discover service joins group and creates the link for profile" + bob <## "#PSA: you changed the role of SimpleX-Directory from member to admin" + bob <# "SimpleX-Directory> Joining the group PSA…" + bob <## "#PSA: SimpleX-Directory joined the group" + bob <# "SimpleX-Directory> Joined the group PSA, creating the link…" + bob <# "SimpleX-Directory> Created the public link to join the group via this directory service that is always online." + bob <## "" + bob <## "Please add it to the group welcome message." + bob <## "For example, add:" + welcomeWithLink <- dropStrPrefix "SimpleX-Directory> " . dropTime <$> getTermLine bob + -- putStrLn "*** update profile without link" + updateGroupProfile bob "Welcome!" + bob <# "SimpleX-Directory> The profile updated for ID 1 (PSA), but the group link is not added to the welcome message." + (superUser Thank you! The group link for ID 1 (PSA) is added to the welcome message." + bob <## "You will be notified once the group is added to the directory - it may take up to 24 hours." + approvalRequested superUser welcomeWithLink (1 :: Int) + -- putStrLn "*** update profile so that it still has link" + let welcomeWithLink' = "Welcome! " <> welcomeWithLink + updateGroupProfile bob welcomeWithLink' + bob <# "SimpleX-Directory> The group ID 1 (PSA) is updated!" + bob <## "It is hidden from the directory until approved." + superUser <# "SimpleX-Directory> The group ID 1 (PSA) is updated." + approvalRequested superUser welcomeWithLink' (2 :: Int) + -- putStrLn "*** try approving with the old registration code" + superUser #> "@SimpleX-Directory /approve 1:PSA 1" + superUser <# "SimpleX-Directory> > /approve 1:PSA 1" + superUser <## " Incorrect approval code" + -- putStrLn "*** update profile so that it has no link" + updateGroupProfile bob "Welcome!" + bob <# "SimpleX-Directory> The group link for ID 1 (PSA) is removed from the welcome message." + bob <## "" + bob <## "The group is hidden from the directory until the group link is added and the group is re-approved." + superUser <# "SimpleX-Directory> The group link is removed from ID 1 (PSA), de-listed." + superUser #> "@SimpleX-Directory /approve 1:PSA 2" + superUser <# "SimpleX-Directory> > /approve 1:PSA 2" + superUser <## " Error: the group ID 1 (PSA) is not pending approval." + -- putStrLn "*** update profile so that it has link again" + updateGroupProfile bob welcomeWithLink' + bob <# "SimpleX-Directory> Thank you! The group link for ID 1 (PSA) is added to the welcome message." + bob <## "You will be notified once the group is added to the directory - it may take up to 24 hours." + approvalRequested superUser welcomeWithLink' (1 :: Int) + superUser #> "@SimpleX-Directory /approve 1:PSA 1" + superUser <# "SimpleX-Directory> > /approve 1:PSA 1" + superUser <## " Group approved!" + bob <# "SimpleX-Directory> The group ID 1 (PSA) is approved and listed in directory!" + bob <## "Please note: if you change the group profile it will be hidden from directory until it is re-approved." + search bob "privacy" welcomeWithLink' + search bob "security" welcomeWithLink' + cath `connectVia` dsLink + search cath "privacy" welcomeWithLink' + where + search u s welcome = do + u #> ("@SimpleX-Directory " <> s) + u <# ("SimpleX-Directory> > " <> s) + u <## " Found 1 group(s)" + u <# "SimpleX-Directory> PSA (Privacy, Security & Anonymity)" + u <## "Welcome message:" + u <## welcome + u <## "2 members" + updateGroupProfile u welcome = do + u ##> ("/set welcome #PSA " <> welcome) + u <## "description changed to:" + u <## welcome + approvalRequested su welcome grId = do + su <# "SimpleX-Directory> bob submitted the group ID 1: PSA (Privacy, Security & Anonymity)" + su <## "Welcome message:" + su <## welcome + su <## "" + su <## "To approve send:" + su <# ("SimpleX-Directory> /approve 1:PSA " <> show grId) + +testSuspendResume :: HasCallStack => FilePath -> IO () +testSuspendResume tmp = + withDirectoryService tmp $ \superUser dsLink -> + withNewTestChat tmp "bob" bobProfile $ \bob -> do + bob `connectVia` dsLink + registerGroup superUser bob "privacy" "Privacy" + groupFound bob "privacy" + superUser #> "@SimpleX-Directory /suspend 1:privacy" + superUser <# "SimpleX-Directory> > /suspend 1:privacy" + superUser <## " Group suspended!" + bob <# "SimpleX-Directory> The group ID 1 (privacy) is suspended and hidden from directory. Please contact the administrators." + groupNotFound bob "privacy" + superUser #> "@SimpleX-Directory /resume 1:privacy" + superUser <# "SimpleX-Directory> > /resume 1:privacy" + superUser <## " Group listing resumed!" + bob <# "SimpleX-Directory> The group ID 1 (privacy) is listed in the directory again!" + groupFound bob "privacy" + +testDelistedOwnerLeaves :: HasCallStack => FilePath -> IO () +testDelistedOwnerLeaves tmp = + withDirectoryService tmp $ \superUser dsLink -> + withNewTestChat tmp "bob" bobProfile $ \bob -> + withNewTestChat tmp "cath" cathProfile $ \cath -> do + bob `connectVia` dsLink + registerGroup superUser bob "privacy" "Privacy" + addCathAsOwner bob cath + leaveGroup "privacy" bob + cath <## "#privacy: bob left the group" + bob <# "SimpleX-Directory> You left the group ID 1 (privacy)." + bob <## "" + bob <## "The group is no longer listed in the directory." + superUser <# "SimpleX-Directory> The group ID 1 (privacy) is de-listed (group owner left)." + groupNotFound cath "privacy" + +testDelistedOwnerRemoved :: HasCallStack => FilePath -> IO () +testDelistedOwnerRemoved tmp = + withDirectoryService tmp $ \superUser dsLink -> + withNewTestChat tmp "bob" bobProfile $ \bob -> + withNewTestChat tmp "cath" cathProfile $ \cath -> do + bob `connectVia` dsLink + registerGroup superUser bob "privacy" "Privacy" + addCathAsOwner bob cath + removeMember "privacy" cath bob + bob <# "SimpleX-Directory> You are removed from the group ID 1 (privacy)." + bob <## "" + bob <## "The group is no longer listed in the directory." + superUser <# "SimpleX-Directory> The group ID 1 (privacy) is de-listed (group owner is removed)." + groupNotFound cath "privacy" + +testNotDelistedMemberLeaves :: HasCallStack => FilePath -> IO () +testNotDelistedMemberLeaves tmp = + withDirectoryService tmp $ \superUser dsLink -> + withNewTestChat tmp "bob" bobProfile $ \bob -> + withNewTestChat tmp "cath" cathProfile $ \cath -> do + bob `connectVia` dsLink + registerGroup superUser bob "privacy" "Privacy" + addCathAsOwner bob cath + leaveGroup "privacy" cath + bob <## "#privacy: cath left the group" + (superUser FilePath -> IO () +testNotDelistedMemberRemoved tmp = + withDirectoryService tmp $ \superUser dsLink -> + withNewTestChat tmp "bob" bobProfile $ \bob -> + withNewTestChat tmp "cath" cathProfile $ \cath -> do + bob `connectVia` dsLink + registerGroup superUser bob "privacy" "Privacy" + addCathAsOwner bob cath + removeMember "privacy" bob cath + (superUser FilePath -> IO () +testDelistedServiceRemoved tmp = + withDirectoryService tmp $ \superUser dsLink -> + withNewTestChat tmp "bob" bobProfile $ \bob -> + withNewTestChat tmp "cath" cathProfile $ \cath -> do + bob `connectVia` dsLink + registerGroup superUser bob "privacy" "Privacy" + addCathAsOwner bob cath + bob ##> "/rm #privacy SimpleX-Directory" + bob <## "#privacy: you removed SimpleX-Directory from the group" + cath <## "#privacy: bob removed SimpleX-Directory from the group" + bob <# "SimpleX-Directory> SimpleX-Directory is removed from the group ID 1 (privacy)." + bob <## "" + bob <## "The group is no longer listed in the directory." + superUser <# "SimpleX-Directory> The group ID 1 (privacy) is de-listed (directory service is removed)." + groupNotFound cath "privacy" + +testDelistedRoleChanges :: HasCallStack => FilePath -> IO () +testDelistedRoleChanges tmp = + withDirectoryService tmp $ \superUser dsLink -> + withNewTestChat tmp "bob" bobProfile $ \bob -> + withNewTestChat tmp "cath" cathProfile $ \cath -> do + bob `connectVia` dsLink + registerGroup superUser bob "privacy" "Privacy" + addCathAsOwner bob cath + groupFoundN 3 cath "privacy" + -- de-listed if service role changed + bob ##> "/mr privacy SimpleX-Directory member" + bob <## "#privacy: you changed the role of SimpleX-Directory from admin to member" + cath <## "#privacy: bob changed the role of SimpleX-Directory from admin to member" + bob <# "SimpleX-Directory> SimpleX-Directory role in the group ID 1 (privacy) is changed to member." + bob <## "" + bob <## "The group is no longer listed in the directory." + superUser <# "SimpleX-Directory> The group ID 1 (privacy) is de-listed (SimpleX-Directory role is changed to member)." + groupNotFound cath "privacy" + -- re-listed if service role changed back without profile changes + cath ##> "/mr privacy SimpleX-Directory admin" + cath <## "#privacy: you changed the role of SimpleX-Directory from member to admin" + bob <## "#privacy: cath changed the role of SimpleX-Directory from member to admin" + bob <# "SimpleX-Directory> SimpleX-Directory role in the group ID 1 (privacy) is changed to admin." + bob <## "" + bob <## "The group is listed in the directory again." + superUser <# "SimpleX-Directory> The group ID 1 (privacy) is listed (SimpleX-Directory role is changed to admin)." + groupFoundN 3 cath "privacy" + -- de-listed if owner role changed + cath ##> "/mr privacy bob admin" + cath <## "#privacy: you changed the role of bob from owner to admin" + bob <## "#privacy: cath changed your role from owner to admin" + bob <# "SimpleX-Directory> Your role in the group ID 1 (privacy) is changed to admin." + bob <## "" + bob <## "The group is no longer listed in the directory." + superUser <# "SimpleX-Directory> The group ID 1 (privacy) is de-listed (user role is set to admin)." + groupNotFound cath "privacy" + -- re-listed if owner role changed back without profile changes + cath ##> "/mr privacy bob owner" + cath <## "#privacy: you changed the role of bob from admin to owner" + bob <## "#privacy: cath changed your role from admin to owner" + bob <# "SimpleX-Directory> Your role in the group ID 1 (privacy) is changed to owner." + bob <## "" + bob <## "The group is listed in the directory again." + superUser <# "SimpleX-Directory> The group ID 1 (privacy) is listed (user role is set to owner)." + groupFoundN 3 cath "privacy" + +testNotDelistedMemberRoleChanged :: HasCallStack => FilePath -> IO () +testNotDelistedMemberRoleChanged tmp = + withDirectoryService tmp $ \superUser dsLink -> + withNewTestChat tmp "bob" bobProfile $ \bob -> + withNewTestChat tmp "cath" cathProfile $ \cath -> do + bob `connectVia` dsLink + registerGroup superUser bob "privacy" "Privacy" + addCathAsOwner bob cath + groupFoundN 3 cath "privacy" + bob ##> "/mr privacy cath member" + bob <## "#privacy: you changed the role of cath from owner to member" + cath <## "#privacy: bob changed your role from owner to member" + groupFoundN 3 cath "privacy" + +testNotSentApprovalBadRoles :: HasCallStack => FilePath -> IO () +testNotSentApprovalBadRoles tmp = + withDirectoryService tmp $ \superUser dsLink -> + withNewTestChat tmp "bob" bobProfile $ \bob -> + withNewTestChat tmp "cath" cathProfile $ \cath -> do + bob `connectVia` dsLink + cath `connectVia` dsLink + submitGroup bob "privacy" "Privacy" + welcomeWithLink <- groupAccepted bob "privacy" + bob ##> "/mr privacy SimpleX-Directory member" + bob <## "#privacy: you changed the role of SimpleX-Directory from admin to member" + updateProfileWithLink bob "privacy" welcomeWithLink 1 + bob <# "SimpleX-Directory> You must grant directory service admin role to register the group" + bob ##> "/mr privacy SimpleX-Directory admin" + bob <## "#privacy: you changed the role of SimpleX-Directory from member to admin" + bob <# "SimpleX-Directory> SimpleX-Directory role in the group ID 1 (privacy) is changed to admin." + bob <## "" + bob <## "The group is submitted for approval." + notifySuperUser superUser bob "privacy" "Privacy" welcomeWithLink 1 + groupNotFound cath "privacy" + approveRegistration superUser bob "privacy" 1 + groupFound cath "privacy" + +testNotApprovedBadRoles :: HasCallStack => FilePath -> IO () +testNotApprovedBadRoles tmp = + withDirectoryService tmp $ \superUser dsLink -> + withNewTestChat tmp "bob" bobProfile $ \bob -> + withNewTestChat tmp "cath" cathProfile $ \cath -> do + bob `connectVia` dsLink + cath `connectVia` dsLink + submitGroup bob "privacy" "Privacy" + welcomeWithLink <- groupAccepted bob "privacy" + updateProfileWithLink bob "privacy" welcomeWithLink 1 + notifySuperUser superUser bob "privacy" "Privacy" welcomeWithLink 1 + bob ##> "/mr privacy SimpleX-Directory member" + bob <## "#privacy: you changed the role of SimpleX-Directory from admin to member" + let approve = "/approve 1:privacy 1" + superUser #> ("@SimpleX-Directory " <> approve) + superUser <# ("SimpleX-Directory> > " <> approve) + superUser <## " Group is not approved: user is not an owner." + groupNotFound cath "privacy" + bob ##> "/mr privacy SimpleX-Directory admin" + bob <## "#privacy: you changed the role of SimpleX-Directory from member to admin" + bob <# "SimpleX-Directory> SimpleX-Directory role in the group ID 1 (privacy) is changed to admin." + bob <## "" + bob <## "The group is submitted for approval." + notifySuperUser superUser bob "privacy" "Privacy" welcomeWithLink 1 + approveRegistration superUser bob "privacy" 1 + groupFound cath "privacy" + +testRegOwnerChangedProfile :: HasCallStack => FilePath -> IO () +testRegOwnerChangedProfile tmp = + withDirectoryService tmp $ \superUser dsLink -> + withNewTestChat tmp "bob" bobProfile $ \bob -> + withNewTestChat tmp "cath" cathProfile $ \cath -> do + bob `connectVia` dsLink + registerGroup superUser bob "privacy" "Privacy" + addCathAsOwner bob cath + bob ##> "/gp privacy privacy Privacy and Security" + bob <## "full name changed to: Privacy and Security" + bob <# "SimpleX-Directory> The group ID 1 (privacy) is updated!" + bob <## "It is hidden from the directory until approved." + cath <## "bob updated group #privacy:" + cath <## "full name changed to: Privacy and Security" + groupNotFound cath "privacy" + superUser <# "SimpleX-Directory> The group ID 1 (privacy) is updated." + reapproveGroup superUser bob + groupFoundN 3 cath "privacy" + +testAnotherOwnerChangedProfile :: HasCallStack => FilePath -> IO () +testAnotherOwnerChangedProfile tmp = + withDirectoryService tmp $ \superUser dsLink -> + withNewTestChat tmp "bob" bobProfile $ \bob -> + withNewTestChat tmp "cath" cathProfile $ \cath -> do + bob `connectVia` dsLink + registerGroup superUser bob "privacy" "Privacy" + addCathAsOwner bob cath + cath ##> "/gp privacy privacy Privacy and Security" + cath <## "full name changed to: Privacy and Security" + bob <## "cath updated group #privacy:" + bob <## "full name changed to: Privacy and Security" + bob <# "SimpleX-Directory> The group ID 1 (privacy) is updated!" + bob <## "It is hidden from the directory until approved." + groupNotFound cath "privacy" + superUser <# "SimpleX-Directory> The group ID 1 (privacy) is updated." + reapproveGroup superUser bob + groupFoundN 3 cath "privacy" + +testRegOwnerRemovedLink :: HasCallStack => FilePath -> IO () +testRegOwnerRemovedLink tmp = + withDirectoryService tmp $ \superUser dsLink -> + withNewTestChat tmp "bob" bobProfile $ \bob -> + withNewTestChat tmp "cath" cathProfile $ \cath -> do + bob `connectVia` dsLink + registerGroup superUser bob "privacy" "Privacy" + addCathAsOwner bob cath + bob ##> "/show welcome #privacy" + bob <## "Welcome message:" + welcomeWithLink <- getTermLine bob + bob ##> "/set welcome #privacy Welcome!" + bob <## "description changed to:" + bob <## "Welcome!" + bob <# "SimpleX-Directory> The group link for ID 1 (privacy) is removed from the welcome message." + bob <## "" + bob <## "The group is hidden from the directory until the group link is added and the group is re-approved." + cath <## "bob updated group #privacy:" + cath <## "description changed to:" + cath <## "Welcome!" + superUser <# "SimpleX-Directory> The group link is removed from ID 1 (privacy), de-listed." + groupNotFound cath "privacy" + bob ##> ("/set welcome #privacy " <> welcomeWithLink) + bob <## "description changed to:" + bob <## welcomeWithLink + bob <# "SimpleX-Directory> Thank you! The group link for ID 1 (privacy) is added to the welcome message." + bob <## "You will be notified once the group is added to the directory - it may take up to 24 hours." + cath <## "bob updated group #privacy:" + cath <## "description changed to:" + cath <## welcomeWithLink + reapproveGroup superUser bob + groupFoundN 3 cath "privacy" + +testAnotherOwnerRemovedLink :: HasCallStack => FilePath -> IO () +testAnotherOwnerRemovedLink tmp = + withDirectoryService tmp $ \superUser dsLink -> + withNewTestChat tmp "bob" bobProfile $ \bob -> + withNewTestChat tmp "cath" cathProfile $ \cath -> do + bob `connectVia` dsLink + registerGroup superUser bob "privacy" "Privacy" + addCathAsOwner bob cath + bob ##> "/show welcome #privacy" + bob <## "Welcome message:" + welcomeWithLink <- getTermLine bob + cath ##> "/set welcome #privacy Welcome!" + cath <## "description changed to:" + cath <## "Welcome!" + bob <## "cath updated group #privacy:" + bob <## "description changed to:" + bob <## "Welcome!" + bob <# "SimpleX-Directory> The group link for ID 1 (privacy) is removed from the welcome message." + bob <## "" + bob <## "The group is hidden from the directory until the group link is added and the group is re-approved." + superUser <# "SimpleX-Directory> The group link is removed from ID 1 (privacy), de-listed." + groupNotFound cath "privacy" + cath ##> ("/set welcome #privacy " <> welcomeWithLink) + cath <## "description changed to:" + cath <## welcomeWithLink + bob <## "cath updated group #privacy:" + bob <## "description changed to:" + bob <## welcomeWithLink + bob <# "SimpleX-Directory> The group link is added by another group member, your registration will not be processed." + bob <## "" + bob <## "Please update the group profile yourself." + bob ##> ("/set welcome #privacy " <> welcomeWithLink <> " - welcome!") + bob <## "description changed to:" + bob <## (welcomeWithLink <> " - welcome!") + bob <# "SimpleX-Directory> Thank you! The group link for ID 1 (privacy) is added to the welcome message." + bob <## "You will be notified once the group is added to the directory - it may take up to 24 hours." + cath <## "bob updated group #privacy:" + cath <## "description changed to:" + cath <## (welcomeWithLink <> " - welcome!") + reapproveGroup superUser bob + groupFoundN 3 cath "privacy" + +testDuplicateAskConfirmation :: HasCallStack => FilePath -> IO () +testDuplicateAskConfirmation tmp = + withDirectoryService tmp $ \superUser dsLink -> + withNewTestChat tmp "bob" bobProfile $ \bob -> + withNewTestChat tmp "cath" cathProfile $ \cath -> do + bob `connectVia` dsLink + submitGroup bob "privacy" "Privacy" + _ <- groupAccepted bob "privacy" + cath `connectVia` dsLink + submitGroup cath "privacy" "Privacy" + cath <# "SimpleX-Directory> The group privacy (Privacy) is already submitted to the directory." + cath <## "To confirm the registration, please send:" + cath <# "SimpleX-Directory> /confirm 1:privacy" + cath #> "@SimpleX-Directory /confirm 1:privacy" + welcomeWithLink <- groupAccepted cath "privacy" + groupNotFound bob "privacy" + completeRegistration superUser cath "privacy" "Privacy" welcomeWithLink 2 + groupFound bob "privacy" + +testDuplicateProhibitRegistration :: HasCallStack => FilePath -> IO () +testDuplicateProhibitRegistration tmp = + withDirectoryService tmp $ \superUser dsLink -> + withNewTestChat tmp "bob" bobProfile $ \bob -> + withNewTestChat tmp "cath" cathProfile $ \cath -> do + bob `connectVia` dsLink + registerGroup superUser bob "privacy" "Privacy" + cath `connectVia` dsLink + groupFound cath "privacy" + _ <- submitGroup cath "privacy" "Privacy" + cath <# "SimpleX-Directory> The group privacy (Privacy) is already listed in the directory, please choose another name." + +testDuplicateProhibitConfirmation :: HasCallStack => FilePath -> IO () +testDuplicateProhibitConfirmation tmp = + withDirectoryService tmp $ \superUser dsLink -> + withNewTestChat tmp "bob" bobProfile $ \bob -> + withNewTestChat tmp "cath" cathProfile $ \cath -> do + bob `connectVia` dsLink + submitGroup bob "privacy" "Privacy" + welcomeWithLink <- groupAccepted bob "privacy" + cath `connectVia` dsLink + submitGroup cath "privacy" "Privacy" + cath <# "SimpleX-Directory> The group privacy (Privacy) is already submitted to the directory." + cath <## "To confirm the registration, please send:" + cath <# "SimpleX-Directory> /confirm 1:privacy" + groupNotFound cath "privacy" + completeRegistration superUser bob "privacy" "Privacy" welcomeWithLink 1 + groupFound cath "privacy" + cath #> "@SimpleX-Directory /confirm 1:privacy" + cath <# "SimpleX-Directory> The group privacy (Privacy) is already listed in the directory, please choose another name." + +testDuplicateProhibitWhenUpdated :: HasCallStack => FilePath -> IO () +testDuplicateProhibitWhenUpdated tmp = + withDirectoryService tmp $ \superUser dsLink -> + withNewTestChat tmp "bob" bobProfile $ \bob -> + withNewTestChat tmp "cath" cathProfile $ \cath -> do + bob `connectVia` dsLink + submitGroup bob "privacy" "Privacy" + welcomeWithLink <- groupAccepted bob "privacy" + cath `connectVia` dsLink + submitGroup cath "privacy" "Privacy" + cath <# "SimpleX-Directory> The group privacy (Privacy) is already submitted to the directory." + cath <## "To confirm the registration, please send:" + cath <# "SimpleX-Directory> /confirm 1:privacy" + cath #> "@SimpleX-Directory /confirm 1:privacy" + welcomeWithLink' <- groupAccepted cath "privacy" + groupNotFound cath "privacy" + completeRegistration superUser bob "privacy" "Privacy" welcomeWithLink 1 + groupFound cath "privacy" + cath ##> ("/set welcome privacy " <> welcomeWithLink') + cath <## "description changed to:" + cath <## welcomeWithLink' + cath <# "SimpleX-Directory> The group privacy (Privacy) is already listed in the directory, please choose another name." + cath ##> "/gp privacy security Security" + cath <## "changed to #security (Security)" + cath <# "SimpleX-Directory> Thank you! The group link for ID 2 (security) is added to the welcome message." + cath <## "You will be notified once the group is added to the directory - it may take up to 24 hours." + notifySuperUser superUser cath "security" "Security" welcomeWithLink' 2 + approveRegistration superUser cath "security" 2 + groupFound bob "security" + groupFound cath "security" + +testDuplicateProhibitApproval :: HasCallStack => FilePath -> IO () +testDuplicateProhibitApproval tmp = + withDirectoryService tmp $ \superUser dsLink -> + withNewTestChat tmp "bob" bobProfile $ \bob -> + withNewTestChat tmp "cath" cathProfile $ \cath -> do + bob `connectVia` dsLink + submitGroup bob "privacy" "Privacy" + welcomeWithLink <- groupAccepted bob "privacy" + cath `connectVia` dsLink + submitGroup cath "privacy" "Privacy" + cath <# "SimpleX-Directory> The group privacy (Privacy) is already submitted to the directory." + cath <## "To confirm the registration, please send:" + cath <# "SimpleX-Directory> /confirm 1:privacy" + cath #> "@SimpleX-Directory /confirm 1:privacy" + welcomeWithLink' <- groupAccepted cath "privacy" + updateProfileWithLink cath "privacy" welcomeWithLink' 2 + notifySuperUser superUser cath "privacy" "Privacy" welcomeWithLink' 2 + groupNotFound cath "privacy" + completeRegistration superUser bob "privacy" "Privacy" welcomeWithLink 1 + groupFound cath "privacy" + -- fails at approval, as already listed + let approve = "/approve 2:privacy 1" + superUser #> ("@SimpleX-Directory " <> approve) + superUser <# ("SimpleX-Directory> > " <> approve) + superUser <## " The group ID 2 (privacy) is already listed in the directory." + +testListUserGroups :: HasCallStack => FilePath -> IO () +testListUserGroups tmp = + withDirectoryService tmp $ \superUser dsLink -> + withNewTestChat tmp "bob" bobProfile $ \bob -> + withNewTestChat tmp "cath" cathProfile $ \cath -> do + bob `connectVia` dsLink + cath `connectVia` dsLink + registerGroup superUser bob "privacy" "Privacy" + connectUsers bob cath + fullAddMember "privacy" "Privacy" bob cath GRMember + joinGroup "privacy" cath bob + cath <## "#privacy: member SimpleX-Directory_1 is connected" + cath <## "contact SimpleX-Directory_1 is merged into SimpleX-Directory" + cath <## "use @SimpleX-Directory to send messages" + registerGroupId superUser bob "security" "Security" 2 2 + registerGroupId superUser cath "anonymity" "Anonymity" 3 1 + cath #> "@SimpleX-Directory /list" + cath <# "SimpleX-Directory> > /list" + cath <## " 1 registered group(s)" + cath <# "SimpleX-Directory> 1. anonymity (Anonymity)" + cath <## "Welcome message:" + cath <##. "Link to join the group anonymity: " + cath <## "2 members" + cath <## "Status: active" + -- with de-listed group + groupFound cath "anonymity" + cath ##> "/mr anonymity SimpleX-Directory member" + cath <## "#anonymity: you changed the role of SimpleX-Directory from admin to member" + cath <# "SimpleX-Directory> SimpleX-Directory role in the group ID 1 (anonymity) is changed to member." + cath <## "" + cath <## "The group is no longer listed in the directory." + superUser <# "SimpleX-Directory> The group ID 3 (anonymity) is de-listed (SimpleX-Directory role is changed to member)." + groupNotFound cath "anonymity" + listGroups superUser bob cath + +testRestoreDirectory :: HasCallStack => FilePath -> IO () +testRestoreDirectory tmp = do + testListUserGroups tmp + restoreDirectoryService tmp 3 3 $ \superUser _dsLink -> + withTestChat tmp "bob" $ \bob -> + withTestChat tmp "cath" $ \cath -> do + bob <## "2 contacts connected (use /cs for the list)" + bob <### + [ "#privacy (Privacy): connected to server(s)", + "#security (Security): connected to server(s)" + ] + cath <## "2 contacts connected (use /cs for the list)" + cath <### + [ "#privacy (Privacy): connected to server(s)", + "#anonymity (Anonymity): connected to server(s)" + ] + listGroups superUser bob cath + groupFoundN 3 bob "privacy" + groupFound bob "security" + groupFoundN 3 cath "privacy" + groupFound cath "security" + +listGroups :: HasCallStack => TestCC -> TestCC -> TestCC -> IO () +listGroups superUser bob cath = do + bob #> "@SimpleX-Directory /list" + bob <# "SimpleX-Directory> > /list" + bob <## " 2 registered group(s)" + bob <# "SimpleX-Directory> 1. privacy (Privacy)" + bob <## "Welcome message:" + bob <##. "Link to join the group privacy: " + bob <## "3 members" + bob <## "Status: active" + bob <# "SimpleX-Directory> 2. security (Security)" + bob <## "Welcome message:" + bob <##. "Link to join the group security: " + bob <## "2 members" + bob <## "Status: active" + cath #> "@SimpleX-Directory /list" + cath <# "SimpleX-Directory> > /list" + cath <## " 1 registered group(s)" + cath <# "SimpleX-Directory> 1. anonymity (Anonymity)" + cath <## "Welcome message:" + cath <##. "Link to join the group anonymity: " + cath <## "2 members" + cath <## "Status: suspended because roles changed" + -- superuser lists all groups + superUser #> "@SimpleX-Directory /last" + superUser <# "SimpleX-Directory> > /last" + superUser <## " 3 registered group(s)" + superUser <# "SimpleX-Directory> 1. privacy (Privacy)" + superUser <## "Welcome message:" + superUser <##. "Link to join the group privacy: " + superUser <## "Owner: bob" + superUser <## "3 members" + superUser <## "Status: active" + superUser <# "SimpleX-Directory> 2. security (Security)" + superUser <## "Welcome message:" + superUser <##. "Link to join the group security: " + superUser <## "Owner: bob" + superUser <## "2 members" + superUser <## "Status: active" + superUser <# "SimpleX-Directory> 3. anonymity (Anonymity)" + superUser <## "Welcome message:" + superUser <##. "Link to join the group anonymity: " + superUser <## "Owner: cath" + superUser <## "2 members" + superUser <## "Status: suspended because roles changed" + -- showing last 1 group + superUser #> "@SimpleX-Directory /last 1" + superUser <# "SimpleX-Directory> > /last 1" + superUser <## " 3 registered group(s), showing the last 1" + superUser <# "SimpleX-Directory> 3. anonymity (Anonymity)" + superUser <## "Welcome message:" + superUser <##. "Link to join the group anonymity: " + superUser <## "Owner: cath" + superUser <## "2 members" + superUser <## "Status: suspended because roles changed" + +reapproveGroup :: HasCallStack => TestCC -> TestCC -> IO () +reapproveGroup superUser bob = do + superUser <#. "SimpleX-Directory> bob submitted the group ID 1: privacy (" + superUser <## "Welcome message:" + superUser <##. "Link to join the group privacy: " + superUser <## "" + superUser <## "To approve send:" + superUser <# "SimpleX-Directory> /approve 1:privacy 1" + superUser #> "@SimpleX-Directory /approve 1:privacy 1" + superUser <# "SimpleX-Directory> > /approve 1:privacy 1" + superUser <## " Group approved!" + bob <# "SimpleX-Directory> The group ID 1 (privacy) is approved and listed in directory!" + bob <## "Please note: if you change the group profile it will be hidden from directory until it is re-approved." + +addCathAsOwner :: HasCallStack => TestCC -> TestCC -> IO () +addCathAsOwner bob cath = do + connectUsers bob cath + fullAddMember "privacy" "Privacy" bob cath GROwner + joinGroup "privacy" cath bob + cath <## "#privacy: member SimpleX-Directory is connected" + +withDirectoryService :: HasCallStack => FilePath -> (TestCC -> String -> IO ()) -> IO () +withDirectoryService tmp test = do + dsLink <- + withNewTestChat tmp serviceDbPrefix directoryProfile $ \ds -> + withNewTestChat tmp "super_user" aliceProfile $ \superUser -> do + connectUsers ds superUser + ds ##> "/ad" + getContactLink ds True + withDirectory tmp dsLink test + +restoreDirectoryService :: HasCallStack => FilePath -> Int -> Int -> (TestCC -> String -> IO ()) -> IO () +restoreDirectoryService tmp ctCount grCount test = do + dsLink <- + withTestChat tmp serviceDbPrefix $ \ds -> do + ds <## (show ctCount <> " contacts connected (use /cs for the list)") + ds <## "Your address is active! To show: /sa" + ds <## (show grCount <> " group links active") + forM_ [1..grCount] $ \_ -> ds <##. "#" + ds ##> "/sa" + dsLink <- getContactLink ds False + ds <## "auto_accept on" + pure dsLink + withDirectory tmp dsLink test + +withDirectory :: HasCallStack => FilePath -> String -> (TestCC -> String -> IO ()) -> IO () +withDirectory tmp dsLink test = do + let opts = mkDirectoryOpts tmp [KnownContact 2 "alice"] + runDirectory opts $ + withTestChat tmp "super_user" $ \superUser -> do + superUser <## "1 contacts connected (use /cs for the list)" + test superUser dsLink + +runDirectory :: DirectoryOpts -> IO () -> IO () +runDirectory opts@DirectoryOpts {directoryLog} action = do + st <- restoreDirectoryStore directoryLog + t <- forkIO $ bot st + threadDelay 500000 + action `finally` (mapM_ hClose (directoryLogFile st) >> killThread t) + where + bot st = simplexChatCore testCfg (mkChatOpts opts) Nothing $ directoryService st opts + +registerGroup :: TestCC -> TestCC -> String -> String -> IO () +registerGroup su u n fn = registerGroupId su u n fn 1 1 + +registerGroupId :: TestCC -> TestCC -> String -> String -> Int -> Int -> IO () +registerGroupId su u n fn gId ugId = do + submitGroup u n fn + welcomeWithLink <- groupAccepted u n + completeRegistrationId su u n fn welcomeWithLink gId ugId + +submitGroup :: TestCC -> String -> String -> IO () +submitGroup u n fn = do + u ##> ("/g " <> n <> " " <> fn) + u <## ("group #" <> n <> " (" <> fn <> ") is created") + u <## ("to add members use /a " <> n <> " or /create link #" <> n) + u ##> ("/a " <> n <> " SimpleX-Directory admin") + u <## ("invitation to join the group #" <> n <> " sent to SimpleX-Directory") + +groupAccepted :: TestCC -> String -> IO String +groupAccepted u n = do + u <# ("SimpleX-Directory> Joining the group " <> n <> "…") + u <## ("#" <> n <> ": SimpleX-Directory joined the group") + u <# ("SimpleX-Directory> Joined the group " <> n <> ", creating the link…") + u <# "SimpleX-Directory> Created the public link to join the group via this directory service that is always online." + u <## "" + u <## "Please add it to the group welcome message." + u <## "For example, add:" + dropStrPrefix "SimpleX-Directory> " . dropTime <$> getTermLine u -- welcome message with link + +completeRegistration :: TestCC -> TestCC -> String -> String -> String -> Int -> IO () +completeRegistration su u n fn welcomeWithLink gId = + completeRegistrationId su u n fn welcomeWithLink gId gId + +completeRegistrationId :: TestCC -> TestCC -> String -> String -> String -> Int -> Int -> IO () +completeRegistrationId su u n fn welcomeWithLink gId ugId = do + updateProfileWithLink u n welcomeWithLink ugId + notifySuperUser su u n fn welcomeWithLink gId + approveRegistrationId su u n gId ugId + +updateProfileWithLink :: TestCC -> String -> String -> Int -> IO () +updateProfileWithLink u n welcomeWithLink ugId = do + u ##> ("/set welcome " <> n <> " " <> welcomeWithLink) + u <## "description changed to:" + u <## welcomeWithLink + u <# ("SimpleX-Directory> Thank you! The group link for ID " <> show ugId <> " (" <> n <> ") is added to the welcome message.") + u <## "You will be notified once the group is added to the directory - it may take up to 24 hours." + +notifySuperUser :: TestCC -> TestCC -> String -> String -> String -> Int -> IO () +notifySuperUser su u n fn welcomeWithLink gId = do + uName <- userName u + su <# ("SimpleX-Directory> " <> uName <> " submitted the group ID " <> show gId <> ": " <> n <> " (" <> fn <> ")") + su <## "Welcome message:" + su <## welcomeWithLink + su <## "" + su <## "To approve send:" + let approve = "/approve " <> show gId <> ":" <> n <> " 1" + su <# ("SimpleX-Directory> " <> approve) + +approveRegistration :: TestCC -> TestCC -> String -> Int -> IO () +approveRegistration su u n gId = + approveRegistrationId su u n gId gId + +approveRegistrationId :: TestCC -> TestCC -> String -> Int -> Int -> IO () +approveRegistrationId su u n gId ugId = do + let approve = "/approve " <> show gId <> ":" <> n <> " 1" + su #> ("@SimpleX-Directory " <> approve) + su <# ("SimpleX-Directory> > " <> approve) + su <## " Group approved!" + u <# ("SimpleX-Directory> The group ID " <> show ugId <> " (" <> n <> ") is approved and listed in directory!") + u <## "Please note: if you change the group profile it will be hidden from directory until it is re-approved." + +connectVia :: TestCC -> String -> IO () +u `connectVia` dsLink = do + u ##> ("/c " <> dsLink) + u <## "connection request sent!" + u <## "SimpleX-Directory: contact is connected" + u <# "SimpleX-Directory> Welcome to SimpleX-Directory service!" + u <## "Send a search string to find groups or /help to learn how to add groups to directory." + u <## "" + u <## "For example, send privacy to find groups about privacy." + +joinGroup :: String -> TestCC -> TestCC -> IO () +joinGroup gName member host = do + let gn = "#" <> gName + memberName <- userName member + hostName <- userName host + member ##> ("/j " <> gName) + member <## (gn <> ": you joined the group") + member <#. (gn <> " " <> hostName <> "> Link to join the group " <> gName <> ": ") + host <## (gn <> ": " <> memberName <> " joined the group") + +leaveGroup :: String -> TestCC -> IO () +leaveGroup gName member = do + let gn = "#" <> gName + member ##> ("/l " <> gName) + member <## (gn <> ": you left the group") + member <## ("use /d " <> gn <> " to delete the group") + +removeMember :: String -> TestCC -> TestCC -> IO () +removeMember gName admin removed = do + let gn = "#" <> gName + adminName <- userName admin + removedName <- userName removed + admin ##> ("/rm " <> gName <> " " <> removedName) + admin <## (gn <> ": you removed " <> removedName <> " from the group") + removed <## (gn <> ": " <> adminName <> " removed you from the group") + removed <## ("use /d " <> gn <> " to delete the group") + +groupFound :: TestCC -> String -> IO () +groupFound = groupFoundN 2 + +groupFoundN :: Int -> TestCC -> String -> IO () +groupFoundN count u name = do + u #> ("@SimpleX-Directory " <> name) + u <# ("SimpleX-Directory> > " <> name) + u <## " Found 1 group(s)" + u <#. ("SimpleX-Directory> " <> name <> " (") + u <## "Welcome message:" + u <##. "Link to join the group " + u <## (show count <> " members") + +groupNotFound :: TestCC -> String -> IO () +groupNotFound u s = do + u #> ("@SimpleX-Directory " <> s) + u <# ("SimpleX-Directory> > " <> s) + u <## " No groups found" diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 7b93b97589..2384daac3f 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -990,7 +990,7 @@ testMuteGroup = (bob hi") bob ##> "/gs" - bob <## "#team (muted, you can /unmute #team)" + bob <## "#team (3 members, muted, you can /unmute #team)" bob ##> "/unmute #team" bob <## "ok" alice #> "#team hi again" @@ -998,7 +998,7 @@ testMuteGroup = (bob <# "#team alice> hi again") (cath <# "#team alice> hi again") bob ##> "/gs" - bob <## "#team" + bob <## "#team (3 members)" testCreateSecondUser :: HasCallStack => FilePath -> IO () testCreateSecondUser = diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 6e1e761208..55087e01ea 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -132,7 +132,7 @@ testGroupShared alice bob cath checkMessages = do when checkMessages $ getReadChats msgItem1 msgItem2 -- list groups alice ##> "/gs" - alice <## "#team" + alice <## "#team (3 members)" -- list group members alice ##> "/ms team" alice @@ -739,18 +739,18 @@ testGroupList = ] -- alice sees both groups alice ##> "/gs" - alice <### ["#team", "#tennis"] + alice <### ["#team (2 members)", "#tennis (1 member)"] -- bob sees #tennis as invitation bob ##> "/gs" bob - <### [ "#team", + <### [ "#team (2 members)", "#tennis - you are invited (/j tennis to join, /d #tennis to delete invitation)" ] -- after deleting invitation bob sees only one group bob ##> "/d #tennis" bob <## "#tennis: you deleted the group" bob ##> "/gs" - bob <## "#team" + bob <## "#team (2 members)" testGroupMessageQuotedReply :: HasCallStack => FilePath -> IO () testGroupMessageQuotedReply = @@ -1817,8 +1817,7 @@ testGroupLinkIncognitoMembership = -- bob connected incognito to alice alice ##> "/c" inv <- getInvitation alice - bob #$> ("/incognito on", id, "ok") - bob ##> ("/c " <> inv) + bob ##> ("/c i " <> inv) bob <## "confirmation sent!" bobIncognito <- getTermLine bob concurrentlyN_ @@ -1827,7 +1826,6 @@ testGroupLinkIncognitoMembership = bob <## "use /i alice to print out this incognito profile again", alice <## (bobIncognito <> ": contact is connected") ] - bob #$> ("/incognito off", id, "ok") -- alice creates group alice ##> "/g team" alice <## "group #team is created" @@ -1870,8 +1868,7 @@ testGroupLinkIncognitoMembership = cath #> ("@" <> bobIncognito <> " hey, I'm cath") bob ?<# "cath> hey, I'm cath" -- dan joins incognito - dan #$> ("/incognito on", id, "ok") - dan ##> ("/c " <> gLink) + dan ##> ("/c i " <> gLink) danIncognito <- getTermLine dan dan <## "connection request sent incognito!" bob <## (danIncognito <> ": accepting request to join group #team...") @@ -1898,7 +1895,6 @@ testGroupLinkIncognitoMembership = cath <## ("#team: " <> bobIncognito <> " added " <> danIncognito <> " to the group (connecting...)") cath <## ("#team: new member " <> danIncognito <> " is connected") ] - dan #$> ("/incognito off", id, "ok") bob ?#> ("@" <> danIncognito <> " hi, I'm incognito") dan ?<# (bobIncognito <> "> hi, I'm incognito") dan ?#> ("@" <> bobIncognito <> " hey, me too") @@ -2006,7 +2002,6 @@ testGroupLinkIncognitoUnusedHostContactsDeleted :: HasCallStack => FilePath -> I testGroupLinkIncognitoUnusedHostContactsDeleted = testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do - bob #$> ("/incognito on", id, "ok") bobIncognitoTeam <- createGroupBobIncognito alice bob "team" "alice" bobIncognitoClub <- createGroupBobIncognito alice bob "club" "alice_1" bobIncognitoTeam `shouldNotBe` bobIncognitoClub @@ -2036,7 +2031,7 @@ testGroupLinkIncognitoUnusedHostContactsDeleted = alice <## ("to add members use /a " <> group <> " or /create link #" <> group) alice ##> ("/create link #" <> group) gLinkTeam <- getGroupLink alice group GRMember True - bob ##> ("/c " <> gLinkTeam) + bob ##> ("/c i " <> gLinkTeam) bobIncognito <- getTermLine bob bob <## "connection request sent incognito!" alice <## (bobIncognito <> ": accepting request to join group #" <> group <> "...") diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 08d33df1d6..9af7a54623 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -27,10 +27,15 @@ chatProfileTests = do it "delete connection requests when contact link deleted" testDeleteConnectionRequests it "auto-reply message" testAutoReplyMessage it "auto-reply message in incognito" testAutoReplyMessageInIncognito - describe "incognito mode" $ do + describe "incognito" $ do it "connect incognito via invitation link" testConnectIncognitoInvitationLink it "connect incognito via contact address" testConnectIncognitoContactAddress it "accept contact request incognito" testAcceptContactRequestIncognito + it "set connection incognito" testSetConnectionIncognito + it "reset connection incognito" testResetConnectionIncognito + it "set connection incognito prohibited during negotiation" testSetConnectionIncognitoProhibitedDuringNegotiation + it "connection incognito unchanged errors" testConnectionIncognitoUnchangedErrors + it "set, reset, set connection incognito" testSetResetSetConnectionIncognito it "join group incognito" testJoinGroupIncognito it "can't invite contact to whom user connected incognito to a group" testCantInviteContactIncognito it "can't see global preferences update" testCantSeeGlobalPrefsUpdateIncognito @@ -489,11 +494,9 @@ testAutoReplyMessageInIncognito = testChat2 aliceProfile bobProfile $ testConnectIncognitoInvitationLink :: HasCallStack => FilePath -> IO () testConnectIncognitoInvitationLink = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do - alice #$> ("/incognito on", id, "ok") - bob #$> ("/incognito on", id, "ok") - alice ##> "/c" + alice ##> "/connect incognito" inv <- getInvitation alice - bob ##> ("/c " <> inv) + bob ##> ("/connect incognito " <> inv) bob <## "confirmation sent!" bobIncognito <- getTermLine bob aliceIncognito <- getTermLine alice @@ -505,9 +508,6 @@ testConnectIncognitoInvitationLink = testChat3 aliceProfile bobProfile cathProfi alice <## (bobIncognito <> ": contact is connected, your incognito profile for this contact is " <> aliceIncognito) alice <## ("use /i " <> bobIncognito <> " to print out this incognito profile again") ] - -- after turning incognito mode off conversation is incognito - alice #$> ("/incognito off", id, "ok") - bob #$> ("/incognito off", id, "ok") alice ?#> ("@" <> bobIncognito <> " psst, I'm incognito") bob ?<# (aliceIncognito <> "> psst, I'm incognito") bob ?#> ("@" <> aliceIncognito <> " me too") @@ -569,8 +569,7 @@ testConnectIncognitoContactAddress = testChat2 aliceProfile bobProfile $ \alice bob -> do alice ##> "/ad" cLink <- getContactLink alice True - bob #$> ("/incognito on", id, "ok") - bob ##> ("/c " <> cLink) + bob ##> ("/c i " <> cLink) bobIncognito <- getTermLine bob bob <## "connection request sent incognito!" alice <## (bobIncognito <> " wants to connect to you!") @@ -585,9 +584,7 @@ testConnectIncognitoContactAddress = testChat2 aliceProfile bobProfile $ bob <## "use /i alice to print out this incognito profile again", alice <## (bobIncognito <> ": contact is connected") ] - -- after turning incognito mode off conversation is incognito - alice #$> ("/incognito off", id, "ok") - bob #$> ("/incognito off", id, "ok") + -- conversation is incognito alice #> ("@" <> bobIncognito <> " who are you?") bob ?<# "alice> who are you?" bob ?#> "@alice I'm Batman" @@ -605,39 +602,162 @@ testConnectIncognitoContactAddress = testChat2 aliceProfile bobProfile $ bob `hasContactProfiles` ["bob"] testAcceptContactRequestIncognito :: HasCallStack => FilePath -> IO () -testAcceptContactRequestIncognito = testChat2 aliceProfile bobProfile $ - \alice bob -> do +testAcceptContactRequestIncognito = testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do alice ##> "/ad" cLink <- getContactLink alice True bob ##> ("/c " <> cLink) alice <#? bob - alice #$> ("/incognito on", id, "ok") - alice ##> "/ac bob" + alice ##> "/accept incognito bob" alice <## "bob (Bob): accepting contact request..." - aliceIncognito <- getTermLine alice + aliceIncognitoBob <- getTermLine alice concurrentlyN_ - [ bob <## (aliceIncognito <> ": contact is connected"), + [ bob <## (aliceIncognitoBob <> ": contact is connected"), do - alice <## ("bob (Bob): contact is connected, your incognito profile for this contact is " <> aliceIncognito) + alice <## ("bob (Bob): contact is connected, your incognito profile for this contact is " <> aliceIncognitoBob) alice <## "use /i bob to print out this incognito profile again" ] - -- after turning incognito mode off conversation is incognito - alice #$> ("/incognito off", id, "ok") - bob #$> ("/incognito off", id, "ok") + -- conversation is incognito alice ?#> "@bob my profile is totally inconspicuous" - bob <# (aliceIncognito <> "> my profile is totally inconspicuous") - bob #> ("@" <> aliceIncognito <> " I know!") + bob <# (aliceIncognitoBob <> "> my profile is totally inconspicuous") + bob #> ("@" <> aliceIncognitoBob <> " I know!") alice ?<# "bob> I know!" -- list contacts alice ##> "/contacts" alice <## "i bob (Bob)" - alice `hasContactProfiles` ["alice", "bob", T.pack aliceIncognito] + alice `hasContactProfiles` ["alice", "bob", T.pack aliceIncognitoBob] -- delete contact, incognito profile is deleted alice ##> "/d bob" alice <## "bob: contact is deleted" alice ##> "/contacts" (alice ("/c " <> cLink) + alice <#? cath + alice ##> "/_accept incognito=on 1" + alice <## "cath (Catherine): accepting contact request..." + aliceIncognitoCath <- getTermLine alice + concurrentlyN_ + [ cath <## (aliceIncognitoCath <> ": contact is connected"), + do + alice <## ("cath (Catherine): contact is connected, your incognito profile for this contact is " <> aliceIncognitoCath) + alice <## "use /i cath to print out this incognito profile again" + ] + alice `hasContactProfiles` ["alice", "cath", T.pack aliceIncognitoCath] + cath `hasContactProfiles` ["cath", T.pack aliceIncognitoCath] + +testSetConnectionIncognito :: HasCallStack => FilePath -> IO () +testSetConnectionIncognito = testChat2 aliceProfile bobProfile $ + \alice bob -> do + alice ##> "/connect" + inv <- getInvitation alice + alice ##> "/_set incognito :1 on" + alice <## "connection 1 changed to incognito" + bob ##> ("/connect " <> inv) + bob <## "confirmation sent!" + aliceIncognito <- getTermLine alice + concurrentlyN_ + [ bob <## (aliceIncognito <> ": contact is connected"), + do + alice <## ("bob (Bob): contact is connected, your incognito profile for this contact is " <> aliceIncognito) + alice <## ("use /i bob to print out this incognito profile again") + ] + alice ?#> ("@bob hi") + bob <# (aliceIncognito <> "> hi") + bob #> ("@" <> aliceIncognito <> " hey") + alice ?<# ("bob> hey") + alice `hasContactProfiles` ["alice", "bob", T.pack aliceIncognito] + bob `hasContactProfiles` ["bob", T.pack aliceIncognito] + +testResetConnectionIncognito :: HasCallStack => FilePath -> IO () +testResetConnectionIncognito = testChat2 aliceProfile bobProfile $ + \alice bob -> do + alice ##> "/_connect 1 incognito=on" + inv <- getInvitation alice + alice ##> "/_set incognito :1 off" + alice <## "connection 1 changed to non incognito" + bob ##> ("/c " <> inv) + bob <## "confirmation sent!" + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "bob (Bob): contact is connected") + alice <##> bob + alice `hasContactProfiles` ["alice", "bob"] + bob `hasContactProfiles` ["alice", "bob"] + +testSetConnectionIncognitoProhibitedDuringNegotiation :: HasCallStack => FilePath -> IO () +testSetConnectionIncognitoProhibitedDuringNegotiation tmp = do + inv <- withNewTestChat tmp "alice" aliceProfile $ \alice -> do + threadDelay 250000 + alice ##> "/connect" + getInvitation alice + withNewTestChat tmp "bob" bobProfile $ \bob -> do + threadDelay 250000 + bob ##> ("/c " <> inv) + bob <## "confirmation sent!" + withTestChat tmp "alice" $ \alice -> do + threadDelay 250000 + alice ##> "/_set incognito :1 on" + alice <## "chat db error: SEPendingConnectionNotFound {connId = 1}" + withTestChat tmp "bob" $ \bob -> do + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "bob (Bob): contact is connected") + alice <##> bob + alice `hasContactProfiles` ["alice", "bob"] + bob `hasContactProfiles` ["alice", "bob"] + +testConnectionIncognitoUnchangedErrors :: HasCallStack => FilePath -> IO () +testConnectionIncognitoUnchangedErrors = testChat2 aliceProfile bobProfile $ + \alice bob -> do + alice ##> "/connect" + inv <- getInvitation alice + alice ##> "/_set incognito :1 off" + alice <## "incognito mode change prohibited" + alice ##> "/_set incognito :1 on" + alice <## "connection 1 changed to incognito" + alice ##> "/_set incognito :1 on" + alice <## "incognito mode change prohibited" + alice ##> "/_set incognito :1 off" + alice <## "connection 1 changed to non incognito" + alice ##> "/_set incognito :1 off" + alice <## "incognito mode change prohibited" + bob ##> ("/c " <> inv) + bob <## "confirmation sent!" + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "bob (Bob): contact is connected") + alice <##> bob + alice `hasContactProfiles` ["alice", "bob"] + bob `hasContactProfiles` ["alice", "bob"] + +testSetResetSetConnectionIncognito :: HasCallStack => FilePath -> IO () +testSetResetSetConnectionIncognito = testChat2 aliceProfile bobProfile $ + \alice bob -> do + alice ##> "/_connect 1 incognito=off" + inv <- getInvitation alice + alice ##> "/_set incognito :1 on" + alice <## "connection 1 changed to incognito" + alice ##> "/_set incognito :1 off" + alice <## "connection 1 changed to non incognito" + alice ##> "/_set incognito :1 on" + alice <## "connection 1 changed to incognito" + bob ##> ("/_connect 1 incognito=off " <> inv) + bob <## "confirmation sent!" + aliceIncognito <- getTermLine alice + concurrentlyN_ + [ bob <## (aliceIncognito <> ": contact is connected"), + do + alice <## ("bob (Bob): contact is connected, your incognito profile for this contact is " <> aliceIncognito) + alice <## ("use /i bob to print out this incognito profile again") + ] + alice ?#> ("@bob hi") + bob <# (aliceIncognito <> "> hi") + bob #> ("@" <> aliceIncognito <> " hey") + alice ?<# ("bob> hey") + alice `hasContactProfiles` ["alice", "bob", T.pack aliceIncognito] + bob `hasContactProfiles` ["bob", T.pack aliceIncognito] testJoinGroupIncognito :: HasCallStack => FilePath -> IO () testJoinGroupIncognito = testChat4 aliceProfile bobProfile cathProfile danProfile $ @@ -651,8 +771,7 @@ testJoinGroupIncognito = testChat4 aliceProfile bobProfile cathProfile danProfil -- cath connected incognito to alice alice ##> "/c" inv <- getInvitation alice - cath #$> ("/incognito on", id, "ok") - cath ##> ("/c " <> inv) + cath ##> ("/c i " <> inv) cath <## "confirmation sent!" cathIncognito <- getTermLine cath concurrentlyN_ @@ -685,10 +804,8 @@ testJoinGroupIncognito = testChat4 aliceProfile bobProfile cathProfile danProfil cath <## "#secret_club: alice invites you to join the group as admin" cath <## ("use /j secret_club to join incognito as " <> cathIncognito) ] - -- cath uses the same incognito profile when joining group, disabling incognito mode doesn't affect it - cath #$> ("/incognito off", id, "ok") + -- cath uses the same incognito profile when joining group, cath and bob don't merge contacts cath ##> "/j secret_club" - -- cath and bob don't merge contacts concurrentlyN_ [ alice <## ("#secret_club: " <> cathIncognito <> " joined the group"), do @@ -770,7 +887,7 @@ testJoinGroupIncognito = testChat4 aliceProfile bobProfile cathProfile danProfil dan <##> cath -- list groups cath ##> "/gs" - cath <## "i #secret_club" + cath <## "i #secret_club (4 members)" -- list group members alice ##> "/ms secret_club" alice @@ -834,8 +951,7 @@ testCantInviteContactIncognito :: HasCallStack => FilePath -> IO () testCantInviteContactIncognito = testChat2 aliceProfile bobProfile $ \alice bob -> do -- alice connected incognito to bob - alice #$> ("/incognito on", id, "ok") - alice ##> "/c" + alice ##> "/c i" inv <- getInvitation alice bob ##> ("/c " <> inv) bob <## "confirmation sent!" @@ -847,7 +963,6 @@ testCantInviteContactIncognito = testChat2 aliceProfile bobProfile $ alice <## "use /i bob to print out this incognito profile again" ] -- alice creates group non incognito - alice #$> ("/incognito off", id, "ok") alice ##> "/g club" alice <## "group #club is created" alice <## "to add members use /a club or /create link #club" @@ -859,10 +974,8 @@ testCantInviteContactIncognito = testChat2 aliceProfile bobProfile $ testCantSeeGlobalPrefsUpdateIncognito :: HasCallStack => FilePath -> IO () testCantSeeGlobalPrefsUpdateIncognito = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do - alice #$> ("/incognito on", id, "ok") - alice ##> "/c" + alice ##> "/c i" invIncognito <- getInvitation alice - alice #$> ("/incognito off", id, "ok") alice ##> "/c" inv <- getInvitation alice bob ##> ("/c " <> invIncognito) @@ -915,8 +1028,7 @@ testDeleteContactThenGroupDeletesIncognitoProfile = testChat2 aliceProfile bobPr -- bob connects incognito to alice alice ##> "/c" inv <- getInvitation alice - bob #$> ("/incognito on", id, "ok") - bob ##> ("/c " <> inv) + bob ##> ("/c i " <> inv) bob <## "confirmation sent!" bobIncognito <- getTermLine bob concurrentlyN_ @@ -967,8 +1079,7 @@ testDeleteGroupThenContactDeletesIncognitoProfile = testChat2 aliceProfile bobPr -- bob connects incognito to alice alice ##> "/c" inv <- getInvitation alice - bob #$> ("/incognito on", id, "ok") - bob ##> ("/c " <> inv) + bob ##> ("/c i " <> inv) bob <## "confirmation sent!" bobIncognito <- getTermLine bob concurrentlyN_ diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index c4b7e16c58..4c7ca8d0a4 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -48,9 +48,15 @@ xit' :: (HasCallStack, Example a) => String -> a -> SpecWith (Arg a) xit' = if os == "linux" then xit else it xit'' :: (HasCallStack, Example a) => String -> a -> SpecWith (Arg a) -xit'' d t = do +xit'' = ifCI xit it + +xdescribe'' :: HasCallStack => String -> SpecWith a -> SpecWith a +xdescribe'' = ifCI xdescribe describe + +ifCI :: HasCallStack => (HasCallStack => String -> a -> SpecWith b) -> (HasCallStack => String -> a -> SpecWith b) -> String -> a -> SpecWith b +ifCI xrun run d t = do ci <- runIO $ lookupEnv "CI" - (if ci == Just "true" then xit else it) d t + (if ci == Just "true" then xrun else run) d t versionTestMatrix2 :: (HasCallStack => TestCC -> TestCC -> IO ()) -> SpecWith FilePath versionTestMatrix2 runTest = do @@ -349,6 +355,11 @@ dropTime_ msg = case splitAt 6 msg of if all isDigit [m, m', s, s'] then Just text else Nothing _ -> Nothing +dropStrPrefix :: HasCallStack => String -> String -> String +dropStrPrefix pfx s = + let (p, rest) = splitAt (length pfx) s + in if p == pfx then rest else error $ "no prefix " <> pfx <> " in string : " <> s + dropReceipt :: HasCallStack => String -> String dropReceipt msg = fromMaybe err $ dropReceipt_ msg where @@ -459,6 +470,7 @@ createGroup3 :: HasCallStack => String -> TestCC -> TestCC -> TestCC -> IO () createGroup3 gName cc1 cc2 cc3 = do createGroup2 gName cc1 cc2 connectUsers cc1 cc3 + name1 <- userName cc1 name3 <- userName cc3 sName2 <- showName cc2 sName3 <- showName cc3 @@ -470,19 +482,23 @@ createGroup3 gName cc1 cc2 cc3 = do cc3 <## ("#" <> gName <> ": you joined the group") cc3 <## ("#" <> gName <> ": member " <> sName2 <> " is connected"), do - cc2 <## ("#" <> gName <> ": alice added " <> sName3 <> " to the group (connecting...)") + cc2 <## ("#" <> gName <> ": " <> name1 <> " added " <> sName3 <> " to the group (connecting...)") cc2 <## ("#" <> gName <> ": new member " <> name3 <> " is connected") ] addMember :: HasCallStack => String -> TestCC -> TestCC -> GroupMemberRole -> IO () -addMember gName inviting invitee role = do +addMember gName = fullAddMember gName "" + +fullAddMember :: HasCallStack => String -> String -> TestCC -> TestCC -> GroupMemberRole -> IO () +fullAddMember gName fullName inviting invitee role = do name1 <- userName inviting memName <- userName invitee inviting ##> ("/a " <> gName <> " " <> memName <> " " <> B.unpack (strEncode role)) + let fullName' = if null fullName || fullName == gName then "" else " (" <> fullName <> ")" concurrentlyN_ [ inviting <## ("invitation to join the group #" <> gName <> " sent to " <> memName), do - invitee <## ("#" <> gName <> ": " <> name1 <> " invites you to join the group as " <> B.unpack (strEncode role)) + invitee <## ("#" <> gName <> fullName' <> ": " <> name1 <> " invites you to join the group as " <> B.unpack (strEncode role)) invitee <## ("use /j " <> gName <> " to accept") ] diff --git a/tests/Test.hs b/tests/Test.hs index 9010aefa0f..d9d36d472b 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -1,5 +1,8 @@ +import Bots.BroadcastTests +import Bots.DirectoryTests import ChatClient import ChatTests +import ChatTests.Utils (xdescribe'') import Control.Logger.Simple import Data.Time.Clock.System import MarkdownTests @@ -23,6 +26,8 @@ main = do around testBracket $ do describe "Mobile API Tests" mobileTests describe "SimpleX chat client" chatTests + xdescribe'' "SimpleX Broadcast bot" broadcastBotTests + xdescribe'' "SimpleX Directory service bot" directoryServiceTests where testBracket test = do t <- getSystemTime