diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0c7a55b148..8785360693 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -293,4 +293,37 @@ jobs: body: | ${{ steps.windows_build.outputs.bin_hash }} + - name: Windows build desktop + id: windows_desktop_build + if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest' + env: + SIMPLEX_CI_REPO_URL: ${{ secrets.SIMPLEX_CI_REPO_URL }} + shell: bash + run: | + scripts/desktop/build-lib-windows.sh + cd apps/multiplatform + ./gradlew packageMsi + path=$(echo $PWD/release/main/msi/*imple*.msi | sed 's#/\([a-z]\)#\1:#' | sed 's#/#\\#g') + echo "package_path=$path" >> $GITHUB_OUTPUT + echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT + + - name: Windows upload desktop package to release + if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest' + uses: svenstaro/upload-release-action@v2 + with: + repo_token: ${{ secrets.GITHUB_TOKEN }} + file: ${{ steps.windows_desktop_build.outputs.package_path }} + asset_name: ${{ matrix.desktop_asset_name }} + tag: ${{ github.ref }} + + - name: Windows update desktop package hash + if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest' + uses: softprops/action-gh-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + append_body: true + body: | + ${{ steps.windows_desktop_build.outputs.package_hash }} + # Windows / diff --git a/Dockerfile b/Dockerfile index b78e2f2448..0c0788c81d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,12 +8,12 @@ RUN a=$(arch); curl https://downloads.haskell.org/~ghcup/$a-linux-ghcup -o /usr/ chmod +x /usr/bin/ghcup # Install ghc -RUN ghcup install ghc 8.10.7 +RUN ghcup install ghc 9.6.2 # Install cabal -RUN ghcup install cabal +RUN ghcup install cabal 3.10.1.0 # Set both as default -RUN ghcup set ghc 8.10.7 && \ - ghcup set cabal +RUN ghcup set ghc 9.6.2 && \ + ghcup set cabal 3.10.1.0 COPY . /project WORKDIR /project diff --git a/apps/ios/Shared/AppDelegate.swift b/apps/ios/Shared/AppDelegate.swift index 01afa18a15..39f5df636c 100644 --- a/apps/ios/Shared/AppDelegate.swift +++ b/apps/ios/Shared/AppDelegate.swift @@ -55,7 +55,6 @@ class AppDelegate: NSObject, UIApplicationDelegate { didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { logger.debug("AppDelegate: didReceiveRemoteNotification") - print("*** userInfo", userInfo) let m = ChatModel.shared if let ntfData = userInfo["notificationData"] as? [AnyHashable : Any], m.notificationMode != .off { diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 3dbcf47004..b69ccbb7c7 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -31,11 +31,11 @@ struct ContentView: View { @State private var chatListActionSheet: ChatListActionSheet? = nil private enum ChatListActionSheet: Identifiable { - case connectViaUrl(action: ConnReqType, link: String) + case planAndConnectSheet(sheet: PlanAndConnectActionSheet) var id: String { switch self { - case let .connectViaUrl(_, link): return "connectViaUrl \(link)" + case let .planAndConnectSheet(sheet): return sheet.id } } } @@ -93,7 +93,7 @@ struct ContentView: View { mainView() .actionSheet(item: $chatListActionSheet) { sheet in switch sheet { - case let .connectViaUrl(action, link): return connectViaUrlSheet(action, link) + case let .planAndConnectSheet(sheet): return planAndConnectActionSheet(sheet, dismiss: false) } } } else { @@ -290,12 +290,16 @@ struct ContentView: View { 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) + planAndConnect( + link, + showAlert: showPlanAndConnectAlert, + showActionSheet: { chatListActionSheet = .planAndConnectSheet(sheet: $0) }, + dismiss: false, + incognito: nil + ) } else { AlertManager.shared.showAlert(Alert(title: Text("Error: URL is invalid"))) } @@ -303,20 +307,8 @@ struct ContentView: View { } } - private func connectViaUrlSheet(_ action: ConnReqType, _ link: String) -> ActionSheet { - let title: LocalizedStringKey - switch action { - case .contact: title = "Connect via contact link" - case .invitation: title = "Connect via one-time link" - } - return ActionSheet( - title: Text(title), - buttons: [ - .default(Text("Use current profile")) { connectViaLink(link, incognito: false) }, - .default(Text("Use new incognito profile")) { connectViaLink(link, incognito: true) }, - .cancel() - ] - ) + private func showPlanAndConnectAlert(_ alert: PlanAndConnectAlert) { + AlertManager.shared.showAlert(planAndConnectAlert(alert, dismiss: false)) } } diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index a1ec2f8f53..f562ea7f51 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -62,8 +62,9 @@ final class ChatModel: ObservableObject { // current chat @Published var chatId: String? @Published var reversedChatItems: [ChatItem] = [] + var chatItemStatuses: Dictionary = [:] @Published var chatToTop: String? - @Published var groupMembers: [GroupMember] = [] + @Published var groupMembers: [GMember] = [] // items in the terminal view @Published var showingTerminal = false @Published var terminalItems: [TerminalItem] = [] @@ -152,6 +153,20 @@ final class ChatModel: ObservableObject { } } + func getGroupChat(_ groupId: Int64) -> Chat? { + chats.first { chat in + if case let .group(groupInfo) = chat.chatInfo { + return groupInfo.groupId == groupId + } else { + return false + } + } + } + + func getGroupMember(_ groupMemberId: Int64) -> GMember? { + groupMembers.first { $0.groupMemberId == groupMemberId } + } + private func getChatIndex(_ id: String) -> Int? { chats.firstIndex(where: { $0.id == id }) } @@ -165,6 +180,7 @@ final class ChatModel: ObservableObject { func updateChatInfo(_ cInfo: ChatInfo) { if let i = getChatIndex(cInfo.id) { chats[i].chatInfo = cInfo + chats[i].created = Date.now } } @@ -296,7 +312,11 @@ final class ChatModel: ObservableObject { return false } else { withAnimation(itemAnimation()) { - reversedChatItems.insert(cItem, at: hasLiveDummy ? 1 : 0) + var ci = cItem + if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus { + ci.meta.itemStatus = status + } + reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0) } return true } @@ -309,26 +329,22 @@ final class ChatModel: ObservableObject { } } - func updateChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) { + func updateChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem, status: CIStatus? = nil) { if chatId == cInfo.id, let i = getChatItemIndex(cItem) { withAnimation { _updateChatItem(at: i, with: cItem) } + } else if let status = status { + chatItemStatuses.updateValue(status, forKey: cItem.id) } } private func _updateChatItem(at i: Int, with cItem: ChatItem) { - let ci = reversedChatItems[i] reversedChatItems[i] = cItem reversedChatItems[i].viewTimestamp = .now - // on some occasions the confirmation of message being accepted by the server (tick) - // arrives earlier than the response from API, and item remains without tick - if case .sndNew = cItem.meta.itemStatus { - reversedChatItems[i].meta.itemStatus = ci.meta.itemStatus - } } - private func getChatItemIndex(_ cItem: ChatItem) -> Int? { + func getChatItemIndex(_ cItem: ChatItem) -> Int? { reversedChatItems.firstIndex(where: { $0.id == cItem.id }) } @@ -464,6 +480,7 @@ final class ChatModel: ObservableObject { } // clear current chat if chatId == cInfo.id { + chatItemStatuses = [:] reversedChatItems = [] } } @@ -516,27 +533,62 @@ final class ChatModel: ObservableObject { users.filter { !$0.user.activeUser }.reduce(0, { unread, next -> Int in unread + next.unreadCount }) } - func getConnectedMemberNames(_ ci: ChatItem) -> [String] { - guard var i = getChatItemIndex(ci) else { return [] } + // this function analyses "connected" events and assumes that each member will be there only once + func getConnectedMemberNames(_ chatItem: ChatItem) -> (Int, [String]) { + var count = 0 var ns: [String] = [] - while i < reversedChatItems.count, let m = reversedChatItems[i].memberConnected { - ns.append(m.displayName) - i += 1 + if let ciCategory = chatItem.mergeCategory, + var i = getChatItemIndex(chatItem) { + while i < reversedChatItems.count { + let ci = reversedChatItems[i] + if ci.mergeCategory != ciCategory { break } + if let m = ci.memberConnected { + ns.append(m.displayName) + } + count += 1 + i += 1 + } } - return ns + return (count, ns) } - func getChatItemNeighbors(_ ci: ChatItem) -> (ChatItem?, ChatItem?) { - if let i = getChatItemIndex(ci) { - return ( - i + 1 < reversedChatItems.count ? reversedChatItems[i + 1] : nil, - i - 1 >= 0 ? reversedChatItems[i - 1] : nil - ) + // returns the index of the passed item and the next item (it has smaller index) + func getNextChatItem(_ ci: ChatItem) -> (Int?, ChatItem?) { + if let i = getChatItemIndex(ci) { + (i, i > 0 ? reversedChatItems[i - 1] : nil) } else { - return (nil, nil) + (nil, nil) } } + // returns the index of the first item in the same merged group (the first hidden item) + // and the previous visible item with another merge category + func getPrevShownChatItem(_ ciIndex: Int?, _ ciCategory: CIMergeCategory?) -> (Int?, ChatItem?) { + guard var i = ciIndex else { return (nil, nil) } + let fst = reversedChatItems.count - 1 + while i < fst { + i = i + 1 + let ci = reversedChatItems[i] + if ciCategory == nil || ciCategory != ci.mergeCategory { + return (i - 1, ci) + } + } + return (i, nil) + } + + // returns the previous member in the same merge group and the count of members in this group + func getPrevHiddenMember(_ member: GroupMember, _ range: ClosedRange) -> (GroupMember?, Int) { + var prevMember: GroupMember? = nil + var memberIds: Set = [] + for i in range { + if case let .groupRcv(m) = reversedChatItems[i].chatDir { + if prevMember == nil && m.groupMemberId != member.groupMemberId { prevMember = m } + memberIds.insert(m.groupMemberId) + } + } + return (prevMember, memberIds.count) + } + func popChat(_ id: String) { if let i = getChatIndex(id) { popChat_(i) @@ -571,13 +623,14 @@ final class ChatModel: ObservableObject { } // update current chat if chatId == groupInfo.id { - if let i = groupMembers.firstIndex(where: { $0.id == member.id }) { + if let i = groupMembers.firstIndex(where: { $0.groupMemberId == member.groupMemberId }) { withAnimation(.default) { - self.groupMembers[i] = member + self.groupMembers[i].wrapped = member + self.groupMembers[i].created = Date.now } return false } else { - withAnimation { groupMembers.append(member) } + withAnimation { groupMembers.append(GMember(member)) } return true } } else { @@ -586,11 +639,10 @@ final class ChatModel: ObservableObject { } func updateGroupMemberConnectionStats(_ groupInfo: GroupInfo, _ member: GroupMember, _ connectionStats: ConnectionStats) { - if let conn = member.activeConn { - var updatedConn = conn - updatedConn.connectionStats = connectionStats + if var conn = member.activeConn { + conn.connectionStats = connectionStats var updatedMember = member - updatedMember.activeConn = updatedConn + updatedMember.activeConn = conn _ = upsertGroupMember(groupInfo, updatedMember) } } @@ -689,40 +741,18 @@ final class Chat: ObservableObject, Identifiable { public static var sampleData: Chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []) } -enum NetworkStatus: Decodable, Equatable { - case unknown - case connected - case disconnected - case error(String) +final class GMember: ObservableObject, Identifiable { + @Published var wrapped: GroupMember + var created = Date.now - var statusString: LocalizedStringKey { - get { - switch self { - case .connected: return "connected" - case .error: return "error" - default: return "connecting" - } - } + init(_ member: GroupMember) { + self.wrapped = member } - var statusExplanation: LocalizedStringKey { - get { - switch self { - case .connected: return "You are connected to the server used to receive messages from this contact." - case let .error(err): return "Trying to connect to the server used to receive messages from this contact (error: \(err))." - default: return "Trying to connect to the server used to receive messages from this contact." - } - } - } - - var imageName: String { - get { - switch self { - case .unknown: return "circle.dotted" - case .connected: return "circle.fill" - case .disconnected: return "ellipsis.circle.fill" - case .error: return "exclamationmark.circle.fill" - } - } - } + var id: String { wrapped.id } + var groupId: Int64 { wrapped.groupId } + var groupMemberId: Int64 { wrapped.groupMemberId } + var displayName: String { wrapped.displayName } + var viewId: String { get { "\(wrapped.id) \(created.timeIntervalSince1970)" } } + static let sampleData = GMember(GroupMember.sampleData) } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 85e66e893d..32e983a841 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -257,6 +257,12 @@ func setXFTPConfig(_ cfg: XFTPFileConfig?) throws { throw r } +func apiSetEncryptLocalFiles(_ enable: Bool) throws { + let r = chatSendCmdSync(.apiSetEncryptLocalFiles(enable: enable)) + if case .cmdOk = r { return } + throw r +} + func apiExportArchive(config: ArchiveConfig) async throws { try await sendCommandOkResp(.apiExportArchive(config: config)) } @@ -306,6 +312,7 @@ func loadChat(chat: Chat, search: String = "") { do { let cInfo = chat.chatInfo let m = ChatModel.shared + m.chatItemStatuses = [:] m.reversedChatItems = [] let chat = try apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search) m.updateChatInfo(chat.chatInfo) @@ -495,6 +502,10 @@ func apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) a try await sendCommandOkResp(.apiSetChatSettings(type: type, id: id, chatSettings: chatSettings)) } +func apiSetMemberSettings(_ groupId: Int64, _ groupMemberId: Int64, _ memberSettings: GroupMemberSettings) async throws { + try await sendCommandOkResp(.apiSetMemberSettings(groupId: groupId, groupMemberId: groupMemberId, memberSettings: memberSettings)) +} + func apiContactInfo(_ contactId: Int64) async throws -> (ConnectionStats?, Profile?) { let r = await chatSendCmd(.apiContactInfo(contactId: contactId)) if case let .contactInfo(_, _, connStats, customUserProfile) = r { return (connStats, customUserProfile) } @@ -586,6 +597,14 @@ func apiSetConnectionIncognito(connId: Int64, incognito: Bool) async throws -> P throw r } +func apiConnectPlan(connReq: String) async throws -> ConnectionPlan { + let userId = try currentUserId("apiConnectPlan") + let r = await chatSendCmd(.apiConnectPlan(userId: userId, connReq: connReq)) + if case let .connectionPlan(_, connectionPlan) = r { return connectionPlan } + logger.error("apiConnectPlan error: \(responseError(r))") + throw r +} + func apiConnect(incognito: Bool, connReq: String) async -> ConnReqType? { let (connReqType, alert) = await apiConnect_(incognito: incognito, connReq: connReq) if let alert = alert { @@ -610,10 +629,7 @@ func apiConnect_(incognito: Bool, connReq: String) async -> (ConnReqType?, Alert if let c = m.getContactChat(contact.contactId) { await MainActor.run { m.chatId = c.id } } - let alert = mkAlert( - title: "Contact already exists", - message: "You are already connected to \(contact.displayName)." - ) + let alert = contactAlreadyExistsAlert(contact) return (nil, alert) case .chatCmdError(_, .error(.invalidConnReq)): let alert = mkAlert( @@ -641,6 +657,13 @@ func apiConnect_(incognito: Bool, connReq: String) async -> (ConnReqType?, Alert return (nil, alert) } +func contactAlreadyExistsAlert(_ contact: Contact) -> Alert { + mkAlert( + title: "Contact already exists", + message: "You are already connected to \(contact.displayName)." + ) +} + private func connectionErrorAlert(_ r: ChatResponse) -> Alert { if let networkErrorAlert = networkErrorAlert(r) { return networkErrorAlert @@ -652,18 +675,18 @@ private func connectionErrorAlert(_ r: ChatResponse) -> Alert { } } -func apiDeleteChat(type: ChatType, id: Int64) async throws { - let r = await chatSendCmd(.apiDeleteChat(type: type, id: id), bgTask: false) +func apiDeleteChat(type: ChatType, id: Int64, notify: Bool? = nil) async throws { + let r = await chatSendCmd(.apiDeleteChat(type: type, id: id, notify: notify), bgTask: false) if case .direct = type, case .contactDeleted = r { return } if case .contactConnection = type, case .contactConnectionDeleted = r { return } if case .group = type, case .groupDeletedUser = r { return } throw r } -func deleteChat(_ chat: Chat) async { +func deleteChat(_ chat: Chat, notify: Bool? = nil) async { do { let cInfo = chat.chatInfo - try await apiDeleteChat(type: cInfo.chatType, id: cInfo.apiId) + try await apiDeleteChat(type: cInfo.chatType, id: cInfo.apiId, notify: notify) DispatchQueue.main.async { ChatModel.shared.removeChat(cInfo.id) } } catch let error { logger.error("deleteChat apiDeleteChat error: \(responseError(error))") @@ -701,8 +724,9 @@ func apiUpdateProfile(profile: Profile) async throws -> (Profile, [Contact])? { let userId = try currentUserId("apiUpdateProfile") let r = await chatSendCmd(.apiUpdateProfile(userId: userId, profile: profile)) switch r { - case .userProfileNoChange: return nil + case .userProfileNoChange: return (profile, []) case let .userProfileUpdated(_, _, toProfile, updateSummary): return (toProfile, updateSummary.changedContacts) + case .chatCmdError(_, .errorStore(.duplicateName)): return nil; default: throw r } } @@ -944,6 +968,12 @@ func apiCallStatus(_ contact: Contact, _ status: String) async throws { } } +func apiGetNetworkStatuses() throws -> [ConnNetworkStatus] { + let r = chatSendCmdSync(.apiGetNetworkStatuses) + if case let .networkStatuses(_, statuses) = r { return statuses } + throw r +} + func markChatRead(_ chat: Chat, aboveItem: ChatItem? = nil) async { do { if chat.chatStats.unreadCount > 0 { @@ -991,9 +1021,9 @@ private func sendCommandOkResp(_ cmd: ChatCommand) async throws { throw r } -func apiNewGroup(_ p: GroupProfile) throws -> GroupInfo { +func apiNewGroup(incognito: Bool, groupProfile: GroupProfile) throws -> GroupInfo { let userId = try currentUserId("apiNewGroup") - let r = chatSendCmdSync(.apiNewGroup(userId: userId, groupProfile: p)) + let r = chatSendCmdSync(.apiNewGroup(userId: userId, incognito: incognito, groupProfile: groupProfile)) if case let .groupCreated(_, groupInfo) = r { return groupInfo } throw r } @@ -1053,8 +1083,8 @@ func apiListMembers(_ groupId: Int64) async -> [GroupMember] { return [] } -func filterMembersToAdd(_ ms: [GroupMember]) -> [Contact] { - let memberContactIds = ms.compactMap{ m in m.memberCurrent ? m.memberContactId : nil } +func filterMembersToAdd(_ ms: [GMember]) -> [Contact] { + let memberContactIds = ms.compactMap{ m in m.wrapped.memberCurrent ? m.wrapped.memberContactId : nil } return ChatModel.shared.chats .compactMap{ $0.chatInfo.contact } .filter{ !memberContactIds.contains($0.apiId) } @@ -1133,6 +1163,7 @@ func initializeChat(start: Bool, dbKey: String? = nil, refreshInvitations: Bool try apiSetTempFolder(tempFolder: getTempFilesDirectory().path) try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path) try setXFTPConfig(getXFTPCfg()) + try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get()) m.chatInitialized = true m.currentUser = try apiGetActiveUser() if m.currentUser == nil { @@ -1335,6 +1366,12 @@ func processReceivedMsg(_ res: ChatResponse) async { m.updateChatInfo(cInfo) } } + case let .groupMemberUpdated(user, groupInfo, _, toMember): + if active(user) { + await MainActor.run { + _ = m.upsertGroupMember(groupInfo, toMember) + } + } case let .contactsMerged(user, intoContact, mergedContact): if active(user) && m.hasChat(mergedContact.id) { await MainActor.run { @@ -1348,13 +1385,6 @@ func processReceivedMsg(_ res: ChatResponse) async { await updateContactsStatus(contactRefs, status: .connected) case let .contactsDisconnected(_, contactRefs): await updateContactsStatus(contactRefs, status: .disconnected) - case let .contactSubError(user, contact, chatError): - await MainActor.run { - if active(user) { - m.updateContact(contact) - } - processContactSubError(contact, chatError) - } case let .contactSubSummary(_, contactSubscriptions): await MainActor.run { for sub in contactSubscriptions { @@ -1369,6 +1399,18 @@ func processReceivedMsg(_ res: ChatResponse) async { } } } + case let .networkStatus(status, connections): + await MainActor.run { + for cId in connections { + m.networkStatuses[cId] = status + } + } + case let .networkStatuses(_, statuses): () + await MainActor.run { + for s in statuses { + m.networkStatuses[s.agentConnId] = s.networkStatus + } + } case let .newChatItem(user, aChatItem): let cInfo = aChatItem.chatInfo let cItem = aChatItem.chatItem @@ -1390,11 +1432,8 @@ func processReceivedMsg(_ res: ChatResponse) async { case let .chatItemStatusUpdated(user, aChatItem): let cInfo = aChatItem.chatInfo let cItem = aChatItem.chatItem - if !cItem.isDeletedContent { - let added = active(user) ? await MainActor.run { m.upsertChatItem(cInfo, cItem) } : true - if added && cItem.showNotification { - NtfManager.shared.notifyMessageReceived(user, cInfo, cItem) - } + if !cItem.isDeletedContent && active(user) { + await MainActor.run { m.updateChatItem(cInfo, cItem, status: cItem.meta.itemStatus) } } if let endTask = m.messageDelivery[cItem.id] { switch cItem.meta.itemStatus { @@ -1446,6 +1485,16 @@ func processReceivedMsg(_ res: ChatResponse) async { m.removeChat(hostContact.activeConn.id) } } + case let .groupLinkConnecting(user, groupInfo, hostMember): + if !active(user) { return } + + await MainActor.run { + m.updateGroup(groupInfo) + if let hostConn = hostMember.activeConn { + m.dismissConnReqView(hostConn.id) + m.removeChat(hostConn.id) + } + } case let .joinedGroupMemberConnecting(user, groupInfo, _, member): if active(user) { await MainActor.run { @@ -1505,10 +1554,11 @@ func processReceivedMsg(_ res: ChatResponse) async { m.updateGroup(toGroup) } } - case let .memberRole(user, groupInfo, _, _, _, _): + case let .memberRole(user, groupInfo, byMember: _, member: member, fromRole: _, toRole: _): if active(user) { await MainActor.run { m.updateGroup(groupInfo) + _ = m.upsertGroupMember(groupInfo, member) } } case let .newMemberContactReceivedInv(user, contact, _, _): @@ -1649,7 +1699,7 @@ func processContactSubError(_ contact: Contact, _ chatError: ChatError) { case .errorAgent(agentError: .SMP(smpErr: .AUTH)): err = "contact deleted" default: err = String(describing: chatError) } - m.setContactNetworkStatus(contact, .error(err)) + m.setContactNetworkStatus(contact, .error(connectionError: err)) } func refreshCallInvitations() throws { diff --git a/apps/ios/Shared/Model/SuspendChat.swift b/apps/ios/Shared/Model/SuspendChat.swift index 67a758f375..1c8c32f8b9 100644 --- a/apps/ios/Shared/Model/SuspendChat.swift +++ b/apps/ios/Shared/Model/SuspendChat.swift @@ -19,7 +19,11 @@ let bgSuspendTimeout: Int = 5 // seconds let terminationTimeout: Int = 3 // seconds private func _suspendChat(timeout: Int) { - if ChatModel.ok { + // this is a redundant check to prevent logical errors, like the one fixed in this PR + let state = appStateGroupDefault.get() + if !state.canSuspend { + logger.error("_suspendChat called, current state: \(state.rawValue, privacy: .public)") + } else if ChatModel.ok { appStateGroupDefault.set(.suspending) apiSuspendChat(timeoutMicroseconds: timeout * 1000000) let endTask = beginBGTask(chatSuspended) @@ -31,9 +35,7 @@ private func _suspendChat(timeout: Int) { func suspendChat() { suspendLockQueue.sync { - if appStateGroupDefault.get() != .stopped { - _suspendChat(timeout: appSuspendTimeout) - } + _suspendChat(timeout: appSuspendTimeout) } } @@ -48,15 +50,22 @@ func suspendBgRefresh() { private var terminating = false func terminateChat() { - terminating = true + logger.debug("terminateChat") suspendLockQueue.sync { switch appStateGroupDefault.get() { case .suspending: // suspend instantly if already suspending _chatSuspended() + // when apiSuspendChat is called with timeout 0, it won't send any events on suspension if ChatModel.ok { apiSuspendChat(timeoutMicroseconds: 0) } - case .stopped: () + chatCloseStore() + case .suspended: + chatCloseStore() + case .stopped: + chatCloseStore() default: + terminating = true + // the store will be closed in _chatSuspended when event is received _suspendChat(timeout: terminationTimeout) } } @@ -77,12 +86,13 @@ private func _chatSuspended() { ChatReceiver.shared.stop() } if terminating { - chatCloseStore() + chatCloseStore() } } func activateChat(appState: AppState = .active) { logger.debug("DEBUGGING: activateChat") + terminating = false suspendLockQueue.sync { appStateGroupDefault.set(appState) if ChatModel.ok { apiActivateChat() } @@ -91,6 +101,7 @@ func activateChat(appState: AppState = .active) { } func initChatAndMigrate(refreshInvitations: Bool = true) { + terminating = false let m = ChatModel.shared if (!m.chatInitialized) { do { @@ -103,6 +114,7 @@ func initChatAndMigrate(refreshInvitations: Bool = true) { } func startChatAndActivate() { + terminating = false logger.debug("DEBUGGING: startChatAndActivate") if ChatModel.shared.chatRunning == true { ChatReceiver.shared.start() diff --git a/apps/ios/Shared/Views/Call/CallController.swift b/apps/ios/Shared/Views/Call/CallController.swift index 6a20eee59a..9ca894ea89 100644 --- a/apps/ios/Shared/Views/Call/CallController.swift +++ b/apps/ios/Shared/Views/Call/CallController.swift @@ -108,7 +108,6 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse try audioSession.setActive(true) logger.debug("audioSession activated") } catch { - print(error) logger.error("failed activating audio session") } } @@ -121,7 +120,6 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse try audioSession.setActive(false) logger.debug("audioSession deactivated") } catch { - print(error) logger.error("failed deactivating audio session") } suspendOnEndCall() diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index 81412bf310..b90c9e7479 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -99,12 +99,12 @@ struct ChatInfoView: View { @Binding var connectionCode: String? @FocusState private var aliasTextFieldFocused: Bool @State private var alert: ChatInfoViewAlert? = nil + @State private var showDeleteContactActionSheet = false @State private var sendReceipts = SendReceipts.userDefault(true) @State private var sendReceiptsUserDefault = true @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false enum ChatInfoViewAlert: Identifiable { - case deleteContactAlert case clearChatAlert case networkStatusAlert case switchAddressAlert @@ -114,7 +114,6 @@ struct ChatInfoView: View { var id: String { switch self { - case .deleteContactAlert: return "deleteContactAlert" case .clearChatAlert: return "clearChatAlert" case .networkStatusAlert: return "networkStatusAlert" case .switchAddressAlert: return "switchAddressAlert" @@ -168,9 +167,9 @@ struct ChatInfoView: View { if let contactLink = contact.contactLink { Section { - QRCode(uri: contactLink) + SimpleXLinkQRCode(uri: contactLink) Button { - showShareSheet(items: [contactLink]) + showShareSheet(items: [simplexChatLink(contactLink)]) } label: { Label("Share address", systemImage: "square.and.arrow.up") } @@ -233,7 +232,6 @@ struct ChatInfoView: View { } .alert(item: $alert) { alertItem in switch(alertItem) { - case .deleteContactAlert: return deleteContactAlert() case .clearChatAlert: return clearChatAlert() case .networkStatusAlert: return networkStatusAlert() case .switchAddressAlert: return switchAddressAlert(switchContactAddress) @@ -242,6 +240,26 @@ struct ChatInfoView: View { case let .error(title, error): return mkAlert(title: title, message: error) } } + .actionSheet(isPresented: $showDeleteContactActionSheet) { + if contact.ready && contact.active { + return ActionSheet( + title: Text("Delete contact?\nThis cannot be undone!"), + buttons: [ + .destructive(Text("Delete and notify contact")) { deleteContact(notify: true) }, + .destructive(Text("Delete")) { deleteContact(notify: false) }, + .cancel() + ] + ) + } else { + return ActionSheet( + title: Text("Delete contact?\nThis cannot be undone!"), + buttons: [ + .destructive(Text("Delete")) { deleteContact() }, + .cancel() + ] + ) + } + } } private func contactInfoHeader() -> some View { @@ -414,7 +432,7 @@ struct ChatInfoView: View { private func deleteContactButton() -> some View { Button(role: .destructive) { - alert = .deleteContactAlert + showDeleteContactActionSheet = true } label: { Label("Delete contact", systemImage: "trash") .foregroundColor(Color.red) @@ -430,30 +448,23 @@ struct ChatInfoView: View { } } - private func deleteContactAlert() -> Alert { - Alert( - title: Text("Delete contact?"), - message: Text("Contact and all messages will be deleted - this cannot be undone!"), - primaryButton: .destructive(Text("Delete")) { - Task { - do { - try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId) - await MainActor.run { - dismiss() - chatModel.chatId = nil - chatModel.removeChat(chat.chatInfo.id) - } - } catch let error { - logger.error("deleteContactAlert apiDeleteChat error: \(responseError(error))") - let a = getErrorAlert(error, "Error deleting contact") - await MainActor.run { - alert = .error(title: a.title, error: a.message) - } - } + private func deleteContact(notify: Bool? = nil) { + Task { + do { + try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, notify: notify) + await MainActor.run { + dismiss() + chatModel.chatId = nil + chatModel.removeChat(chat.chatInfo.id) } - }, - secondaryButton: .cancel() - ) + } catch let error { + logger.error("deleteContactAlert apiDeleteChat error: \(responseError(error))") + let a = getErrorAlert(error, "Error deleting contact") + await MainActor.run { + alert = .error(title: a.title, error: a.message) + } + } + } } private func clearChatAlert() -> Alert { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift index 28740a8cf4..f0bf43dffe 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift @@ -11,7 +11,7 @@ import SimpleXChat struct CICallItemView: View { @EnvironmentObject var m: ChatModel - var chatInfo: ChatInfo + @ObservedObject var chat: Chat var chatItem: ChatItem var status: CICallStatus var duration: Int @@ -60,7 +60,7 @@ struct CICallItemView: View { @ViewBuilder private func acceptCallButton() -> some View { - if case let .direct(contact) = chatInfo { + if case let .direct(contact) = chat.chatInfo { Button { if let invitation = m.callInvitations[contact.id] { CallController.shared.answerCall(invitation: invitation) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift index a7bff2f421..03afa30331 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift @@ -10,20 +10,92 @@ import SwiftUI import SimpleXChat struct CIChatFeatureView: View { + @EnvironmentObject var m: ChatModel var chatItem: ChatItem + @Binding var revealed: Bool var feature: Feature var icon: String? = nil var iconColor: Color var body: some View { + if !revealed, let fs = mergedFeautures() { + HStack { + ForEach(fs, content: featureIconView) + } + .padding(.horizontal, 6) + .padding(.vertical, 6) + } else { + fullFeatureView + } + } + + private struct FeatureInfo: Identifiable { + var icon: String + var scale: CGFloat + var color: Color + var param: String? + + init(_ f: Feature, _ color: Color, _ param: Int?) { + self.icon = f.iconFilled + self.scale = f.iconScale + self.color = color + self.param = f.hasParam && param != nil ? timeText(param) : nil + } + + var id: String { + "\(icon) \(color) \(param ?? "")" + } + } + + private func mergedFeautures() -> [FeatureInfo]? { + var fs: [FeatureInfo] = [] + var icons: Set = [] + if var i = m.getChatItemIndex(chatItem) { + while i < m.reversedChatItems.count, + let f = featureInfo(m.reversedChatItems[i]) { + if !icons.contains(f.icon) { + fs.insert(f, at: 0) + icons.insert(f.icon) + } + i += 1 + } + } + return fs.count > 1 ? fs : nil + } + + private func featureInfo(_ ci: ChatItem) -> FeatureInfo? { + switch ci.content { + case let .rcvChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor, param) + case let .sndChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor, param) + case let .rcvGroupFeature(feature, preference, param): FeatureInfo(feature, preference.enable.iconColor, param) + case let .sndGroupFeature(feature, preference, param): FeatureInfo(feature, preference.enable.iconColor, param) + default: nil + } + } + + @ViewBuilder private func featureIconView(_ f: FeatureInfo) -> some View { + let i = Image(systemName: f.icon) + .foregroundColor(f.color) + .scaleEffect(f.scale) + if let param = f.param { + HStack { + i + chatEventText(Text(param)).lineLimit(1) + } + } else { + i + } + } + + private var fullFeatureView: some View { HStack(alignment: .bottom, spacing: 4) { Image(systemName: icon ?? feature.iconFilled) .foregroundColor(iconColor) .scaleEffect(feature.iconScale) chatEventText(chatItem) } - .padding(.leading, 6) - .padding(.bottom, 6) + .padding(.horizontal, 6) + .padding(.vertical, 4) .textSelection(.disabled) } } @@ -31,6 +103,6 @@ struct CIChatFeatureView: View { struct CIChatFeatureView_Previews: PreviewProvider { static var previews: some View { let enabled = FeatureEnabled(forUser: false, forContact: false) - CIChatFeatureView(chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor) + CIChatFeatureView(chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), revealed: Binding.constant(true), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIEventView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIEventView.swift index 9b372154ed..b6be3f837e 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIEventView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIEventView.swift @@ -13,11 +13,9 @@ struct CIEventView: View { var eventText: Text var body: some View { - HStack(alignment: .bottom, spacing: 0) { - eventText - } - .padding(.leading, 6) - .padding(.bottom, 6) + eventText + .padding(.horizontal, 6) + .padding(.vertical, 4) .textSelection(.disabled) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift index bb38cc872a..e52a92a3c6 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift @@ -10,7 +10,7 @@ import SwiftUI import SimpleXChat struct CIFeaturePreferenceView: View { - @EnvironmentObject var chat: Chat + @ObservedObject var chat: Chat var chatItem: ChatItem var feature: ChatFeature var allowed: FeatureAllowed @@ -80,7 +80,6 @@ struct CIFeaturePreferenceView_Previews: PreviewProvider { quotedItem: nil, file: nil ) - CIFeaturePreferenceView(chatItem: chatItem, feature: ChatFeature.timedMessages, allowed: .yes, param: 30) - .environmentObject(Chat.sampleData) + CIFeaturePreferenceView(chat: Chat.sampleData, chatItem: chatItem, feature: ChatFeature.timedMessages, allowed: .yes, param: 30) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift index 359633a5f5..4ae2296f46 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift @@ -10,6 +10,7 @@ import SwiftUI import SimpleXChat struct CIFileView: View { + @EnvironmentObject var m: ChatModel @Environment(\.colorScheme) var colorScheme let file: CIFile? let edited: Bool @@ -83,7 +84,7 @@ struct CIFileView: View { if fileSizeValid() { Task { logger.debug("CIFileView fileAction - in .rcvInvitation, in Task") - if let user = ChatModel.shared.currentUser { + if let user = m.currentUser { let encrypted = privacyEncryptLocalFilesGroupDefault.get() await receiveFile(user: user, fileId: file.fileId, encrypted: encrypted) } @@ -234,18 +235,17 @@ struct CIFileView_Previews: PreviewProvider { file: nil ) Group { - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentFile, revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: fileChatItemWtFile, revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: sentFile, revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: fileChatItemWtFile, revealed: Binding.constant(false)) } .previewLayout(.fixed(width: 360, height: 360)) - .environmentObject(Chat.sampleData) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift index c7ec3ca713..72013877ca 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift @@ -17,34 +17,45 @@ struct CIGroupInvitationView: View { var memberRole: GroupMemberRole var chatIncognito: Bool = false @State private var frameWidth: CGFloat = 0 + @State private var inProgress = false + @State private var progressByTimeout = false var body: some View { let action = !chatItem.chatDir.sent && groupInvitation.status == .pending let v = ZStack(alignment: .bottomTrailing) { - VStack(alignment: .leading) { - groupInfoView(action) - .padding(.horizontal, 2) - .padding(.top, 8) - .padding(.bottom, 6) - .overlay(DetermineWidth()) + ZStack { + VStack(alignment: .leading) { + groupInfoView(action) + .padding(.horizontal, 2) + .padding(.top, 8) + .padding(.bottom, 6) + .overlay(DetermineWidth()) - Divider().frame(width: frameWidth) + Divider().frame(width: frameWidth) - if action { - groupInvitationText() - .overlay(DetermineWidth()) - Text(chatIncognito ? "Tap to join incognito" : "Tap to join") - .foregroundColor(chatIncognito ? .indigo : .accentColor) - .font(.callout) - .padding(.trailing, 60) - .overlay(DetermineWidth()) - } else { - groupInvitationText() - .padding(.trailing, 60) - .overlay(DetermineWidth()) + if action { + VStack(alignment: .leading, spacing: 2) { + groupInvitationText() + .overlay(DetermineWidth()) + Text(chatIncognito ? "Tap to join incognito" : "Tap to join") + .foregroundColor(inProgress ? .secondary : chatIncognito ? .indigo : .accentColor) + .font(.callout) + .padding(.trailing, 60) + .overlay(DetermineWidth()) + } + } else { + groupInvitationText() + .padding(.trailing, 60) + .overlay(DetermineWidth()) + } + } + .padding(.bottom, 2) + + if progressByTimeout { + ProgressView().scaleEffect(2) } } - .padding(.bottom, 2) + chatItem.timestampText .font(.caption) .foregroundColor(.secondary) @@ -55,11 +66,24 @@ struct CIGroupInvitationView: View { .cornerRadius(18) .textSelection(.disabled) .onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 } + .onChange(of: inProgress) { inProgress in + if inProgress { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + progressByTimeout = inProgress + } + } else { + progressByTimeout = false + } + } if action { v.onTapGesture { - joinGroup(groupInvitation.groupId) + inProgress = true + joinGroup(groupInvitation.groupId) { + await MainActor.run { inProgress = false } + } } + .disabled(inProgress) } else { v } @@ -67,7 +91,7 @@ struct CIGroupInvitationView: View { private func groupInfoView(_ action: Bool) -> some View { var color: Color - if action { + if action && !inProgress { color = chatIncognito ? .indigo : .accentColor } else { color = Color(uiColor: .tertiaryLabel) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift index bb43179577..9ae52ae01b 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift @@ -10,6 +10,7 @@ import SwiftUI import SimpleXChat struct CIImageView: View { + @EnvironmentObject var m: ChatModel @Environment(\.colorScheme) var colorScheme let chatItem: ChatItem let image: String @@ -36,7 +37,7 @@ struct CIImageView: View { switch file.fileStatus { case .rcvInvitation: Task { - if let user = ChatModel.shared.currentUser { + if let user = m.currentUser { await receiveFile(user: user, fileId: file.fileId, encrypted: chatItem.encryptLocalFile) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift index a204d83f1e..da82ed4dd2 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift @@ -10,6 +10,7 @@ import SwiftUI import SimpleXChat struct CIMemberCreatedContactView: View { + @EnvironmentObject var m: ChatModel var chatItem: ChatItem var body: some View { @@ -21,7 +22,7 @@ struct CIMemberCreatedContactView: View { .onTapGesture { dismissAllSheets(animated: true) DispatchQueue.main.async { - ChatModel.shared.chatId = "@\(contactId)" + m.chatId = "@\(contactId)" } } } else { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift index 30430dc19a..c189abde24 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift @@ -10,7 +10,7 @@ import SwiftUI import SimpleXChat struct CIMetaView: View { - @EnvironmentObject var chat: Chat + @ObservedObject var chat: Chat var chatItem: ChatItem var metaColor = Color.secondary var paleMetaColor = Color(UIColor.tertiaryLabel) @@ -95,15 +95,14 @@ private func statusIconText(_ icon: String, _ color: Color) -> Text { struct CIMetaView_Previews: PreviewProvider { static var previews: some View { Group { - CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete))) - CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .partial))) - CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .complete))) - CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .partial))) - CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .badMsgHash, sndProgress: .complete))) - CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), itemEdited: true)) - CIMetaView(chatItem: ChatItem.getDeletedContentSample()) + CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete))) + CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .partial))) + CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .complete))) + CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .partial))) + CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .badMsgHash, sndProgress: .complete))) + CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), itemEdited: true)) + CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample()) } .previewLayout(.fixed(width: 360, height: 100)) - .environmentObject(Chat.sampleData) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift index e1a5c252ec..f276025dda 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift @@ -12,7 +12,8 @@ import SimpleXChat let decryptErrorReason: LocalizedStringKey = "It can happen when you or your connection used the old database backup." struct CIRcvDecryptionError: View { - @EnvironmentObject var chat: Chat + @EnvironmentObject var m: ChatModel + @ObservedObject var chat: Chat var msgDecryptError: MsgDecryptError var msgCount: UInt32 var chatItem: ChatItem @@ -45,7 +46,7 @@ struct CIRcvDecryptionError: View { do { let (member, stats) = try apiGroupMemberInfo(groupInfo.apiId, groupMember.groupMemberId) if let s = stats { - ChatModel.shared.updateGroupMemberConnectionStats(groupInfo, member, s) + m.updateGroupMemberConnectionStats(groupInfo, member, s) } } catch let error { logger.error("apiGroupMemberInfo error: \(responseError(error))") @@ -79,8 +80,8 @@ struct CIRcvDecryptionError: View { } } else if case let .group(groupInfo) = chat.chatInfo, case let .groupRcv(groupMember) = chatItem.chatDir, - let modelMember = ChatModel.shared.groupMembers.first(where: { $0.id == groupMember.id }), - let memberStats = modelMember.activeConn?.connectionStats { + let mem = m.getGroupMember(groupMember.groupMemberId), + let memberStats = mem.wrapped.activeConn?.connectionStats { if memberStats.ratchetSyncAllowed { decryptionErrorItemFixButton(syncSupported: true) { alert = .syncAllowedAlert { syncMemberConnection(groupInfo, groupMember) } @@ -122,7 +123,7 @@ struct CIRcvDecryptionError: View { ) } .padding(.horizontal, 12) - CIMetaView(chatItem: chatItem) + CIMetaView(chat: chat, chatItem: chatItem) .padding(.horizontal, 12) } .onTapGesture(perform: { onClick() }) @@ -142,7 +143,7 @@ struct CIRcvDecryptionError: View { + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true) } .padding(.horizontal, 12) - CIMetaView(chatItem: chatItem) + CIMetaView(chat: chat, chatItem: chatItem) .padding(.horizontal, 12) } .onTapGesture(perform: { onClick() }) @@ -173,7 +174,7 @@ struct CIRcvDecryptionError: View { do { let (mem, stats) = try apiSyncGroupMemberRatchet(groupInfo.apiId, member.groupMemberId, false) await MainActor.run { - ChatModel.shared.updateGroupMemberConnectionStats(groupInfo, mem, stats) + m.updateGroupMemberConnectionStats(groupInfo, mem, stats) } } catch let error { logger.error("syncMemberConnection apiSyncGroupMemberRatchet error: \(responseError(error))") @@ -190,7 +191,7 @@ struct CIRcvDecryptionError: View { do { let stats = try apiSyncContactRatchet(contact.apiId, false) await MainActor.run { - ChatModel.shared.updateContactConnectionStats(contact, stats) + m.updateContactConnectionStats(contact, stats) } } catch let error { logger.error("syncContactConnection apiSyncContactRatchet error: \(responseError(error))") diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift index 3807a11b4e..bc7153ed47 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift @@ -11,6 +11,7 @@ import AVKit import SimpleXChat struct CIVideoView: View { + @EnvironmentObject var m: ChatModel @Environment(\.colorScheme) var colorScheme private let chatItem: ChatItem private let image: String @@ -101,7 +102,7 @@ struct CIVideoView: View { let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete VideoPlayerView(player: player, url: url, showControls: false) .frame(width: w, height: w * preview.size.height / preview.size.width) - .onChange(of: ChatModel.shared.stopPreviousRecPlay) { playingUrl in + .onChange(of: m.stopPreviousRecPlay) { playingUrl in if playingUrl != url { player.pause() videoPlaying = false @@ -124,7 +125,7 @@ struct CIVideoView: View { } if !videoPlaying { Button { - ChatModel.shared.stopPreviousRecPlay = url + m.stopPreviousRecPlay = url player.play() } label: { playPauseIcon(canBePlayed ? "play.fill" : "play.slash") @@ -256,7 +257,7 @@ struct CIVideoView: View { // TODO encrypt: where file size is checked? private func receiveFileIfValidSize(file: CIFile, encrypted: Bool, receiveFile: @escaping (User, Int64, Bool, Bool) async -> Void) { Task { - if let user = ChatModel.shared.currentUser { + if let user = m.currentUser { await receiveFile(user, file.fileId, encrypted, false) } } @@ -290,7 +291,7 @@ struct CIVideoView: View { ) .onAppear { DispatchQueue.main.asyncAfter(deadline: .now()) { - ChatModel.shared.stopPreviousRecPlay = url + m.stopPreviousRecPlay = url if let player = fullPlayer { player.play() fullScreenTimeObserver = NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: player.currentItem, queue: .main) { _ in diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift index b0875abe8d..2e54ba4143 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift @@ -10,6 +10,7 @@ import SwiftUI import SimpleXChat struct CIVoiceView: View { + @ObservedObject var chat: Chat var chatItem: ChatItem let recordingFile: CIFile? let duration: Int @@ -91,7 +92,7 @@ struct CIVoiceView: View { } private func metaView() -> some View { - CIMetaView(chatItem: chatItem) + CIMetaView(chat: chat, chatItem: chatItem) } } @@ -219,7 +220,7 @@ struct VoiceMessagePlayer: View { private func downloadButton(_ recordingFile: CIFile) -> some View { Button { Task { - if let user = ChatModel.shared.currentUser { + if let user = chatModel.currentUser { await receiveFile(user: user, fileId: recordingFile.fileId, encrypted: privacyEncryptLocalFilesGroupDefault.get()) } } @@ -284,6 +285,7 @@ struct CIVoiceView_Previews: PreviewProvider { ) Group { CIVoiceView( + chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), recordingFile: CIFile.getSample(fileName: "voice.m4a", fileSize: 65536, fileStatus: .rcvComplete), duration: 30, @@ -292,12 +294,11 @@ struct CIVoiceView_Previews: PreviewProvider { playbackTime: .constant(TimeInterval(20)), allowMenu: Binding.constant(true) ) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: voiceMessageWtFile, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) } .previewLayout(.fixed(width: 360, height: 360)) - .environmentObject(Chat.sampleData) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift index af4df40978..4763707421 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift @@ -11,6 +11,7 @@ import SimpleXChat struct DeletedItemView: View { @Environment(\.colorScheme) var colorScheme + @ObservedObject var chat: Chat var chatItem: ChatItem var body: some View { @@ -18,7 +19,7 @@ struct DeletedItemView: View { Text(chatItem.content.text) .foregroundColor(.secondary) .italic() - CIMetaView(chatItem: chatItem) + CIMetaView(chat: chat, chatItem: chatItem) .padding(.horizontal, 12) } .padding(.leading, 12) @@ -32,8 +33,8 @@ struct DeletedItemView: View { struct DeletedItemView_Previews: PreviewProvider { static var previews: some View { Group { - DeletedItemView(chatItem: ChatItem.getDeletedContentSample()) - DeletedItemView(chatItem: ChatItem.getDeletedContentSample(dir: .groupRcv(groupMember: GroupMember.sampleData))) + DeletedItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample()) + DeletedItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample(dir: .groupRcv(groupMember: GroupMember.sampleData))) } .previewLayout(.fixed(width: 360, height: 200)) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift index f5ae761e84..f57e45fed0 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift @@ -10,6 +10,7 @@ import SwiftUI import SimpleXChat struct EmojiItemView: View { + @ObservedObject var chat: Chat var chatItem: ChatItem var body: some View { @@ -17,7 +18,7 @@ struct EmojiItemView: View { emojiText(chatItem.content.text) .padding(.top, 8) .padding(.horizontal, 6) - CIMetaView(chatItem: chatItem) + CIMetaView(chat: chat, chatItem: chatItem) .padding(.bottom, 8) .padding(.horizontal, 12) } @@ -32,8 +33,8 @@ func emojiText(_ text: String) -> Text { struct EmojiItemView_Previews: PreviewProvider { static var previews: some View { Group{ - EmojiItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete))) - EmojiItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "👍")) + EmojiItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete))) + EmojiItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "👍")) } .previewLayout(.fixed(width: 360, height: 70)) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift index 3f7ca3f836..af5c917dc8 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift @@ -88,13 +88,12 @@ struct FramedCIVoiceView_Previews: PreviewProvider { file: CIFile.getSample(fileStatus: .sndComplete) ) Group { - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage, revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: voiceMessageWithQuote, revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWithQuote, revealed: Binding.constant(false)) } .previewLayout(.fixed(width: 360, height: 360)) - .environmentObject(Chat.sampleData) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index aab0cd5f55..51dfa3cb50 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -15,9 +15,11 @@ private let sentQuoteColorLight = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, private let sentQuoteColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.09) struct FramedItemView: View { + @EnvironmentObject var m: ChatModel @Environment(\.colorScheme) var colorScheme - var chatInfo: ChatInfo + @ObservedObject var chat: Chat var chatItem: ChatItem + @Binding var revealed: Bool var maxWidth: CGFloat = .infinity @State var scrollProxy: ScrollViewProxy? = nil @State var msgWidth: CGFloat = 0 @@ -35,9 +37,12 @@ struct FramedItemView: View { let v = ZStack(alignment: .bottomTrailing) { VStack(alignment: .leading, spacing: 0) { if let di = chatItem.meta.itemDeleted { - if case let .moderated(_, byGroupMember) = di { - framedItemHeader(icon: "flag", caption: Text("moderated by \(byGroupMember.chatViewName)").italic()) - } else { + switch di { + case let .moderated(_, byGroupMember): + framedItemHeader(icon: "flag", caption: Text("moderated by \(byGroupMember.displayName)").italic()) + case .blocked: + framedItemHeader(icon: "hand.raised", caption: Text("blocked").italic()) + default: framedItemHeader(icon: "trash", caption: Text("marked deleted").italic()) } } else if chatItem.meta.isLive { @@ -48,7 +53,7 @@ struct FramedItemView: View { ciQuoteView(qi) .onTapGesture { if let proxy = scrollProxy, - let ci = ChatModel.shared.reversedChatItems.first(where: { $0.id == qi.itemId }) { + let ci = m.reversedChatItems.first(where: { $0.id == qi.itemId }) { withAnimation { proxy.scrollTo(ci.viewId, anchor: .bottom) } @@ -56,14 +61,14 @@ struct FramedItemView: View { } } - ChatItemContentView(chatInfo: chatInfo, chatItem: chatItem, msgContentView: framedMsgContentView) + ChatItemContentView(chat: chat, chatItem: chatItem, revealed: $revealed, msgContentView: framedMsgContentView) .padding(chatItem.content.msgContent != nil ? 0 : 4) .overlay(DetermineWidth()) } .onPreferenceChange(MetaColorPreferenceKey.self) { metaColor = $0 } if chatItem.content.msgContent != nil { - CIMetaView(chatItem: chatItem, metaColor: metaColor) + CIMetaView(chat: chat, chatItem: chatItem, metaColor: metaColor) .padding(.horizontal, 12) .padding(.bottom, 6) .overlay(DetermineWidth()) @@ -247,7 +252,7 @@ struct FramedItemView: View { } private func ciQuotedMsgTextView(_ qi: CIQuote, lines: Int) -> some View { - MsgContentView(text: qi.text, formattedText: qi.formattedText) + MsgContentView(chat: chat, text: qi.text, formattedText: qi.formattedText) .lineLimit(lines) .font(.subheadline) .padding(.bottom, 6) @@ -264,7 +269,7 @@ struct FramedItemView: View { } private func membership() -> GroupMember? { - switch chatInfo { + switch chat.chatInfo { case let .group(groupInfo: groupInfo): return groupInfo.membership default: return nil } @@ -274,6 +279,7 @@ struct FramedItemView: View { let text = ci.meta.isLive ? ci.content.msgContent?.text ?? ci.text : ci.text let rtl = isRightToLeft(text) let v = MsgContentView( + chat: chat, text: text, formattedText: text == "" ? [] : ci.formattedText, meta: ci.meta, @@ -356,14 +362,14 @@ func chatItemFrameContextColor(_ ci: ChatItem, _ colorScheme: ColorScheme) -> Co struct FramedItemView_Previews: PreviewProvider { static var previews: some View { Group{ - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -"), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line "), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat"), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat"), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd)), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv)), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv)), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -"), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line "), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat"), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat"), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) } .previewLayout(.fixed(width: 360, height: 200)) } @@ -372,16 +378,16 @@ struct FramedItemView_Previews: PreviewProvider { struct FramedItemView_Edited_Previews: PreviewProvider { static var previews: some View { Group { - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello there this is a long text", quotedItem: CIQuote.getSample(1, .now, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello there this is a long text", quotedItem: CIQuote.getSample(1, .now, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemEdited: true), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) } .previewLayout(.fixed(width: 360, height: 200)) } @@ -390,16 +396,16 @@ struct FramedItemView_Edited_Previews: PreviewProvider { struct FramedItemView_Deleted_Previews: PreviewProvider { static var previews: some View { Group { - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello there this is a long text", quotedItem: CIQuote.getSample(1, .now, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemDeleted: .deleted(deletedTs: .now)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent(sndProgress: .complete), quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello there this is a long text", quotedItem: CIQuote.getSample(1, .now, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) } .previewLayout(.fixed(width: 360, height: 200)) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift index be5b61b61a..0e721acdcb 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift @@ -150,7 +150,7 @@ struct FullScreenMediaView: View { private func startPlayerAndNotify() { if let player = player { - ChatModel.shared.stopPreviousRecPlay = url + m.stopPreviousRecPlay = url player.play() } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift index 9908d4d104..1aa0093c9a 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift @@ -10,11 +10,12 @@ import SwiftUI import SimpleXChat struct IntegrityErrorItemView: View { + @ObservedObject var chat: Chat var msgError: MsgErrorType var chatItem: ChatItem var body: some View { - CIMsgError(chatItem: chatItem) { + CIMsgError(chat: chat, chatItem: chatItem) { switch msgError { case .msgSkipped: AlertManager.shared.showAlertMsg( @@ -52,6 +53,7 @@ struct IntegrityErrorItemView: View { } struct CIMsgError: View { + @ObservedObject var chat: Chat var chatItem: ChatItem var onTap: () -> Void @@ -60,7 +62,7 @@ struct CIMsgError: View { Text(chatItem.content.text) .foregroundColor(.red) .italic() - CIMetaView(chatItem: chatItem) + CIMetaView(chat: chat, chatItem: chatItem) .padding(.horizontal, 12) } .padding(.leading, 12) @@ -74,6 +76,6 @@ struct CIMsgError: View { struct IntegrityErrorItemView_Previews: PreviewProvider { static var previews: some View { - IntegrityErrorItemView(msgError: .msgBadHash, chatItem: ChatItem.getIntegrityErrorSample()) + IntegrityErrorItemView(chat: Chat.sampleData, msgError: .msgBadHash, chatItem: ChatItem.getIntegrityErrorSample()) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift index 8e042a8c76..c6af95e6f6 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift @@ -10,39 +10,70 @@ import SwiftUI import SimpleXChat struct MarkedDeletedItemView: View { + @EnvironmentObject var m: ChatModel @Environment(\.colorScheme) var colorScheme + @ObservedObject var chat: Chat var chatItem: ChatItem + @Binding var revealed: Bool var body: some View { - HStack(alignment: .bottom, spacing: 0) { - if case let .moderated(_, byGroupMember) = chatItem.meta.itemDeleted { - markedDeletedText("moderated by \(byGroupMember.chatViewName)") - } else { - markedDeletedText("marked deleted") - } - CIMetaView(chatItem: chatItem) - .padding(.horizontal, 12) - } - .padding(.leading, 12) + (Text(mergedMarkedDeletedText).italic() + Text(" ") + chatItem.timestampText) + .font(.caption) + .foregroundColor(.secondary) + .padding(.horizontal, 12) .padding(.vertical, 6) .background(chatItemFrameColor(chatItem, colorScheme)) .cornerRadius(18) .textSelection(.disabled) } - func markedDeletedText(_ s: LocalizedStringKey) -> some View { - Text(s) - .font(.caption) - .foregroundColor(.secondary) - .italic() - .lineLimit(1) + var mergedMarkedDeletedText: LocalizedStringKey { + if !revealed, + let ciCategory = chatItem.mergeCategory, + var i = m.getChatItemIndex(chatItem) { + var moderated = 0 + var blocked = 0 + var deleted = 0 + var moderatedBy: Set = [] + while i < m.reversedChatItems.count, + let ci = .some(m.reversedChatItems[i]), + ci.mergeCategory == ciCategory, + let itemDeleted = ci.meta.itemDeleted { + switch itemDeleted { + case let .moderated(_, byGroupMember): + moderated += 1 + moderatedBy.insert(byGroupMember.displayName) + case .blocked: blocked += 1 + case .deleted: deleted += 1 + } + i += 1 + } + let total = moderated + blocked + deleted + return total <= 1 + ? markedDeletedText + : total == moderated + ? "\(total) messages moderated by \(moderatedBy.joined(separator: ", "))" + : total == blocked + ? "\(total) messages blocked" + : "\(total) messages marked deleted" + } else { + return markedDeletedText + } + } + + var markedDeletedText: LocalizedStringKey { + switch chatItem.meta.itemDeleted { + case let .moderated(_, byGroupMember): "moderated by \(byGroupMember.displayName)" + case .blocked: "blocked" + default: "marked deleted" + } } } struct MarkedDeletedItemView_Previews: PreviewProvider { static var previews: some View { Group { - MarkedDeletedItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now))) + MarkedDeletedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true)) } .previewLayout(.fixed(width: 360, height: 200)) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index 498b3cb2e0..d0d2bdf3dd 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -25,7 +25,7 @@ private func typing(_ w: Font.Weight = .light) -> Text { } struct MsgContentView: View { - @EnvironmentObject var chat: Chat + @ObservedObject var chat: Chat var text: String var formattedText: [FormattedText]? = nil var sender: String? = nil @@ -121,13 +121,11 @@ private func formatText(_ ft: FormattedText, _ preview: Bool) -> Text { case .secret: return Text(t).foregroundColor(.clear).underline(color: .primary) case let .colored(color): return Text(t).foregroundColor(color.uiColor) case .uri: return linkText(t, t, preview, prefix: "") - case let .simplexLink(linkType, simplexUri, trustedUri, smpHosts): + case let .simplexLink(linkType, simplexUri, smpHosts): switch privacySimplexLinkModeDefault.get() { case .description: return linkText(simplexLinkText(linkType, smpHosts), simplexUri, preview, prefix: "") case .full: return linkText(t, simplexUri, preview, prefix: "") - case .browser: return trustedUri - ? linkText(t, t, preview, prefix: "") - : linkText(t, t, preview, prefix: "", color: .red, uiColor: .red) + case .browser: return linkText(t, simplexUri, preview, prefix: "") } case .email: return linkText(t, t, preview, prefix: "mailto:") case .phone: return linkText(t, t.replacingOccurrences(of: " ", with: ""), preview, prefix: "tel:") @@ -154,6 +152,7 @@ struct MsgContentView_Previews: PreviewProvider { static var previews: some View { let chatItem = ChatItem.getSample(1, .directSnd, .now, "hello") return MsgContentView( + chat: Chat.sampleData, text: chatItem.text, formattedText: chatItem.formattedText, sender: chatItem.memberDisplayName, diff --git a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift index 61802c61fe..83c4cdcda6 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift @@ -10,6 +10,7 @@ import SwiftUI import SimpleXChat struct ChatItemInfoView: View { + @EnvironmentObject var chatModel: ChatModel @Environment(\.colorScheme) var colorScheme var ci: ChatItem @Binding var chatItemInfo: ChatItemInfo? @@ -290,8 +291,8 @@ struct ChatItemInfoView: View { private func membersStatuses(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> [(GroupMember, CIStatus)] { memberDeliveryStatuses.compactMap({ mds in - if let mem = ChatModel.shared.groupMembers.first(where: { $0.groupMemberId == mds.groupMemberId }) { - return (mem, mds.memberDeliveryStatus) + if let mem = chatModel.getGroupMember(mds.groupMemberId) { + return (mem.wrapped, mds.memberDeliveryStatus) } else { return nil } diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index 31fe19c39a..657df60654 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -10,7 +10,7 @@ import SwiftUI import SimpleXChat struct ChatItemView: View { - var chatInfo: ChatInfo + @ObservedObject var chat: Chat var chatItem: ChatItem var maxWidth: CGFloat = .infinity @State var scrollProxy: ScrollViewProxy? = nil @@ -19,8 +19,19 @@ struct ChatItemView: View { @Binding var audioPlayer: AudioPlayer? @Binding var playbackState: VoiceMessagePlaybackState @Binding var playbackTime: TimeInterval? - init(chatInfo: ChatInfo, chatItem: ChatItem, showMember: Bool = false, maxWidth: CGFloat = .infinity, scrollProxy: ScrollViewProxy? = nil, revealed: Binding, allowMenu: Binding = .constant(false), audioPlayer: Binding = .constant(nil), playbackState: Binding = .constant(.noPlayback), playbackTime: Binding = .constant(nil)) { - self.chatInfo = chatInfo + init( + chat: Chat, + chatItem: ChatItem, + showMember: Bool = false, + maxWidth: CGFloat = .infinity, + scrollProxy: ScrollViewProxy? = nil, + revealed: Binding, + allowMenu: Binding = .constant(false), + audioPlayer: Binding = .constant(nil), + playbackState: Binding = .constant(.noPlayback), + playbackTime: Binding = .constant(nil) + ) { + self.chat = chat self.chatItem = chatItem self.maxWidth = maxWidth _scrollProxy = .init(initialValue: scrollProxy) @@ -33,15 +44,15 @@ struct ChatItemView: View { var body: some View { let ci = chatItem - if chatItem.meta.itemDeleted != nil && !revealed { - MarkedDeletedItemView(chatItem: chatItem) + if chatItem.meta.itemDeleted != nil && (!revealed || chatItem.isDeletedContent) { + MarkedDeletedItemView(chat: chat, chatItem: chatItem, revealed: $revealed) } else if ci.quotedItem == nil && ci.meta.itemDeleted == nil && !ci.meta.isLive { if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) { - EmojiItemView(chatItem: ci) + EmojiItemView(chat: chat, chatItem: ci) } else if ci.content.text.isEmpty, case let .voice(_, duration) = ci.content.msgContent { - CIVoiceView(chatItem: ci, recordingFile: ci.file, duration: duration, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime, allowMenu: $allowMenu) + CIVoiceView(chat: chat, chatItem: ci, recordingFile: ci.file, duration: duration, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime, allowMenu: $allowMenu) } else if ci.content.msgContent == nil { - ChatItemContentView(chatInfo: chatInfo, chatItem: chatItem, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case + ChatItemContentView(chat: chat, chatItem: chatItem, revealed: $revealed, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case } else { framedItemView() } @@ -51,14 +62,15 @@ struct ChatItemView: View { } private func framedItemView() -> some View { - FramedItemView(chatInfo: chatInfo, chatItem: chatItem, maxWidth: maxWidth, scrollProxy: scrollProxy, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime) + FramedItemView(chat: chat, chatItem: chatItem, revealed: $revealed, maxWidth: maxWidth, scrollProxy: scrollProxy, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime) } } struct ChatItemContentView: View { @EnvironmentObject var chatModel: ChatModel - var chatInfo: ChatInfo + @ObservedObject var chat: Chat var chatItem: ChatItem + @Binding var revealed: Bool var msgContentView: () -> Content @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @@ -72,15 +84,14 @@ struct ChatItemContentView: View { case let .rcvCall(status, duration): callItemView(status, duration) case let .rcvIntegrityError(msgError): if developerTools { - IntegrityErrorItemView(msgError: msgError, chatItem: chatItem) + IntegrityErrorItemView(chat: chat, msgError: msgError, chatItem: chatItem) } else { ZStack {} } - case let .rcvDecryptionError(msgDecryptError, msgCount): CIRcvDecryptionError(msgDecryptError: msgDecryptError, msgCount: msgCount, chatItem: chatItem) + case let .rcvDecryptionError(msgDecryptError, msgCount): CIRcvDecryptionError(chat: chat, msgDecryptError: msgDecryptError, msgCount: msgCount, chatItem: chatItem) case let .rcvGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole) case let .sndGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole) case .rcvDirectEvent: eventItemView() - case .rcvGroupEvent(.memberConnected): CIEventView(eventText: membersConnectedItemText) case .rcvGroupEvent(.memberCreatedContact): CIMemberCreatedContactView(chatItem: chatItem) case .rcvGroupEvent: eventItemView() case .sndGroupEvent: eventItemView() @@ -89,9 +100,9 @@ struct ChatItemContentView: View { case let .rcvChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor) case let .sndChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor) case let .rcvChatPreference(feature, allowed, param): - CIFeaturePreferenceView(chatItem: chatItem, feature: feature, allowed: allowed, param: param) + CIFeaturePreferenceView(chat: chat, chatItem: chatItem, feature: feature, allowed: allowed, param: param) case let .sndChatPreference(feature, _, _): - CIChatFeatureView(chatItem: chatItem, feature: feature, icon: feature.icon, iconColor: .secondary) + CIChatFeatureView(chatItem: chatItem, revealed: $revealed, feature: feature, icon: feature.icon, iconColor: .secondary) case let .rcvGroupFeature(feature, preference, _): chatFeatureView(feature, preference.enable.iconColor) case let .sndGroupFeature(feature, preference, _): chatFeatureView(feature, preference.enable.iconColor) case let .rcvChatFeatureRejected(feature): chatFeatureView(feature, .red) @@ -103,15 +114,15 @@ struct ChatItemContentView: View { } private func deletedItemView() -> some View { - DeletedItemView(chatItem: chatItem) + DeletedItemView(chat: chat, chatItem: chatItem) } private func callItemView(_ status: CICallStatus, _ duration: Int) -> some View { - CICallItemView(chatInfo: chatInfo, chatItem: chatItem, status: status, duration: duration) + CICallItemView(chat: chat, chatItem: chatItem, status: status, duration: duration) } private func groupInvitationItemView(_ groupInvitation: CIGroupInvitation, _ memberRole: GroupMemberRole) -> some View { - CIGroupInvitationView(chatItem: chatItem, groupInvitation: groupInvitation, memberRole: memberRole, chatIncognito: chatInfo.incognito) + CIGroupInvitationView(chatItem: chatItem, groupInvitation: groupInvitation, memberRole: memberRole, chatIncognito: chat.chatInfo.incognito) } private func eventItemView() -> some View { @@ -119,7 +130,9 @@ struct ChatItemContentView: View { } private func eventItemViewText() -> Text { - if let member = chatItem.memberDisplayName { + if !revealed, let t = mergedGroupEventText { + return chatEventText(t + Text(" ") + chatItem.timestampText) + } else if let member = chatItem.memberDisplayName { return Text(member + " ") .font(.caption) .foregroundColor(.secondary) @@ -131,36 +144,44 @@ struct ChatItemContentView: View { } private func chatFeatureView(_ feature: Feature, _ iconColor: Color) -> some View { - CIChatFeatureView(chatItem: chatItem, feature: feature, iconColor: iconColor) + CIChatFeatureView(chatItem: chatItem, revealed: $revealed, feature: feature, iconColor: iconColor) } - private var membersConnectedItemText: Text { - if let t = membersConnectedText { - return chatEventText(t, chatItem.timestampText) + private var mergedGroupEventText: Text? { + let (count, ns) = chatModel.getConnectedMemberNames(chatItem) + let members: LocalizedStringKey = + switch ns.count { + case 1: "\(ns[0]) connected" + case 2: "\(ns[0]) and \(ns[1]) connected" + case 3: "\(ns[0] + ", " + ns[1]) and \(ns[2]) connected" + default: + ns.count > 3 + ? "\(ns[0]), \(ns[1]) and \(ns.count - 2) other members connected" + : "" + } + return if count <= 1 { + nil + } else if ns.count == 0 { + Text("\(count) group events") + } else if count > ns.count { + Text(members) + Text(" ") + Text("and \(count - ns.count) other events") } else { - return eventItemViewText() + Text(members) } } - - private var membersConnectedText: LocalizedStringKey? { - let ns = chatModel.getConnectedMemberNames(chatItem) - return ns.count > 3 - ? "\(ns[0]), \(ns[1]) and \(ns.count - 2) other members connected" - : ns.count == 3 - ? "\(ns[0] + ", " + ns[1]) and \(ns[2]) connected" - : ns.count == 2 - ? "\(ns[0]) and \(ns[1]) connected" - : nil - } } -func chatEventText(_ eventText: LocalizedStringKey, _ ts: Text) -> Text { - (Text(eventText) + Text(" ") + ts) +func chatEventText(_ text: Text) -> Text { + text .font(.caption) .foregroundColor(.secondary) .fontWeight(.light) } +func chatEventText(_ eventText: LocalizedStringKey, _ ts: Text) -> Text { + chatEventText(Text(eventText) + Text(" ") + ts) +} + func chatEventText(_ ci: ChatItem) -> Text { chatEventText("\(ci.content.text)", ci.timestampText) } @@ -168,15 +189,15 @@ func chatEventText(_ ci: ChatItem) -> Text { struct ChatItemView_Previews: PreviewProvider { static var previews: some View { Group{ - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getDeletedContentSample(), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample(), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(false)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true)) + ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true)) } .previewLayout(.fixed(width: 360, height: 70)) .environmentObject(Chat.sampleData) @@ -188,7 +209,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider { let ciFeatureContent = CIContent.rcvChatFeature(feature: .fullDelete, enabled: FeatureEnabled(forUser: false, forContact: false), param: nil) Group{ ChatItemView( - chatInfo: ChatInfo.sampleData.direct, + chat: Chat.sampleData, chatItem: ChatItem( chatDir: .directRcv, meta: CIMeta.getSample(1, .now, "1 skipped message", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), @@ -199,7 +220,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider { revealed: Binding.constant(true) ) ChatItemView( - chatInfo: ChatInfo.sampleData.direct, + chat: Chat.sampleData, chatItem: ChatItem( chatDir: .directRcv, meta: CIMeta.getSample(1, .now, "1 skipped message", .rcvRead), @@ -210,7 +231,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider { revealed: Binding.constant(true) ) ChatItemView( - chatInfo: ChatInfo.sampleData.direct, + chat: Chat.sampleData, chatItem: ChatItem( chatDir: .directRcv, meta: CIMeta.getSample(1, .now, "received invitation to join group team as admin", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), @@ -221,7 +242,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider { revealed: Binding.constant(true) ) ChatItemView( - chatInfo: ChatInfo.sampleData.direct, + chat: Chat.sampleData, chatItem: ChatItem( chatDir: .directRcv, meta: CIMeta.getSample(1, .now, "group event text", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), @@ -232,7 +253,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider { revealed: Binding.constant(true) ) ChatItemView( - chatInfo: ChatInfo.sampleData.direct, + chat: Chat.sampleData, chatItem: ChatItem( chatDir: .directRcv, meta: CIMeta.getSample(1, .now, ciFeatureContent.text, .rcvRead, itemDeleted: .deleted(deletedTs: .now)), diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 389080efc5..5e5a7f8b51 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -21,9 +21,7 @@ struct ChatView: View { @State private var showChatInfoSheet: Bool = false @State private var showAddMembersSheet: Bool = false @State private var composeState = ComposeState() - @State private var deletingItem: ChatItem? = nil @State private var keyboardVisible = false - @State private var showDeleteMessage = false @State private var connectionStats: ConnectionStats? @State private var customUserProfile: Profile? @State private var connectionCode: String? @@ -36,7 +34,12 @@ struct ChatView: View { @State private var searchText: String = "" @FocusState private var searchFocussed // opening GroupMemberInfoView on member icon - @State private var selectedMember: GroupMember? = nil + @State private var membersLoaded = false + @State private var selectedMember: GMember? = nil + // opening GroupLinkView on link button (incognito) + @State private var showGroupLinkSheet: Bool = false + @State private var groupLink: String? + @State private var groupLinkMemberRole: GroupMemberRole = .member var body: some View { if #available(iOS 16.0, *) { @@ -91,7 +94,10 @@ struct ChatView: View { chatModel.chatId = nil DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { if chatModel.chatId == nil { + chatModel.chatItemStatuses = [:] chatModel.reversedChatItems = [] + chatModel.groupMembers = [] + membersLoaded = false } } } @@ -129,18 +135,21 @@ struct ChatView: View { } } else if case let .group(groupInfo) = cInfo { Button { - Task { - let groupMembers = await apiListMembers(groupInfo.groupId) - await MainActor.run { - ChatModel.shared.groupMembers = groupMembers - showChatInfoSheet = true - } - } + Task { await loadGroupMembers(groupInfo) { showChatInfoSheet = true } } } label: { ChatInfoToolbar(chat: chat) } .appSheet(isPresented: $showChatInfoSheet) { - GroupChatInfoView(chat: chat, groupInfo: groupInfo) + GroupChatInfoView( + chat: chat, + groupInfo: Binding( + get: { groupInfo }, + set: { gInfo in + chat.chatInfo = .group(groupInfo: gInfo) + chat.created = Date.now + } + ) + ) } } } @@ -172,9 +181,16 @@ struct ChatView: View { HStack { if groupInfo.canAddMembers { if (chat.chatInfo.incognito) { - Image(systemName: "person.crop.circle.badge.plus") - .foregroundColor(Color(uiColor: .tertiaryLabel)) - .onTapGesture { AlertManager.shared.showAlert(cantInviteIncognitoAlert()) } + groupLinkButton() + .appSheet(isPresented: $showGroupLinkSheet) { + GroupLinkView( + groupId: groupInfo.groupId, + groupLink: $groupLink, + groupLinkMemberRole: $groupLinkMemberRole, + showTitle: true, + creatingGroup: false + ) + } } else { addMembersButton() .appSheet(isPresented: $showAddMembersSheet) { @@ -196,6 +212,17 @@ struct ChatView: View { } } + private func loadGroupMembers(_ groupInfo: GroupInfo, updateView: @escaping () -> Void = {}) async { + let groupMembers = await apiListMembers(groupInfo.groupId) + await MainActor.run { + if chatModel.chatId == groupInfo.id { + chatModel.groupMembers = groupMembers.map { GMember.init($0) } + membersLoaded = true + updateView() + } + } + } + private func initChatView() { let cInfo = chat.chatInfo if case let .direct(contact) = cInfo { @@ -404,19 +431,32 @@ struct ChatView: View { private func addMembersButton() -> some View { Button { if case let .group(gInfo) = chat.chatInfo { - Task { - let groupMembers = await apiListMembers(gInfo.groupId) - await MainActor.run { - ChatModel.shared.groupMembers = groupMembers - showAddMembersSheet = true - } - } + Task { await loadGroupMembers(gInfo) { showAddMembersSheet = true } } } } label: { Image(systemName: "person.crop.circle.badge.plus") } } - + + private func groupLinkButton() -> some View { + Button { + if case let .group(gInfo) = chat.chatInfo { + Task { + do { + if let link = try apiGetGroupLink(gInfo.groupId) { + (groupLink, groupLinkMemberRole) = link + } + } catch let error { + logger.error("ChatView apiGetGroupLink: \(responseError(error))") + } + showGroupLinkSheet = true + } + } + } label: { + Image(systemName: "link.badge.plus") + } + } + private func loadChatItems(_ cInfo: ChatInfo, _ ci: ChatItem, _ proxy: ScrollViewProxy) { if let firstItem = chatModel.reversedChatItems.last, firstItem.id == ci.id { if loadingItems || firstPage { return } @@ -446,73 +486,30 @@ struct ChatView: View { } @ViewBuilder private func chatItemView(_ ci: ChatItem, _ maxWidth: CGFloat) -> some View { - if case let .groupRcv(member) = ci.chatDir, - case let .group(groupInfo) = chat.chatInfo { - let (prevItem, nextItem) = chatModel.getChatItemNeighbors(ci) - if ci.memberConnected != nil && nextItem?.memberConnected != nil { - // memberConnected events are aggregated at the last chat item in a row of such events, see ChatItemView - ZStack {} // scroll doesn't work if it's EmptyView() - } else { - if prevItem == nil || showMemberImage(member, prevItem) { - VStack(alignment: .leading, spacing: 4) { - if ci.content.showMemberName { - Text(member.displayName) - .font(.caption) - .foregroundStyle(.secondary) - .padding(.leading, memberImageSize + 14) - .padding(.top, 7) - } - HStack(alignment: .top, spacing: 8) { - ProfileImage(imageStr: member.memberProfile.image) - .frame(width: memberImageSize, height: memberImageSize) - .onTapGesture { selectedMember = member } - .appSheet(item: $selectedMember) { member in - GroupMemberInfoView(groupInfo: groupInfo, member: member, navigation: true) - } - chatItemWithMenu(ci, maxWidth) - } - } - .padding(.top, 5) - .padding(.trailing) - .padding(.leading, 12) - } else { - chatItemWithMenu(ci, maxWidth) - .padding(.top, 5) - .padding(.trailing) - .padding(.leading, memberImageSize + 8 + 12) - } - } - } else { - chatItemWithMenu(ci, maxWidth) - .padding(.horizontal) - .padding(.top, 5) - } - } - - private func chatItemWithMenu(_ ci: ChatItem, _ maxWidth: CGFloat) -> some View { ChatItemWithMenu( - ci: ci, + chat: chat, + chatItem: ci, maxWidth: maxWidth, - scrollProxy: scrollProxy, - deleteMessage: deleteMessage, - deletingItem: $deletingItem, composeState: $composeState, - showDeleteMessage: $showDeleteMessage + selectedMember: $selectedMember, + chatView: self ) - .environmentObject(chat) } private struct ChatItemWithMenu: View { - @EnvironmentObject var chat: Chat + @EnvironmentObject var m: ChatModel @Environment(\.colorScheme) var colorScheme - var ci: ChatItem + @ObservedObject var chat: Chat + var chatItem: ChatItem var maxWidth: CGFloat - var scrollProxy: ScrollViewProxy? - var deleteMessage: (CIDeleteMode) -> Void - @Binding var deletingItem: ChatItem? @Binding var composeState: ComposeState - @Binding var showDeleteMessage: Bool + @Binding var selectedMember: GMember? + var chatView: ChatView + @State private var deletingItem: ChatItem? = nil + @State private var showDeleteMessage = false + @State private var deletingItems: [Int64] = [] + @State private var showDeleteMessages = false @State private var revealed = false @State private var showChatItemInfoSheet: Bool = false @State private var chatItemInfo: ChatItemInfo? @@ -524,18 +521,114 @@ struct ChatView: View { @State private var playbackTime: TimeInterval? var body: some View { + let (currIndex, nextItem) = m.getNextChatItem(chatItem) + let ciCategory = chatItem.mergeCategory + if (ciCategory != nil && ciCategory == nextItem?.mergeCategory) { + // memberConnected events and deleted items are aggregated at the last chat item in a row, see ChatItemView + ZStack {} // scroll doesn't work if it's EmptyView() + } else { + let (prevHidden, prevItem) = m.getPrevShownChatItem(currIndex, ciCategory) + let range = itemsRange(currIndex, prevHidden) + if revealed, let range = range { + let items = Array(zip(Array(range), m.reversedChatItems[range])) + ForEach(items, id: \.1.viewId) { (i, ci) in + let prev = i == prevHidden ? prevItem : m.reversedChatItems[i + 1] + chatItemView(ci, nil, prev) + } + } else { + chatItemView(chatItem, range, prevItem) + } + } + } + + @ViewBuilder func chatItemView(_ ci: ChatItem, _ range: ClosedRange?, _ prevItem: ChatItem?) -> some View { + if case let .groupRcv(member) = ci.chatDir, + case let .group(groupInfo) = chat.chatInfo { + let (prevMember, memCount): (GroupMember?, Int) = + if let range = range { + m.getPrevHiddenMember(member, range) + } else { + (nil, 1) + } + if prevItem == nil || showMemberImage(member, prevItem) || prevMember != nil { + VStack(alignment: .leading, spacing: 4) { + if ci.content.showMemberName { + Text(memberNames(member, prevMember, memCount)) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.leading, memberImageSize + 14) + .padding(.top, 7) + } + HStack(alignment: .top, spacing: 8) { + ProfileImage(imageStr: member.memberProfile.image) + .frame(width: memberImageSize, height: memberImageSize) + .onTapGesture { + if chatView.membersLoaded { + selectedMember = m.getGroupMember(member.groupMemberId) + } else { + Task { + await chatView.loadGroupMembers(groupInfo) { + selectedMember = m.getGroupMember(member.groupMemberId) + } + } + } + } + .appSheet(item: $selectedMember) { member in + GroupMemberInfoView(groupInfo: groupInfo, groupMember: member, navigation: true) + } + chatItemWithMenu(ci, range, maxWidth) + } + } + .padding(.top, 5) + .padding(.trailing) + .padding(.leading, 12) + } else { + chatItemWithMenu(ci, range, maxWidth) + .padding(.top, 5) + .padding(.trailing) + .padding(.leading, memberImageSize + 8 + 12) + } + } else { + chatItemWithMenu(ci, range, maxWidth) + .padding(.horizontal) + .padding(.top, 5) + } + } + + private func memberNames(_ member: GroupMember, _ prevMember: GroupMember?, _ memCount: Int) -> LocalizedStringKey { + let name = member.displayName + return if let prevName = prevMember?.displayName { + memCount > 2 + ? "\(name), \(prevName) and \(memCount - 2) members" + : "\(name) and \(prevName)" + } else { + "\(name)" + } + } + + @ViewBuilder func chatItemWithMenu(_ ci: ChatItem, _ range: ClosedRange?, _ maxWidth: CGFloat) -> some View { let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading let uiMenu: Binding = Binding( - get: { UIMenu(title: "", children: menu(live: composeState.liveMessage != nil)) }, + get: { UIMenu(title: "", children: menu(ci, range, live: composeState.liveMessage != nil)) }, set: { _ in } ) VStack(alignment: alignment.horizontal, spacing: 3) { - ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, maxWidth: maxWidth, scrollProxy: scrollProxy, revealed: $revealed, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime) - .uiKitContextMenu(menu: uiMenu, allowMenu: $allowMenu) - .accessibilityLabel("") + ChatItemView( + chat: chat, + chatItem: ci, + maxWidth: maxWidth, + scrollProxy: chatView.scrollProxy, + revealed: $revealed, + allowMenu: $allowMenu, + audioPlayer: $audioPlayer, + playbackState: $playbackState, + playbackTime: $playbackTime + ) + .uiKitContextMenu(menu: uiMenu, allowMenu: $allowMenu) + .accessibilityLabel("") if ci.content.msgContent != nil && (ci.meta.itemDeleted == nil || revealed) && ci.reactions.count > 0 { - chatItemReactions() + chatItemReactions(ci) .padding(.bottom, 4) } } @@ -549,6 +642,11 @@ struct ChatView: View { } } } + .confirmationDialog(deleteMessagesTitle, isPresented: $showDeleteMessages, titleVisibility: .visible) { + Button("Delete for me", role: .destructive) { + deleteMessages() + } + } .frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment) .frame(minWidth: 0, maxWidth: .infinity, alignment: alignment) .onDisappear { @@ -566,7 +664,15 @@ struct ChatView: View { } } - private func chatItemReactions() -> some View { + private func showMemberImage(_ member: GroupMember, _ prevItem: ChatItem?) -> Bool { + switch (prevItem?.chatDir) { + case .groupSnd: return true + case let .groupRcv(prevMember): return prevMember.groupMemberId != member.groupMemberId + default: return false + } + } + + private func chatItemReactions(_ ci: ChatItem) -> some View { HStack(spacing: 4) { ForEach(ci.reactions, id: \.reaction) { r in let v = HStack(spacing: 4) { @@ -586,7 +692,7 @@ struct ChatView: View { if chat.chatInfo.featureEnabled(.reactions) && (ci.allowAddReaction || r.userReacted) { v.onTapGesture { - setReaction(add: !r.userReacted, reaction: r.reaction) + setReaction(ci, add: !r.userReacted, reaction: r.reaction) } } else { v @@ -595,10 +701,10 @@ struct ChatView: View { } } - private func menu(live: Bool) -> [UIMenuElement] { + private func menu(_ ci: ChatItem, _ range: ClosedRange?, live: Bool) -> [UIMenuElement] { var menu: [UIMenuElement] = [] if let mc = ci.content.msgContent, ci.meta.itemDeleted == nil || revealed { - let rs = allReactions() + let rs = allReactions(ci) if chat.chatInfo.featureEnabled(.reactions) && ci.allowAddReaction, rs.count > 0 { var rm: UIMenu @@ -615,10 +721,10 @@ struct ChatView: View { menu.append(rm) } if ci.meta.itemDeleted == nil && !ci.isLiveDummy && !live { - menu.append(replyUIAction()) + menu.append(replyUIAction(ci)) } - menu.append(shareUIAction()) - menu.append(copyUIAction()) + menu.append(shareUIAction(ci)) + menu.append(copyUIAction(ci)) if let fileSource = getLoadedFileSource(ci.file) { if case .image = ci.content.msgContent, let image = getLoadedImage(ci.file) { if image.imageData != nil { @@ -631,9 +737,9 @@ struct ChatView: View { } } if ci.meta.editable && !mc.isVoice && !live { - menu.append(editAction()) + menu.append(editAction(ci)) } - menu.append(viewInfoUIAction()) + menu.append(viewInfoUIAction(ci)) if revealed { menu.append(hideUIAction()) } @@ -643,25 +749,31 @@ struct ChatView: View { menu.append(cancelFileUIAction(file.fileId, cancelAction)) } if !live || !ci.meta.isLive { - menu.append(deleteUIAction()) + menu.append(deleteUIAction(ci)) } if let (groupInfo, _) = ci.memberToModerate(chat.chatInfo) { - menu.append(moderateUIAction(groupInfo)) + menu.append(moderateUIAction(ci, groupInfo)) } } else if ci.meta.itemDeleted != nil { - if !ci.isDeletedContent { + if revealed { + menu.append(hideUIAction()) + } else if !ci.isDeletedContent { menu.append(revealUIAction()) + } else if range != nil { + menu.append(expandUIAction()) } - menu.append(viewInfoUIAction()) - menu.append(deleteUIAction()) + menu.append(viewInfoUIAction(ci)) + menu.append(deleteUIAction(ci)) } else if ci.isDeletedContent { - menu.append(viewInfoUIAction()) - menu.append(deleteUIAction()) + menu.append(viewInfoUIAction(ci)) + menu.append(deleteUIAction(ci)) + } else if ci.mergeCategory != nil { + menu.append(revealed ? shrinkUIAction() : expandUIAction()) } return menu } - private func replyUIAction() -> UIAction { + private func replyUIAction(_ ci: ChatItem) -> UIAction { UIAction( title: NSLocalizedString("Reply", comment: "chat item action"), image: UIImage(systemName: "arrowshape.turn.up.left") @@ -696,11 +808,11 @@ struct ChatView: View { ) } - private func allReactions() -> [UIAction] { + private func allReactions(_ ci: ChatItem) -> [UIAction] { MsgReaction.values.compactMap { r in ci.reactions.contains(where: { $0.userReacted && $0.reaction == r }) ? nil - : UIAction(title: r.text) { _ in setReaction(add: true, reaction: r) } + : UIAction(title: r.text) { _ in setReaction(ci, add: true, reaction: r) } } } @@ -708,7 +820,7 @@ struct ChatView: View { rs.count > 4 ? 3 : 4 } - private func setReaction(add: Bool, reaction: MsgReaction) { + private func setReaction(_ ci: ChatItem, add: Bool, reaction: MsgReaction) { Task { do { let cInfo = chat.chatInfo @@ -720,7 +832,7 @@ struct ChatView: View { reaction: reaction ) await MainActor.run { - ChatModel.shared.updateChatItem(chat.chatInfo, chatItem) + m.updateChatItem(chat.chatInfo, chatItem) } } catch let error { logger.error("apiChatItemReaction error: \(responseError(error))") @@ -728,7 +840,7 @@ struct ChatView: View { } } - private func shareUIAction() -> UIAction { + private func shareUIAction(_ ci: ChatItem) -> UIAction { UIAction( title: NSLocalizedString("Share", comment: "chat item action"), image: UIImage(systemName: "square.and.arrow.up") @@ -741,7 +853,7 @@ struct ChatView: View { } } - private func copyUIAction() -> UIAction { + private func copyUIAction(_ ci: ChatItem) -> UIAction { UIAction( title: NSLocalizedString("Copy", comment: "chat item action"), image: UIImage(systemName: "doc.on.doc") @@ -774,7 +886,7 @@ struct ChatView: View { } } - private func editAction() -> UIAction { + private func editAction(_ ci: ChatItem) -> UIAction { UIAction( title: NSLocalizedString("Edit", comment: "chat item action"), image: UIImage(systemName: "square.and.pencil") @@ -785,7 +897,7 @@ struct ChatView: View { } } - private func viewInfoUIAction() -> UIAction { + private func viewInfoUIAction(_ ci: ChatItem) -> UIAction { UIAction( title: NSLocalizedString("Info", comment: "chat item action"), image: UIImage(systemName: "info.circle") @@ -798,10 +910,7 @@ struct ChatView: View { chatItemInfo = ciInfo } if case let .group(gInfo) = chat.chatInfo { - let groupMembers = await apiListMembers(gInfo.groupId) - await MainActor.run { - ChatModel.shared.groupMembers = groupMembers - } + await chatView.loadGroupMembers(gInfo) } } catch let error { logger.error("apiGetChatItemInfo error: \(responseError(error))") @@ -822,7 +931,7 @@ struct ChatView: View { message: Text(cancelAction.alert.message), primaryButton: .destructive(Text(cancelAction.alert.confirm)) { Task { - if let user = ChatModel.shared.currentUser { + if let user = m.currentUser { await cancelFile(user: user, fileId: fileId) } } @@ -843,18 +952,45 @@ struct ChatView: View { } } - private func deleteUIAction() -> UIAction { + private func deleteUIAction(_ ci: ChatItem) -> UIAction { UIAction( title: NSLocalizedString("Delete", comment: "chat item action"), image: UIImage(systemName: "trash"), attributes: [.destructive] ) { _ in - showDeleteMessage = true - deletingItem = ci + if !revealed && ci.meta.itemDeleted != nil, + let currIndex = m.getChatItemIndex(ci), + let ciCategory = ci.mergeCategory { + let (prevHidden, _) = m.getPrevShownChatItem(currIndex, ciCategory) + if let range = itemsRange(currIndex, prevHidden) { + var itemIds: [Int64] = [] + for i in range { + itemIds.append(m.reversedChatItems[i].id) + } + showDeleteMessages = true + deletingItems = itemIds + } else { + showDeleteMessage = true + deletingItem = ci + } + } else { + showDeleteMessage = true + deletingItem = ci + } } } - private func moderateUIAction(_ groupInfo: GroupInfo) -> UIAction { + private func itemsRange(_ currIndex: Int?, _ prevHidden: Int?) -> ClosedRange? { + if let currIndex = currIndex, + let prevHidden = prevHidden, + prevHidden > currIndex { + currIndex...prevHidden + } else { + nil + } + } + + private func moderateUIAction(_ ci: ChatItem, _ groupInfo: GroupInfo) -> UIAction { UIAction( title: NSLocalizedString("Moderate", comment: "chat item action"), image: UIImage(systemName: "flag"), @@ -886,20 +1022,105 @@ struct ChatView: View { } } } - + + private func expandUIAction() -> UIAction { + UIAction( + title: NSLocalizedString("Expand", comment: "chat item action"), + image: UIImage(systemName: "arrow.up.and.line.horizontal.and.arrow.down") + ) { _ in + withAnimation { + revealed = true + } + } + } + + private func shrinkUIAction() -> UIAction { + UIAction( + title: NSLocalizedString("Hide", comment: "chat item action"), + image: UIImage(systemName: "arrow.down.and.line.horizontal.and.arrow.up") + ) { _ in + withAnimation { + revealed = false + } + } + } + private var broadcastDeleteButtonText: LocalizedStringKey { chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone" } - } - private func showMemberImage(_ member: GroupMember, _ prevItem: ChatItem?) -> Bool { - switch (prevItem?.chatDir) { - case .groupSnd: return true - case let .groupRcv(prevMember): return prevMember.groupMemberId != member.groupMemberId - default: return false + var deleteMessagesTitle: LocalizedStringKey { + let n = deletingItems.count + return n == 1 ? "Delete message?" : "Delete \(n) messages?" + } + + private func deleteMessages() { + let itemIds = deletingItems + if itemIds.count > 0 { + let chatInfo = chat.chatInfo + Task { + var deletedItems: [ChatItem] = [] + for itemId in itemIds { + do { + let (di, _) = try await apiDeleteChatItem( + type: chatInfo.chatType, + id: chatInfo.apiId, + itemId: itemId, + mode: .cidmInternal + ) + deletedItems.append(di) + } catch { + logger.error("ChatView.deleteMessage error: \(error.localizedDescription)") + } + } + await MainActor.run { + for di in deletedItems { + m.removeChatItem(chatInfo, di) + } + } + } + } + } + + private func deleteMessage(_ mode: CIDeleteMode) { + logger.debug("ChatView deleteMessage") + Task { + logger.debug("ChatView deleteMessage: in Task") + do { + if let di = deletingItem { + var deletedItem: ChatItem + var toItem: ChatItem? + if case .cidmBroadcast = mode, + let (groupInfo, groupMember) = di.memberToModerate(chat.chatInfo) { + (deletedItem, toItem) = try await apiDeleteMemberChatItem( + groupId: groupInfo.apiId, + groupMemberId: groupMember.groupMemberId, + itemId: di.id + ) + } else { + (deletedItem, toItem) = try await apiDeleteChatItem( + type: chat.chatInfo.chatType, + id: chat.chatInfo.apiId, + itemId: di.id, + mode: mode + ) + } + DispatchQueue.main.async { + deletingItem = nil + if let toItem = toItem { + _ = m.upsertChatItem(chat.chatInfo, toItem) + } else { + m.removeChatItem(chat.chatInfo, deletedItem) + } + } + } + } catch { + logger.error("ChatView.deleteMessage error: \(error.localizedDescription)") + } + } } } - + private func scrollToBottom(_ proxy: ScrollViewProxy) { if let ci = chatModel.reversedChatItems.first { withAnimation { proxy.scrollTo(ci.viewId, anchor: .top) } @@ -911,44 +1132,6 @@ struct ChatView: View { withAnimation { proxy.scrollTo(ci.viewId, anchor: .top) } } } - - private func deleteMessage(_ mode: CIDeleteMode) { - logger.debug("ChatView deleteMessage") - Task { - logger.debug("ChatView deleteMessage: in Task") - do { - if let di = deletingItem { - var deletedItem: ChatItem - var toItem: ChatItem? - if case .cidmBroadcast = mode, - let (groupInfo, groupMember) = di.memberToModerate(chat.chatInfo) { - (deletedItem, toItem) = try await apiDeleteMemberChatItem( - groupId: groupInfo.apiId, - groupMemberId: groupMember.groupMemberId, - itemId: di.id - ) - } else { - (deletedItem, toItem) = try await apiDeleteChatItem( - type: chat.chatInfo.chatType, - id: chat.chatInfo.apiId, - itemId: di.id, - mode: mode - ) - } - DispatchQueue.main.async { - deletingItem = nil - if let toItem = toItem { - _ = chatModel.upsertChatItem(chat.chatInfo, toItem) - } else { - chatModel.removeChatItem(chat.chatInfo, deletedItem) - } - } - } - } catch { - logger.error("ChatView.deleteMessage error: \(error.localizedDescription)") - } - } - } } @ViewBuilder func toggleNtfsButton(_ chat: Chat) -> some View { @@ -965,7 +1148,7 @@ struct ChatView: View { func toggleNotifications(_ chat: Chat, enableNtfs: Bool) { var chatSettings = chat.chatInfo.chatSettings ?? ChatSettings.defaults - chatSettings.enableNtfs = enableNtfs + chatSettings.enableNtfs = enableNtfs ? .all : .none updateChatSettings(chat, chatSettings: chatSettings) } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 3328da8dbe..0572821770 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -592,12 +592,14 @@ struct ComposeView: View { EmptyView() case let .quotedItem(chatItem: quotedItem): ContextItemView( + chat: chat, contextItem: quotedItem, contextIcon: "arrowshape.turn.up.left", cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) } ) case let .editingItem(chatItem: editingItem): ContextItemView( + chat: chat, contextItem: editingItem, contextIcon: "pencil", cancelContextItem: { clearState() } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift index 153d89fc27..868ae3274a 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift @@ -11,6 +11,7 @@ import SimpleXChat struct ContextItemView: View { @Environment(\.colorScheme) var colorScheme + @ObservedObject var chat: Chat let contextItem: ChatItem let contextIcon: String let cancelContextItem: () -> Void @@ -48,6 +49,7 @@ struct ContextItemView: View { private func msgContentView(lines: Int) -> some View { MsgContentView( + chat: chat, text: contextItem.text, formattedText: contextItem.formattedText ) @@ -59,6 +61,6 @@ struct ContextItemView: View { struct ContextItemView_Previews: PreviewProvider { static var previews: some View { let contextItem: ChatItem = ChatItem.getSample(1, .directSnd, .now, "hello") - return ContextItemView(contextItem: contextItem, contextIcon: "pencil.circle", cancelContextItem: {}) + return ContextItemView(chat: Chat.sampleData, contextItem: contextItem, contextIcon: "pencil.circle", cancelContextItem: {}) } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift index 51deced72c..3eead5b0af 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift @@ -14,20 +14,28 @@ import PhotosUI struct NativeTextEditor: UIViewRepresentable { @Binding var text: String @Binding var disableEditing: Bool - let height: CGFloat - let font: UIFont + @Binding var height: CGFloat @Binding var focused: Bool let alignment: TextAlignment let onImagesAdded: ([UploadContent]) -> Void + private let minHeight: CGFloat = 37 + + private let defaultHeight: CGFloat = { + let field = CustomUITextField(height: Binding.constant(0)) + field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4) + return min(max(field.sizeThatFits(CGSizeMake(field.frame.size.width, CGFloat.greatestFiniteMagnitude)).height, 37), 360).rounded(.down) + }() + func makeUIView(context: Context) -> UITextView { - let field = CustomUITextField() + let field = CustomUITextField(height: _height) field.text = text - field.font = font field.textAlignment = alignment == .leading ? .left : .right field.autocapitalizationType = .sentences field.setOnTextChangedListener { newText, images in if !disableEditing { + // Speed up the process of updating layout, reduce jumping content on screen + if !isShortEmoji(newText) { updateHeight(field) } text = newText } else { field.text = text @@ -39,24 +47,72 @@ struct NativeTextEditor: UIViewRepresentable { field.setOnFocusChangedListener { focused = $0 } field.delegate = field field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4) + updateFont(field) + updateHeight(field) return field } func updateUIView(_ field: UITextView, context: Context) { field.text = text - field.font = font field.textAlignment = alignment == .leading ? .left : .right + updateFont(field) + updateHeight(field) + } + + private func updateHeight(_ field: UITextView) { + let maxHeight = min(360, field.font!.lineHeight * 12) + // When having emoji in text view and then removing it, sizeThatFits shows previous size (too big for empty text view), so using work around with default size + let newHeight = field.text == "" + ? defaultHeight + : min(max(field.sizeThatFits(CGSizeMake(field.frame.size.width, CGFloat.greatestFiniteMagnitude)).height, minHeight), maxHeight).rounded(.down) + + if field.frame.size.height != newHeight { + field.frame.size = CGSizeMake(field.frame.size.width, newHeight) + (field as! CustomUITextField).invalidateIntrinsicContentHeight(newHeight) + } + } + + private func updateFont(_ field: UITextView) { + field.font = isShortEmoji(field.text) + ? (field.text.count < 4 ? largeEmojiUIFont : mediumEmojiUIFont) + : UIFont.preferredFont(forTextStyle: .body) } } private class CustomUITextField: UITextView, UITextViewDelegate { + var height: Binding + var newHeight: CGFloat = 0 var onTextChanged: (String, [UploadContent]) -> Void = { newText, image in } var onFocusChanged: (Bool) -> Void = { focused in } + init(height: Binding) { + self.height = height + super.init(frame: .zero, textContainer: nil) + } + + required init?(coder: NSCoder) { + fatalError("Not implemented") + } + + // This func here needed because using frame.size.height in intrinsicContentSize while loading a screen with text (for example. when you have a draft), + // produces incorrect height because at that point intrinsicContentSize has old value of frame.size.height even if it was set to new value right before the call + // (who knows why...) + func invalidateIntrinsicContentHeight(_ newHeight: CGFloat) { + self.newHeight = newHeight + invalidateIntrinsicContentSize() + } + + override var intrinsicContentSize: CGSize { + if height.wrappedValue != newHeight { + DispatchQueue.main.asyncAfter(deadline: .now(), execute: { self.height.wrappedValue = self.newHeight }) + } + return CGSizeMake(0, newHeight) + } + func setOnTextChangedListener(onTextChanged: @escaping (String, [UploadContent]) -> Void) { self.onTextChanged = onTextChanged } - + func setOnFocusChangedListener(onFocusChanged: @escaping (Bool) -> Void) { self.onFocusChanged = onFocusChanged } @@ -144,14 +200,14 @@ private class CustomUITextField: UITextView, UITextViewDelegate { struct NativeTextEditor_Previews: PreviewProvider{ static var previews: some View { - return NativeTextEditor( + NativeTextEditor( text: Binding.constant("Hello, world!"), disableEditing: Binding.constant(false), - height: 100, - font: UIFont.preferredFont(forTextStyle: .body), + height: Binding.constant(100), focused: Binding.constant(false), alignment: TextAlignment.leading, onImagesAdded: { _ in } ) + .fixedSize(horizontal: false, vertical: true) } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index 6eed51788e..8f7b23c888 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -32,15 +32,12 @@ struct SendMessageView: View { var sendButtonColor = Color.accentColor @State private var teHeight: CGFloat = 42 @State private var teFont: Font = .body - @State private var teUiFont: UIFont = UIFont.preferredFont(forTextStyle: .body) @State private var sendButtonSize: CGFloat = 29 @State private var sendButtonOpacity: CGFloat = 1 @State private var showCustomDisappearingMessageDialogue = false @State private var showCustomTimePicker = false @State private var selectedDisappearingMessageTime: Int? = customDisappearingMessageTimeDefault.get() @State private var progressByTimeout = false - var maxHeight: CGFloat = 360 - var minHeight: CGFloat = 37 @AppStorage(DEFAULT_LIVE_MESSAGE_ALERT_SHOWN) private var liveMessageAlertShown = false var body: some View { @@ -57,30 +54,16 @@ struct SendMessageView: View { .frame(maxWidth: .infinity) } else { let alignment: TextAlignment = isRightToLeft(composeState.message) ? .trailing : .leading - Text(composeState.message) - .lineLimit(10) - .font(teFont) - .multilineTextAlignment(alignment) -// put text on top (after NativeTextEditor) and set color to precisely align it on changes -// .foregroundColor(.red) - .foregroundColor(.clear) - .padding(.horizontal, 10) - .padding(.top, 8) - .padding(.bottom, 6) - .matchedGeometryEffect(id: "te", in: namespace) - .background(GeometryReader(content: updateHeight)) - NativeTextEditor( text: $composeState.message, disableEditing: $composeState.inProgress, - height: teHeight, - font: teUiFont, + height: $teHeight, focused: $keyboardVisible, alignment: alignment, onImagesAdded: onMediaAdded ) .allowsTightening(false) - .frame(height: teHeight) + .fixedSize(horizontal: false, vertical: true) } } @@ -100,11 +83,13 @@ struct SendMessageView: View { .frame(height: teHeight, alignment: .bottom) } } - + .padding(.vertical, 1) + .overlay( RoundedRectangle(cornerSize: CGSize(width: 20, height: 20)) .strokeBorder(.secondary, lineWidth: 0.3, antialiased: true) - .frame(height: teHeight) + ) } + .onChange(of: composeState.message, perform: { text in updateFont(text) }) .onChange(of: composeState.inProgress) { inProgress in if inProgress { DispatchQueue.main.asyncAfter(deadline: .now() + 3) { @@ -415,16 +400,12 @@ struct SendMessageView: View { .padding([.bottom, .trailing], 4) } - private func updateHeight(_ g: GeometryProxy) -> Color { + private func updateFont(_ text: String) { DispatchQueue.main.async { - teHeight = min(max(g.frame(in: .local).size.height, minHeight), maxHeight) - (teFont, teUiFont) = isShortEmoji(composeState.message) - ? composeState.message.count < 4 - ? (largeEmojiFont, largeEmojiUIFont) - : (mediumEmojiFont, mediumEmojiUIFont) - : (.body, UIFont.preferredFont(forTextStyle: .body)) + teFont = isShortEmoji(text) + ? (text.count < 4 ? largeEmojiFont : mediumEmojiFont) + : .body } - return Color.clear } } diff --git a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift index 123d93714d..d206b9b418 100644 --- a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift +++ b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift @@ -144,7 +144,7 @@ struct AddGroupMembersViewCommon: View { do { for contactId in selectedContacts { let member = try await apiAddMember(groupInfo.groupId, contactId, selectedRole) - await MainActor.run { _ = ChatModel.shared.upsertGroupMember(groupInfo, member) } + await MainActor.run { _ = chatModel.upsertGroupMember(groupInfo, member) } } addedMembersCb(selectedContacts) } catch { diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 3b9ef347e8..94a018749e 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -15,7 +15,7 @@ struct GroupChatInfoView: View { @EnvironmentObject var chatModel: ChatModel @Environment(\.dismiss) var dismiss: DismissAction @ObservedObject var chat: Chat - @State var groupInfo: GroupInfo + @Binding var groupInfo: GroupInfo @ObservedObject private var alertManager = AlertManager.shared @State private var alert: GroupChatInfoViewAlert? = nil @State private var groupLink: String? @@ -35,14 +35,30 @@ struct GroupChatInfoView: View { case leaveGroupAlert case cantInviteIncognitoAlert case largeGroupReceiptsDisabled + case blockMemberAlert(mem: GroupMember) + case unblockMemberAlert(mem: GroupMember) + case removeMemberAlert(mem: GroupMember) + case error(title: LocalizedStringKey, error: LocalizedStringKey) - var id: GroupChatInfoViewAlert { get { self } } + var id: String { + switch self { + case .deleteGroupAlert: return "deleteGroupAlert" + case .clearChatAlert: return "clearChatAlert" + case .leaveGroupAlert: return "leaveGroupAlert" + case .cantInviteIncognitoAlert: return "cantInviteIncognitoAlert" + case .largeGroupReceiptsDisabled: return "largeGroupReceiptsDisabled" + case let .blockMemberAlert(mem): return "blockMemberAlert \(mem.groupMemberId)" + case let .unblockMemberAlert(mem): return "unblockMemberAlert \(mem.groupMemberId)" + case let .removeMemberAlert(mem): return "removeMemberAlert \(mem.groupMemberId)" + case let .error(title, _): return "error \(title)" + } + } } var body: some View { NavigationView { let members = chatModel.groupMembers - .filter { $0.memberStatus != .memLeft && $0.memberStatus != .memRemoved } + .filter { m in let status = m.wrapped.memberStatus; return status != .memLeft && status != .memRemoved } .sorted { $0.displayName.lowercased() < $1.displayName.lowercased() } List { @@ -57,7 +73,7 @@ struct GroupChatInfoView: View { addOrEditWelcomeMessage() } groupPreferencesButton($groupInfo) - if members.filter({ $0.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT { + if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT { sendReceiptsOption() } else { sendReceiptsOptionDisabled() @@ -84,17 +100,17 @@ struct GroupChatInfoView: View { .padding(.leading, 8) } let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase - let filteredMembers = s == "" ? members : members.filter { $0.chatViewName.localizedLowercase.contains(s) } - memberView(groupInfo.membership, user: true) + let filteredMembers = s == "" ? members : members.filter { $0.wrapped.chatViewName.localizedLowercase.contains(s) } + MemberRowView(groupInfo: groupInfo, groupMember: GMember(groupInfo.membership), user: true, alert: $alert) ForEach(filteredMembers) { member in ZStack { NavigationLink { - memberInfoView(member.groupMemberId) + memberInfoView(member) } label: { EmptyView() } .opacity(0) - memberView(member) + MemberRowView(groupInfo: groupInfo, groupMember: member, alert: $alert) } } } @@ -126,6 +142,10 @@ struct GroupChatInfoView: View { case .leaveGroupAlert: return leaveGroupAlert() case .cantInviteIncognitoAlert: return cantInviteIncognitoAlert() case .largeGroupReceiptsDisabled: return largeGroupReceiptsDisabledAlert() + case let .blockMemberAlert(mem): return blockMemberAlert(groupInfo, mem) + case let .unblockMemberAlert(mem): return unblockMemberAlert(groupInfo, mem) + case let .removeMemberAlert(mem): return removeMemberAlert(mem) + case let .error(title, error): return Alert(title: Text(title), message: Text(error)) } } .onAppear { @@ -174,7 +194,7 @@ struct GroupChatInfoView: View { Task { let groupMembers = await apiListMembers(groupInfo.groupId) await MainActor.run { - ChatModel.shared.groupMembers = groupMembers + chatModel.groupMembers = groupMembers.map { GMember.init($0) } } } } @@ -183,51 +203,92 @@ struct GroupChatInfoView: View { } } - private func memberView(_ member: GroupMember, user: Bool = false) -> some View { - HStack{ - ProfileImage(imageStr: member.image) - .frame(width: 38, height: 38) - .padding(.trailing, 2) - // TODO server connection status - VStack(alignment: .leading) { - let t = Text(member.chatViewName).foregroundColor(member.memberIncognito ? .indigo : .primary) - (member.verified ? memberVerifiedShield + t : t) - .lineLimit(1) - let s = Text(member.memberStatus.shortText) - (user ? Text ("you: ") + s : s) - .lineLimit(1) - .font(.caption) - .foregroundColor(.secondary) + private struct MemberRowView: View { + var groupInfo: GroupInfo + @ObservedObject var groupMember: GMember + var user: Bool = false + @Binding var alert: GroupChatInfoViewAlert? + + var body: some View { + let member = groupMember.wrapped + let v = HStack{ + ProfileImage(imageStr: member.image) + .frame(width: 38, height: 38) + .padding(.trailing, 2) + // TODO server connection status + VStack(alignment: .leading) { + let t = Text(member.chatViewName).foregroundColor(member.memberIncognito ? .indigo : .primary) + (member.verified ? memberVerifiedShield + t : t) + .lineLimit(1) + let s = Text(member.memberStatus.shortText) + (user ? Text ("you: ") + s : s) + .lineLimit(1) + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + let role = member.memberRole + if role == .owner || role == .admin { + Text(member.memberRole.text) + .foregroundColor(.secondary) + } } - Spacer() - let role = member.memberRole - if role == .owner || role == .admin { - Text(member.memberRole.text) - .foregroundColor(.secondary) + + if user { + v + } else if member.canBeRemoved(groupInfo: groupInfo) { + removeSwipe(member, blockSwipe(member, v)) + } else { + blockSwipe(member, v) + } + } + + private func blockSwipe(_ member: GroupMember, _ v: V) -> some View { + v.swipeActions(edge: .leading) { + if member.memberSettings.showMessages { + Button { + alert = .blockMemberAlert(mem: member) + } label: { + Label("Block member", systemImage: "hand.raised").foregroundColor(.secondary) + } + } else { + Button { + alert = .unblockMemberAlert(mem: member) + } label: { + Label("Unblock member", systemImage: "hand.raised.slash").foregroundColor(.accentColor) + } + } + } + } + + private func removeSwipe(_ member: GroupMember, _ v: V) -> some View { + v.swipeActions(edge: .trailing) { + Button(role: .destructive) { + alert = .removeMemberAlert(mem: member) + } label: { + Label("Remove member", systemImage: "trash") + .foregroundColor(Color.red) + } } } } - private var memberVerifiedShield: Text { - (Text(Image(systemName: "checkmark.shield")) + Text(" ")) - .font(.caption) - .baselineOffset(2) - .kerning(-2) - .foregroundColor(.secondary) - } - - @ViewBuilder private func memberInfoView(_ groupMemberId: Int64?) -> some View { - if let mId = groupMemberId, let member = chatModel.groupMembers.first(where: { $0.groupMemberId == mId }) { - GroupMemberInfoView(groupInfo: groupInfo, member: member) - .navigationBarHidden(false) - } + private func memberInfoView(_ groupMember: GMember) -> some View { + GroupMemberInfoView(groupInfo: groupInfo, groupMember: groupMember) + .navigationBarHidden(false) } private func groupLinkButton() -> some View { NavigationLink { - GroupLinkView(groupId: groupInfo.groupId, groupLink: $groupLink, groupLinkMemberRole: $groupLinkMemberRole) - .navigationBarTitle("Group link") - .navigationBarTitleDisplayMode(.large) + GroupLinkView( + groupId: groupInfo.groupId, + groupLink: $groupLink, + groupLinkMemberRole: $groupLinkMemberRole, + showTitle: false, + creatingGroup: false + ) + .navigationBarTitle("Group link") + .navigationBarTitleDisplayMode(.large) } label: { if groupLink == nil { Label("Create group link", systemImage: "link.badge.plus") @@ -375,6 +436,28 @@ struct GroupChatInfoView: View { alert = .largeGroupReceiptsDisabled } } + + private func removeMemberAlert(_ mem: GroupMember) -> Alert { + Alert( + title: Text("Remove member?"), + message: Text("Member will be removed from group - this cannot be undone!"), + primaryButton: .destructive(Text("Remove")) { + Task { + do { + let updatedMember = try await apiRemoveMember(groupInfo.groupId, mem.groupMemberId) + await MainActor.run { + _ = chatModel.upsertGroupMember(groupInfo, updatedMember) + } + } catch let error { + logger.error("apiRemoveMember error: \(responseError(error))") + let a = getErrorAlert(error, "Error removing member") + alert = .error(title: a.title, error: a.message) + } + } + }, + secondaryButton: .cancel() + ) + } } func groupPreferencesButton(_ groupInfo: Binding, _ creatingGroup: Bool = false) -> some View { @@ -396,6 +479,14 @@ func groupPreferencesButton(_ groupInfo: Binding, _ creatingGroup: Bo } } +private var memberVerifiedShield: Text { + (Text(Image(systemName: "checkmark.shield")) + Text(" ")) + .font(.caption) + .baselineOffset(2) + .kerning(-2) + .foregroundColor(.secondary) +} + func cantInviteIncognitoAlert() -> Alert { Alert( title: Text("Can't invite contacts!"), @@ -412,6 +503,9 @@ func largeGroupReceiptsDisabledAlert() -> Alert { struct GroupChatInfoView_Previews: PreviewProvider { static var previews: some View { - GroupChatInfoView(chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []), groupInfo: GroupInfo.sampleData) + GroupChatInfoView( + chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []), + groupInfo: Binding.constant(GroupInfo.sampleData) + ) } } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift index 3731e0c4d7..bf2179bea4 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift @@ -13,6 +13,9 @@ struct GroupLinkView: View { var groupId: Int64 @Binding var groupLink: String? @Binding var groupLinkMemberRole: GroupMemberRole + var showTitle: Bool = false + var creatingGroup: Bool = false + var linkCreatedCb: (() -> Void)? = nil @State private var creatingLink = false @State private var alert: GroupLinkAlert? @@ -29,10 +32,35 @@ struct GroupLinkView: View { } var body: some View { + if creatingGroup { + NavigationView { + groupLinkView() + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button ("Continue") { linkCreatedCb?() } + } + } + } + } else { + groupLinkView() + } + } + + private func groupLinkView() -> some View { List { - Text("You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.") - .listRowBackground(Color.clear) - .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + Group { + if showTitle { + Text("Group link") + .font(.largeTitle) + .bold() + .fixedSize(horizontal: false, vertical: true) + } + Text("You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.") + } + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + Section { if let groupLink = groupLink { Picker("Initial role", selection: $groupLinkMemberRole) { @@ -41,15 +69,17 @@ struct GroupLinkView: View { } } .frame(height: 36) - QRCode(uri: groupLink) + SimpleXLinkQRCode(uri: groupLink) Button { - showShareSheet(items: [groupLink]) + showShareSheet(items: [simplexChatLink(groupLink)]) } label: { Label("Share link", systemImage: "square.and.arrow.up") } - Button(role: .destructive) { alert = .deleteLink } label: { - Label("Delete link", systemImage: "trash") + if !creatingGroup { + Button(role: .destructive) { alert = .deleteLink } label: { + Label("Delete link", systemImage: "trash") + } } } else { Button(action: createGroupLink) { diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 5ec14f5be1..4a187cecb9 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -12,38 +12,40 @@ import SimpleXChat struct GroupMemberInfoView: View { @EnvironmentObject var chatModel: ChatModel @Environment(\.dismiss) var dismiss: DismissAction - var groupInfo: GroupInfo - @State var member: GroupMember + @State var groupInfo: GroupInfo + @ObservedObject var groupMember: GMember var navigation: Bool = false @State private var connectionStats: ConnectionStats? = nil @State private var connectionCode: String? = nil @State private var newRole: GroupMemberRole = .member @State private var alert: GroupMemberInfoViewAlert? - @State private var connectToMemberDialog: Bool = false + @State private var sheet: PlanAndConnectActionSheet? @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @State private var justOpened = true @State private var progressIndicator = false enum GroupMemberInfoViewAlert: Identifiable { + case blockMemberAlert(mem: GroupMember) + case unblockMemberAlert(mem: GroupMember) case removeMemberAlert(mem: GroupMember) case changeMemberRoleAlert(mem: GroupMember, role: GroupMemberRole) case switchAddressAlert case abortSwitchAddressAlert case syncConnectionForceAlert - case connRequestSentAlert(type: ConnReqType) + case planAndConnectAlert(alert: PlanAndConnectAlert) case error(title: LocalizedStringKey, error: LocalizedStringKey) - case other(alert: Alert) var id: String { switch self { - case .removeMemberAlert: return "removeMemberAlert" - case let .changeMemberRoleAlert(_, role): return "changeMemberRoleAlert \(role.rawValue)" + case let .blockMemberAlert(mem): return "blockMemberAlert \(mem.groupMemberId)" + case let .unblockMemberAlert(mem): return "unblockMemberAlert \(mem.groupMemberId)" + case let .removeMemberAlert(mem): return "removeMemberAlert \(mem.groupMemberId)" + case let .changeMemberRoleAlert(mem, role): return "changeMemberRoleAlert \(mem.groupMemberId) \(role.rawValue)" case .switchAddressAlert: return "switchAddressAlert" case .abortSwitchAddressAlert: return "abortSwitchAddressAlert" case .syncConnectionForceAlert: return "syncConnectionForceAlert" - case .connRequestSentAlert: return "connRequestSentAlert" + case let .planAndConnectAlert(alert): return "planAndConnectAlert \(alert.id)" case let .error(title, _): return "error \(title)" - case let .other(alert): return "other \(alert)" } } } @@ -68,6 +70,7 @@ struct GroupMemberInfoView: View { private func groupMemberInfoView() -> some View { ZStack { VStack { + let member = groupMember.wrapped List { groupMemberInfoHeader(member) .listRowBackground(Color.clear) @@ -96,9 +99,9 @@ struct GroupMemberInfoView: View { if let contactLink = member.contactLink { Section { - QRCode(uri: contactLink) + SimpleXLinkQRCode(uri: contactLink) Button { - showShareSheet(items: [contactLink]) + showShareSheet(items: [simplexChatLink(contactLink)]) } label: { Label("Share address", systemImage: "square.and.arrow.up") } @@ -161,9 +164,14 @@ struct GroupMemberInfoView: View { } } - if member.canBeRemoved(groupInfo: groupInfo) { - Section { - removeMemberButton(member) + Section { + if member.memberSettings.showMessages { + blockMemberButton(member) + } else { + unblockMemberButton(member) + } + if member.canBeRemoved(groupInfo: groupInfo) { + removeMemberButton(member) } } @@ -184,7 +192,7 @@ struct GroupMemberInfoView: View { do { let (_, stats) = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId) let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil) - member = mem + _ = chatModel.upsertGroupMember(groupInfo, mem) connectionStats = stats connectionCode = code } catch let error { @@ -192,25 +200,30 @@ struct GroupMemberInfoView: View { } justOpened = false } - .onChange(of: newRole) { _ in + .onChange(of: newRole) { newRole in if newRole != member.memberRole { alert = .changeMemberRoleAlert(mem: member, role: newRole) } } + .onChange(of: member.memberRole) { role in + newRole = role + } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .alert(item: $alert) { alertItem in switch(alertItem) { + case let .blockMemberAlert(mem): return blockMemberAlert(groupInfo, mem) + case let .unblockMemberAlert(mem): return unblockMemberAlert(groupInfo, mem) case let .removeMemberAlert(mem): return removeMemberAlert(mem) case let .changeMemberRoleAlert(mem, _): return changeMemberRoleAlert(mem) case .switchAddressAlert: return switchAddressAlert(switchMemberAddress) case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchMemberAddress) case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncMemberConnection(force: true) }) - case let .connRequestSentAlert(type): return connReqSentAlert(type) + case let .planAndConnectAlert(alert): return planAndConnectAlert(alert, dismiss: true) case let .error(title, error): return Alert(title: Text(title), message: Text(error)) - case let .other(alert): return alert } } + .actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true) } if progressIndicator { ProgressView().scaleEffect(2) @@ -220,25 +233,16 @@ struct GroupMemberInfoView: View { func connectViaAddressButton(_ contactLink: String) -> some View { Button { - connectToMemberDialog = true + planAndConnect( + contactLink, + showAlert: { alert = .planAndConnectAlert(alert: $0) }, + showActionSheet: { sheet = $0 }, + dismiss: true, + incognito: nil + ) } 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 connectViaAddress(incognito: Bool, contactLink: String) { - Task { - let (connReqType, connectAlert) = await apiConnect_(incognito: incognito, connReq: contactLink) - if let connReqType = connReqType { - alert = .connRequestSentAlert(type: connReqType) - } else if let connectAlert = connectAlert { - alert = .other(alert: connectAlert) - } - } } func knownDirectChatButton(_ chat: Chat) -> some View { @@ -274,7 +278,7 @@ struct GroupMemberInfoView: View { progressIndicator = true Task { do { - let memberContact = try await apiCreateMemberContact(groupInfo.apiId, member.groupMemberId) + let memberContact = try await apiCreateMemberContact(groupInfo.apiId, groupMember.groupMemberId) await MainActor.run { progressIndicator = false chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact))) @@ -332,20 +336,20 @@ struct GroupMemberInfoView: View { } private func verifyCodeButton(_ code: String) -> some View { - NavigationLink { + let member = groupMember.wrapped + return NavigationLink { VerifyCodeView( displayName: member.displayName, connectionCode: code, connectionVerified: member.verified, verify: { code in + var member = groupMember.wrapped if let r = apiVerifyGroupMember(member.groupId, member.groupMemberId, connectionCode: code) { let (verified, existingCode) = r let connCode = verified ? SecurityCode(securityCode: existingCode, verifiedAt: .now) : nil connectionCode = existingCode member.activeConn?.connectionCode = connCode - if let i = chatModel.groupMembers.firstIndex(where: { $0.groupMemberId == member.groupMemberId }) { - chatModel.groupMembers[i].activeConn?.connectionCode = connCode - } + _ = chatModel.upsertGroupMember(groupInfo, member) return r } return nil @@ -379,12 +383,29 @@ struct GroupMemberInfoView: View { } } + private func blockMemberButton(_ mem: GroupMember) -> some View { + Button(role: .destructive) { + alert = .blockMemberAlert(mem: mem) + } label: { + Label("Block member", systemImage: "hand.raised") + .foregroundColor(.red) + } + } + + private func unblockMemberButton(_ mem: GroupMember) -> some View { + Button { + alert = .unblockMemberAlert(mem: mem) + } label: { + Label("Unblock member", systemImage: "hand.raised.slash") + } + } + private func removeMemberButton(_ mem: GroupMember) -> some View { Button(role: .destructive) { alert = .removeMemberAlert(mem: mem) } label: { Label("Remove member", systemImage: "trash") - .foregroundColor(Color.red) + .foregroundColor(.red) } } @@ -420,7 +441,6 @@ struct GroupMemberInfoView: View { do { let updatedMember = try await apiMemberRole(groupInfo.groupId, mem.groupMemberId, newRole) await MainActor.run { - member = updatedMember _ = chatModel.upsertGroupMember(groupInfo, updatedMember) } @@ -441,10 +461,10 @@ struct GroupMemberInfoView: View { private func switchMemberAddress() { Task { do { - let stats = try apiSwitchGroupMember(groupInfo.apiId, member.groupMemberId) + let stats = try apiSwitchGroupMember(groupInfo.apiId, groupMember.groupMemberId) connectionStats = stats await MainActor.run { - chatModel.updateGroupMemberConnectionStats(groupInfo, member, stats) + chatModel.updateGroupMemberConnectionStats(groupInfo, groupMember.wrapped, stats) dismiss() } } catch let error { @@ -460,10 +480,10 @@ struct GroupMemberInfoView: View { private func abortSwitchMemberAddress() { Task { do { - let stats = try apiAbortSwitchGroupMember(groupInfo.apiId, member.groupMemberId) + let stats = try apiAbortSwitchGroupMember(groupInfo.apiId, groupMember.groupMemberId) connectionStats = stats await MainActor.run { - chatModel.updateGroupMemberConnectionStats(groupInfo, member, stats) + chatModel.updateGroupMemberConnectionStats(groupInfo, groupMember.wrapped, stats) } } catch let error { logger.error("abortSwitchMemberAddress apiAbortSwitchGroupMember error: \(responseError(error))") @@ -478,7 +498,7 @@ struct GroupMemberInfoView: View { private func syncMemberConnection(force: Bool) { Task { do { - let (mem, stats) = try apiSyncGroupMemberRatchet(groupInfo.apiId, member.groupMemberId, force) + let (mem, stats) = try apiSyncGroupMemberRatchet(groupInfo.apiId, groupMember.groupMemberId, force) connectionStats = stats await MainActor.run { chatModel.updateGroupMemberConnectionStats(groupInfo, mem, stats) @@ -495,11 +515,54 @@ struct GroupMemberInfoView: View { } } +func blockMemberAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert { + Alert( + title: Text("Block member?"), + message: Text("All new messages from \(mem.chatViewName) will be hidden!"), + primaryButton: .destructive(Text("Block")) { + toggleShowMemberMessages(gInfo, mem, false) + }, + secondaryButton: .cancel() + ) +} + +func unblockMemberAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert { + Alert( + title: Text("Unblock member?"), + message: Text("Messages from \(mem.chatViewName) will be shown!"), + primaryButton: .default(Text("Unblock")) { + toggleShowMemberMessages(gInfo, mem, true) + }, + secondaryButton: .cancel() + ) +} + +func toggleShowMemberMessages(_ gInfo: GroupInfo, _ member: GroupMember, _ showMessages: Bool) { + var memberSettings = member.memberSettings + memberSettings.showMessages = showMessages + updateMemberSettings(gInfo, member, memberSettings) +} + +func updateMemberSettings(_ gInfo: GroupInfo, _ member: GroupMember, _ memberSettings: GroupMemberSettings) { + Task { + do { + try await apiSetMemberSettings(gInfo.groupId, member.groupMemberId, memberSettings) + await MainActor.run { + var mem = member + mem.memberSettings = memberSettings + _ = ChatModel.shared.upsertGroupMember(gInfo, mem) + } + } catch let error { + logger.error("apiSetMemberSettings error \(responseError(error))") + } + } +} + struct GroupMemberInfoView_Previews: PreviewProvider { static var previews: some View { GroupMemberInfoView( groupInfo: GroupInfo.sampleData, - member: GroupMember.sampleData + groupMember: GMember.sampleData ) } } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift index af1a778ad1..860a6febb0 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift @@ -27,8 +27,7 @@ struct GroupPreferencesView: View { featureSection(.directMessages, $preferences.directMessages.enable) featureSection(.reactions, $preferences.reactions.enable) featureSection(.voice, $preferences.voice.enable) -// TODO uncomment in 5.3 -// featureSection(.files, $preferences.files.enable) + featureSection(.files, $preferences.files.enable) if groupInfo.canEdit { Section { diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index f445ae4b5d..971c0e0888 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -32,19 +32,33 @@ struct ChatListNavLink: View { @State private var showJoinGroupDialog = false @State private var showContactConnectionInfo = false @State private var showInvalidJSON = false + @State private var showDeleteContactActionSheet = false + @State private var inProgress = false + @State private var progressByTimeout = false var body: some View { - switch chat.chatInfo { - case let .direct(contact): - contactNavLink(contact) - case let .group(groupInfo): - groupNavLink(groupInfo) - case let .contactRequest(cReq): - contactRequestNavLink(cReq) - case let .contactConnection(cConn): - contactConnectionNavLink(cConn) - case let .invalidJSON(json): - invalidJSONPreview(json) + Group { + switch chat.chatInfo { + case let .direct(contact): + contactNavLink(contact) + case let .group(groupInfo): + groupNavLink(groupInfo) + case let .contactRequest(cReq): + contactRequestNavLink(cReq) + case let .contactConnection(cConn): + contactConnectionNavLink(cConn) + case let .invalidJSON(json): + invalidJSONPreview(json) + } + } + .onChange(of: inProgress) { inProgress in + if inProgress { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + progressByTimeout = inProgress + } + } else { + progressByTimeout = false + } } } @@ -52,7 +66,7 @@ struct ChatListNavLink: View { NavLinkPlain( tag: chat.chatInfo.id, selection: $chatModel.chatId, - label: { ChatPreviewView(chat: chat) } + label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) } ) .swipeActions(edge: .leading, allowsFullSwipe: true) { markReadButton() @@ -64,23 +78,43 @@ struct ChatListNavLink: View { clearChatButton() } Button { - AlertManager.shared.showAlert( - contact.ready || !contact.active - ? deleteContactAlert(chat.chatInfo) - : deletePendingContactAlert(chat, contact) - ) + if contact.ready || !contact.active { + showDeleteContactActionSheet = true + } else { + AlertManager.shared.showAlert(deletePendingContactAlert(chat, contact)) + } } label: { Label("Delete", systemImage: "trash") } .tint(.red) } .frame(height: rowHeights[dynamicTypeSize]) + .actionSheet(isPresented: $showDeleteContactActionSheet) { + if contact.ready && contact.active { + return ActionSheet( + title: Text("Delete contact?\nThis cannot be undone!"), + buttons: [ + .destructive(Text("Delete and notify contact")) { Task { await deleteChat(chat, notify: true) } }, + .destructive(Text("Delete")) { Task { await deleteChat(chat, notify: false) } }, + .cancel() + ] + ) + } else { + return ActionSheet( + title: Text("Delete contact?\nThis cannot be undone!"), + buttons: [ + .destructive(Text("Delete")) { Task { await deleteChat(chat) } }, + .cancel() + ] + ) + } + } } @ViewBuilder private func groupNavLink(_ groupInfo: GroupInfo) -> some View { switch (groupInfo.membership.memberStatus) { case .memInvited: - ChatPreviewView(chat: chat) + ChatPreviewView(chat: chat, progressByTimeout: $progressByTimeout) .frame(height: rowHeights[dynamicTypeSize]) .swipeActions(edge: .trailing, allowsFullSwipe: true) { joinGroupButton() @@ -91,12 +125,16 @@ struct ChatListNavLink: View { .onTapGesture { showJoinGroupDialog = true } .confirmationDialog("Group invitation", isPresented: $showJoinGroupDialog, titleVisibility: .visible) { Button(chat.chatInfo.incognito ? "Join incognito" : "Join group") { - joinGroup(groupInfo.groupId) + inProgress = true + joinGroup(groupInfo.groupId) { + await MainActor.run { inProgress = false } + } } Button("Delete invitation", role: .destructive) { Task { await deleteChat(chat) } } } + .disabled(inProgress) case .memAccepted: - ChatPreviewView(chat: chat) + ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) .frame(height: rowHeights[dynamicTypeSize]) .onTapGesture { AlertManager.shared.showAlert(groupInvitationAcceptedAlert()) @@ -113,7 +151,7 @@ struct ChatListNavLink: View { NavLinkPlain( tag: chat.chatInfo.id, selection: $chatModel.chatId, - label: { ChatPreviewView(chat: chat) }, + label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) }, disabled: !groupInfo.ready ) .frame(height: rowHeights[dynamicTypeSize]) @@ -138,7 +176,10 @@ struct ChatListNavLink: View { private func joinGroupButton() -> some View { Button { - joinGroup(chat.chatInfo.apiId) + inProgress = true + joinGroup(chat.chatInfo.apiId) { + await MainActor.run { inProgress = false } + } } label: { Label("Join", systemImage: chat.chatInfo.incognito ? "theatermasks" : "ipad.and.arrow.forward") } @@ -269,17 +310,6 @@ struct ChatListNavLink: View { } } - private func deleteContactAlert(_ chatInfo: ChatInfo) -> Alert { - Alert( - title: Text("Delete contact?"), - message: Text("Contact and all messages will be deleted - this cannot be undone!"), - primaryButton: .destructive(Text("Delete")) { - Task { await deleteChat(chat) } - }, - secondaryButton: .cancel() - ) - } - private func deleteGroupAlert(_ groupInfo: GroupInfo) -> Alert { Alert( title: Text("Delete group?"), @@ -409,7 +439,7 @@ func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection, ) } -func joinGroup(_ groupId: Int64) { +func joinGroup(_ groupId: Int64, _ onComplete: @escaping () async -> Void) { Task { logger.debug("joinGroup") do { @@ -424,7 +454,9 @@ func joinGroup(_ groupId: Int64) { AlertManager.shared.showAlertMsg(title: "No group!", message: "This group no longer exists.") await deleteGroup() } + await onComplete() } catch let error { + await onComplete() let a = getErrorAlert(error, "Error joining group") AlertManager.shared.showAlertMsg(title: a.title, message: a.message) } diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index eb0a5cba68..baab2bcf95 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -29,7 +29,7 @@ struct ChatListView: View { ZStack(alignment: .topLeading) { NavStackCompat( isActive: Binding( - get: { ChatModel.shared.chatId != nil }, + get: { chatModel.chatId != nil }, set: { _ in } ), destination: chatView diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 2eb6d9f6bd..71f8baf748 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -12,6 +12,7 @@ import SimpleXChat struct ChatPreviewView: View { @EnvironmentObject var chatModel: ChatModel @ObservedObject var chat: Chat + @Binding var progressByTimeout: Bool @Environment(\.colorScheme) var colorScheme var darkGreen = Color(red: 0, green: 0.5, blue: 0) @@ -111,14 +112,17 @@ struct ChatPreviewView: View { private func chatPreviewLayout(_ text: Text, draft: Bool = false) -> some View { ZStack(alignment: .topTrailing) { - text + let t = text .lineLimit(2) .multilineTextAlignment(.leading) .frame(maxWidth: .infinity, alignment: .topLeading) .padding(.leading, 8) .padding(.trailing, 36) - .privacySensitive(!showChatPreviews && !draft) - .redacted(reason: .privacy) + if !showChatPreviews && !draft { + t.privacySensitive(true).redacted(reason: .privacy) + } else { + t + } let s = chat.chatStats if s.unreadCount > 0 || s.unreadChat { unreadCountText(s.unreadCount) @@ -249,6 +253,12 @@ struct ChatPreviewView: View { } else { incognitoIcon(chat.chatInfo.incognito) } + case .group: + if progressByTimeout { + ProgressView() + } else { + incognitoIcon(chat.chatInfo.incognito) + } default: incognitoIcon(chat.chatInfo.incognito) } @@ -277,30 +287,30 @@ struct ChatPreviewView_Previews: PreviewProvider { ChatPreviewView(chat: Chat( chatInfo: ChatInfo.sampleData.direct, chatItems: [] - )) + ), progressByTimeout: Binding.constant(false)) ChatPreviewView(chat: Chat( chatInfo: ChatInfo.sampleData.direct, chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))] - )) + ), progressByTimeout: Binding.constant(false)) ChatPreviewView(chat: Chat( chatInfo: ChatInfo.sampleData.direct, chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))], chatStats: ChatStats(unreadCount: 11, minUnreadItemId: 0) - )) + ), progressByTimeout: Binding.constant(false)) ChatPreviewView(chat: Chat( chatInfo: ChatInfo.sampleData.direct, chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now))] - )) + ), progressByTimeout: Binding.constant(false)) ChatPreviewView(chat: Chat( chatInfo: ChatInfo.sampleData.direct, chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))], chatStats: ChatStats(unreadCount: 3, minUnreadItemId: 0) - )) + ), progressByTimeout: Binding.constant(false)) ChatPreviewView(chat: Chat( chatInfo: ChatInfo.sampleData.group, chatItems: [ChatItem.getSample(1, .directSnd, .now, "Lorem ipsum dolor sit amet, d. consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.")], chatStats: ChatStats(unreadCount: 11, minUnreadItemId: 0) - )) + ), progressByTimeout: Binding.constant(false)) } .previewLayout(.fixed(width: 360, height: 78)) } diff --git a/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift b/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift index 3e42d2f207..6d2fba99c6 100644 --- a/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift +++ b/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift @@ -61,7 +61,7 @@ struct ContactConnectionInfo: View { if contactConnection.initiated, let connReqInv = contactConnection.connReqInv { - QRCode(uri: connReqInv) + SimpleXLinkQRCode(uri: simplexChatLink(connReqInv)) incognitoEnabled() shareLinkButton(connReqInv) oneTimeLinkLearnMoreButton() @@ -119,7 +119,7 @@ struct ContactConnectionInfo: View { if let conn = try await apiSetConnectionAlias(connId: contactConnection.pccConnId, localAlias: localAlias) { await MainActor.run { contactConnection = conn - ChatModel.shared.updateContactConnection(conn) + m.updateContactConnection(conn) dismiss() } } diff --git a/apps/ios/Shared/Views/Helpers/VideoPlayerView.swift b/apps/ios/Shared/Views/Helpers/VideoPlayerView.swift index 69393cabb8..5b982f5f0a 100644 --- a/apps/ios/Shared/Views/Helpers/VideoPlayerView.swift +++ b/apps/ios/Shared/Views/Helpers/VideoPlayerView.swift @@ -52,7 +52,6 @@ struct VideoPlayerView: UIViewRepresentable { var timeObserver: Any? = nil deinit { - print("deinit coordinator of VideoPlayer") if let timeObserver = timeObserver { NotificationCenter.default.removeObserver(timeObserver) } diff --git a/apps/ios/Shared/Views/NewChat/AddContactView.swift b/apps/ios/Shared/Views/NewChat/AddContactView.swift index 31b6b64f32..de8e35d2a6 100644 --- a/apps/ios/Shared/Views/NewChat/AddContactView.swift +++ b/apps/ios/Shared/Views/NewChat/AddContactView.swift @@ -21,7 +21,7 @@ struct AddContactView: View { List { Section { if connReqInvitation != "" { - QRCode(uri: connReqInvitation) + SimpleXLinkQRCode(uri: connReqInvitation) } else { ProgressView() .progressViewStyle(.circular) @@ -48,7 +48,7 @@ struct AddContactView: View { let conn = try await apiSetConnectionIncognito(connId: contactConn.pccConnId, incognito: incognito) { await MainActor.run { contactConnection = conn - ChatModel.shared.updateContactConnection(conn) + chatModel.updateContactConnection(conn) } } } catch { @@ -99,7 +99,7 @@ func sharedProfileInfo(_ incognito: Bool) -> Text { func shareLinkButton(_ connReqInvitation: String) -> some View { Button { - showShareSheet(items: [connReqInvitation]) + showShareSheet(items: [simplexChatLink(connReqInvitation)]) } label: { settingsRow("square.and.arrow.up") { Text("Share 1-time link") diff --git a/apps/ios/Shared/Views/NewChat/AddGroupView.swift b/apps/ios/Shared/Views/NewChat/AddGroupView.swift index 186a24e995..2d7f31c58e 100644 --- a/apps/ios/Shared/Views/NewChat/AddGroupView.swift +++ b/apps/ios/Shared/Views/NewChat/AddGroupView.swift @@ -12,6 +12,7 @@ import SimpleXChat struct AddGroupView: View { @EnvironmentObject var m: ChatModel @Environment(\.dismiss) var dismiss: DismissAction + @AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false @State private var chat: Chat? @State private var groupInfo: GroupInfo? @State private var profile = GroupProfile(displayName: "", fullName: "") @@ -21,18 +22,35 @@ struct AddGroupView: View { @State private var showTakePhoto = false @State private var chosenImage: UIImage? = nil @State private var showInvalidNameAlert = false + @State private var groupLink: String? + @State private var groupLinkMemberRole: GroupMemberRole = .member var body: some View { if let chat = chat, let groupInfo = groupInfo { - AddGroupMembersViewCommon( - chat: chat, - groupInfo: groupInfo, - creatingGroup: true, - showFooterCounter: false - ) { _ in - dismiss() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - m.chatId = groupInfo.id + if !groupInfo.membership.memberIncognito { + AddGroupMembersViewCommon( + chat: chat, + groupInfo: groupInfo, + creatingGroup: true, + showFooterCounter: false + ) { _ in + dismiss() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + m.chatId = groupInfo.id + } + } + } else { + GroupLinkView( + groupId: groupInfo.groupId, + groupLink: $groupLink, + groupLinkMemberRole: $groupLinkMemberRole, + showTitle: true, + creatingGroup: true + ) { + dismiss() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + m.chatId = groupInfo.id + } } } } else { @@ -41,77 +59,62 @@ struct AddGroupView: View { } func createGroupView() -> some View { - VStack(alignment: .leading) { - Text("Create secret group") - .font(.largeTitle) - .padding(.vertical, 4) - Text("The group is fully decentralized – it is visible only to the members.") - .padding(.bottom, 4) + List { + Group { + Text("Create secret group") + .font(.largeTitle) + .bold() + .fixedSize(horizontal: false, vertical: true) + .padding(.bottom, 24) + .onTapGesture(perform: hideKeyboard) - HStack { - Image(systemName: "info.circle").foregroundColor(.secondary).font(.footnote) - Spacer().frame(width: 8) - Text("Your chat profile will be sent to group members").font(.footnote) - } - .padding(.bottom) - - ZStack(alignment: .center) { - ZStack(alignment: .topTrailing) { - profileImageView(profile.image) - if profile.image != nil { - Button { - profile.image = nil - } label: { - Image(systemName: "multiply") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 12) + ZStack(alignment: .center) { + ZStack(alignment: .topTrailing) { + ProfileImage(imageStr: profile.image, color: Color(uiColor: .secondarySystemGroupedBackground)) + .aspectRatio(1, contentMode: .fit) + .frame(maxWidth: 128, maxHeight: 128) + if profile.image != nil { + Button { + profile.image = nil + } label: { + Image(systemName: "multiply") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 12) + } } } + + editImageButton { showChooseSource = true } + .buttonStyle(BorderlessButtonStyle()) // otherwise whole "list row" is clickable } - - editImageButton { showChooseSource = true } + .frame(maxWidth: .infinity, alignment: .center) } - .frame(maxWidth: .infinity, alignment: .center) - .padding(.bottom, 4) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) - ZStack(alignment: .topLeading) { - let name = profile.displayName.trimmingCharacters(in: .whitespaces) - if name != mkValidName(name) { - Button { - showInvalidNameAlert = true - } label: { - Image(systemName: "exclamationmark.circle").foregroundColor(.red) - } - } else { - Image(systemName: "exclamationmark.circle").foregroundColor(.clear) + Section { + groupNameTextField() + Button(action: createGroup) { + settingsRow("checkmark", color: .accentColor) { Text("Create group") } } - textField("Enter group name…", text: $profile.displayName) - .focused($focusDisplayName) - .submitLabel(.go) - .onSubmit { - if canCreateProfile() { createGroup() } - } + .disabled(!canCreateProfile()) + IncognitoToggle(incognitoEnabled: $incognitoDefault) + } footer: { + VStack(alignment: .leading, spacing: 4) { + sharedGroupProfileInfo(incognitoDefault) + Text("Fully decentralized – visible only to members.") + } + .frame(maxWidth: .infinity, alignment: .leading) + .onTapGesture(perform: hideKeyboard) } - .padding(.bottom) - - Spacer() - - Button { - createGroup() - } label: { - Text("Create") - Image(systemName: "greaterthan") - } - .disabled(!canCreateProfile()) - .frame(maxWidth: .infinity, alignment: .trailing) } .onAppear() { DispatchQueue.main.asyncAfter(deadline: .now() + 1) { focusDisplayName = true } } - .padding() .confirmationDialog("Group image", isPresented: $showChooseSource, titleVisibility: .visible) { Button("Take picture") { showTakePhoto = true @@ -141,24 +144,52 @@ struct AddGroupView: View { profile.image = nil } } - .contentShape(Rectangle()) - .onTapGesture { hideKeyboard() } + } + + func groupNameTextField() -> some View { + ZStack(alignment: .leading) { + let name = profile.displayName.trimmingCharacters(in: .whitespaces) + if name != mkValidName(name) { + Button { + showInvalidNameAlert = true + } label: { + Image(systemName: "exclamationmark.circle").foregroundColor(.red) + } + } else { + Image(systemName: "pencil").foregroundColor(.secondary) + } + textField("Enter group name…", text: $profile.displayName) + .focused($focusDisplayName) + .submitLabel(.continue) + .onSubmit { + if canCreateProfile() { createGroup() } + } + } } func textField(_ placeholder: LocalizedStringKey, text: Binding) -> some View { TextField(placeholder, text: text) - .padding(.leading, 32) + .padding(.leading, 36) + } + + func sharedGroupProfileInfo(_ incognito: Bool) -> Text { + let name = ChatModel.shared.currentUser?.displayName ?? "" + return Text( + incognito + ? "A new random profile will be shared." + : "Your profile **\(name)** will be shared." + ) } func createGroup() { hideKeyboard() do { profile.displayName = profile.displayName.trimmingCharacters(in: .whitespaces) - let gInfo = try apiNewGroup(profile) + let gInfo = try apiNewGroup(incognito: incognitoDefault, groupProfile: profile) Task { let groupMembers = await apiListMembers(gInfo.groupId) await MainActor.run { - ChatModel.shared.groupMembers = groupMembers + m.groupMembers = groupMembers.map { GMember.init($0) } } } let c = Chat(chatInfo: .group(groupInfo: gInfo), chatItems: []) diff --git a/apps/ios/Shared/Views/NewChat/NewChatButton.swift b/apps/ios/Shared/Views/NewChat/NewChatButton.swift index a727ad6be0..8d095e907b 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatButton.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatButton.swift @@ -58,65 +58,369 @@ struct NewChatButton: View { } } -enum ConnReqType: Equatable { - case contact - case invitation +enum PlanAndConnectAlert: Identifiable { + case ownInvitationLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool) + case invitationLinkConnecting(connectionLink: String) + case ownContactAddressConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool) + case contactAddressConnectingConfirmReconnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool) + case groupLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool) + case groupLinkConnectingConfirmReconnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool) + case groupLinkConnecting(connectionLink: String, groupInfo: GroupInfo?) + + var id: String { + switch self { + case let .ownInvitationLinkConfirmConnect(connectionLink, _, _): return "ownInvitationLinkConfirmConnect \(connectionLink)" + case let .invitationLinkConnecting(connectionLink): return "invitationLinkConnecting \(connectionLink)" + case let .ownContactAddressConfirmConnect(connectionLink, _, _): return "ownContactAddressConfirmConnect \(connectionLink)" + case let .contactAddressConnectingConfirmReconnect(connectionLink, _, _): return "contactAddressConnectingConfirmReconnect \(connectionLink)" + case let .groupLinkConfirmConnect(connectionLink, _, _): return "groupLinkConfirmConnect \(connectionLink)" + case let .groupLinkConnectingConfirmReconnect(connectionLink, _, _): return "groupLinkConnectingConfirmReconnect \(connectionLink)" + case let .groupLinkConnecting(connectionLink, _): return "groupLinkConnecting \(connectionLink)" + } + } } -func connectViaLink(_ connectionLink: String, dismiss: DismissAction? = nil, incognito: Bool) { - Task { - if let connReqType = await apiConnect(incognito: incognito, connReq: connectionLink) { - DispatchQueue.main.async { - dismiss?() - AlertManager.shared.showAlert(connReqSentAlert(connReqType)) - } +func planAndConnectAlert(_ alert: PlanAndConnectAlert, dismiss: Bool) -> Alert { + switch alert { + case let .ownInvitationLinkConfirmConnect(connectionLink, connectionPlan, incognito): + return Alert( + title: Text("Connect to yourself?"), + message: Text("This is your own one-time link!"), + primaryButton: .destructive( + Text(incognito ? "Connect incognito" : "Connect"), + action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) } + ), + secondaryButton: .cancel() + ) + case .invitationLinkConnecting: + return Alert( + title: Text("Already connecting!"), + message: Text("You are already connecting via this one-time link!") + ) + case let .ownContactAddressConfirmConnect(connectionLink, connectionPlan, incognito): + return Alert( + title: Text("Connect to yourself?"), + message: Text("This is your own SimpleX address!"), + primaryButton: .destructive( + Text(incognito ? "Connect incognito" : "Connect"), + action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) } + ), + secondaryButton: .cancel() + ) + case let .contactAddressConnectingConfirmReconnect(connectionLink, connectionPlan, incognito): + return Alert( + title: Text("Repeat connection request?"), + message: Text("You have already requested connection via this address!"), + primaryButton: .destructive( + Text(incognito ? "Connect incognito" : "Connect"), + action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) } + ), + secondaryButton: .cancel() + ) + case let .groupLinkConfirmConnect(connectionLink, connectionPlan, incognito): + return Alert( + title: Text("Join group?"), + message: Text("You will connect to all group members."), + primaryButton: .default( + Text(incognito ? "Join incognito" : "Join"), + action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) } + ), + secondaryButton: .cancel() + ) + case let .groupLinkConnectingConfirmReconnect(connectionLink, connectionPlan, incognito): + return Alert( + title: Text("Repeat join request?"), + message: Text("You are already joining the group via this link!"), + primaryButton: .destructive( + Text(incognito ? "Join incognito" : "Join"), + action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) } + ), + secondaryButton: .cancel() + ) + case let .groupLinkConnecting(_, groupInfo): + if let groupInfo = groupInfo { + return Alert( + title: Text("Group already exists!"), + message: Text("You are already joining the group \(groupInfo.displayName).") + ) } else { - DispatchQueue.main.async { - dismiss?() + return Alert( + title: Text("Already joining the group!"), + message: Text("You are already joining the group via this link.") + ) + } + } +} + +enum PlanAndConnectActionSheet: Identifiable { + case askCurrentOrIncognitoProfile(connectionLink: String, connectionPlan: ConnectionPlan?, title: LocalizedStringKey) + case askCurrentOrIncognitoProfileDestructive(connectionLink: String, connectionPlan: ConnectionPlan, title: LocalizedStringKey) + case ownGroupLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool?, groupInfo: GroupInfo) + + var id: String { + switch self { + case let .askCurrentOrIncognitoProfile(connectionLink, _, _): return "askCurrentOrIncognitoProfile \(connectionLink)" + case let .askCurrentOrIncognitoProfileDestructive(connectionLink, _, _): return "askCurrentOrIncognitoProfileDestructive \(connectionLink)" + case let .ownGroupLinkConfirmConnect(connectionLink, _, _, _): return "ownGroupLinkConfirmConnect \(connectionLink)" + } + } +} + +func planAndConnectActionSheet(_ sheet: PlanAndConnectActionSheet, dismiss: Bool) -> ActionSheet { + switch sheet { + case let .askCurrentOrIncognitoProfile(connectionLink, connectionPlan, title): + return ActionSheet( + title: Text(title), + buttons: [ + .default(Text("Use current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false) }, + .default(Text("Use new incognito profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true) }, + .cancel() + ] + ) + case let .askCurrentOrIncognitoProfileDestructive(connectionLink, connectionPlan, title): + return ActionSheet( + title: Text(title), + buttons: [ + .destructive(Text("Use current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false) }, + .destructive(Text("Use new incognito profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true) }, + .cancel() + ] + ) + case let .ownGroupLinkConfirmConnect(connectionLink, connectionPlan, incognito, groupInfo): + if let incognito = incognito { + return ActionSheet( + title: Text("Join your group?\nThis is your link for group \(groupInfo.displayName)!"), + buttons: [ + .default(Text("Open group")) { openKnownGroup(groupInfo, dismiss: dismiss, showAlreadyExistsAlert: nil) }, + .destructive(Text(incognito ? "Join incognito" : "Join with current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) }, + .cancel() + ] + ) + } else { + return ActionSheet( + title: Text("Join your group?\nThis is your link for group \(groupInfo.displayName)!"), + buttons: [ + .default(Text("Open group")) { openKnownGroup(groupInfo, dismiss: dismiss, showAlreadyExistsAlert: nil) }, + .destructive(Text("Use current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false) }, + .destructive(Text("Use new incognito profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true) }, + .cancel() + ] + ) + } + } +} + +func planAndConnect( + _ connectionLink: String, + showAlert: @escaping (PlanAndConnectAlert) -> Void, + showActionSheet: @escaping (PlanAndConnectActionSheet) -> Void, + dismiss: Bool, + incognito: Bool? +) { + Task { + do { + let connectionPlan = try await apiConnectPlan(connReq: connectionLink) + switch connectionPlan { + case let .invitationLink(ilp): + switch ilp { + case .ok: + logger.debug("planAndConnect, .invitationLink, .ok, incognito=\(incognito?.description ?? "nil")") + if let incognito = incognito { + connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) + } else { + showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect via one-time link")) + } + case .ownLink: + logger.debug("planAndConnect, .invitationLink, .ownLink, incognito=\(incognito?.description ?? "nil")") + if let incognito = incognito { + showAlert(.ownInvitationLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) + } else { + showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own one-time link!")) + } + case let .connecting(contact_): + logger.debug("planAndConnect, .invitationLink, .connecting, incognito=\(incognito?.description ?? "nil")") + if let contact = contact_ { + openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyConnectingAlert(contact)) } + } else { + showAlert(.invitationLinkConnecting(connectionLink: connectionLink)) + } + case let .known(contact): + logger.debug("planAndConnect, .invitationLink, .known, incognito=\(incognito?.description ?? "nil")") + openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyExistsAlert(contact)) } + } + case let .contactAddress(cap): + switch cap { + case .ok: + logger.debug("planAndConnect, .contactAddress, .ok, incognito=\(incognito?.description ?? "nil")") + if let incognito = incognito { + connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) + } else { + showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect via contact address")) + } + case .ownLink: + logger.debug("planAndConnect, .contactAddress, .ownLink, incognito=\(incognito?.description ?? "nil")") + if let incognito = incognito { + showAlert(.ownContactAddressConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) + } else { + showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own SimpleX address!")) + } + case .connectingConfirmReconnect: + logger.debug("planAndConnect, .contactAddress, .connectingConfirmReconnect, incognito=\(incognito?.description ?? "nil")") + if let incognito = incognito { + showAlert(.contactAddressConnectingConfirmReconnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) + } else { + showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "You have already requested connection!\nRepeat connection request?")) + } + case let .connectingProhibit(contact): + logger.debug("planAndConnect, .contactAddress, .connectingProhibit, incognito=\(incognito?.description ?? "nil")") + openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyConnectingAlert(contact)) } + case let .known(contact): + logger.debug("planAndConnect, .contactAddress, .known, incognito=\(incognito?.description ?? "nil")") + openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyExistsAlert(contact)) } + } + case let .groupLink(glp): + switch glp { + case .ok: + if let incognito = incognito { + showAlert(.groupLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) + } else { + showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Join group")) + } + case let .ownLink(groupInfo): + logger.debug("planAndConnect, .groupLink, .ownLink, incognito=\(incognito?.description ?? "nil")") + showActionSheet(.ownGroupLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito, groupInfo: groupInfo)) + case .connectingConfirmReconnect: + logger.debug("planAndConnect, .groupLink, .connectingConfirmReconnect, incognito=\(incognito?.description ?? "nil")") + if let incognito = incognito { + showAlert(.groupLinkConnectingConfirmReconnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) + } else { + showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "You are already joining the group!\nRepeat join request?")) + } + case let .connectingProhibit(groupInfo_): + logger.debug("planAndConnect, .groupLink, .connectingProhibit, incognito=\(incognito?.description ?? "nil")") + showAlert(.groupLinkConnecting(connectionLink: connectionLink, groupInfo: groupInfo_)) + case let .known(groupInfo): + logger.debug("planAndConnect, .groupLink, .known, incognito=\(incognito?.description ?? "nil")") + openKnownGroup(groupInfo, dismiss: dismiss) { AlertManager.shared.showAlert(groupAlreadyExistsAlert(groupInfo)) } + } + } + } catch { + logger.debug("planAndConnect, plan error") + if let incognito = incognito { + connectViaLink(connectionLink, connectionPlan: nil, dismiss: dismiss, incognito: incognito) + } else { + showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: nil, title: "Connect via link")) } } } } -struct CReqClientData: Decodable { - var type: String - var groupLinkId: String? -} - -func parseLinkQueryData(_ connectionLink: String) -> CReqClientData? { - if let hashIndex = connectionLink.firstIndex(of: "#"), - let urlQuery = URL(string: String(connectionLink[connectionLink.index(after: hashIndex)...])), - let components = URLComponents(url: urlQuery, resolvingAgainstBaseURL: false), - let data = components.queryItems?.first(where: { $0.name == "data" })?.value, - let d = data.data(using: .utf8), - let crData = try? getJSONDecoder().decode(CReqClientData.self, from: d) { - return crData - } else { - return nil +private func connectViaLink(_ connectionLink: String, connectionPlan: ConnectionPlan?, dismiss: Bool, incognito: Bool) { + Task { + if let connReqType = await apiConnect(incognito: incognito, connReq: connectionLink) { + let crt: ConnReqType + if let plan = connectionPlan { + crt = planToConnReqType(plan) + } else { + crt = connReqType + } + DispatchQueue.main.async { + if dismiss { + dismissAllSheets(animated: true) { + AlertManager.shared.showAlert(connReqSentAlert(crt)) + } + } else { + AlertManager.shared.showAlert(connReqSentAlert(crt)) + } + } + } else { + if dismiss { + DispatchQueue.main.async { + dismissAllSheets(animated: true) + } + } + } } } -func checkCRDataGroup(_ crData: CReqClientData) -> Bool { - return crData.type == "group" && crData.groupLinkId != nil +func openKnownContact(_ contact: Contact, dismiss: Bool, showAlreadyExistsAlert: (() -> Void)?) { + Task { + let m = ChatModel.shared + if let c = m.getContactChat(contact.contactId) { + DispatchQueue.main.async { + if dismiss { + dismissAllSheets(animated: true) { + m.chatId = c.id + showAlreadyExistsAlert?() + } + } else { + m.chatId = c.id + showAlreadyExistsAlert?() + } + } + } + } } -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(incognito ? "Connect incognito" : "Connect")) { - connectViaLink(connectionLink, incognito: incognito) - }, - secondaryButton: .cancel() +func openKnownGroup(_ groupInfo: GroupInfo, dismiss: Bool, showAlreadyExistsAlert: (() -> Void)?) { + Task { + let m = ChatModel.shared + if let g = m.getGroupChat(groupInfo.groupId) { + DispatchQueue.main.async { + if dismiss { + dismissAllSheets(animated: true) { + m.chatId = g.id + showAlreadyExistsAlert?() + } + } else { + m.chatId = g.id + showAlreadyExistsAlert?() + } + } + } + } +} + +func contactAlreadyConnectingAlert(_ contact: Contact) -> Alert { + mkAlert( + title: "Contact already exists", + message: "You are already connecting to \(contact.displayName)." ) } +func groupAlreadyExistsAlert(_ groupInfo: GroupInfo) -> Alert { + mkAlert( + title: "Group already exists", + message: "You are already in group \(groupInfo.displayName)." + ) +} + +enum ConnReqType: Equatable { + case invitation + case contact + case groupLink + + var connReqSentText: LocalizedStringKey { + switch self { + case .invitation: return "You will be connected when your contact's device is online, please wait or check later!" + case .contact: return "You will be connected when your connection request is accepted, please wait or check later!" + case .groupLink: return "You will be connected when group link host's device is online, please wait or check later!" + } + } +} + +private func planToConnReqType(_ connectionPlan: ConnectionPlan) -> ConnReqType { + switch connectionPlan { + case .invitationLink: return .invitation + case .contactAddress: return .contact + case .groupLink: return .groupLink + } +} + func connReqSentAlert(_ type: ConnReqType) -> Alert { return mkAlert( title: "Connection request sent!", - message: type == .contact - ? "You will be connected when your connection request is accepted, please wait or check later!" - : "You will be connected when your contact's device is online, please wait or check later!" + message: type.connReqSentText ) } diff --git a/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift b/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift index ec199327ce..7c272fb631 100644 --- a/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift +++ b/apps/ios/Shared/Views/NewChat/PasteToConnectView.swift @@ -14,6 +14,8 @@ struct PasteToConnectView: View { @State private var connectionLink: String = "" @AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false @FocusState private var linkEditorFocused: Bool + @State private var alert: PlanAndConnectAlert? + @State private var sheet: PlanAndConnectActionSheet? var body: some View { List { @@ -52,11 +54,15 @@ struct PasteToConnectView: View { IncognitoToggle(incognitoEnabled: $incognitoDefault) } footer: { - sharedProfileInfo(incognitoDefault) - + Text(String("\n\n")) - + Text("You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button.") + VStack(alignment: .leading, spacing: 4) { + sharedProfileInfo(incognitoDefault) + Text("You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button.") + } + .frame(maxWidth: .infinity, alignment: .leading) } } + .alert(item: $alert) { a in planAndConnectAlert(a, dismiss: true) } + .actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true) } } private func linkEditor() -> some View { @@ -83,13 +89,13 @@ struct PasteToConnectView: View { private func connect() { let link = connectionLink.trimmingCharacters(in: .whitespaces) - if let crData = parseLinkQueryData(link), - checkCRDataGroup(crData) { - dismiss() - AlertManager.shared.showAlert(groupLinkAlert(link, incognito: incognitoDefault)) - } else { - connectViaLink(link, dismiss: dismiss, incognito: incognitoDefault) - } + planAndConnect( + link, + showAlert: { alert = $0 }, + showActionSheet: { sheet = $0 }, + dismiss: true, + incognito: incognitoDefault + ) } } diff --git a/apps/ios/Shared/Views/NewChat/QRCode.swift b/apps/ios/Shared/Views/NewChat/QRCode.swift index 0785826fa9..4bec3f8e66 100644 --- a/apps/ios/Shared/Views/NewChat/QRCode.swift +++ b/apps/ios/Shared/Views/NewChat/QRCode.swift @@ -28,6 +28,22 @@ struct MutableQRCode: View { } } +struct SimpleXLinkQRCode: View { + let uri: String + var withLogo: Bool = true + var tintColor = UIColor(red: 0.023, green: 0.176, blue: 0.337, alpha: 1) + + var body: some View { + QRCode(uri: simplexChatLink(uri), withLogo: withLogo, tintColor: tintColor) + } +} + +func simplexChatLink(_ uri: String) -> String { + uri.starts(with: "simplex:/") + ? uri.replacingOccurrences(of: "simplex:/", with: "https://simplex.chat/") + : uri +} + struct QRCode: View { let uri: String var withLogo: Bool = true diff --git a/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift b/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift index 01c8a4659e..9a11eee92b 100644 --- a/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift +++ b/apps/ios/Shared/Views/NewChat/ScanToConnectView.swift @@ -13,6 +13,8 @@ import CodeScanner struct ScanToConnectView: View { @Environment(\.dismiss) var dismiss: DismissAction @AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false + @State private var alert: PlanAndConnectAlert? + @State private var sheet: PlanAndConnectActionSheet? var body: some View { ScrollView { @@ -36,11 +38,11 @@ struct ScanToConnectView: View { ) .padding(.top) - Group { + VStack(alignment: .leading, spacing: 4) { sharedProfileInfo(incognitoDefault) - + Text(String("\n\n")) - + Text("If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link.") + Text("If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link.") } + .frame(maxWidth: .infinity, alignment: .leading) .font(.footnote) .foregroundColor(.secondary) .padding(.horizontal) @@ -49,18 +51,20 @@ struct ScanToConnectView: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } .background(Color(.systemGroupedBackground)) + .alert(item: $alert) { a in planAndConnectAlert(a, dismiss: true) } + .actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true) } } func processQRCode(_ resp: Result) { switch resp { case let .success(r): - if let crData = parseLinkQueryData(r.string), - checkCRDataGroup(crData) { - dismiss() - AlertManager.shared.showAlert(groupLinkAlert(r.string, incognito: incognitoDefault)) - } else { - Task { connectViaLink(r.string, dismiss: dismiss, incognito: incognitoDefault) } - } + planAndConnect( + r.string, + showAlert: { alert = $0 }, + showActionSheet: { sheet = $0 }, + dismiss: true, + incognito: incognitoDefault + ) case let .failure(e): logger.error("ConnectContactView.processQRCode QR code error: \(e.localizedDescription)") dismiss() diff --git a/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift b/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift index 049c4bee08..935f09cc1b 100644 --- a/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift +++ b/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift @@ -31,7 +31,7 @@ struct CreateSimpleXAddress: View { Spacer() if let userAddress = m.userAddress { - QRCode(uri: userAddress.connReqContact) + SimpleXLinkQRCode(uri: userAddress.connReqContact) .frame(maxHeight: g.size.width) shareQRCodeButton(userAddress) .frame(maxWidth: .infinity) @@ -126,7 +126,7 @@ struct CreateSimpleXAddress: View { private func shareQRCodeButton(_ userAddress: UserContactLink) -> some View { Button { - showShareSheet(items: [userAddress.connReqContact]) + showShareSheet(items: [simplexChatLink(userAddress.connReqContact)]) } label: { Label("Share", systemImage: "square.and.arrow.up") } @@ -194,7 +194,7 @@ struct SendAddressMailView: View { let messageBody = String(format: NSLocalizedString("""

Hi!

Connect to me via SimpleX Chat

- """, comment: "email text"), userAddress.connReqContact) + """, comment: "email text"), simplexChatLink(userAddress.connReqContact)) MailView( isShowing: self.$showMailView, result: $mailViewResult, diff --git a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift index 34b6f147bd..90b83fa4f3 100644 --- a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift +++ b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift @@ -66,6 +66,9 @@ struct PrivacySettings: View { Section { settingsRow("lock.doc") { Toggle("Encrypt local files", isOn: $encryptLocalFiles) + .onChange(of: encryptLocalFiles) { + setEncryptLocalFiles($0) + } } settingsRow("photo") { Toggle("Auto-accept images", isOn: $autoAcceptImages) @@ -90,7 +93,9 @@ struct PrivacySettings: View { } settingsRow("link") { Picker("SimpleX links", selection: $simplexLinkMode) { - ForEach(SimpleXLinkMode.values) { mode in + ForEach( + SimpleXLinkMode.values + (SimpleXLinkMode.values.contains(simplexLinkMode) ? [] : [simplexLinkMode]) + ) { mode in Text(mode.text) } } @@ -101,10 +106,6 @@ struct PrivacySettings: View { } } header: { Text("Chats") - } footer: { - if case .browser = simplexLinkMode { - Text("Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red.") - } } Section { @@ -118,7 +119,7 @@ struct PrivacySettings: View { Text("Send delivery receipts to") } footer: { VStack(alignment: .leading) { - Text("These settings are for your current profile **\(ChatModel.shared.currentUser?.displayName ?? "")**.") + Text("These settings are for your current profile **\(m.currentUser?.displayName ?? "")**.") Text("They can be overridden in contact and group settings.") } .frame(maxWidth: .infinity, alignment: .leading) @@ -183,6 +184,16 @@ struct PrivacySettings: View { } } + private func setEncryptLocalFiles(_ enable: Bool) { + do { + try apiSetEncryptLocalFiles(enable) + } catch let error { + let err = responseError(error) + logger.error("apiSetEncryptLocalFiles \(err)") + alert = .error(title: "Error", error: "\(err)") + } + } + private func setOrAskSendReceiptsContacts(_ enable: Bool) { contactReceiptsOverrides = m.chats.reduce(0) { count, chat in let sendRcpts = chat.chatInfo.contact?.chatSettings.sendRcpts @@ -345,7 +356,7 @@ struct SimplexLockView: View { var id: Self { self } } - let laDelays: [Int] = [10, 30, 60, 180, 0] + let laDelays: [Int] = [10, 30, 60, 180, 600, 0] func laDelayText(_ t: Int) -> LocalizedStringKey { let m = t / 60 @@ -367,6 +378,7 @@ struct SimplexLockView: View { Text(mode.text) } } + .frame(height: 36) if performLA { Picker("Lock after", selection: $laLockDelay) { let delays = laDelays.contains(laLockDelay) ? laDelays : [laLockDelay] + laDelays @@ -374,6 +386,7 @@ struct SimplexLockView: View { Text(laDelayText(t)) } } + .frame(height: 36) if showChangePassword && laMode == .passcode { Button("Change passcode") { changeLAPassword() diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index f076a4eb7d..1cc859f49e 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -93,7 +93,7 @@ enum SimpleXLinkMode: String, Identifiable { case full case browser - static var values: [SimpleXLinkMode] = [.description, .full, .browser] + static var values: [SimpleXLinkMode] = [.description, .full] public var id: Self { self } diff --git a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift index 86bf0048b8..60e5cc7b04 100644 --- a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift @@ -190,7 +190,7 @@ struct UserAddressView: View { @ViewBuilder private func existingAddressView(_ userAddress: UserContactLink) -> some View { Section { - MutableQRCode(uri: Binding.constant(userAddress.connReqContact)) + MutableQRCode(uri: Binding.constant(simplexChatLink(userAddress.connReqContact))) shareQRCodeButton(userAddress) if MFMailComposeViewController.canSendMail() { shareViaEmailButton(userAddress) @@ -248,7 +248,7 @@ struct UserAddressView: View { private func shareQRCodeButton(_ userAddress: UserContactLink) -> some View { Button { - showShareSheet(items: [userAddress.connReqContact]) + showShareSheet(items: [simplexChatLink(userAddress.connReqContact)]) } label: { settingsRow("square.and.arrow.up") { Text("Share address") diff --git a/apps/ios/Shared/Views/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift index b1a362a5a1..b64ec21de6 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfile.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfile.swift @@ -174,11 +174,13 @@ struct UserProfile: View { chatModel.updateCurrentUser(newProfile) profile = newProfile } + editProfile = false + } else { + alert = .duplicateUserError } } catch { logger.error("UserProfile apiUpdateProfile error: \(responseError(error))") } - editProfile = false } } } diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index 645fdb5952..ea52f4be89 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -216,6 +216,7 @@ func startChat() -> DBMigrationResult? { try apiSetTempFolder(tempFolder: getTempFilesDirectory().path) try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path) try setXFTPConfig(xftpConfig) + try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get()) let justStarted = try apiStartChat() chatStarted = true if justStarted { @@ -351,6 +352,12 @@ func setXFTPConfig(_ cfg: XFTPFileConfig?) throws { throw r } +func apiSetEncryptLocalFiles(_ enable: Bool) throws { + let r = sendSimpleXCmd(.apiSetEncryptLocalFiles(enable: enable)) + 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 8b45e45f95..fafb43145c 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -16,7 +16,6 @@ 18415C6C56DBCEC2CBBD2F11 /* WebRTCClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18415323A4082FC92887F906 /* WebRTCClient.swift */; }; 18415F9A2D551F9757DA4654 /* CIVideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18415FD2E36F13F596A45BB4 /* CIVideoView.swift */; }; 18415FEFE153C5920BFB7828 /* GroupWelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1841516F0CE5992B0EDFB377 /* GroupWelcomeView.swift */; }; - 3C71477A281C0F6800CB4D4B /* www in Resources */ = {isa = PBXBuildFile; fileRef = 3C714779281C0F6800CB4D4B /* www */; }; 3C8C548928133C84000A3EC7 /* PasteToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8C548828133C84000A3EC7 /* PasteToConnectView.swift */; }; 3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */; }; 3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4727FF621E00354CDD /* CILinkView.swift */; }; @@ -114,15 +113,15 @@ 5CC1C99527A6CF7F000D9FF6 /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */; }; 5CC2C0FC2809BF11000C35E3 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CC2C0FA2809BF11000C35E3 /* Localizable.strings */; }; 5CC2C0FF2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CC2C0FD2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings */; }; - 5CC7398D2AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC739882AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp-ghc8.10.7.a */; }; - 5CC7398E2AC9D168009470A9 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC739892AC9D168009470A9 /* libgmp.a */; }; - 5CC7398F2AC9D168009470A9 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC7398A2AC9D168009470A9 /* libffi.a */; }; - 5CC739902AC9D168009470A9 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC7398B2AC9D168009470A9 /* libgmpxx.a */; }; - 5CC739912AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC7398C2AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp.a */; }; 5CC868F329EB540C0017BBFD /* CIRcvDecryptionError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC868F229EB540C0017BBFD /* CIRcvDecryptionError.swift */; }; 5CCB939C297EFCB100399E78 /* NavStackCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */; }; 5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403327A5F6DF00368C90 /* AddContactView.swift */; }; 5CCD403727A5F9A200368C90 /* ScanToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */; }; + 5CD089312AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD0892C2AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO-ghc8.10.7.a */; }; + 5CD089322AE59CB300669208 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD0892D2AE59CB300669208 /* libffi.a */; }; + 5CD089332AE59CB300669208 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD0892E2AE59CB300669208 /* libgmpxx.a */; }; + 5CD089342AE59CB300669208 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD0892F2AE59CB300669208 /* libgmp.a */; }; + 5CD089352AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD089302AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO.a */; }; 5CDCAD482818589900503DA2 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD472818589900503DA2 /* NotificationService.swift */; }; 5CE2BA702845308900EC33A6 /* SimpleXChat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; }; 5CE2BA712845308900EC33A6 /* SimpleXChat.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -257,7 +256,6 @@ 18415B08031E8FB0F7FC27F9 /* CallViewRenderers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CallViewRenderers.swift; sourceTree = ""; }; 18415DAAAD1ADBEDB0EDA852 /* VideoPlayerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoPlayerView.swift; sourceTree = ""; }; 18415FD2E36F13F596A45BB4 /* CIVideoView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CIVideoView.swift; sourceTree = ""; }; - 3C714779281C0F6800CB4D4B /* www */ = {isa = PBXFileReference; lastKnownFileType = folder; name = www; path = ../multiplatform/android/src/main/assets/www; sourceTree = ""; }; 3C8C548828133C84000A3EC7 /* PasteToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasteToConnectView.swift; sourceTree = ""; }; 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeLinkView.swift; sourceTree = ""; }; 3CDBCF4727FF621E00354CDD /* CILinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CILinkView.swift; sourceTree = ""; }; @@ -395,15 +393,15 @@ 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheet.swift; sourceTree = ""; }; 5CC2C0FB2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; 5CC2C0FE2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = "ru.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; - 5CC739882AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp-ghc8.10.7.a"; sourceTree = ""; }; - 5CC739892AC9D168009470A9 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5CC7398A2AC9D168009470A9 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5CC7398B2AC9D168009470A9 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 5CC7398C2AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp.a"; sourceTree = ""; }; 5CC868F229EB540C0017BBFD /* CIRcvDecryptionError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIRcvDecryptionError.swift; sourceTree = ""; }; 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavStackCompat.swift; sourceTree = ""; }; 5CCD403327A5F6DF00368C90 /* AddContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactView.swift; sourceTree = ""; }; 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanToConnectView.swift; sourceTree = ""; }; + 5CD0892C2AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO-ghc8.10.7.a"; sourceTree = ""; }; + 5CD0892D2AE59CB300669208 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5CD0892E2AE59CB300669208 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5CD0892F2AE59CB300669208 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5CD089302AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO.a"; sourceTree = ""; }; 5CDCAD452818589900503DA2 /* SimpleX NSE.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "SimpleX NSE.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; 5CDCAD472818589900503DA2 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; 5CDCAD492818589900503DA2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -507,13 +505,13 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5CC739902AC9D168009470A9 /* libgmpxx.a in Frameworks */, + 5CD089352AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, - 5CC7398D2AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp-ghc8.10.7.a in Frameworks */, - 5CC7398E2AC9D168009470A9 /* libgmp.a in Frameworks */, - 5CC739912AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp.a in Frameworks */, - 5CC7398F2AC9D168009470A9 /* libffi.a in Frameworks */, + 5CD089332AE59CB300669208 /* libgmpxx.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, + 5CD089312AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO-ghc8.10.7.a in Frameworks */, + 5CD089342AE59CB300669208 /* libgmp.a in Frameworks */, + 5CD089322AE59CB300669208 /* libffi.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -574,11 +572,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5CC7398A2AC9D168009470A9 /* libffi.a */, - 5CC739892AC9D168009470A9 /* libgmp.a */, - 5CC7398B2AC9D168009470A9 /* libgmpxx.a */, - 5CC739882AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp-ghc8.10.7.a */, - 5CC7398C2AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp.a */, + 5CD0892D2AE59CB300669208 /* libffi.a */, + 5CD0892F2AE59CB300669208 /* libgmp.a */, + 5CD0892E2AE59CB300669208 /* libgmpxx.a */, + 5CD0892C2AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO-ghc8.10.7.a */, + 5CD089302AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO.a */, ); path = Libraries; sourceTree = ""; @@ -638,7 +636,6 @@ isa = PBXGroup; children = ( 5C55A92D283D0FDE00C4E99E /* sounds */, - 3C714779281C0F6800CB4D4B /* www */, 5CC2C0FD2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings */, 5CC2C0FA2809BF11000C35E3 /* Localizable.strings */, 5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */, @@ -1050,7 +1047,6 @@ buildActionMask = 2147483647; files = ( 5C55A92E283D0FDE00C4E99E /* sounds in Resources */, - 3C71477A281C0F6800CB4D4B /* www in Resources */, 5CA059EF279559F40002BEB4 /* Assets.xcassets in Resources */, 5CC2C0FC2809BF11000C35E3 /* Localizable.strings in Resources */, 5CC2C0FF2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings in Resources */, @@ -1486,7 +1482,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 176; + CURRENT_PROJECT_VERSION = 180; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1528,7 +1524,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 176; + CURRENT_PROJECT_VERSION = 180; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -1608,7 +1604,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 176; + CURRENT_PROJECT_VERSION = 180; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1640,7 +1636,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 176; + CURRENT_PROJECT_VERSION = 180; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GENERATE_INFOPLIST_FILE = YES; @@ -1672,7 +1668,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 176; + CURRENT_PROJECT_VERSION = 180; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -1718,7 +1714,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 176; + CURRENT_PROJECT_VERSION = 180; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; diff --git a/apps/ios/SimpleXChat/API.swift b/apps/ios/SimpleXChat/API.swift index fbf9d3b2ca..c7e94a2dc0 100644 --- a/apps/ios/SimpleXChat/API.swift +++ b/apps/ios/SimpleXChat/API.swift @@ -139,8 +139,11 @@ public func chatResponse(_ s: String) -> ChatResponse { var type: String? var json: String? if let j = try? JSONSerialization.jsonObject(with: d) as? NSDictionary { - if let jResp = j["resp"] as? NSDictionary, jResp.count == 1 { + if let jResp = j["resp"] as? NSDictionary, jResp.count == 1 || jResp.count == 2 { type = jResp.allKeys[0] as? String + if jResp.count == 2 && type == "_owsf" { + type = jResp.allKeys[1] as? String + } if type == "apiChats" { if let jApiChats = jResp["apiChats"] as? NSDictionary, let user: UserRef = try? decodeObject(jApiChats["user"] as Any), diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 951f726be9..8c15d94537 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -32,6 +32,7 @@ public enum ChatCommand { case setTempFolder(tempFolder: String) case setFilesFolder(filesFolder: String) case apiSetXFTPConfig(config: XFTPFileConfig?) + case apiSetEncryptLocalFiles(enable: Bool) case apiExportArchive(config: ArchiveConfig) case apiImportArchive(config: ArchiveConfig) case apiDeleteStorage @@ -49,7 +50,7 @@ public enum ChatCommand { case apiVerifyToken(token: DeviceToken, nonce: String, code: String) case apiDeleteToken(token: DeviceToken) case apiGetNtfMessage(nonce: String, encNtfInfo: String) - case apiNewGroup(userId: Int64, groupProfile: GroupProfile) + case apiNewGroup(userId: Int64, incognito: Bool, groupProfile: GroupProfile) case apiAddMember(groupId: Int64, contactId: Int64, memberRole: GroupMemberRole) case apiJoinGroup(groupId: Int64) case apiMemberRole(groupId: Int64, memberId: Int64, memberRole: GroupMemberRole) @@ -72,6 +73,7 @@ public enum ChatCommand { case apiGetNetworkConfig case reconnectAllServers case apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) + case apiSetMemberSettings(groupId: Int64, groupMemberId: Int64, memberSettings: GroupMemberSettings) case apiContactInfo(contactId: Int64) case apiGroupMemberInfo(groupId: Int64, groupMemberId: Int64) case apiSwitchContact(contactId: Int64) @@ -86,8 +88,9 @@ public enum ChatCommand { case apiVerifyGroupMember(groupId: Int64, groupMemberId: Int64, connectionCode: String?) case apiAddContact(userId: Int64, incognito: Bool) case apiSetConnectionIncognito(connId: Int64, incognito: Bool) + case apiConnectPlan(userId: Int64, connReq: String) case apiConnect(userId: Int64, incognito: Bool, connReq: String) - case apiDeleteChat(type: ChatType, id: Int64) + case apiDeleteChat(type: ChatType, id: Int64, notify: Bool?) case apiClearChat(type: ChatType, id: Int64) case apiListContacts(userId: Int64) case apiUpdateProfile(userId: Int64, profile: Profile) @@ -110,10 +113,11 @@ public enum ChatCommand { case apiEndCall(contact: Contact) case apiGetCallInvitations case apiCallStatus(contact: Contact, callStatus: WebRTCCallStatus) + case apiGetNetworkStatuses case apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) case apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) - case receiveFile(fileId: Int64, encrypted: Bool, inline: Bool?) - case setFileToReceive(fileId: Int64, encrypted: Bool) + case receiveFile(fileId: Int64, encrypted: Bool?, inline: Bool?) + case setFileToReceive(fileId: Int64, encrypted: Bool?) case cancelFile(fileId: Int64) case showVersion case string(String) @@ -150,6 +154,7 @@ public enum ChatCommand { } else { return "/_xftp off" } + case let .apiSetEncryptLocalFiles(enable): return "/_files_encrypt \(onOff(enable))" case let .apiExportArchive(cfg): return "/_db export \(encodeJSON(cfg))" case let .apiImportArchive(cfg): return "/_db import \(encodeJSON(cfg))" case .apiDeleteStorage: return "/_db delete" @@ -171,7 +176,7 @@ public enum ChatCommand { case let .apiVerifyToken(token, nonce, code): return "/_ntf verify \(token.cmdString) \(nonce) \(code)" case let .apiDeleteToken(token): return "/_ntf delete \(token.cmdString)" case let .apiGetNtfMessage(nonce, encNtfInfo): return "/_ntf message \(nonce) \(encNtfInfo)" - case let .apiNewGroup(userId, groupProfile): return "/_group \(userId) \(encodeJSON(groupProfile))" + case let .apiNewGroup(userId, incognito, groupProfile): return "/_group \(userId) incognito=\(onOff(incognito)) \(encodeJSON(groupProfile))" case let .apiAddMember(groupId, contactId, memberRole): return "/_add #\(groupId) \(contactId) \(memberRole)" case let .apiJoinGroup(groupId): return "/_join #\(groupId)" case let .apiMemberRole(groupId, memberId, memberRole): return "/_member role #\(groupId) \(memberId) \(memberRole.rawValue)" @@ -194,6 +199,7 @@ public enum ChatCommand { case .apiGetNetworkConfig: return "/network" case .reconnectAllServers: return "/reconnect" case let .apiSetChatSettings(type, id, chatSettings): return "/_settings \(ref(type, id)) \(encodeJSON(chatSettings))" + case let .apiSetMemberSettings(groupId, groupMemberId, memberSettings): return "/_member settings #\(groupId) \(groupMemberId) \(encodeJSON(memberSettings))" case let .apiContactInfo(contactId): return "/_info @\(contactId)" case let .apiGroupMemberInfo(groupId, groupMemberId): return "/_info #\(groupId) \(groupMemberId)" case let .apiSwitchContact(contactId): return "/_switch @\(contactId)" @@ -218,8 +224,13 @@ public enum ChatCommand { case let .apiVerifyGroupMember(groupId, groupMemberId, .none): return "/_verify code #\(groupId) \(groupMemberId)" case let .apiAddContact(userId, incognito): return "/_connect \(userId) incognito=\(onOff(incognito))" case let .apiSetConnectionIncognito(connId, incognito): return "/_set incognito :\(connId) \(onOff(incognito))" + case let .apiConnectPlan(userId, connReq): return "/_connect plan \(userId) \(connReq)" 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 .apiDeleteChat(type, id, notify): if let notify = notify { + return "/_delete \(ref(type, id)) notify=\(onOff(notify))" + } else { + return "/_delete \(ref(type, id))" + } case let .apiClearChat(type, id): return "/_clear chat \(ref(type, id))" case let .apiListContacts(userId): return "/_contacts \(userId)" case let .apiUpdateProfile(userId, profile): return "/_profile \(userId) \(encodeJSON(profile))" @@ -241,15 +252,11 @@ public enum ChatCommand { case let .apiEndCall(contact): return "/_call end @\(contact.apiId)" case .apiGetCallInvitations: return "/_call get" case let .apiCallStatus(contact, callStatus): return "/_call status @\(contact.apiId) \(callStatus.rawValue)" + case .apiGetNetworkStatuses: return "/_network_statuses" case let .apiChatRead(type, id, itemRange: (from, to)): return "/_read chat \(ref(type, id)) from=\(from) to=\(to)" case let .apiChatUnread(type, id, unreadChat): return "/_unread chat \(ref(type, id)) \(onOff(unreadChat))" - case let .receiveFile(fileId, encrypted, inline): - let s = "/freceive \(fileId) encrypt=\(onOff(encrypted))" - if let inline = inline { - return s + " inline=\(onOff(inline))" - } - return s - case let .setFileToReceive(fileId, encrypted): return "/_set_file_to_receive \(fileId) encrypt=\(onOff(encrypted))" + case let .receiveFile(fileId, encrypt, inline): return "/freceive \(fileId)\(onOffParam("encrypt", encrypt))\(onOffParam("inline", inline))" + case let .setFileToReceive(fileId, encrypt): return "/_set_file_to_receive \(fileId)\(onOffParam("encrypt", encrypt))" case let .cancelFile(fileId): return "/fcancel \(fileId)" case .showVersion: return "/version" case let .string(str): return str @@ -279,6 +286,7 @@ public enum ChatCommand { case .setTempFolder: return "setTempFolder" case .setFilesFolder: return "setFilesFolder" case .apiSetXFTPConfig: return "apiSetXFTPConfig" + case .apiSetEncryptLocalFiles: return "apiSetEncryptLocalFiles" case .apiExportArchive: return "apiExportArchive" case .apiImportArchive: return "apiImportArchive" case .apiDeleteStorage: return "apiDeleteStorage" @@ -319,6 +327,7 @@ public enum ChatCommand { case .apiGetNetworkConfig: return "apiGetNetworkConfig" case .reconnectAllServers: return "reconnectAllServers" case .apiSetChatSettings: return "apiSetChatSettings" + case .apiSetMemberSettings: return "apiSetMemberSettings" case .apiContactInfo: return "apiContactInfo" case .apiGroupMemberInfo: return "apiGroupMemberInfo" case .apiSwitchContact: return "apiSwitchContact" @@ -333,6 +342,7 @@ public enum ChatCommand { case .apiVerifyGroupMember: return "apiVerifyGroupMember" case .apiAddContact: return "apiAddContact" case .apiSetConnectionIncognito: return "apiSetConnectionIncognito" + case .apiConnectPlan: return "apiConnectPlan" case .apiConnect: return "apiConnect" case .apiDeleteChat: return "apiDeleteChat" case .apiClearChat: return "apiClearChat" @@ -356,6 +366,7 @@ public enum ChatCommand { case .apiEndCall: return "apiEndCall" case .apiGetCallInvitations: return "apiGetCallInvitations" case .apiCallStatus: return "apiCallStatus" + case .apiGetNetworkStatuses: return "apiGetNetworkStatuses" case .apiChatRead: return "apiChatRead" case .apiChatUnread: return "apiChatUnread" case .receiveFile: return "receiveFile" @@ -414,6 +425,13 @@ public enum ChatCommand { b ? "on" : "off" } + private func onOffParam(_ param: String, _ b: Bool?) -> String { + if let b = b { + return " \(param)=\(onOff(b))" + } + return "" + } + private func maybePwd(_ pwd: String?) -> String { pwd == "" || pwd == nil ? "" : " " + encodeJSON(pwd) } @@ -457,6 +475,7 @@ public enum ChatResponse: Decodable, Error { case connectionVerified(user: UserRef, verified: Bool, expectedCode: String) case invitation(user: UserRef, connReqInvitation: String, connection: PendingContactConnection) case connectionIncognitoUpdated(user: UserRef, toConnection: PendingContactConnection) + case connectionPlan(user: UserRef, connectionPlan: ConnectionPlan) case sentConfirmation(user: UserRef) case sentInvitation(user: UserRef) case contactAlreadyExists(user: UserRef, contact: Contact) @@ -480,11 +499,15 @@ public enum ChatResponse: Decodable, Error { case acceptingContactRequest(user: UserRef, contact: Contact) case contactRequestRejected(user: UserRef) case contactUpdated(user: UserRef, toContact: Contact) + case groupMemberUpdated(user: UserRef, groupInfo: GroupInfo, fromMember: GroupMember, toMember: GroupMember) + // TODO remove events below case contactsSubscribed(server: String, contactRefs: [ContactRef]) case contactsDisconnected(server: String, contactRefs: [ContactRef]) - case contactSubError(user: UserRef, contact: Contact, chatError: ChatError) case contactSubSummary(user: UserRef, contactSubscriptions: [ContactSubStatus]) - case groupSubscribed(user: UserRef, groupInfo: GroupInfo) + // TODO remove events above + case networkStatus(networkStatus: NetworkStatus, connections: [String]) + case networkStatuses(user_: UserRef?, networkStatuses: [ConnNetworkStatus]) + case groupSubscribed(user: UserRef, groupInfo: GroupRef) case memberSubErrors(user: UserRef, memberSubErrors: [MemberSubError]) case groupEmpty(user: UserRef, groupInfo: GroupInfo) case userContactLinkSubscribed @@ -499,6 +522,7 @@ public enum ChatResponse: Decodable, Error { case groupCreated(user: UserRef, groupInfo: GroupInfo) case sentGroupInvitation(user: UserRef, groupInfo: GroupInfo, contact: Contact, member: GroupMember) case userAcceptedGroupSent(user: UserRef, groupInfo: GroupInfo, hostContact: Contact?) + case groupLinkConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember) case userDeletedMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember) case leftMemberUser(user: UserRef, groupInfo: GroupInfo) case groupMembers(user: UserRef, group: Group) @@ -595,6 +619,7 @@ public enum ChatResponse: Decodable, Error { case .connectionVerified: return "connectionVerified" case .invitation: return "invitation" case .connectionIncognitoUpdated: return "connectionIncognitoUpdated" + case .connectionPlan: return "connectionPlan" case .sentConfirmation: return "sentConfirmation" case .sentInvitation: return "sentInvitation" case .contactAlreadyExists: return "contactAlreadyExists" @@ -618,10 +643,12 @@ public enum ChatResponse: Decodable, Error { case .acceptingContactRequest: return "acceptingContactRequest" case .contactRequestRejected: return "contactRequestRejected" case .contactUpdated: return "contactUpdated" + case .groupMemberUpdated: return "groupMemberUpdated" case .contactsSubscribed: return "contactsSubscribed" case .contactsDisconnected: return "contactsDisconnected" - case .contactSubError: return "contactSubError" case .contactSubSummary: return "contactSubSummary" + case .networkStatus: return "networkStatus" + case .networkStatuses: return "networkStatuses" case .groupSubscribed: return "groupSubscribed" case .memberSubErrors: return "memberSubErrors" case .groupEmpty: return "groupEmpty" @@ -636,6 +663,7 @@ public enum ChatResponse: Decodable, Error { case .groupCreated: return "groupCreated" case .sentGroupInvitation: return "sentGroupInvitation" case .userAcceptedGroupSent: return "userAcceptedGroupSent" + case .groupLinkConnecting: return "groupLinkConnecting" case .userDeletedMember: return "userDeletedMember" case .leftMemberUser: return "leftMemberUser" case .groupMembers: return "groupMembers" @@ -732,6 +760,7 @@ public enum ChatResponse: Decodable, Error { case let .connectionVerified(u, verified, expectedCode): return withUser(u, "verified: \(verified)\nconnectionCode: \(expectedCode)") case let .invitation(u, connReqInvitation, _): return withUser(u, connReqInvitation) case let .connectionIncognitoUpdated(u, toConnection): return withUser(u, String(describing: toConnection)) + case let .connectionPlan(u, connectionPlan): return withUser(u, String(describing: connectionPlan)) case .sentConfirmation: return noDetails case .sentInvitation: return noDetails case let .contactAlreadyExists(u, contact): return withUser(u, String(describing: contact)) @@ -755,10 +784,12 @@ public enum ChatResponse: Decodable, Error { case let .acceptingContactRequest(u, contact): return withUser(u, String(describing: contact)) case .contactRequestRejected: return noDetails case let .contactUpdated(u, toContact): return withUser(u, String(describing: toContact)) + case let .groupMemberUpdated(u, groupInfo, fromMember, toMember): return withUser(u, "groupInfo: \(groupInfo)\nfromMember: \(fromMember)\ntoMember: \(toMember)") case let .contactsSubscribed(server, contactRefs): return "server: \(server)\ncontacts:\n\(String(describing: contactRefs))" case let .contactsDisconnected(server, contactRefs): return "server: \(server)\ncontacts:\n\(String(describing: contactRefs))" - case let .contactSubError(u, contact, chatError): return withUser(u, "contact:\n\(String(describing: contact))\nerror:\n\(String(describing: chatError))") case let .contactSubSummary(u, contactSubscriptions): return withUser(u, String(describing: contactSubscriptions)) + case let .networkStatus(status, conns): return "networkStatus: \(String(describing: status))\nconnections: \(String(describing: conns))" + case let .networkStatuses(u, statuses): return withUser(u, String(describing: statuses)) case let .groupSubscribed(u, groupInfo): return withUser(u, String(describing: groupInfo)) case let .memberSubErrors(u, memberSubErrors): return withUser(u, String(describing: memberSubErrors)) case let .groupEmpty(u, groupInfo): return withUser(u, String(describing: groupInfo)) @@ -773,6 +804,7 @@ public enum ChatResponse: Decodable, Error { case let .groupCreated(u, groupInfo): return withUser(u, String(describing: groupInfo)) case let .sentGroupInvitation(u, groupInfo, contact, member): return withUser(u, "groupInfo: \(groupInfo)\ncontact: \(contact)\nmember: \(member)") case let .userAcceptedGroupSent(u, groupInfo, hostContact): return withUser(u, "groupInfo: \(groupInfo)\nhostContact: \(String(describing: hostContact))") + case let .groupLinkConnecting(u, groupInfo, hostMember): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(String(describing: hostMember))") case let .userDeletedMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") case let .leftMemberUser(u, groupInfo): return withUser(u, String(describing: groupInfo)) case let .groupMembers(u, group): return withUser(u, String(describing: group)) @@ -851,6 +883,35 @@ public func chatError(_ chatResponse: ChatResponse) -> ChatErrorType? { } } +public enum ConnectionPlan: Decodable { + case invitationLink(invitationLinkPlan: InvitationLinkPlan) + case contactAddress(contactAddressPlan: ContactAddressPlan) + case groupLink(groupLinkPlan: GroupLinkPlan) +} + +public enum InvitationLinkPlan: Decodable { + case ok + case ownLink + case connecting(contact_: Contact?) + case known(contact: Contact) +} + +public enum ContactAddressPlan: Decodable { + case ok + case ownLink + case connectingConfirmReconnect + case connectingProhibit(contact: Contact) + case known(contact: Contact) +} + +public enum GroupLinkPlan: Decodable { + case ok + case ownLink(groupInfo: GroupInfo) + case connectingConfirmReconnect + case connectingProhibit(groupInfo_: GroupInfo?) + case known(groupInfo: GroupInfo) +} + struct NewUser: Encodable { var profile: Profile? var sameServers: Bool @@ -1181,18 +1242,67 @@ public struct KeepAliveOpts: Codable, Equatable { public static let defaults: KeepAliveOpts = KeepAliveOpts(keepIdle: 30, keepIntvl: 15, keepCnt: 4) } +public enum NetworkStatus: Decodable, Equatable { + case unknown + case connected + case disconnected + case error(connectionError: String) + + public var statusString: LocalizedStringKey { + get { + switch self { + case .connected: return "connected" + case .error: return "error" + default: return "connecting" + } + } + } + + public var statusExplanation: LocalizedStringKey { + get { + switch self { + case .connected: return "You are connected to the server used to receive messages from this contact." + case let .error(err): return "Trying to connect to the server used to receive messages from this contact (error: \(err))." + default: return "Trying to connect to the server used to receive messages from this contact." + } + } + } + + public var imageName: String { + get { + switch self { + case .unknown: return "circle.dotted" + case .connected: return "circle.fill" + case .disconnected: return "ellipsis.circle.fill" + case .error: return "exclamationmark.circle.fill" + } + } + } +} + +public struct ConnNetworkStatus: Decodable { + public var agentConnId: String + public var networkStatus: NetworkStatus +} + public struct ChatSettings: Codable { - public var enableNtfs: Bool + public var enableNtfs: MsgFilter public var sendRcpts: Bool? public var favorite: Bool - public init(enableNtfs: Bool, sendRcpts: Bool?, favorite: Bool) { + public init(enableNtfs: MsgFilter, sendRcpts: Bool?, favorite: Bool) { self.enableNtfs = enableNtfs self.sendRcpts = sendRcpts self.favorite = favorite } - public static let defaults: ChatSettings = ChatSettings(enableNtfs: true, sendRcpts: nil, favorite: false) + public static let defaults: ChatSettings = ChatSettings(enableNtfs: .all, sendRcpts: nil, favorite: false) +} + +public enum MsgFilter: String, Codable { + case none + case all + case mentions } public struct UserMsgReceiptSettings: Codable { @@ -1420,6 +1530,7 @@ public enum ChatErrorType: Decodable { case chatNotStarted case chatNotStopped case chatStoreChanged + case connectionPlan(connectionPlan: ConnectionPlan) case invalidConnReq case invalidChatMessage(connection: Connection, message: String) case contactNotReady(contact: Contact) diff --git a/apps/ios/SimpleXChat/AppGroup.swift b/apps/ios/SimpleXChat/AppGroup.swift index e09b957171..cc61fae53f 100644 --- a/apps/ios/SimpleXChat/AppGroup.swift +++ b/apps/ios/SimpleXChat/AppGroup.swift @@ -80,6 +80,14 @@ public enum AppState: String { default: return false } } + + public var canSuspend: Bool { + switch self { + case .active: return true + case .bgRefresh: return true + default: return false + } + } } public enum DBContainer: String { diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index f9996d8400..25511e1bae 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -422,8 +422,8 @@ public enum CustomTimeUnit { public func timeText(_ seconds: Int?) -> String { - guard let seconds = seconds else { return "off" } - if seconds == 0 { return "0 sec" } + guard let seconds = seconds else { return NSLocalizedString("off", comment: "time to disappear") } + if seconds == 0 { return NSLocalizedString("0 sec", comment: "time to disappear") } return CustomTimeUnit.toText(seconds: seconds) } @@ -1292,7 +1292,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat { } public var ntfsEnabled: Bool { - self.chatSettings?.enableNtfs ?? false + self.chatSettings?.enableNtfs == .all } public var chatSettings: ChatSettings? { @@ -1729,6 +1729,11 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat { ) } +public struct GroupRef: Decodable { + public var groupId: Int64 + var localDisplayName: GroupName +} + public struct GroupProfile: Codable, NamedChat { public init(displayName: String, fullName: String, description: String? = nil, image: String? = nil, groupPreferences: GroupPreferences? = nil) { self.displayName = displayName @@ -1758,6 +1763,7 @@ public struct GroupMember: Identifiable, Decodable { public var memberRole: GroupMemberRole public var memberCategory: GroupMemberCategory public var memberStatus: GroupMemberStatus + public var memberSettings: GroupMemberSettings public var invitedBy: InvitedBy public var localDisplayName: ContactName public var memberProfile: LocalProfile @@ -1851,6 +1857,7 @@ public struct GroupMember: Identifiable, Decodable { memberRole: .admin, memberCategory: .inviteeMember, memberStatus: .memComplete, + memberSettings: GroupMemberSettings(showMessages: true), invitedBy: .user, localDisplayName: "alice", memberProfile: LocalProfile.sampleData, @@ -1860,11 +1867,20 @@ public struct GroupMember: Identifiable, Decodable { ) } +public struct GroupMemberSettings: Codable { + public var showMessages: Bool +} + public struct GroupMemberRef: Decodable { var groupMemberId: Int64 var profile: Profile } +public struct GroupMemberIds: Decodable { + var groupMemberId: Int64 + var groupId: Int64 +} + public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Decodable { case observer = "observer" case member = "member" @@ -1957,7 +1973,7 @@ public enum InvitedBy: Decodable { } public struct MemberSubError: Decodable { - var member: GroupMember + var member: GroupMemberIds var memberError: ChatError } @@ -1983,8 +1999,8 @@ public enum ConnectionEntity: Decodable { public var ntfsEnabled: Bool { switch self { - case let .rcvDirectMsgConnection(contact): return contact?.chatSettings.enableNtfs ?? false - case let .rcvGroupMsgConnection(groupInfo, _): return groupInfo.chatSettings.enableNtfs + case let .rcvDirectMsgConnection(contact): return contact?.chatSettings.enableNtfs == .all + case let .rcvGroupMsgConnection(groupInfo, _): return groupInfo.chatSettings.enableNtfs == .all case .sndFileConnection: return false case .rcvFileConnection: return false case let .userContactConnection(userContact): return userContact.groupId == nil @@ -2074,7 +2090,7 @@ public struct ChatItem: Identifiable, Decodable { public var memberConnected: GroupMember? { switch chatDir { - case .groupRcv(let groupMember): + case let .groupRcv(groupMember): switch content { case .rcvGroupEvent(rcvGroupEvent: .memberConnected): return groupMember default: return nil @@ -2083,6 +2099,35 @@ public struct ChatItem: Identifiable, Decodable { } } + public var mergeCategory: CIMergeCategory? { + switch content { + case .rcvChatFeature: .chatFeature + case .sndChatFeature: .chatFeature + case .rcvGroupFeature: .chatFeature + case .sndGroupFeature: .chatFeature + case let.rcvGroupEvent(event): + switch event { + case .userRole: nil + case .userDeleted: nil + case .groupDeleted: nil + case .memberCreatedContact: nil + default: .rcvGroupEvent + } + case let .sndGroupEvent(event): + switch event { + case .userRole: nil + case .userLeft: nil + default: .sndGroupEvent + } + default: + if meta.itemDeleted == nil { + nil + } else { + chatDir.sent ? .sndItemDeleted : .rcvItemDeleted + } + } + } + private var showNtfDir: Bool { return !chatDir.sent } @@ -2160,7 +2205,7 @@ public struct ChatItem: Identifiable, Decodable { public var memberDisplayName: String? { get { if case let .groupRcv(groupMember) = chatDir { - return groupMember.displayName + return groupMember.chatViewName } else { return nil } @@ -2314,6 +2359,15 @@ public struct ChatItem: Identifiable, Decodable { } } +public enum CIMergeCategory { + case memberConnected + case rcvGroupEvent + case sndGroupEvent + case sndItemDeleted + case rcvItemDeleted + case chatFeature +} + public enum CIDirection: Decodable { case directSnd case directRcv @@ -2492,11 +2546,13 @@ public enum SndCIStatusProgress: String, Decodable { public enum CIDeleted: Decodable { case deleted(deletedTs: Date?) + case blocked(deletedTs: Date?) case moderated(deletedTs: Date?, byGroupMember: GroupMember) var id: String { switch self { case .deleted: return "deleted" + case .blocked: return "blocked" case .moderated: return "moderated" } } @@ -2514,8 +2570,8 @@ protocol ItemContent { public enum CIContent: Decodable, ItemContent { case sndMsgContent(msgContent: MsgContent) case rcvMsgContent(msgContent: MsgContent) - case sndDeleted(deleteMode: CIDeleteMode) - case rcvDeleted(deleteMode: CIDeleteMode) + case sndDeleted(deleteMode: CIDeleteMode) // legacy - since v4.3.0 itemDeleted field is used + case rcvDeleted(deleteMode: CIDeleteMode) // legacy - since v4.3.0 itemDeleted field is used case sndCall(status: CICallStatus, duration: Int) case rcvCall(status: CICallStatus, duration: Int) case rcvIntegrityError(msgError: MsgErrorType) @@ -3052,7 +3108,7 @@ public enum Format: Decodable, Equatable { case secret case colored(color: FormatColor) case uri - case simplexLink(linkType: SimplexLinkType, simplexUri: String, trustedUri: Bool, smpHosts: [String]) + case simplexLink(linkType: SimplexLinkType, simplexUri: String, smpHosts: [String]) case email case phone } diff --git a/apps/multiplatform/android/build.gradle.kts b/apps/multiplatform/android/build.gradle.kts index 67a8fea87b..a35d3f5195 100644 --- a/apps/multiplatform/android/build.gradle.kts +++ b/apps/multiplatform/android/build.gradle.kts @@ -8,7 +8,7 @@ plugins { } android { - compileSdkVersion(33) + compileSdkVersion(34) defaultConfig { applicationId = "chat.simplex.app" @@ -77,6 +77,7 @@ android { } jniLibs.useLegacyPackaging = rootProject.extra["compression.level"] as Int != 0 } + android.sourceSets["main"].assets.setSrcDirs(listOf("../common/src/commonMain/resources/assets")) val isRelease = gradle.startParameter.taskNames.find { it.toLowerCase().contains("release") } != null val isBundle = gradle.startParameter.taskNames.find { it.toLowerCase().contains("bundle") } != null // if (isRelease) { @@ -143,7 +144,7 @@ dependencies { androidTestImplementation("androidx.test.ext:junit:1.1.3") androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0") //androidTestImplementation("androidx.compose.ui:ui-test-junit4:$compose_version") - debugImplementation("androidx.compose.ui:ui-tooling:${rootProject.extra["compose.version"] as String}") + debugImplementation("androidx.compose.ui:ui-tooling:1.4.3") } tasks { diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt index 512e9efc10..19305c2e51 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt @@ -91,11 +91,11 @@ class MainActivity: FragmentActivity() { // When pressed Back and there is no one wants to process the back event, clear auth state to force re-auth on launch AppLock.clearAuthState() AppLock.laFailed.value = true - AppLock.destroyedAfterBackPress.value = true } if (!onBackPressedDispatcher.hasEnabledCallbacks()) { // Drop shared content SimplexApp.context.chatModel.sharedContent.value = null + finish() } } } @@ -143,6 +143,7 @@ fun processExternalIntent(intent: Intent?) { val text = intent.getStringExtra(Intent.EXTRA_TEXT) val uri = intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri if (uri != null) { + if (uri.scheme != "content") return showNonContentUriAlert() // Shared file that contains plain text, like `*.log` file chatModel.sharedContent.value = SharedContent.File(text ?: "", uri.toURI()) } else if (text != null) { @@ -153,12 +154,14 @@ fun processExternalIntent(intent: Intent?) { isMediaIntent(intent) -> { val uri = intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri if (uri != null) { + if (uri.scheme != "content") return showNonContentUriAlert() chatModel.sharedContent.value = SharedContent.Media(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", listOf(uri.toURI())) } // All other mime types } else -> { val uri = intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri if (uri != null) { + if (uri.scheme != "content") return showNonContentUriAlert() chatModel.sharedContent.value = SharedContent.File(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", uri.toURI()) } } @@ -173,6 +176,7 @@ fun processExternalIntent(intent: Intent?) { isMediaIntent(intent) -> { val uris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM) as? List if (uris != null) { + if (uris.any { it.scheme != "content" }) return showNonContentUriAlert() chatModel.sharedContent.value = SharedContent.Media(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", uris.map { it.toURI() }) } // All other mime types } @@ -185,6 +189,13 @@ fun processExternalIntent(intent: Intent?) { fun isMediaIntent(intent: Intent): Boolean = intent.type?.startsWith("image/") == true || intent.type?.startsWith("video/") == true +private fun showNonContentUriAlert() { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.non_content_uri_alert_title), + text = generalGetString(MR.strings.non_content_uri_alert_text) + ) +} + //fun testJson() { // val str: String = """ // """.trimIndent() diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt index f70032788b..d2c4465172 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt @@ -9,12 +9,14 @@ import chat.simplex.common.helpers.APPLICATION_ID import chat.simplex.common.helpers.requiresIgnoringBattery import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.model.ChatModel.updatingChatsMutex import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.OnboardingStage import chat.simplex.common.platform.* import chat.simplex.common.views.call.RcvCallInvitation import com.jakewharton.processphoenix.ProcessPhoenix import kotlinx.coroutines.* +import kotlinx.coroutines.sync.withLock import java.io.* import java.util.* import java.util.concurrent.TimeUnit @@ -52,21 +54,23 @@ class SimplexApp: Application(), LifecycleEventObserver { Lifecycle.Event.ON_START -> { isAppOnForeground = true if (chatModel.chatRunning.value == true) { - kotlin.runCatching { - val currentUserId = chatModel.currentUser.value?.userId - val chats = ArrayList(chatController.apiGetChats()) - /** Active user can be changed in background while [ChatController.apiGetChats] is executing */ - if (chatModel.currentUser.value?.userId == currentUserId) { - val currentChatId = chatModel.chatId.value - val oldStats = if (currentChatId != null) chatModel.getChat(currentChatId)?.chatStats else null - if (oldStats != null) { - val indexOfCurrentChat = chats.indexOfFirst { it.id == currentChatId } - /** Pass old chatStats because unreadCounter can be changed already while [ChatController.apiGetChats] is executing */ - if (indexOfCurrentChat >= 0) chats[indexOfCurrentChat] = chats[indexOfCurrentChat].copy(chatStats = oldStats) + updatingChatsMutex.withLock { + kotlin.runCatching { + val currentUserId = chatModel.currentUser.value?.userId + val chats = ArrayList(chatController.apiGetChats()) + /** Active user can be changed in background while [ChatController.apiGetChats] is executing */ + if (chatModel.currentUser.value?.userId == currentUserId) { + val currentChatId = chatModel.chatId.value + val oldStats = if (currentChatId != null) chatModel.getChat(currentChatId)?.chatStats else null + if (oldStats != null) { + val indexOfCurrentChat = chats.indexOfFirst { it.id == currentChatId } + /** Pass old chatStats because unreadCounter can be changed already while [ChatController.apiGetChats] is executing */ + if (indexOfCurrentChat >= 0) chats[indexOfCurrentChat] = chats[indexOfCurrentChat].copy(chatStats = oldStats) + } + chatModel.updateChats(chats) } - chatModel.updateChats(chats) - } - }.onFailure { Log.e(TAG, it.stackTraceToString()) } + }.onFailure { Log.e(TAG, it.stackTraceToString()) } + } } } Lifecycle.Event.ON_RESUME -> { diff --git a/apps/multiplatform/build.gradle.kts b/apps/multiplatform/build.gradle.kts index 3a6fbcbf94..9d6fd0c20b 100644 --- a/apps/multiplatform/build.gradle.kts +++ b/apps/multiplatform/build.gradle.kts @@ -36,7 +36,7 @@ buildscript { extra.set("desktop.mac.signing.keychain", prop["desktop.mac.signing.keychain"] ?: extra.getOrNull("compose.desktop.mac.signing.keychain")) extra.set("desktop.mac.notarization.apple_id", prop["desktop.mac.notarization.apple_id"] ?: extra.getOrNull("compose.desktop.mac.notarization.appleID")) extra.set("desktop.mac.notarization.password", prop["desktop.mac.notarization.password"] ?: extra.getOrNull("compose.desktop.mac.notarization.password")) - extra.set("desktop.mac.notarization.team_id", prop["desktop.mac.notarization.team_id"] ?: extra.getOrNull("compose.desktop.mac.notarization.ascProvider")) + extra.set("desktop.mac.notarization.team_id", prop["desktop.mac.notarization.team_id"] ?: extra.getOrNull("compose.desktop.mac.notarization.teamID")) repositories { google() diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index 9fb40c93d8..55e03f6209 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -98,6 +98,8 @@ kotlin { implementation("com.sshtools:two-slices:0.9.0-SNAPSHOT") implementation("org.slf4j:slf4j-simple:2.0.7") implementation("uk.co.caprica:vlcj:4.7.3") + implementation("com.github.NanoHttpd.nanohttpd:nanohttpd:efb2ebf85a") + implementation("com.github.NanoHttpd.nanohttpd:nanohttpd-websocket:efb2ebf85a") } } val desktopTest by getting @@ -105,7 +107,7 @@ kotlin { } android { - compileSdkVersion(33) + compileSdkVersion(34) sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") defaultConfig { minSdkVersion(26) @@ -136,6 +138,7 @@ buildConfig { buildConfigField("String", "ANDROID_VERSION_NAME", "\"${extra["android.version_name"]}\"") buildConfigField("int", "ANDROID_VERSION_CODE", "${extra["android.version_code"]}") buildConfigField("String", "DESKTOP_VERSION_NAME", "\"${extra["desktop.version_name"]}\"") + buildConfigField("int", "DESKTOP_VERSION_CODE", "${extra["desktop.version_code"]}") } } diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Modifier.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Modifier.android.kt index 41349654be..115027c1a0 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Modifier.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Modifier.android.kt @@ -23,3 +23,5 @@ actual fun Modifier.desktopOnExternalDrag( onImage: (Painter) -> Unit, onText: (String) -> Unit ): Modifier = this + +actual fun Modifier.onRightClick(action: () -> Unit): Modifier = this diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt index c458561f96..ca497cbc5b 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/UI.android.kt @@ -69,3 +69,5 @@ actual fun hideKeyboard(view: Any?) { (androidAppContext.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow(view.windowToken, 0) } } + +actual fun androidIsFinishingMainActivity(): Boolean = (mainActivity.get()?.isFinishing == true) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt index 260182b5a4..790345e97e 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -43,6 +44,9 @@ import chat.simplex.res.MR import com.google.accompanist.permissions.rememberMultiplePermissionsState import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.datetime.Clock import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString @@ -52,7 +56,7 @@ actual fun ActiveCallView() { val chatModel = ChatModel BackHandler(onBack = { val call = chatModel.activeCall.value - if (call != null) withApi { chatModel.callManager.endCall(call) } + if (call != null) withBGApi { chatModel.callManager.endCall(call) } }) val audioViaBluetooth = rememberSaveable { mutableStateOf(false) } val ntfModeService = remember { chatModel.controller.appPrefs.notificationsMode.get() == NotificationsMode.SERVICE } @@ -112,30 +116,30 @@ actual fun ActiveCallView() { if (call != null) { Log.d(TAG, "has active call $call") when (val r = apiMsg.resp) { - is WCallResponse.Capabilities -> withApi { + is WCallResponse.Capabilities -> withBGApi { val callType = CallType(call.localMedia, r.capabilities) chatModel.controller.apiSendCallInvitation(call.contact, callType) chatModel.activeCall.value = call.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities) } - is WCallResponse.Offer -> withApi { + is WCallResponse.Offer -> withBGApi { chatModel.controller.apiSendCallOffer(call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities) chatModel.activeCall.value = call.copy(callState = CallState.OfferSent, localCapabilities = r.capabilities) } - is WCallResponse.Answer -> withApi { + is WCallResponse.Answer -> withBGApi { chatModel.controller.apiSendCallAnswer(call.contact, r.answer, r.iceCandidates) chatModel.activeCall.value = call.copy(callState = CallState.Negotiated) } - is WCallResponse.Ice -> withApi { + is WCallResponse.Ice -> withBGApi { chatModel.controller.apiSendCallExtraInfo(call.contact, r.iceCandidates) } is WCallResponse.Connection -> try { val callStatus = json.decodeFromString("\"${r.state.connectionState}\"") if (callStatus == WebRTCCallStatus.Connected) { - chatModel.activeCall.value = call.copy(callState = CallState.Connected) + chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectedAt = Clock.System.now()) setCallSound(call.soundSpeaker, audioViaBluetooth) } - withApi { chatModel.controller.apiCallStatus(call.contact, callStatus) } + withBGApi { chatModel.controller.apiCallStatus(call.contact, callStatus) } } catch (e: Error) { Log.d(TAG,"call status ${r.state.connectionState} not used") } @@ -145,9 +149,12 @@ actual fun ActiveCallView() { setCallSound(call.soundSpeaker, audioViaBluetooth) } } + is WCallResponse.End -> { + withBGApi { chatModel.callManager.endCall(call) } + } is WCallResponse.Ended -> { chatModel.activeCall.value = call.copy(callState = CallState.Ended) - withApi { chatModel.callManager.endCall(call) } + withBGApi { chatModel.callManager.endCall(call) } chatModel.showCallView.value = false } is WCallResponse.Ok -> when (val cmd = apiMsg.command) { @@ -162,7 +169,7 @@ actual fun ActiveCallView() { is WCallCommand.Camera -> { chatModel.activeCall.value = call.copy(localCamera = cmd.camera) if (!call.audioEnabled) { - chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Audio, enable = false) + chatModel.callCommand.add(WCallCommand.Media(CallMediaType.Audio, enable = false)) } } is WCallCommand.End -> @@ -187,11 +194,14 @@ actual fun ActiveCallView() { // Lock orientation to portrait in order to have good experience with calls activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT chatModel.activeCallViewIsVisible.value = true + // After the first call, End command gets added to the list which prevents making another calls + chatModel.callCommand.removeAll { it is WCallCommand.End } onDispose { activity.volumeControlStream = prevVolumeControlStream // Unlock orientation activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED chatModel.activeCallViewIsVisible.value = false + chatModel.callCommand.clear() } } } @@ -201,9 +211,9 @@ private fun ActiveCallOverlay(call: Call, chatModel: ChatModel, audioViaBluetoot ActiveCallOverlayLayout( call = call, speakerCanBeEnabled = !audioViaBluetooth.value, - dismiss = { withApi { chatModel.callManager.endCall(call) } }, - toggleAudio = { chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Audio, enable = !call.audioEnabled) }, - toggleVideo = { chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Video, enable = !call.videoEnabled) }, + dismiss = { withBGApi { chatModel.callManager.endCall(call) } }, + toggleAudio = { chatModel.callCommand.add(WCallCommand.Media(CallMediaType.Audio, enable = !call.audioEnabled)) }, + toggleVideo = { chatModel.callCommand.add(WCallCommand.Media(CallMediaType.Video, enable = !call.videoEnabled)) }, toggleSound = { var call = chatModel.activeCall.value if (call != null) { @@ -212,7 +222,7 @@ private fun ActiveCallOverlay(call: Call, chatModel: ChatModel, audioViaBluetoot setCallSound(call.soundSpeaker, audioViaBluetooth) } }, - flipCamera = { chatModel.callCommand.value = WCallCommand.Camera(call.localCamera.flipped) } + flipCamera = { chatModel.callCommand.add(WCallCommand.Camera(call.localCamera.flipped)) } ) } @@ -439,7 +449,7 @@ private fun DisabledBackgroundCallsButton() { //} @Composable -fun WebRTCView(callCommand: MutableState, onResponse: (WVAPIMessage) -> Unit) { +fun WebRTCView(callCommand: SnapshotStateList, onResponse: (WVAPIMessage) -> Unit) { val scope = rememberCoroutineScope() val webView = remember { mutableStateOf(null) } val permissionsState = rememberMultiplePermissionsState( @@ -470,13 +480,19 @@ fun WebRTCView(callCommand: MutableState, onResponse: (WVAPIMessa webView.value = null } } - LaunchedEffect(callCommand.value, webView.value) { - val cmd = callCommand.value - val wv = webView.value - if (cmd != null && wv != null) { - Log.d(TAG, "WebRTCView LaunchedEffect executing $cmd") - processCommand(wv, cmd) - callCommand.value = null + val wv = webView.value + if (wv != null) { + LaunchedEffect(Unit) { + snapshotFlow { callCommand.firstOrNull() } + .distinctUntilChanged() + .filterNotNull() + .collect { + while (callCommand.isNotEmpty()) { + val cmd = callCommand.removeFirst() + Log.d(TAG, "WebRTCView LaunchedEffect executing $cmd") + processCommand(wv, cmd) + } + } } } val assetLoader = WebViewAssetLoader.Builder() @@ -502,7 +518,7 @@ fun WebRTCView(callCommand: MutableState, onResponse: (WVAPIMessa } } } - this.webViewClient = LocalContentWebViewClient(assetLoader) + this.webViewClient = LocalContentWebViewClient(webView, assetLoader) this.clearHistory() this.clearCache(true) this.addJavascriptInterface(WebRTCInterface(onResponse), "WebRTCInterface") @@ -512,19 +528,10 @@ fun WebRTCView(callCommand: MutableState, onResponse: (WVAPIMessa webViewSettings.javaScriptEnabled = true webViewSettings.mediaPlaybackRequiresUserGesture = false webViewSettings.cacheMode = WebSettings.LOAD_NO_CACHE - this.loadUrl("file:android_asset/www/call.html") + this.loadUrl("file:android_asset/www/android/call.html") } } - ) { wv -> - Log.d(TAG, "WebRTCView: webview ready") - // for debugging - // wv.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null) - scope.launch { - delay(2000L) - wv.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null) - webView.value = wv - } - } + ) { /* WebView */ } } } } @@ -539,19 +546,28 @@ class WebRTCInterface(private val onResponse: (WVAPIMessage) -> Unit) { // for debugging // onResponse(message) onResponse(json.decodeFromString(message)) - } catch (e: Error) { + } catch (e: Exception) { Log.e(TAG, "failed parsing WebView message: $message") } } } -private class LocalContentWebViewClient(private val assetLoader: WebViewAssetLoader) : WebViewClientCompat() { +private class LocalContentWebViewClient(val webView: MutableState, private val assetLoader: WebViewAssetLoader) : WebViewClientCompat() { override fun shouldInterceptRequest( view: WebView, request: WebResourceRequest ): WebResourceResponse? { return assetLoader.shouldInterceptRequest(request.url) } + + override fun onPageFinished(view: WebView, url: String) { + super.onPageFinished(view, url) + view.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null) + webView.value = view + Log.d(TAG, "WebRTCView: webview ready") + // for debugging + // view.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null) + } } @Preview diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.android.kt index 3f33913e54..30f5b81387 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.android.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import chat.simplex.common.platform.onRightClick import chat.simplex.common.views.helpers.* @Composable diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt new file mode 100644 index 0000000000..cb74664a48 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.android.kt @@ -0,0 +1,8 @@ +package chat.simplex.common.views.chatlist + +import androidx.compose.runtime.* +import chat.simplex.common.views.helpers.* +import kotlinx.coroutines.flow.MutableStateFlow + +@Composable +actual fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow) {} diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/DefaultDropDownMenu.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/DefaultDropDownMenu.android.kt deleted file mode 100644 index 38dd78dd99..0000000000 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/DefaultDropDownMenu.android.kt +++ /dev/null @@ -1,48 +0,0 @@ -package chat.simplex.common.views.helpers - -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.DpOffset -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.PopupProperties - -actual fun Modifier.onRightClick(action: () -> Unit): Modifier = this - -actual interface DefaultExposedDropdownMenuBoxScope { - @Composable - actual fun DefaultExposedDropdownMenu( - expanded: Boolean, - onDismissRequest: () -> Unit, - modifier: Modifier, - content: @Composable ColumnScope.() -> Unit - ) { - DropdownMenu(expanded, onDismissRequest, modifier, content = content) - } - - @Composable - fun DropdownMenu( - expanded: Boolean, - onDismissRequest: () -> Unit, - modifier: Modifier = Modifier, - offset: DpOffset = DpOffset(0.dp, 0.dp), - properties: PopupProperties = PopupProperties(focusable = true), - content: @Composable ColumnScope.() -> Unit - ) { - androidx.compose.material.DropdownMenu(expanded, onDismissRequest, modifier, offset, properties, content) - } -} - -@Composable -actual fun DefaultExposedDropdownMenuBox( - expanded: Boolean, - onExpandedChange: (Boolean) -> Unit, - modifier: Modifier, - content: @Composable DefaultExposedDropdownMenuBoxScope.() -> Unit -) { - val scope = remember { object : DefaultExposedDropdownMenuBoxScope {} } - androidx.compose.material.ExposedDropdownMenuBox(expanded, onExpandedChange, modifier, content = { - scope.content() - }) -} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt index 4531f88f96..82201cce04 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt @@ -150,7 +150,7 @@ fun MainScreen() { LaunchedEffect(Unit) { // With these constrains when user presses back button while on ChatList, activity destroys and shows auth request // while the screen moves to a launcher. Detect it and prevent showing the auth - if (!(AppLock.destroyedAfterBackPress.value && chatModel.controller.appPrefs.laMode.get() == LAMode.SYSTEM)) { + if (!(androidIsFinishingMainActivity() && chatModel.controller.appPrefs.laMode.get() == LAMode.SYSTEM)) { AppLock.runAuthenticate() } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt index 7228f4ebf4..a1d4c0c62a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt @@ -24,7 +24,6 @@ object AppLock { // Remember result and show it after orientation change val laFailed = mutableStateOf(false) - val destroyedAfterBackPress = mutableStateOf(false) fun clearAuthState() { userAuthorized.value = null 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 8687ac390a..4d95bfd499 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 @@ -6,17 +6,17 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.* import androidx.compose.ui.text.style.TextDecoration -import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* import chat.simplex.common.views.chat.ComposeState import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.onboarding.OnboardingStage import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.datetime.* import kotlinx.datetime.TimeZone import kotlinx.serialization.* @@ -53,6 +53,7 @@ object ChatModel { // current chat val chatId = mutableStateOf(null) val chatItems = mutableStateListOf() + val chatItemStatuses = mutableMapOf() val groupMembers = mutableStateListOf() val terminalItems = mutableStateListOf() @@ -87,7 +88,7 @@ object ChatModel { val activeCallInvitation = mutableStateOf(null) val activeCall = mutableStateOf(null) val activeCallViewIsVisible = mutableStateOf(false) - val callCommand = mutableStateOf(null) + val callCommand = mutableStateListOf() val showCallView = mutableStateOf(false) val switchingCall = mutableStateOf(false) @@ -103,6 +104,8 @@ object ChatModel { val filesToDelete = mutableSetOf() val simplexLinkMode by lazy { mutableStateOf(ChatController.appPrefs.simplexLinkMode.get()) } + var updatingChatsMutex: Mutex = Mutex() + fun getUser(userId: Long): User? = if (currentUser.value?.userId == userId) { currentUser.value } else { @@ -133,6 +136,8 @@ object ChatModel { fun hasChat(id: String): Boolean = chats.toList().firstOrNull { it.id == id } != null fun getChat(id: String): Chat? = chats.toList().firstOrNull { it.id == id } fun getContactChat(contactId: Long): Chat? = chats.toList().firstOrNull { it.chatInfo is ChatInfo.Direct && it.chatInfo.apiId == contactId } + fun getGroupChat(groupId: Long): Chat? = chats.toList().firstOrNull { it.chatInfo is ChatInfo.Group && it.chatInfo.apiId == groupId } + fun getGroupMember(groupMemberId: Long): GroupMember? = groupMembers.firstOrNull { it.groupMemberId == groupMemberId } private fun getChatIndex(id: String): Int = chats.toList().indexOfFirst { it.id == id } fun addChat(chat: Chat) = chats.add(index = 0, chat) @@ -199,7 +204,7 @@ object ChatModel { } } - suspend fun addChatItem(cInfo: ChatInfo, cItem: ChatItem) { + suspend fun addChatItem(cInfo: ChatInfo, cItem: ChatItem) = updatingChatsMutex.withLock { // update previews val i = getChatIndex(cInfo.id) val chat: Chat @@ -222,10 +227,11 @@ object ChatModel { } else { addChat(Chat(chatInfo = cInfo, chatItems = arrayListOf(cItem))) } - // add to current chat - if (chatId.value == cInfo.id) { - Log.d(TAG, "TODOCHAT: addChatItem: adding to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}") - withContext(Dispatchers.Main) { + Log.d(TAG, "TODOCHAT: addChatItem: adding to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}") + withContext(Dispatchers.Main) { + // add to current chat + if (chatId.value == cInfo.id) { + Log.d(TAG, "TODOCHAT: addChatItem: chatIds are equal, size ${chatItems.size}") // Prevent situation when chat item already in the list received from backend if (chatItems.none { it.id == cItem.id }) { if (chatItems.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { @@ -239,7 +245,7 @@ object ChatModel { } } - suspend fun upsertChatItem(cInfo: ChatInfo, cItem: ChatItem): Boolean { + suspend fun upsertChatItem(cInfo: ChatInfo, cItem: ChatItem): Boolean = updatingChatsMutex.withLock { // update previews val i = getChatIndex(cInfo.id) val chat: Chat @@ -259,33 +265,41 @@ object ChatModel { addChat(Chat(chatInfo = cInfo, chatItems = arrayListOf(cItem))) res = true } - // update current chat - return if (chatId.value == cInfo.id) { - Log.d(TAG, "TODOCHAT: upsertChatItem: upserting to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}") - withContext(Dispatchers.Main) { + Log.d(TAG, "TODOCHAT: upsertChatItem: upserting to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}") + return withContext(Dispatchers.Main) { + // update current chat + if (chatId.value == cInfo.id) { val itemIndex = chatItems.indexOfFirst { it.id == cItem.id } if (itemIndex >= 0) { chatItems[itemIndex] = cItem Log.d(TAG, "TODOCHAT: upsertChatItem: updated in chat $chatId from ${cInfo.id} ${cItem.id}, size ${chatItems.size}") false } else { - chatItems.add(cItem) + val status = chatItemStatuses.remove(cItem.id) + val ci = if (status != null && cItem.meta.itemStatus is CIStatus.SndNew) { + cItem.copy(meta = cItem.meta.copy(itemStatus = status)) + } else { + cItem + } + chatItems.add(ci) Log.d(TAG, "TODOCHAT: upsertChatItem: added to chat $chatId from ${cInfo.id} ${cItem.id}, size ${chatItems.size}") true } + } else { + res } - } else { - res } } - suspend fun updateChatItem(cInfo: ChatInfo, cItem: ChatItem) { - if (chatId.value == cInfo.id) { - withContext(Dispatchers.Main) { + suspend fun updateChatItem(cInfo: ChatInfo, cItem: ChatItem, status: CIStatus? = null) { + withContext(Dispatchers.Main) { + if (chatId.value == cInfo.id) { val itemIndex = chatItems.indexOfFirst { it.id == cItem.id } if (itemIndex >= 0) { chatItems[itemIndex] = cItem } + } else if (status != null) { + chatItemStatuses[cItem.id] = status } } } @@ -323,6 +337,7 @@ object ChatModel { } // clear current chat if (chatId.value == cInfo.id) { + chatItemStatuses.clear() chatItems.clear() } } @@ -428,6 +443,78 @@ object ChatModel { } } + fun getChatItemIndexOrNull(cItem: ChatItem): Int? { + val reversedChatItems = chatItems.asReversed() + val index = reversedChatItems.indexOfFirst { it.id == cItem.id } + return if (index != -1) index else null + } + + // this function analyses "connected" events and assumes that each member will be there only once + fun getConnectedMemberNames(cItem: ChatItem): Pair> { + var count = 0 + val ns = mutableListOf() + var idx = getChatItemIndexOrNull(cItem) + if (cItem.mergeCategory != null && idx != null) { + val reversedChatItems = chatItems.asReversed() + while (idx < reversedChatItems.size) { + val ci = reversedChatItems[idx] + if (ci.mergeCategory != cItem.mergeCategory) break + val m = ci.memberConnected + if (m != null) { + ns.add(m.displayName) + } + count++ + idx++ + } + } + return count to ns + } + + // returns the index of the passed item and the next item (it has smaller index) + fun getNextChatItem(ci: ChatItem): Pair { + val i = getChatItemIndexOrNull(ci) + return if (i != null) { + val reversedChatItems = chatItems.asReversed() + i to if (i > 0) reversedChatItems[i - 1] else null + } else { + null to null + } + } + + // returns the index of the first item in the same merged group (the first hidden item) + // and the previous visible item with another merge category + fun getPrevShownChatItem(ciIndex: Int?, ciCategory: CIMergeCategory?): Pair { + var i = ciIndex ?: return null to null + val reversedChatItems = chatItems.asReversed() + val fst = reversedChatItems.lastIndex + while (i < fst) { + i++ + val ci = reversedChatItems[i] + if (ciCategory == null || ciCategory != ci.mergeCategory) { + return i - 1 to ci + } + } + return i to null + } + + // returns the previous member in the same merge group and the count of members in this group + fun getPrevHiddenMember(member: GroupMember, range: IntRange): Pair { + val reversedChatItems = chatItems.asReversed() + var prevMember: GroupMember? = null + val names: MutableSet = mutableSetOf() + for (i in range) { + val dir = reversedChatItems[i].chatDir + if (dir is CIDirection.GroupRcv) { + val m = dir.groupMember + if (prevMember == null && m.groupMemberId != member.groupMemberId) { + prevMember = m + } + names.add(m.groupMemberId) + } + } + return prevMember to names.size + } + // func popChat(_ id: String) { // if let i = getChatIndex(id) { // popChat_(i) @@ -460,7 +547,7 @@ object ChatModel { } // update current chat return if (chatId.value == groupInfo.id) { - val memberIndex = groupMembers.indexOfFirst { it.id == member.id } + val memberIndex = groupMembers.indexOfFirst { it.groupMemberId == member.groupMemberId } if (memberIndex >= 0) { groupMembers[memberIndex] = member false @@ -726,7 +813,7 @@ sealed class ChatInfo: SomeChat, NamedChat { override val apiId get() = contactConnection.apiId override val ready get() = contactConnection.ready override val sendMsgEnabled get() = contactConnection.sendMsgEnabled - override val ntfsEnabled get() = contactConnection.incognito + override val ntfsEnabled get() = false override val incognito get() = contactConnection.incognito override fun featureEnabled(feature: ChatFeature) = contactConnection.featureEnabled(feature) override val timedMessagesTTL: Int? get() = contactConnection.timedMessagesTTL @@ -786,16 +873,19 @@ sealed class NetworkStatus { val statusExplanation: String get() = when (this) { is Connected -> generalGetString(MR.strings.connected_to_server_to_receive_messages_from_contact) - is Error -> String.format(generalGetString(MR.strings.trying_to_connect_to_server_to_receive_messages_with_error), error) + is Error -> String.format(generalGetString(MR.strings.trying_to_connect_to_server_to_receive_messages_with_error), connectionError) else -> generalGetString(MR.strings.trying_to_connect_to_server_to_receive_messages) } @Serializable @SerialName("unknown") class Unknown: NetworkStatus() @Serializable @SerialName("connected") class Connected: NetworkStatus() @Serializable @SerialName("disconnected") class Disconnected: NetworkStatus() - @Serializable @SerialName("error") class Error(val error: String): NetworkStatus() + @Serializable @SerialName("error") class Error(val connectionError: String): NetworkStatus() } +@Serializable +data class ConnNetworkStatus(val agentConnId: String, val networkStatus: NetworkStatus) + @Serializable data class Contact( val contactId: Long, @@ -822,7 +912,7 @@ data class Contact( (ready && active && !(activeConn.connectionStats?.ratchetSyncSendProhibited ?: false)) || nextSendGrpInv val nextSendGrpInv get() = contactGroupMemberId != null && !contactGrpInvSent - override val ntfsEnabled get() = chatSettings.enableNtfs + override val ntfsEnabled get() = chatSettings.enableNtfs == MsgFilter.All override val incognito get() = contactConnIncognito override fun featureEnabled(feature: ChatFeature) = when (feature) { ChatFeature.TimedMessages -> mergedPreferences.timedMessages.enabled.forUser @@ -869,7 +959,7 @@ data class Contact( activeConn = Connection.sampleData, contactUsed = true, contactStatus = ContactStatus.Active, - chatSettings = ChatSettings(enableNtfs = true, sendRcpts = null, favorite = false), + chatSettings = ChatSettings(enableNtfs = MsgFilter.All, sendRcpts = null, favorite = false), userPreferences = ChatPreferences.sampleData, mergedPreferences = ContactUserPreferences.sampleData, createdAt = Clock.System.now(), @@ -1009,7 +1099,7 @@ data class GroupInfo ( override val apiId get() = groupId override val ready get() = membership.memberActive override val sendMsgEnabled get() = membership.memberActive - override val ntfsEnabled get() = chatSettings.enableNtfs + override val ntfsEnabled get() = chatSettings.enableNtfs == MsgFilter.All override val incognito get() = membership.memberIncognito override fun featureEnabled(feature: ChatFeature) = when (feature) { ChatFeature.TimedMessages -> fullGroupPreferences.timedMessages.on @@ -1041,13 +1131,16 @@ data class GroupInfo ( fullGroupPreferences = FullGroupPreferences.sampleData, membership = GroupMember.sampleData, hostConnCustomUserProfileId = null, - chatSettings = ChatSettings(enableNtfs = true, sendRcpts = null, favorite = false), + chatSettings = ChatSettings(enableNtfs = MsgFilter.All, sendRcpts = null, favorite = false), createdAt = Clock.System.now(), updatedAt = Clock.System.now() ) } } +@Serializable +data class GroupRef(val groupId: Long, val localDisplayName: String) + @Serializable data class GroupProfile ( override val displayName: String, @@ -1070,10 +1163,11 @@ data class GroupMember ( val groupMemberId: Long, val groupId: Long, val memberId: String, - var memberRole: GroupMemberRole, - var memberCategory: GroupMemberCategory, - var memberStatus: GroupMemberStatus, - var invitedBy: InvitedBy, + val memberRole: GroupMemberRole, + val memberCategory: GroupMemberCategory, + val memberStatus: GroupMemberStatus, + val memberSettings: GroupMemberSettings, + val invitedBy: InvitedBy, val localDisplayName: String, val memberProfile: LocalProfile, val memberContactId: Long? = null, @@ -1140,6 +1234,7 @@ data class GroupMember ( memberRole = GroupMemberRole.Member, memberCategory = GroupMemberCategory.InviteeMember, memberStatus = GroupMemberStatus.MemComplete, + memberSettings = GroupMemberSettings(showMessages = true), invitedBy = InvitedBy.IBUser(), localDisplayName = "alice", memberProfile = LocalProfile.sampleData, @@ -1151,11 +1246,20 @@ data class GroupMember ( } @Serializable -class GroupMemberRef( +data class GroupMemberSettings(val showMessages: Boolean) {} + +@Serializable +data class GroupMemberRef( val groupMemberId: Long, val profile: Profile ) +@Serializable +data class GroupMemberIds( + val groupMemberId: Long, + val groupId: Long +) + @Serializable enum class GroupMemberRole(val memberRole: String) { @SerialName("observer") Observer("observer"), // order matters in comparisons @@ -1249,7 +1353,7 @@ class LinkPreview ( @Serializable class MemberSubError ( - val member: GroupMember, + val member: GroupMemberIds, val memberError: ChatError ) @@ -1436,7 +1540,7 @@ data class ChatItem ( chatController.appPrefs.privacyEncryptLocalFiles.get() val memberDisplayName: String? get() = - if (chatDir is CIDirection.GroupRcv) chatDir.groupMember.displayName + if (chatDir is CIDirection.GroupRcv) chatDir.groupMember.chatViewName else null val isDeletedContent: Boolean get() = @@ -1460,6 +1564,29 @@ data class ChatItem ( else -> null } + val mergeCategory: CIMergeCategory? + get() = when (content) { + is CIContent.RcvChatFeature, + is CIContent.SndChatFeature, + is CIContent.RcvGroupFeature, + is CIContent.SndGroupFeature -> CIMergeCategory.ChatFeature + is CIContent.RcvGroupEventContent -> when (content.rcvGroupEvent) { + is RcvGroupEvent.UserRole, is RcvGroupEvent.UserDeleted, is RcvGroupEvent.GroupDeleted, is RcvGroupEvent.MemberCreatedContact -> null + else -> CIMergeCategory.RcvGroupEvent + } + is CIContent.SndGroupEventContent -> when (content.sndGroupEvent) { + is SndGroupEvent.UserRole, is SndGroupEvent.UserLeft -> null + else -> CIMergeCategory.SndGroupEvent + } + else -> { + if (meta.itemDeleted == null) { + null + } else { + if (chatDir.sent) CIMergeCategory.SndItemDeleted else CIMergeCategory.RcvItemDeleted + } + } + } + fun memberToModerate(chatInfo: ChatInfo): Pair? { return if (chatInfo is ChatInfo.Group && chatDir is CIDirection.GroupRcv) { val m = chatInfo.groupInfo.membership @@ -1664,6 +1791,15 @@ data class ChatItem ( } } +enum class CIMergeCategory { + MemberConnected, + RcvGroupEvent, + SndGroupEvent, + SndItemDeleted, + RcvItemDeleted, + ChatFeature, +} + @Serializable sealed class CIDirection { @Serializable @SerialName("directSnd") class DirectSnd: CIDirection() @@ -1844,6 +1980,7 @@ enum class SndCIStatusProgress { @Serializable sealed class CIDeleted { @Serializable @SerialName("deleted") class Deleted(val deletedTs: Instant?): CIDeleted() + @Serializable @SerialName("blocked") class Blocked(val deletedTs: Instant?): CIDeleted() @Serializable @SerialName("moderated") class Moderated(val deletedTs: Instant?, val byGroupMember: GroupMember): CIDeleted() } @@ -1863,7 +2000,9 @@ sealed class CIContent: ItemContent { @Serializable @SerialName("sndMsgContent") class SndMsgContent(override val msgContent: MsgContent): CIContent() @Serializable @SerialName("rcvMsgContent") class RcvMsgContent(override val msgContent: MsgContent): CIContent() + // legacy - since v4.3.0 itemDeleted field is used @Serializable @SerialName("sndDeleted") class SndDeleted(val deleteMode: CIDeleteMode): CIContent() { override val msgContent: MsgContent? get() = null } + // legacy - since v4.3.0 itemDeleted field is used @Serializable @SerialName("rcvDeleted") class RcvDeleted(val deleteMode: CIDeleteMode): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("sndCall") class SndCall(val status: CICallStatus, val duration: Int): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("rcvCall") class RcvCall(val status: CICallStatus, val duration: Int): CIContent() { override val msgContent: MsgContent? get() = null } @@ -2395,7 +2534,7 @@ sealed class Format { @Serializable @SerialName("secret") class Secret: Format() @Serializable @SerialName("colored") class Colored(val color: FormatColor): Format() @Serializable @SerialName("uri") class Uri: Format() - @Serializable @SerialName("simplexLink") class SimplexLink(val linkType: SimplexLinkType, val simplexUri: String, val trustedUri: Boolean, val smpHosts: List): Format() + @Serializable @SerialName("simplexLink") class SimplexLink(val linkType: SimplexLinkType, val simplexUri: String, val smpHosts: List): Format() @Serializable @SerialName("email") class Email: Format() @Serializable @SerialName("phone") class Phone: Format() 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 43043d65fe..9a9b48a35e 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 @@ -4,6 +4,7 @@ import chat.simplex.common.views.helpers.* import androidx.compose.runtime.* import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter +import chat.simplex.common.model.ChatModel.updatingChatsMutex import dev.icerock.moko.resources.compose.painterResource import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* @@ -16,6 +17,7 @@ import com.charleskorn.kaml.YamlConfiguration import chat.simplex.res.MR import com.russhwolf.settings.Settings import kotlinx.coroutines.* +import kotlinx.coroutines.sync.withLock import kotlinx.datetime.Clock import kotlinx.datetime.Instant import kotlinx.serialization.* @@ -336,6 +338,7 @@ object ChatController { apiSetTempFolder(coreTmpDir.absolutePath) apiSetFilesFolder(appFilesDir.absolutePath) apiSetXFTPConfig(getXFTPCfg()) + apiSetEncryptLocalFiles(appPrefs.privacyEncryptLocalFiles.get()) val justStarted = apiStartChat() val users = listUsers() chatModel.users.clear() @@ -349,8 +352,10 @@ object ChatController { startReceiver() Log.d(TAG, "startChat: started") } else { - val chats = apiGetChats() - chatModel.updateChats(chats) + updatingChatsMutex.withLock { + val chats = apiGetChats() + chatModel.updateChats(chats) + } Log.d(TAG, "startChat: running") } } catch (e: Error) { @@ -384,8 +389,10 @@ object ChatController { suspend fun getUserChatData() { chatModel.userAddress.value = apiGetUserAddress() chatModel.chatItemTTL.value = getChatItemTTL() - val chats = apiGetChats() - chatModel.updateChats(chats) + updatingChatsMutex.withLock { + val chats = apiGetChats() + chatModel.updateChats(chats) + } } private fun startReceiver() { @@ -553,6 +560,8 @@ object ChatController { throw Error("apiSetXFTPConfig bad response: ${r.responseType} ${r.details}") } + suspend fun apiSetEncryptLocalFiles(enable: Boolean) = sendCommandOkResp(CC.ApiSetEncryptLocalFiles(enable)) + suspend fun apiExportArchive(config: ArchiveConfig) { val r = sendCmd(CC.ApiExportArchive(config)) if (r is CR.CmdOk) return @@ -736,6 +745,9 @@ object ChatController { } } + suspend fun apiSetMemberSettings(groupId: Long, groupMemberId: Long, memberSettings: GroupMemberSettings): Boolean = + sendCommandOkResp(CC.ApiSetMemberSettings(groupId, groupMemberId, memberSettings)) + suspend fun apiContactInfo(contactId: Long): Pair? { val r = sendCmd(CC.APIContactInfo(contactId)) if (r is CR.ContactInfo) return r.connectionStats to r.customUserProfile @@ -846,6 +858,14 @@ object ChatController { return null } + suspend fun apiConnectPlan(connReq: String): ConnectionPlan? { + val userId = kotlin.runCatching { currentUserId("apiConnectPlan") }.getOrElse { return null } + val r = sendCmd(CC.APIConnectPlan(userId, connReq)) + if (r is CR.CRConnectionPlan) return r.connectionPlan + Log.e(TAG, "apiConnectPlan 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") @@ -887,8 +907,8 @@ object ChatController { } } - suspend fun apiDeleteChat(type: ChatType, id: Long): Boolean { - val r = sendCmd(CC.ApiDeleteChat(type, id)) + suspend fun apiDeleteChat(type: ChatType, id: Long, notify: Boolean? = null): Boolean { + val r = sendCmd(CC.ApiDeleteChat(type, id, notify)) when { r is CR.ContactDeleted && type == ChatType.Direct -> return true r is CR.ContactConnectionDeleted && type == ChatType.ContactConnection -> return true @@ -926,6 +946,9 @@ object ChatController { val r = sendCmd(CC.ApiUpdateProfile(userId, profile)) if (r is CR.UserProfileNoChange) return profile to emptyList() if (r is CR.UserProfileUpdated) return r.toProfile to r.updateSummary.changedContacts + if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.DuplicateName) { + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.failed_to_create_user_duplicate_title), generalGetString(MR.strings.failed_to_create_user_duplicate_desc)) + } Log.e(TAG, "apiUpdateProfile bad response: ${r.responseType} ${r.details}") return null } @@ -1076,6 +1099,13 @@ object ChatController { return r is CR.CmdOk } + suspend fun apiGetNetworkStatuses(): List? { + val r = sendCmd(CC.ApiGetNetworkStatuses()) + if (r is CR.NetworkStatuses) return r.networkStatuses + Log.e(TAG, "apiGetNetworkStatuses bad response: ${r.responseType} ${r.details}") + return null + } + suspend fun apiChatRead(type: ChatType, id: Long, range: CC.ItemRange): Boolean { val r = sendCmd(CC.ApiChatRead(type, id, range)) if (r is CR.CmdOk) return true @@ -1139,9 +1169,9 @@ object ChatController { } } - suspend fun apiNewGroup(p: GroupProfile): GroupInfo? { + suspend fun apiNewGroup(incognito: Boolean, groupProfile: GroupProfile): GroupInfo? { val userId = kotlin.runCatching { currentUserId("apiNewGroup") }.getOrElse { return null } - val r = sendCmd(CC.ApiNewGroup(userId, p)) + val r = sendCmd(CC.ApiNewGroup(userId, incognito, groupProfile)) if (r is CR.GroupCreated) return r.groupInfo Log.e(TAG, "apiNewGroup bad response: ${r.responseType} ${r.details}") return null @@ -1314,6 +1344,13 @@ object ChatController { } } + private suspend fun sendCommandOkResp(cmd: CC): Boolean { + val r = sendCmd(cmd) + val ok = r is CR.CmdOk + if (!ok) apiErrorAlert(cmd.cmdType, generalGetString(MR.strings.error_alert_title), r) + return ok + } + suspend fun apiGetVersion(): CoreVersionInfo? { val r = sendCmd(CC.ShowVersion()) return if (r is CR.VersionInfo) { @@ -1409,6 +1446,11 @@ object ChatController { chatModel.updateChatInfo(cInfo) } } + is CR.GroupMemberUpdated -> { + if (active(r.user)) { + chatModel.upsertGroupMember(r.groupInfo, r.toMember) + } + } is CR.ContactsMerged -> { if (active(r.user) && chatModel.hasChat(r.mergedContact.id)) { if (chatModel.chatId.value == r.mergedContact.id) { @@ -1419,12 +1461,6 @@ object ChatController { } is CR.ContactsSubscribed -> updateContactsStatus(r.contactRefs, NetworkStatus.Connected()) is CR.ContactsDisconnected -> updateContactsStatus(r.contactRefs, NetworkStatus.Disconnected()) - is CR.ContactSubError -> { - if (active(r.user)) { - chatModel.updateContact(r.contact) - } - processContactSubError(r.contact, r.chatError) - } is CR.ContactSubSummary -> { for (sub in r.contactSubscriptions) { if (active(r.user)) { @@ -1438,6 +1474,16 @@ object ChatController { } } } + is CR.NetworkStatusResp -> { + for (cId in r.connections) { + chatModel.networkStatuses[cId] = r.networkStatus + } + } + is CR.NetworkStatuses -> { + for (s in r.networkStatuses) { + chatModel.networkStatuses[s.agentConnId] = s.networkStatus + } + } is CR.NewChatItem -> { val cInfo = r.chatItem.chatInfo val cItem = r.chatItem.chatItem @@ -1462,11 +1508,8 @@ object ChatController { is CR.ChatItemStatusUpdated -> { val cInfo = r.chatItem.chatInfo val cItem = r.chatItem.chatItem - if (!cItem.isDeletedContent) { - val added = if (active(r.user)) chatModel.upsertChatItem(cInfo, cItem) else true - if (added && cItem.showNotification) { - ntfManager.notifyMessageReceived(r.user, cInfo, cItem) - } + if (!cItem.isDeletedContent && active(r.user)) { + chatModel.updateChatItem(cInfo, cItem, status = cItem.meta.itemStatus) } } is CR.ChatItemUpdated -> @@ -1518,6 +1561,16 @@ object ChatController { chatModel.removeChat(r.hostContact.activeConn.id) } } + is CR.GroupLinkConnecting -> { + if (!active(r.user)) return + + chatModel.updateGroup(r.groupInfo) + val hostConn = r.hostMember.activeConn + if (hostConn != null) { + chatModel.dismissConnReqView(hostConn.id) + chatModel.removeChat(hostConn.id) + } + } is CR.JoinedGroupMemberConnecting -> if (active(r.user)) { chatModel.upsertGroupMember(r.groupInfo, r.member) @@ -1615,25 +1668,25 @@ object ChatController { val useRelay = appPrefs.webrtcPolicyRelay.get() val iceServers = getIceServers() Log.d(TAG, ".callOffer iceServers $iceServers") - chatModel.callCommand.value = WCallCommand.Offer( + chatModel.callCommand.add(WCallCommand.Offer( offer = r.offer.rtcSession, iceCandidates = r.offer.rtcIceCandidates, media = r.callType.media, aesKey = r.sharedKey, iceServers = iceServers, relay = useRelay - ) + )) } } is CR.CallAnswer -> { withCall(r, r.contact) { call -> chatModel.activeCall.value = call.copy(callState = CallState.AnswerReceived) - chatModel.callCommand.value = WCallCommand.Answer(answer = r.answer.rtcSession, iceCandidates = r.answer.rtcIceCandidates) + chatModel.callCommand.add(WCallCommand.Answer(answer = r.answer.rtcSession, iceCandidates = r.answer.rtcIceCandidates)) } } is CR.CallExtraInfo -> { withCall(r, r.contact) { _ -> - chatModel.callCommand.value = WCallCommand.Ice(iceCandidates = r.extraInfo.rtcIceCandidates) + chatModel.callCommand.add(WCallCommand.Ice(iceCandidates = r.extraInfo.rtcIceCandidates)) } } is CR.CallEnded -> { @@ -1642,7 +1695,7 @@ object ChatController { chatModel.callManager.reportCallRemoteEnded(invitation = invitation) } withCall(r, r.contact) { _ -> - chatModel.callCommand.value = WCallCommand.End + chatModel.callCommand.add(WCallCommand.End) withApi { chatModel.activeCall.value = null chatModel.showCallView.value = false @@ -1841,6 +1894,7 @@ sealed class CC { class SetTempFolder(val tempFolder: String): CC() class SetFilesFolder(val filesFolder: String): CC() class ApiSetXFTPConfig(val config: XFTPFileConfig?): CC() + class ApiSetEncryptLocalFiles(val enable: Boolean): CC() class ApiExportArchive(val config: ArchiveConfig): CC() class ApiImportArchive(val config: ArchiveConfig): CC() class ApiDeleteStorage: CC() @@ -1853,7 +1907,7 @@ sealed class CC { class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemId: Long, val mode: CIDeleteMode): CC() class ApiDeleteMemberChatItem(val groupId: Long, val groupMemberId: Long, val itemId: Long): CC() class ApiChatItemReaction(val type: ChatType, val id: Long, val itemId: Long, val add: Boolean, val reaction: MsgReaction): CC() - class ApiNewGroup(val userId: Long, val groupProfile: GroupProfile): CC() + class ApiNewGroup(val userId: Long, val incognito: Boolean, val groupProfile: GroupProfile): CC() class ApiAddMember(val groupId: Long, val contactId: Long, val memberRole: GroupMemberRole): CC() class ApiJoinGroup(val groupId: Long): CC() class ApiMemberRole(val groupId: Long, val memberId: Long, val memberRole: GroupMemberRole): CC() @@ -1875,6 +1929,7 @@ sealed class CC { class APISetNetworkConfig(val networkConfig: NetCfg): CC() class APIGetNetworkConfig: CC() class APISetChatSettings(val type: ChatType, val id: Long, val chatSettings: ChatSettings): CC() + class ApiSetMemberSettings(val groupId: Long, val groupMemberId: Long, val memberSettings: GroupMemberSettings): CC() class APIContactInfo(val contactId: Long): CC() class APIGroupMemberInfo(val groupId: Long, val groupMemberId: Long): CC() class APISwitchContact(val contactId: Long): CC() @@ -1889,8 +1944,9 @@ sealed class CC { class APIVerifyGroupMember(val groupId: Long, val groupMemberId: Long, val connectionCode: String?): CC() class APIAddContact(val userId: Long, val incognito: Boolean): CC() class ApiSetConnectionIncognito(val connId: Long, val incognito: Boolean): CC() + class APIConnectPlan(val userId: Long, val connReq: String): CC() class APIConnect(val userId: Long, val incognito: Boolean, val connReq: String): CC() - class ApiDeleteChat(val type: ChatType, val id: Long): CC() + class ApiDeleteChat(val type: ChatType, val id: Long, val notify: Boolean?): CC() class ApiClearChat(val type: ChatType, val id: Long): CC() class ApiListContacts(val userId: Long): CC() class ApiUpdateProfile(val userId: Long, val profile: Profile): CC() @@ -1909,11 +1965,12 @@ 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 ApiGetNetworkStatuses(): 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() - class ReceiveFile(val fileId: Long, val encrypted: Boolean, val inline: Boolean?): CC() + class ReceiveFile(val fileId: Long, val encrypt: Boolean, val inline: Boolean?): CC() class CancelFile(val fileId: Long): CC() class ShowVersion(): CC() @@ -1945,6 +2002,7 @@ 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 ApiSetEncryptLocalFiles -> "/_files_encrypt ${onOff(enable)}" is ApiExportArchive -> "/_db export ${json.encodeToString(config)}" is ApiImportArchive -> "/_db import ${json.encodeToString(config)}" is ApiDeleteStorage -> "/_db delete" @@ -1960,7 +2018,7 @@ sealed class CC { is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} $itemId ${mode.deleteMode}" is ApiDeleteMemberChatItem -> "/_delete member item #$groupId $groupMemberId $itemId" is ApiChatItemReaction -> "/_reaction ${chatRef(type, id)} $itemId ${onOff(add)} ${json.encodeToString(reaction)}" - is ApiNewGroup -> "/_group $userId ${json.encodeToString(groupProfile)}" + is ApiNewGroup -> "/_group $userId incognito=${onOff(incognito)} ${json.encodeToString(groupProfile)}" is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}" is ApiJoinGroup -> "/_join #$groupId" is ApiMemberRole -> "/_member role #$groupId $memberId ${memberRole.memberRole}" @@ -1982,6 +2040,7 @@ sealed class CC { is APISetNetworkConfig -> "/_network ${json.encodeToString(networkConfig)}" is APIGetNetworkConfig -> "/network" is APISetChatSettings -> "/_settings ${chatRef(type, id)} ${json.encodeToString(chatSettings)}" + is ApiSetMemberSettings -> "/_member settings #$groupId $groupMemberId ${json.encodeToString(memberSettings)}" is APIContactInfo -> "/_info @$contactId" is APIGroupMemberInfo -> "/_info #$groupId $groupMemberId" is APISwitchContact -> "/_switch @$contactId" @@ -1996,8 +2055,13 @@ sealed class CC { is APIVerifyGroupMember -> "/_verify code #$groupId $groupMemberId" + if (connectionCode != null) " $connectionCode" else "" is APIAddContact -> "/_connect $userId incognito=${onOff(incognito)}" is ApiSetConnectionIncognito -> "/_set incognito :$connId ${onOff(incognito)}" + is APIConnectPlan -> "/_connect plan $userId $connReq" is APIConnect -> "/_connect $userId incognito=${onOff(incognito)} $connReq" - is ApiDeleteChat -> "/_delete ${chatRef(type, id)}" + is ApiDeleteChat -> if (notify != null) { + "/_delete ${chatRef(type, id)} notify=${onOff(notify)}" + } else { + "/_delete ${chatRef(type, id)}" + } is ApiClearChat -> "/_clear chat ${chatRef(type, id)}" is ApiListContacts -> "/_contacts $userId" is ApiUpdateProfile -> "/_profile $userId ${json.encodeToString(profile)}" @@ -2018,9 +2082,13 @@ sealed class CC { is ApiSendCallExtraInfo -> "/_call extra @${contact.apiId} ${json.encodeToString(extraInfo)}" is ApiEndCall -> "/_call end @${contact.apiId}" is ApiCallStatus -> "/_call status @${contact.apiId} ${callStatus.value}" + is ApiGetNetworkStatuses -> "/_network_statuses" is ApiChatRead -> "/_read chat ${chatRef(type, id)} from=${range.from} to=${range.to}" is ApiChatUnread -> "/_unread chat ${chatRef(type, id)} ${onOff(unreadChat)}" - is ReceiveFile -> "/freceive $fileId encrypt=${onOff(encrypted)}" + (if (inline == null) "" else " inline=${onOff(inline)}") + is ReceiveFile -> + "/freceive $fileId" + + (if (encrypt == null) "" else " encrypt=${onOff(encrypt)}") + + (if (inline == null) "" else " inline=${onOff(inline)}") is CancelFile -> "/fcancel $fileId" is ShowVersion -> "/version" } @@ -2044,6 +2112,7 @@ sealed class CC { is SetTempFolder -> "setTempFolder" is SetFilesFolder -> "setFilesFolder" is ApiSetXFTPConfig -> "apiSetXFTPConfig" + is ApiSetEncryptLocalFiles -> "apiSetEncryptLocalFiles" is ApiExportArchive -> "apiExportArchive" is ApiImportArchive -> "apiImportArchive" is ApiDeleteStorage -> "apiDeleteStorage" @@ -2075,9 +2144,10 @@ sealed class CC { is APITestProtoServer -> "testProtoServer" is APISetChatItemTTL -> "apiSetChatItemTTL" is APIGetChatItemTTL -> "apiGetChatItemTTL" - is APISetNetworkConfig -> "/apiSetNetworkConfig" - is APIGetNetworkConfig -> "/apiGetNetworkConfig" - is APISetChatSettings -> "/apiSetChatSettings" + is APISetNetworkConfig -> "apiSetNetworkConfig" + is APIGetNetworkConfig -> "apiGetNetworkConfig" + is APISetChatSettings -> "apiSetChatSettings" + is ApiSetMemberSettings -> "apiSetMemberSettings" is APIContactInfo -> "apiContactInfo" is APIGroupMemberInfo -> "apiGroupMemberInfo" is APISwitchContact -> "apiSwitchContact" @@ -2092,6 +2162,7 @@ sealed class CC { is APIVerifyGroupMember -> "apiVerifyGroupMember" is APIAddContact -> "apiAddContact" is ApiSetConnectionIncognito -> "apiSetConnectionIncognito" + is APIConnectPlan -> "apiConnectPlan" is APIConnect -> "apiConnect" is ApiDeleteChat -> "apiDeleteChat" is ApiClearChat -> "apiClearChat" @@ -2114,6 +2185,7 @@ sealed class CC { is ApiSendCallExtraInfo -> "apiSendCallExtraInfo" is ApiEndCall -> "apiEndCall" is ApiCallStatus -> "apiCallStatus" + is ApiGetNetworkStatuses -> "apiGetNetworkStatuses" is ApiChatRead -> "apiChatRead" is ApiChatUnread -> "apiChatUnread" is ReceiveFile -> "receiveFile" @@ -2472,15 +2544,22 @@ data class KeepAliveOpts( @Serializable data class ChatSettings( - val enableNtfs: Boolean, + val enableNtfs: MsgFilter, val sendRcpts: Boolean?, val favorite: Boolean ) { companion object { - val defaults: ChatSettings = ChatSettings(enableNtfs = true, sendRcpts = null, favorite = false) + val defaults: ChatSettings = ChatSettings(enableNtfs = MsgFilter.All, sendRcpts = null, favorite = false) } } +@Serializable +enum class MsgFilter { + @SerialName("all") All, + @SerialName("none") None, + @SerialName("mentions") Mentions, +} + @Serializable data class UserMsgReceiptSettings(val enable: Boolean, val clearOverrides: Boolean) @@ -3297,6 +3376,7 @@ sealed class CR { @Serializable @SerialName("connectionVerified") class ConnectionVerified(val user: UserRef, val verified: Boolean, val expectedCode: String): CR() @Serializable @SerialName("invitation") class Invitation(val user: UserRef, val connReqInvitation: String, val connection: PendingContactConnection): CR() @Serializable @SerialName("connectionIncognitoUpdated") class ConnectionIncognitoUpdated(val user: UserRef, val toConnection: PendingContactConnection): CR() + @Serializable @SerialName("connectionPlan") class CRConnectionPlan(val user: UserRef, val connectionPlan: ConnectionPlan): CR() @Serializable @SerialName("sentConfirmation") class SentConfirmation(val user: UserRef): CR() @Serializable @SerialName("sentInvitation") class SentInvitation(val user: UserRef): CR() @Serializable @SerialName("contactAlreadyExists") class ContactAlreadyExists(val user: UserRef, val contact: Contact): CR() @@ -3320,11 +3400,15 @@ sealed class CR { @Serializable @SerialName("acceptingContactRequest") class AcceptingContactRequest(val user: UserRef, val contact: Contact): CR() @Serializable @SerialName("contactRequestRejected") class ContactRequestRejected(val user: UserRef): CR() @Serializable @SerialName("contactUpdated") class ContactUpdated(val user: UserRef, val toContact: Contact): CR() + @Serializable @SerialName("groupMemberUpdated") class GroupMemberUpdated(val user: UserRef, val groupInfo: GroupInfo, val fromMember: GroupMember, val toMember: GroupMember): CR() + // TODO remove below @Serializable @SerialName("contactsSubscribed") class ContactsSubscribed(val server: String, val contactRefs: List): CR() @Serializable @SerialName("contactsDisconnected") class ContactsDisconnected(val server: String, val contactRefs: List): CR() - @Serializable @SerialName("contactSubError") class ContactSubError(val user: UserRef, val contact: Contact, val chatError: ChatError): CR() @Serializable @SerialName("contactSubSummary") class ContactSubSummary(val user: UserRef, val contactSubscriptions: List): CR() - @Serializable @SerialName("groupSubscribed") class GroupSubscribed(val user: UserRef, val group: GroupInfo): CR() + // TODO remove above + @Serializable @SerialName("networkStatus") class NetworkStatusResp(val networkStatus: NetworkStatus, val connections: List): CR() + @Serializable @SerialName("networkStatuses") class NetworkStatuses(val user_: UserRef?, val networkStatuses: List): CR() + @Serializable @SerialName("groupSubscribed") class GroupSubscribed(val user: UserRef, val group: GroupRef): CR() @Serializable @SerialName("memberSubErrors") class MemberSubErrors(val user: UserRef, val memberSubErrors: List): CR() @Serializable @SerialName("groupEmpty") class GroupEmpty(val user: UserRef, val group: GroupInfo): CR() @Serializable @SerialName("userContactLinkSubscribed") class UserContactLinkSubscribed: CR() @@ -3339,6 +3423,7 @@ sealed class CR { @Serializable @SerialName("groupCreated") class GroupCreated(val user: UserRef, val groupInfo: GroupInfo): CR() @Serializable @SerialName("sentGroupInvitation") class SentGroupInvitation(val user: UserRef, val groupInfo: GroupInfo, val contact: Contact, val member: GroupMember): CR() @Serializable @SerialName("userAcceptedGroupSent") class UserAcceptedGroupSent (val user: UserRef, val groupInfo: GroupInfo, val hostContact: Contact? = null): CR() + @Serializable @SerialName("groupLinkConnecting") class GroupLinkConnecting (val user: UserRef, val groupInfo: GroupInfo, val hostMember: GroupMember): CR() @Serializable @SerialName("userDeletedMember") class UserDeletedMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("leftMemberUser") class LeftMemberUser(val user: UserRef, val groupInfo: GroupInfo): CR() @Serializable @SerialName("groupMembers") class GroupMembers(val user: UserRef, val group: Group): CR() @@ -3429,6 +3514,7 @@ sealed class CR { is ConnectionVerified -> "connectionVerified" is Invitation -> "invitation" is ConnectionIncognitoUpdated -> "connectionIncognitoUpdated" + is CRConnectionPlan -> "connectionPlan" is SentConfirmation -> "sentConfirmation" is SentInvitation -> "sentInvitation" is ContactAlreadyExists -> "contactAlreadyExists" @@ -3452,10 +3538,12 @@ sealed class CR { is AcceptingContactRequest -> "acceptingContactRequest" is ContactRequestRejected -> "contactRequestRejected" is ContactUpdated -> "contactUpdated" + is GroupMemberUpdated -> "groupMemberUpdated" is ContactsSubscribed -> "contactsSubscribed" is ContactsDisconnected -> "contactsDisconnected" - is ContactSubError -> "contactSubError" is ContactSubSummary -> "contactSubSummary" + is NetworkStatusResp -> "networkStatus" + is NetworkStatuses -> "networkStatuses" is GroupSubscribed -> "groupSubscribed" is MemberSubErrors -> "memberSubErrors" is GroupEmpty -> "groupEmpty" @@ -3470,6 +3558,7 @@ sealed class CR { is GroupCreated -> "groupCreated" is SentGroupInvitation -> "sentGroupInvitation" is UserAcceptedGroupSent -> "userAcceptedGroupSent" + is GroupLinkConnecting -> "groupLinkConnecting" is UserDeletedMember -> "userDeletedMember" is LeftMemberUser -> "leftMemberUser" is GroupMembers -> "groupMembers" @@ -3558,6 +3647,7 @@ sealed class CR { is ConnectionVerified -> withUser(user, "verified: $verified\nconnectionCode: $expectedCode") is Invitation -> withUser(user, connReqInvitation) is ConnectionIncognitoUpdated -> withUser(user, json.encodeToString(toConnection)) + is CRConnectionPlan -> withUser(user, json.encodeToString(connectionPlan)) is SentConfirmation -> withUser(user, noDetails()) is SentInvitation -> withUser(user, noDetails()) is ContactAlreadyExists -> withUser(user, json.encodeToString(contact)) @@ -3581,10 +3671,12 @@ sealed class CR { is AcceptingContactRequest -> withUser(user, json.encodeToString(contact)) is ContactRequestRejected -> withUser(user, noDetails()) is ContactUpdated -> withUser(user, json.encodeToString(toContact)) + is GroupMemberUpdated -> withUser(user, "groupInfo: $groupInfo\nfromMember: $fromMember\ntoMember: $toMember") is ContactsSubscribed -> "server: $server\ncontacts:\n${json.encodeToString(contactRefs)}" is ContactsDisconnected -> "server: $server\ncontacts:\n${json.encodeToString(contactRefs)}" - is ContactSubError -> withUser(user, "error:\n${chatError.string}\ncontact:\n${json.encodeToString(contact)}") is ContactSubSummary -> withUser(user, json.encodeToString(contactSubscriptions)) + is NetworkStatusResp -> "networkStatus $networkStatus\nconnections: $connections" + is NetworkStatuses -> withUser(user_, json.encodeToString(networkStatuses)) is GroupSubscribed -> withUser(user, json.encodeToString(group)) is MemberSubErrors -> withUser(user, json.encodeToString(memberSubErrors)) is GroupEmpty -> withUser(user, json.encodeToString(group)) @@ -3599,6 +3691,7 @@ sealed class CR { is GroupCreated -> withUser(user, json.encodeToString(groupInfo)) is SentGroupInvitation -> withUser(user, "groupInfo: $groupInfo\ncontact: $contact\nmember: $member") is UserAcceptedGroupSent -> json.encodeToString(groupInfo) + is GroupLinkConnecting -> withUser(user, "groupInfo: $groupInfo\nhostMember: $hostMember") is UserDeletedMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member") is LeftMemberUser -> withUser(user, json.encodeToString(groupInfo)) is GroupMembers -> withUser(user, json.encodeToString(group)) @@ -3670,6 +3763,39 @@ fun chatError(r: CR): ChatErrorType? { ) } +@Serializable +sealed class ConnectionPlan { + @Serializable @SerialName("invitationLink") class InvitationLink(val invitationLinkPlan: InvitationLinkPlan): ConnectionPlan() + @Serializable @SerialName("contactAddress") class ContactAddress(val contactAddressPlan: ContactAddressPlan): ConnectionPlan() + @Serializable @SerialName("groupLink") class GroupLink(val groupLinkPlan: GroupLinkPlan): ConnectionPlan() +} + +@Serializable +sealed class InvitationLinkPlan { + @Serializable @SerialName("ok") object Ok: InvitationLinkPlan() + @Serializable @SerialName("ownLink") object OwnLink: InvitationLinkPlan() + @Serializable @SerialName("connecting") class Connecting(val contact_: Contact? = null): InvitationLinkPlan() + @Serializable @SerialName("known") class Known(val contact: Contact): InvitationLinkPlan() +} + +@Serializable +sealed class ContactAddressPlan { + @Serializable @SerialName("ok") object Ok: ContactAddressPlan() + @Serializable @SerialName("ownLink") object OwnLink: ContactAddressPlan() + @Serializable @SerialName("connectingConfirmReconnect") object ConnectingConfirmReconnect: ContactAddressPlan() + @Serializable @SerialName("connectingProhibit") class ConnectingProhibit(val contact: Contact): ContactAddressPlan() + @Serializable @SerialName("known") class Known(val contact: Contact): ContactAddressPlan() +} + +@Serializable +sealed class GroupLinkPlan { + @Serializable @SerialName("ok") object Ok: GroupLinkPlan() + @Serializable @SerialName("ownLink") class OwnLink(val groupInfo: GroupInfo): GroupLinkPlan() + @Serializable @SerialName("connectingConfirmReconnect") object ConnectingConfirmReconnect: GroupLinkPlan() + @Serializable @SerialName("connectingProhibit") class ConnectingProhibit(val groupInfo_: GroupInfo? = null): GroupLinkPlan() + @Serializable @SerialName("known") class Known(val groupInfo: GroupInfo): GroupLinkPlan() +} + abstract class TerminalItem { abstract val id: Long val date: Instant = Clock.System.now() @@ -3829,6 +3955,7 @@ sealed class ChatErrorType { is ChatNotStarted -> "chatNotStarted" is ChatNotStopped -> "chatNotStopped" is ChatStoreChanged -> "chatStoreChanged" + is ConnectionPlanChatError -> "connectionPlan" is InvalidConnReq -> "invalidConnReq" is InvalidChatMessage -> "invalidChatMessage" is ContactNotReady -> "contactNotReady" @@ -3905,6 +4032,7 @@ sealed class ChatErrorType { @Serializable @SerialName("chatNotStarted") object ChatNotStarted: ChatErrorType() @Serializable @SerialName("chatNotStopped") object ChatNotStopped: ChatErrorType() @Serializable @SerialName("chatStoreChanged") object ChatStoreChanged: ChatErrorType() + @Serializable @SerialName("connectionPlan") class ConnectionPlanChatError(val connectionPlan: ConnectionPlan): ChatErrorType() @Serializable @SerialName("invalidConnReq") object InvalidConnReq: ChatErrorType() @Serializable @SerialName("invalidChatMessage") class InvalidChatMessage(val connection: Connection, val message: String): ChatErrorType() @Serializable @SerialName("contactNotReady") class ContactNotReady(val contact: Contact): ChatErrorType() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt index d36a6aec16..b10a30233f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt @@ -21,7 +21,7 @@ expect val appPlatform: AppPlatform val appVersionInfo: Pair = if (appPlatform == AppPlatform.ANDROID) BuildConfigCommon.ANDROID_VERSION_NAME to BuildConfigCommon.ANDROID_VERSION_CODE else - BuildConfigCommon.DESKTOP_VERSION_NAME to null + BuildConfigCommon.DESKTOP_VERSION_NAME to BuildConfigCommon.DESKTOP_VERSION_CODE class FifoQueue(private var capacity: Int) : LinkedList() { override fun add(element: E): Boolean { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt index 543444d0eb..a143057a32 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Modifier.kt @@ -20,3 +20,5 @@ expect fun Modifier.desktopOnExternalDrag( onImage: (Painter) -> Unit = {}, onText: (String) -> Unit = {} ): Modifier + +expect fun Modifier.onRightClick(action: () -> Unit): Modifier diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/UI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/UI.kt index 38629f038b..c61d8564c4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/UI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/UI.kt @@ -14,3 +14,5 @@ expect fun LocalMultiplatformView(): Any? @Composable expect fun getKeyboardState(): State expect fun hideKeyboard(view: Any?) + +expect fun androidIsFinishingMainActivity(): Boolean diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt index fe4da718a8..f601776f98 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt @@ -3,8 +3,6 @@ package chat.simplex.common.views.call import chat.simplex.common.model.ChatModel import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.withApi -import chat.simplex.common.views.helpers.withBGApi -import chat.simplex.common.views.usersettings.showInDevelopingAlert import kotlinx.datetime.Clock import kotlin.time.Duration.Companion.minutes @@ -26,10 +24,6 @@ class CallManager(val chatModel: ChatModel) { } fun acceptIncomingCall(invitation: RcvCallInvitation) { - if (appPlatform.isDesktop) { - return showInDevelopingAlert() - } - val call = chatModel.activeCall.value if (call == null) { justAcceptIncomingCall(invitation = invitation) @@ -58,12 +52,12 @@ class CallManager(val chatModel: ChatModel) { val useRelay = controller.appPrefs.webrtcPolicyRelay.get() val iceServers = getIceServers() Log.d(TAG, "answerIncomingCall iceServers: $iceServers") - callCommand.value = WCallCommand.Start( + callCommand.add(WCallCommand.Start( media = invitation.callType.media, aesKey = invitation.sharedKey, iceServers = iceServers, relay = useRelay - ) + )) callInvitations.remove(invitation.contact.id) if (invitation.contact.id == activeCallInvitation.value?.contact?.id) { activeCallInvitation.value = null @@ -80,7 +74,7 @@ class CallManager(val chatModel: ChatModel) { showCallView.value = false } else { Log.d(TAG, "CallManager.endCall: ending call...") - callCommand.value = WCallCommand.End + callCommand.add(WCallCommand.End) showCallView.value = false controller.apiEndCall(call.contact) activeCall.value = null diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt index 3e14414fc7..4be49d4c07 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt @@ -1,7 +1,5 @@ package chat.simplex.common.views.call -import androidx.compose.runtime.Composable -import dev.icerock.moko.resources.compose.stringResource import chat.simplex.common.views.helpers.generalGetString import chat.simplex.common.model.* import chat.simplex.res.MR @@ -23,16 +21,17 @@ data class Call( val videoEnabled: Boolean = localMedia == CallMediaType.Video, val soundSpeaker: Boolean = localMedia == CallMediaType.Video, var localCamera: VideoCamera = VideoCamera.User, - val connectionInfo: ConnectionInfo? = null + val connectionInfo: ConnectionInfo? = null, + var connectedAt: Instant? = null ) { val encrypted: Boolean get() = localEncrypted && sharedKey != null val localEncrypted: Boolean get() = localCapabilities?.encryption ?: false - val encryptionStatus: String @Composable get() = when(callState) { + val encryptionStatus: String get() = when(callState) { CallState.WaitCapabilities -> "" - CallState.InvitationSent -> stringResource(if (localEncrypted) MR.strings.status_e2e_encrypted else MR.strings.status_no_e2e_encryption) - CallState.InvitationAccepted -> stringResource(if (sharedKey == null) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_contact_has_e2e_encryption) - else -> stringResource(if (!localEncrypted) MR.strings.status_no_e2e_encryption else if (sharedKey == null) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_e2e_encrypted) + CallState.InvitationSent -> generalGetString(if (localEncrypted) MR.strings.status_e2e_encrypted else MR.strings.status_no_e2e_encryption) + CallState.InvitationAccepted -> generalGetString(if (sharedKey == null) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_contact_has_e2e_encryption) + else -> generalGetString(if (!localEncrypted) MR.strings.status_no_e2e_encryption else if (sharedKey == null) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_e2e_encrypted) } val hasMedia: Boolean get() = callState == CallState.OfferSent || callState == CallState.Negotiated || callState == CallState.Connected @@ -49,16 +48,16 @@ enum class CallState { Connected, Ended; - val text: String @Composable get() = when(this) { - WaitCapabilities -> stringResource(MR.strings.callstate_starting) - InvitationSent -> stringResource(MR.strings.callstate_waiting_for_answer) - InvitationAccepted -> stringResource(MR.strings.callstate_starting) - OfferSent -> stringResource(MR.strings.callstate_waiting_for_confirmation) - OfferReceived -> stringResource(MR.strings.callstate_received_answer) - AnswerReceived -> stringResource(MR.strings.callstate_received_confirmation) - Negotiated -> stringResource(MR.strings.callstate_connecting) - Connected -> stringResource(MR.strings.callstate_connected) - Ended -> stringResource(MR.strings.callstate_ended) + val text: String get() = when(this) { + WaitCapabilities -> generalGetString(MR.strings.callstate_starting) + InvitationSent -> generalGetString(MR.strings.callstate_waiting_for_answer) + InvitationAccepted -> generalGetString(MR.strings.callstate_starting) + OfferSent -> generalGetString(MR.strings.callstate_waiting_for_confirmation) + OfferReceived -> generalGetString(MR.strings.callstate_received_answer) + AnswerReceived -> generalGetString(MR.strings.callstate_received_confirmation) + Negotiated -> generalGetString(MR.strings.callstate_connecting) + Connected -> generalGetString(MR.strings.callstate_connected) + Ended -> generalGetString(MR.strings.callstate_ended) } } @@ -67,13 +66,14 @@ enum class CallState { @Serializable sealed class WCallCommand { - @Serializable @SerialName("capabilities") object Capabilities: WCallCommand() + @Serializable @SerialName("capabilities") data class Capabilities(val media: CallMediaType): WCallCommand() @Serializable @SerialName("start") data class Start(val media: CallMediaType, val aesKey: String? = null, val iceServers: List? = null, val relay: Boolean? = null): WCallCommand() @Serializable @SerialName("offer") data class Offer(val offer: String, val iceCandidates: String, val media: CallMediaType, val aesKey: String? = null, val iceServers: List? = null, val relay: Boolean? = null): WCallCommand() @Serializable @SerialName("answer") data class Answer (val answer: String, val iceCandidates: String): WCallCommand() @Serializable @SerialName("ice") data class Ice(val iceCandidates: String): WCallCommand() @Serializable @SerialName("media") data class Media(val media: CallMediaType, val enable: Boolean): WCallCommand() @Serializable @SerialName("camera") data class Camera(val camera: VideoCamera): WCallCommand() + @Serializable @SerialName("description") data class Description(val state: String, val description: String): WCallCommand() @Serializable @SerialName("end") object End: WCallCommand() } @@ -85,6 +85,7 @@ sealed class WCallResponse { @Serializable @SerialName("ice") data class Ice(val iceCandidates: String): WCallResponse() @Serializable @SerialName("connection") data class Connection(val state: ConnectionState): WCallResponse() @Serializable @SerialName("connected") data class Connected(val connectionInfo: ConnectionInfo): WCallResponse() + @Serializable @SerialName("end") object End: WCallResponse() @Serializable @SerialName("ended") object Ended: WCallResponse() @Serializable @SerialName("ok") object Ok: WCallResponse() @Serializable @SerialName("error") data class Error(val message: String): WCallResponse() @@ -106,14 +107,14 @@ sealed class WCallResponse { } @Serializable data class CallCapabilities(val encryption: Boolean) @Serializable data class ConnectionInfo(private val localCandidate: RTCIceCandidate?, private val remoteCandidate: RTCIceCandidate?) { - val text: String @Composable get() { + val text: String get() { val local = localCandidate?.candidateType val remote = remoteCandidate?.candidateType return when { local == RTCIceCandidateType.Host && remote == RTCIceCandidateType.Host -> - stringResource(MR.strings.call_connection_peer_to_peer) + generalGetString(MR.strings.call_connection_peer_to_peer) local == RTCIceCandidateType.Relay && remote == RTCIceCandidateType.Relay -> - stringResource(MR.strings.call_connection_via_relay) + generalGetString(MR.strings.call_connection_via_relay) else -> "${local?.value ?: "unknown"} / ${remote?.value ?: "unknown"}" } 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 1ba742b034..4564a4a6e8 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 @@ -31,10 +31,10 @@ import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.newchat.QRCode import chat.simplex.common.views.usersettings.* import chat.simplex.common.platform.* import chat.simplex.common.views.chatlist.updateChatSettings +import chat.simplex.common.views.newchat.* import chat.simplex.res.MR import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* @@ -196,28 +196,67 @@ sealed class SendReceipts { } fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) { - AlertManager.shared.showAlertDialog( + AlertManager.shared.showAlertDialogButtonsColumn( title = generalGetString(MR.strings.delete_contact_question), - text = generalGetString(MR.strings.delete_contact_all_messages_deleted_cannot_undo_warning), - confirmText = generalGetString(MR.strings.delete_verb), - onConfirm = { - withApi { - val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId) - if (r) { - chatModel.removeChat(chatInfo.id) - if (chatModel.chatId.value == chatInfo.id) { - chatModel.chatId.value = null - ModalManager.end.closeModals() + text = AnnotatedString(generalGetString(MR.strings.delete_contact_all_messages_deleted_cannot_undo_warning)), + buttons = { + Column { + if (chatInfo is ChatInfo.Direct && chatInfo.contact.ready && chatInfo.contact.active) { + // Delete and notify contact + SectionItemView({ + AlertManager.shared.hideAlert() + withApi { + deleteContact(chatInfo, chatModel, close, notify = true) + } + }) { + Text(generalGetString(MR.strings.delete_and_notify_contact), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) } - ntfManager.cancelNotificationsForChat(chatInfo.id) - close?.invoke() + // Delete + SectionItemView({ + AlertManager.shared.hideAlert() + withApi { + deleteContact(chatInfo, chatModel, close, notify = false) + } + }) { + Text(generalGetString(MR.strings.delete_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) + } + } else { + // Delete + SectionItemView({ + AlertManager.shared.hideAlert() + withApi { + deleteContact(chatInfo, chatModel, close) + } + }) { + Text(generalGetString(MR.strings.delete_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) + } + } + // Cancel + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } } - }, - destructive = true, + } ) } +fun deleteContact(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)?, notify: Boolean? = null) { + withApi { + val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId, notify) + if (r) { + chatModel.removeChat(chatInfo.id) + if (chatModel.chatId.value == chatInfo.id) { + chatModel.chatId.value = null + ModalManager.end.closeModals() + } + ntfManager.cancelNotificationsForChat(chatInfo.id) + close?.invoke() + } + } +} + fun clearChatDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.clear_chat_question), @@ -309,9 +348,9 @@ fun ChatInfoLayout( if (contact.contactLink != null) { SectionView(stringResource(MR.strings.address_section_title).uppercase()) { - QRCode(contact.contactLink, Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF).aspectRatio(1f)) + SimpleXLinkQRCode(contact.contactLink, Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF).aspectRatio(1f)) val clipboard = LocalClipboardManager.current - ShareAddressButton { clipboard.shareText(contact.contactLink) } + ShareAddressButton { clipboard.shareText(simplexChatLink(contact.contactLink)) } SectionTextFooter(stringResource(MR.strings.you_can_share_this_address_with_your_contacts).format(contact.displayName)) } SectionDividerSpaced() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt index 53a5fd9d62..63cd25092e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* +import chat.simplex.common.platform.onRightClick import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.chat.item.MarkdownText import chat.simplex.common.views.helpers.* @@ -382,7 +383,7 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d private fun membersStatuses(chatModel: ChatModel, memberDeliveryStatuses: List): List> { return memberDeliveryStatuses.mapNotNull { mds -> - chatModel.groupMembers.firstOrNull { it.groupMemberId == mds.groupMemberId }?.let { mem -> + chatModel.getGroupMember(mds.groupMemberId)?.let { mem -> mem to mds.memberDeliveryStatus } } 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 4afcdacbd8..e724dfd7cb 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 @@ -33,7 +33,6 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.model.GroupInfo import chat.simplex.common.platform.* import chat.simplex.common.platform.AudioPlayer -import chat.simplex.common.views.usersettings.showInDevelopingAlert import chat.simplex.res.MR import kotlinx.coroutines.* import kotlinx.coroutines.flow.* @@ -153,6 +152,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: hideKeyboard(view) AudioPlayer.stop() chatModel.chatId.value = null + chatModel.groupMembers.clear() }, info = { if (ModalManager.end.hasModalsOpen()) { @@ -213,7 +213,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: setGroupMembers(groupInfo, chatModel) ModalManager.end.closeModals() ModalManager.end.showModalCloseable(true) { close -> - remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem -> + remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> GroupMemberInfoView(groupInfo, mem, stats, code, chatModel, close, close) } } @@ -264,33 +264,56 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: } } }, + deleteMessages = { itemIds -> + if (itemIds.isNotEmpty()) { + val chatInfo = chat.chatInfo + withBGApi { + val deletedItems: ArrayList = arrayListOf() + for (itemId in itemIds) { + val di = chatModel.controller.apiDeleteChatItem( + chatInfo.chatType, chatInfo.apiId, itemId, CIDeleteMode.cidmInternal + )?.deletedChatItem?.chatItem + if (di != null) { + deletedItems.add(di) + } + } + for (di in deletedItems) { + chatModel.removeChatItem(chatInfo, di) + } + } + } + }, receiveFile = { fileId, encrypted -> withApi { chatModel.controller.receiveFile(user, fileId, encrypted) } }, cancelFile = { fileId -> withApi { chatModel.controller.cancelFile(user, fileId) } }, - joinGroup = { groupId -> - withApi { chatModel.controller.apiJoinGroup(groupId) } + joinGroup = { groupId, onComplete -> + withApi { + chatModel.controller.apiJoinGroup(groupId) + onComplete.invoke() + } }, startCall = out@ { media -> - if (appPlatform.isDesktop) { - return@out showInDevelopingAlert() - } withBGApi { val cInfo = chat.chatInfo if (cInfo is ChatInfo.Direct) { chatModel.activeCall.value = Call(contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media) chatModel.showCallView.value = true - chatModel.callCommand.value = WCallCommand.Capabilities + chatModel.callCommand.add(WCallCommand.Capabilities(media)) } } }, + endCall = { + val call = chatModel.activeCall.value + if (call != null) withApi { chatModel.callManager.endCall(call) } + }, acceptCall = { contact -> hideKeyboard(view) val invitation = chatModel.callInvitations.remove(contact.id) if (invitation == null) { - AlertManager.shared.showAlertMsg("Call already ended!") + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.call_already_ended)) } else { chatModel.callManager.acceptIncomingCall(invitation = invitation) } @@ -386,6 +409,16 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: } } }, + openGroupLink = { groupInfo -> + hideKeyboard(view) + withApi { + val link = chatModel.controller.apiGetGroupLink(groupInfo.groupId) + ModalManager.end.closeModals() + ModalManager.end.showModalCloseable(true) { + GroupLinkView(chatModel, groupInfo, link?.first, link?.second, onGroupLinkUpdated = null) + } + } + }, markRead = { range, unreadCountAfter -> chatModel.markChatItemsRead(chat.chatInfo, range, unreadCountAfter) ntfManager.cancelNotificationsForChat(chat.id) @@ -429,10 +462,12 @@ fun ChatLayout( showMemberInfo: (GroupInfo, GroupMember) -> Unit, loadPrevMessages: (ChatInfo) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, + deleteMessages: (List) -> Unit, receiveFile: (Long, Boolean) -> Unit, cancelFile: (Long) -> Unit, - joinGroup: (Long) -> Unit, + joinGroup: (Long, () -> Unit) -> Unit, startCall: (CallMediaType) -> Unit, + endCall: () -> Unit, acceptCall: (Contact) -> Unit, acceptFeature: (Contact, ChatFeature, Int?) -> Unit, openDirectChat: (Long) -> Unit, @@ -445,6 +480,7 @@ fun ChatLayout( setReaction: (ChatInfo, ChatItem, Boolean, MsgReaction) -> Unit, showItemDetails: (ChatInfo, ChatItem) -> Unit, addMembers: (GroupInfo) -> Unit, + openGroupLink: (GroupInfo) -> Unit, markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit, changeNtfsState: (Boolean, currentValue: MutableState) -> Unit, onSearchValueChanged: (String) -> Unit, @@ -491,7 +527,7 @@ fun ChatLayout( } Scaffold( - topBar = { ChatInfoToolbar(chat, back, info, startCall, addMembers, changeNtfsState, onSearchValueChanged) }, + topBar = { ChatInfoToolbar(chat, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged) }, bottomBar = composeView, modifier = Modifier.navigationBarsWithImePadding(), floatingActionButton = { floatingButton.value() }, @@ -502,7 +538,7 @@ fun ChatLayout( ) { ChatItemsList( chat, unreadCount, composeState, chatItems, searchValue, - useLinkPreviews, linkMode, showMemberInfo, loadPrevMessages, deleteMessage, + useLinkPreviews, linkMode, showMemberInfo, loadPrevMessages, deleteMessage, deleteMessages, receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat, updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember, setReaction, showItemDetails, markRead, setFloatingButton, onComposed, developerTools, @@ -520,7 +556,9 @@ fun ChatInfoToolbar( back: () -> Unit, info: () -> Unit, startCall: (CallMediaType) -> Unit, + endCall: () -> Unit, addMembers: (GroupInfo) -> Unit, + openGroupLink: (GroupInfo) -> Unit, changeNtfsState: (Boolean, currentValue: MutableState) -> Unit, onSearchValueChanged: (String) -> Unit, ) { @@ -540,6 +578,7 @@ fun ChatInfoToolbar( } val barButtons = arrayListOf<@Composable RowScope.() -> Unit>() val menuItems = arrayListOf<@Composable () -> Unit>() + val activeCall by remember { chatModel.activeCall } menuItems.add { ItemAction(stringResource(MR.strings.search_verb), painterResource(MR.images.ic_search), onClick = { showMenu.value = false @@ -548,20 +587,52 @@ fun ChatInfoToolbar( } if (chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.allowsFeature(ChatFeature.Calls)) { - barButtons.add { - IconButton({ - showMenu.value = false - startCall(CallMediaType.Audio) - }, - enabled = chat.chatInfo.contact.ready && chat.chatInfo.contact.active) { - Icon( - painterResource(MR.images.ic_call_500), - stringResource(MR.strings.icon_descr_more_button), - tint = if (chat.chatInfo.contact.ready && chat.chatInfo.contact.active) MaterialTheme.colors.primary else MaterialTheme.colors.secondary - ) + if (activeCall == null) { + barButtons.add { + IconButton( + { + showMenu.value = false + startCall(CallMediaType.Audio) + }, + enabled = chat.chatInfo.contact.ready && chat.chatInfo.contact.active + ) { + Icon( + painterResource(MR.images.ic_call_500), + stringResource(MR.strings.icon_descr_more_button), + tint = if (chat.chatInfo.contact.ready && chat.chatInfo.contact.active) MaterialTheme.colors.primary else MaterialTheme.colors.secondary + ) + } + } + } else if (activeCall?.contact?.id == chat.id) { + barButtons.add { + val call = remember { chatModel.activeCall }.value + val connectedAt = call?.connectedAt + if (connectedAt != null) { + val time = remember { mutableStateOf(durationText(0)) } + LaunchedEffect(Unit) { + while (true) { + time.value = durationText((Clock.System.now() - connectedAt).inWholeSeconds.toInt()) + delay(250) + } + } + val sp50 = with(LocalDensity.current) { 50.sp.toDp() } + Text(time.value, Modifier.widthIn(min = sp50)) + } + } + barButtons.add { + IconButton({ + showMenu.value = false + endCall() + }) { + Icon( + painterResource(MR.images.ic_call_end_filled), + null, + tint = MaterialTheme.colors.error + ) + } } } - if (chat.chatInfo.contact.ready && chat.chatInfo.contact.active) { + if (chat.chatInfo.contact.ready && chat.chatInfo.contact.active && activeCall == null) { menuItems.add { ItemAction(stringResource(MR.strings.icon_descr_video_call).capitalize(Locale.current), painterResource(MR.images.ic_videocam), onClick = { showMenu.value = false @@ -569,13 +640,24 @@ fun ChatInfoToolbar( }) } } - } else if (chat.chatInfo is ChatInfo.Group && chat.chatInfo.groupInfo.canAddMembers && !chat.chatInfo.incognito) { - barButtons.add { - IconButton({ - showMenu.value = false - addMembers(chat.chatInfo.groupInfo) - }) { - Icon(painterResource(MR.images.ic_person_add_500), stringResource(MR.strings.icon_descr_add_members), tint = MaterialTheme.colors.primary) + } else if (chat.chatInfo is ChatInfo.Group && chat.chatInfo.groupInfo.canAddMembers) { + if (!chat.chatInfo.incognito) { + barButtons.add { + IconButton({ + showMenu.value = false + addMembers(chat.chatInfo.groupInfo) + }) { + Icon(painterResource(MR.images.ic_person_add_500), stringResource(MR.strings.icon_descr_add_members), tint = MaterialTheme.colors.primary) + } + } + } else { + barButtons.add { + IconButton({ + showMenu.value = false + openGroupLink(chat.chatInfo.groupInfo) + }) { + Icon(painterResource(MR.images.ic_add_link), stringResource(MR.strings.group_link), tint = MaterialTheme.colors.primary) + } } } } @@ -683,9 +765,10 @@ fun BoxWithConstraintsScope.ChatItemsList( showMemberInfo: (GroupInfo, GroupMember) -> Unit, loadPrevMessages: (ChatInfo) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit, + deleteMessages: (List) -> Unit, receiveFile: (Long, Boolean) -> Unit, cancelFile: (Long) -> Unit, - joinGroup: (Long) -> Unit, + joinGroup: (Long, () -> Unit) -> Unit, acceptCall: (Contact) -> Unit, acceptFeature: (Contact, ChatFeature, Int?) -> Unit, openDirectChat: (Long) -> Unit, @@ -785,31 +868,27 @@ fun BoxWithConstraintsScope.ChatItemsList( } } } - val voiceWithTransparentBack = cItem.content.msgContent is MsgContent.MCVoice && cItem.content.text.isEmpty() && cItem.quotedItem == null - if (chat.chatInfo is ChatInfo.Group) { - if (cItem.chatDir is CIDirection.GroupRcv) { - val prevItem = if (i < reversedChatItems.lastIndex) reversedChatItems[i + 1] else null - val nextItem = if (i - 1 >= 0) reversedChatItems[i - 1] else null - fun getConnectedMemberNames(): List { - val ns = mutableListOf() - var idx = i - while (idx < reversedChatItems.size) { - val m = reversedChatItems[idx].memberConnected - if (m != null) { - ns.add(m.displayName) - } else { - break - } - idx++ - } - return ns - } - if (cItem.memberConnected != null && nextItem?.memberConnected != null) { - // memberConnected events are aggregated at the last chat item in a row of such events, see ChatItemView - Box(Modifier.size(0.dp)) {} - } else { + + val revealed = remember { mutableStateOf(false) } + + @Composable + fun ChatItemViewShortHand(cItem: ChatItem, range: IntRange?) { + ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, range = range, deleteMessage = deleteMessage, deleteMessages = deleteMessages, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = { _, _ -> }, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools) + } + + @Composable + fun ChatItemView(cItem: ChatItem, range: IntRange?, prevItem: ChatItem?) { + val voiceWithTransparentBack = cItem.content.msgContent is MsgContent.MCVoice && cItem.content.text.isEmpty() && cItem.quotedItem == null + if (chat.chatInfo is ChatInfo.Group) { + if (cItem.chatDir is CIDirection.GroupRcv) { val member = cItem.chatDir.groupMember - if (showMemberImage(member, prevItem)) { + val (prevMember, memCount) = + if (range != null) { + chatModel.getPrevHiddenMember(member, range) + } else { + null to 1 + } + if (prevItem == null || showMemberImage(member, prevItem) || prevMember != null) { Column( Modifier .padding(top = 8.dp) @@ -819,7 +898,7 @@ fun BoxWithConstraintsScope.ChatItemsList( ) { if (cItem.content.showMemberName) { Text( - member.displayName, + memberNames(member, prevMember, memCount), Modifier.padding(start = MEMBER_IMAGE_SIZE + 10.dp), style = TextStyle(fontSize = 13.5.sp, color = CurrentColors.value.colors.secondary) ) @@ -837,7 +916,7 @@ fun BoxWithConstraintsScope.ChatItemsList( ) { MemberImage(member) } - ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, getConnectedMemberNames = ::getConnectedMemberNames, developerTools = developerTools) + ChatItemViewShortHand(cItem, range) } } } else { @@ -846,28 +925,45 @@ fun BoxWithConstraintsScope.ChatItemsList( .padding(start = 8.dp + MEMBER_IMAGE_SIZE + 4.dp, end = if (voiceWithTransparentBack) 12.dp else 66.dp) .then(swipeableModifier) ) { - ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, getConnectedMemberNames = ::getConnectedMemberNames, developerTools = developerTools) + ChatItemViewShortHand(cItem, range) } } + } else { + Box( + Modifier + .padding(start = if (voiceWithTransparentBack) 12.dp else 104.dp, end = 12.dp) + .then(swipeableModifier) + ) { + ChatItemViewShortHand(cItem, range) + } } - } else { + } else { // direct message + val sent = cItem.chatDir.sent Box( - Modifier - .padding(start = if (voiceWithTransparentBack) 12.dp else 104.dp, end = 12.dp) - .then(swipeableModifier) + Modifier.padding( + start = if (sent && !voiceWithTransparentBack) 76.dp else 12.dp, + end = if (sent || voiceWithTransparentBack) 12.dp else 76.dp, + ).then(swipeableModifier) ) { - ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools) + ChatItemViewShortHand(cItem, range) } } - } else { // direct message - val sent = cItem.chatDir.sent - Box( - Modifier.padding( - start = if (sent && !voiceWithTransparentBack) 76.dp else 12.dp, - end = if (sent || voiceWithTransparentBack) 12.dp else 76.dp, - ).then(swipeableModifier) - ) { - ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools) + } + + val (currIndex, nextItem) = chatModel.getNextChatItem(cItem) + val ciCategory = cItem.mergeCategory + if (ciCategory != null && ciCategory == nextItem?.mergeCategory) { + // memberConnected events and deleted items are aggregated at the last chat item in a row, see ChatItemView + } else { + val (prevHidden, prevItem) = chatModel.getPrevShownChatItem(currIndex, ciCategory) + val range = chatViewItemsRange(currIndex, prevHidden) + if (revealed.value && range != null) { + reversedChatItems.subList(range.first, range.last + 1).forEachIndexed { index, ci -> + val prev = if (index + range.first == prevHidden) prevItem else reversedChatItems[index + range.first + 1] + ChatItemView(ci, null, prev) + } + } else { + ChatItemView(cItem, range, prevItem) } } @@ -1045,10 +1141,12 @@ fun PreloadItems( } } -fun showMemberImage(member: GroupMember, prevItem: ChatItem?): Boolean { - return prevItem == null || prevItem.chatDir is CIDirection.GroupSnd || - (prevItem.chatDir is CIDirection.GroupRcv && prevItem.chatDir.groupMember.groupMemberId != member.groupMemberId) -} +private fun showMemberImage(member: GroupMember, prevItem: ChatItem?): Boolean = + when (val dir = prevItem?.chatDir) { + is CIDirection.GroupSnd -> true + is CIDirection.GroupRcv -> dir.groupMember.groupMemberId != member.groupMemberId + else -> false + } val MEMBER_IMAGE_SIZE: Dp = 38.dp @@ -1145,6 +1243,29 @@ private fun markUnreadChatAsRead(activeChat: MutableState, chatModel: Cha } } +@Composable +private fun memberNames(member: GroupMember, prevMember: GroupMember?, memCount: Int): String { + val name = member.displayName + val prevName = prevMember?.displayName + return if (prevName != null) { + if (memCount > 2) { + stringResource(MR.strings.group_members_n).format(name, prevName, memCount - 2) + } else { + stringResource(MR.strings.group_members_2).format(name, prevName) + } + } else { + name + } +} + +fun chatViewItemsRange(currIndex: Int?, prevHidden: Int?): IntRange? = + if (currIndex != null && prevHidden != null && prevHidden > currIndex) { + currIndex..prevHidden + } else { + null + } + + sealed class ProviderMedia { data class Image(val data: ByteArray, val image: ImageBitmap): ProviderMedia() data class Video(val uri: URI, val preview: String): ProviderMedia() @@ -1286,10 +1407,12 @@ fun PreviewChatLayout() { showMemberInfo = { _, _ -> }, loadPrevMessages = { _ -> }, deleteMessage = { _, _ -> }, + deleteMessages = { _ -> }, receiveFile = { _, _ -> }, cancelFile = {}, - joinGroup = {}, + joinGroup = { _, _ -> }, startCall = {}, + endCall = {}, acceptCall = { _ -> }, acceptFeature = { _, _, _ -> }, openDirectChat = { _ -> }, @@ -1302,6 +1425,7 @@ fun PreviewChatLayout() { setReaction = { _, _, _, _ -> }, showItemDetails = { _, _ -> }, addMembers = { _ -> }, + openGroupLink = {}, markRead = { _, _ -> }, changeNtfsState = { _, _ -> }, onSearchValueChanged = {}, @@ -1355,10 +1479,12 @@ fun PreviewGroupChatLayout() { showMemberInfo = { _, _ -> }, loadPrevMessages = { _ -> }, deleteMessage = { _, _ -> }, + deleteMessages = {}, receiveFile = { _, _ -> }, cancelFile = {}, - joinGroup = {}, + joinGroup = { _, _ -> }, startCall = {}, + endCall = {}, acceptCall = { _ -> }, acceptFeature = { _, _, _ -> }, openDirectChat = { _ -> }, @@ -1371,6 +1497,7 @@ fun PreviewGroupChatLayout() { setReaction = { _, _, _, _ -> }, showItemDetails = { _, _ -> }, addMembers = { _ -> }, + openGroupLink = {}, markRead = { _, _ -> }, changeNtfsState = { _, _ -> }, onSearchValueChanged = {}, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index 972bc6621f..84a5879b2b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -324,8 +324,26 @@ fun ComposeView( } fun deleteUnusedFiles() { - chatModel.filesToDelete.forEach { it.delete() } - chatModel.filesToDelete.clear() + val shared = chatModel.sharedContent.value + if (shared == null) { + chatModel.filesToDelete.forEach { it.delete() } + chatModel.filesToDelete.clear() + } else { + val sharedPaths = when (shared) { + is SharedContent.Media -> shared.uris.map { it.toString() } + is SharedContent.File -> listOf(shared.uri.toString()) + is SharedContent.Text -> emptyList() + } + // When sharing a file and pasting it in SimpleX itself, the file shouldn't be deleted before sending or before leaving the chat after sharing + chatModel.filesToDelete.removeAll { file -> + if (sharedPaths.any { it.endsWith(file.name) }) { + false + } else { + file.delete() + true + } + } + } } suspend fun send(cInfo: ChatInfo, mc: MsgContent, quoted: Long?, file: CryptoFile? = null, live: Boolean = false, ttl: Int?): ChatItem? { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index f475d045cf..8c9619703b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -4,10 +4,12 @@ import InfoRow import SectionBottomSpacer import SectionDividerSpaced import SectionItemView +import SectionItemViewLongClickable import SectionSpacer import SectionTextFooter import SectionView import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* import androidx.compose.material.* @@ -31,6 +33,7 @@ import chat.simplex.common.views.usersettings.* import chat.simplex.common.model.GroupInfo import chat.simplex.common.platform.* import chat.simplex.common.views.chat.* +import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.chatlist.* import chat.simplex.res.MR import kotlinx.coroutines.launch @@ -82,7 +85,7 @@ fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, groupLinkMemberR member to null } ModalManager.end.showModalCloseable(true) { closeCurrent -> - remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem -> + remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> GroupMemberInfoView(groupInfo, mem, stats, code, chatModel, closeCurrent) { closeCurrent() close() @@ -157,6 +160,23 @@ fun leaveGroupDialog(groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> U ) } +private fun removeMemberAlert(groupInfo: GroupInfo, mem: GroupMember) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.button_remove_member_question), + text = generalGetString(MR.strings.member_will_be_removed_from_group_cannot_be_undone), + confirmText = generalGetString(MR.strings.remove_member_confirmation), + onConfirm = { + withApi { + val updatedMember = chatModel.controller.apiRemoveMember(groupInfo.groupId, mem.groupMemberId) + if (updatedMember != null) { + chatModel.upsertGroupMember(groupInfo, updatedMember) + } + } + }, + destructive = true, + ) +} + @Composable fun GroupChatInfoLayout( chat: Chat, @@ -238,8 +258,10 @@ fun GroupChatInfoLayout( } items(filteredMembers.value) { member -> Divider() - SectionItemView({ showMemberInfo(member) }, minHeight = 54.dp) { - MemberRow(member) + val showMenu = remember { mutableStateOf(false) } + SectionItemViewLongClickable({ showMemberInfo(member) }, { showMenu.value = true }, minHeight = 54.dp) { + DropDownMenuForMember(member, groupInfo, showMenu) + MemberRow(member, onClick = { showMemberInfo(member) }) } } item { @@ -344,7 +366,7 @@ private fun AddMembersButton(tint: Color = MaterialTheme.colors.primary, onClick } @Composable -private fun MemberRow(member: GroupMember, user: Boolean = false) { +private fun MemberRow(member: GroupMember, user: Boolean = false, onClick: (() -> Unit)? = null) { Row( Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -390,6 +412,29 @@ private fun MemberVerifiedShield() { Icon(painterResource(MR.images.ic_verified_user), null, Modifier.padding(end = 3.dp).size(16.dp), tint = MaterialTheme.colors.secondary) } +@Composable +private fun DropDownMenuForMember(member: GroupMember, groupInfo: GroupInfo, showMenu: MutableState) { + DefaultDropdownMenu(showMenu) { + if (member.canBeRemoved(groupInfo)) { + ItemAction(stringResource(MR.strings.remove_member_button), painterResource(MR.images.ic_delete), color = MaterialTheme.colors.error, onClick = { + removeMemberAlert(groupInfo, member) + showMenu.value = false + }) + } + if (member.memberSettings.showMessages) { + ItemAction(stringResource(MR.strings.block_member_button), painterResource(MR.images.ic_back_hand), color = MaterialTheme.colors.error, onClick = { + blockMemberAlert(groupInfo, member) + showMenu.value = false + }) + } else { + ItemAction(stringResource(MR.strings.unblock_member_button), painterResource(MR.images.ic_do_not_touch), onClick = { + unblockMemberAlert(groupInfo, member) + showMenu.value = false + }) + } + } +} + @Composable private fun GroupLinkButton(onClick: () -> Unit) { SettingsActionItem( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt index 7c767f9b7e..809c7c2fd6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt @@ -19,11 +19,19 @@ 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.newchat.QRCode +import chat.simplex.common.views.newchat.* import chat.simplex.res.MR @Composable -fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: String?, memberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair?) -> Unit) { +fun GroupLinkView( + chatModel: ChatModel, + groupInfo: GroupInfo, + connReqContact: String?, + memberRole: GroupMemberRole?, + onGroupLinkUpdated: ((Pair?) -> Unit)?, + creatingGroup: Boolean = false, + close: (() -> Unit)? = null +) { var groupLink by rememberSaveable { mutableStateOf(connReqContact) } val groupLinkMemberRole = rememberSaveable { mutableStateOf(memberRole) } var creatingLink by rememberSaveable { mutableStateOf(false) } @@ -34,7 +42,7 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St if (link != null) { groupLink = link.first groupLinkMemberRole.value = link.second - onGroupLinkUpdated(link) + onGroupLinkUpdated?.invoke(link) } creatingLink = false } @@ -44,14 +52,12 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St createLink() } } - val clipboard = LocalClipboardManager.current GroupLinkLayout( groupLink = groupLink, groupInfo, groupLinkMemberRole, creatingLink, createLink = ::createLink, - share = { clipboard.shareText(groupLink ?: return@GroupLinkLayout) }, updateLink = { val role = groupLinkMemberRole.value if (role != null) { @@ -60,7 +66,7 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St if (link != null) { groupLink = link.first groupLinkMemberRole.value = link.second - onGroupLinkUpdated(link) + onGroupLinkUpdated?.invoke(link) } } } @@ -75,13 +81,15 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St val r = chatModel.controller.apiDeleteGroupLink(groupInfo.groupId) if (r) { groupLink = null - onGroupLinkUpdated(null) + onGroupLinkUpdated?.invoke(null) } } }, destructive = true, ) - } + }, + creatingGroup = creatingGroup, + close = close ) if (creatingLink) { ProgressIndicator() @@ -95,10 +103,20 @@ fun GroupLinkLayout( groupLinkMemberRole: MutableState, creatingLink: Boolean, createLink: () -> Unit, - share: () -> Unit, updateLink: () -> Unit, - deleteLink: () -> Unit + deleteLink: () -> Unit, + creatingGroup: Boolean = false, + close: (() -> Unit)? = null ) { + @Composable + fun ContinueButton(close: () -> Unit) { + SimpleButton( + stringResource(MR.strings.continue_to_next_step), + icon = painterResource(MR.images.ic_check), + click = close + ) + } + Column( Modifier .verticalScroll(rememberScrollState()), @@ -115,7 +133,16 @@ fun GroupLinkLayout( verticalArrangement = Arrangement.SpaceEvenly ) { if (groupLink == null) { - SimpleButton(stringResource(MR.strings.button_create_group_link), icon = painterResource(MR.images.ic_add_link), disabled = creatingLink, click = createLink) + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = DEFAULT_PADDING, vertical = 10.dp) + ) { + SimpleButton(stringResource(MR.strings.button_create_group_link), icon = painterResource(MR.images.ic_add_link), disabled = creatingLink, click = createLink) + if (creatingGroup && close != null) { + ContinueButton(close) + } + } } else { RoleSelectionRow(groupInfo, groupLinkMemberRole) var initialLaunch by remember { mutableStateOf(true) } @@ -125,23 +152,28 @@ fun GroupLinkLayout( } initialLaunch = false } - QRCode(groupLink, Modifier.aspectRatio(1f).padding(horizontal = DEFAULT_PADDING)) + SimpleXLinkQRCode(groupLink, Modifier.aspectRatio(1f).padding(horizontal = DEFAULT_PADDING)) Row( horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = DEFAULT_PADDING, vertical = 10.dp) ) { + val clipboard = LocalClipboardManager.current SimpleButton( stringResource(MR.strings.share_link), icon = painterResource(MR.images.ic_share), - click = share - ) - SimpleButton( - stringResource(MR.strings.delete_link), - icon = painterResource(MR.images.ic_delete), - color = Color.Red, - click = deleteLink + click = { clipboard.shareText(simplexChatLink(groupLink)) } ) + if (creatingGroup && close != null) { + ContinueButton(close) + } else { + SimpleButton( + stringResource(MR.strings.delete_link), + icon = painterResource(MR.images.ic_delete), + color = Color.Red, + click = deleteLink + ) + } } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index e14089ec52..9f52f61deb 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,7 +3,6 @@ package chat.simplex.common.views.chat.group import InfoRow import SectionBottomSpacer import SectionDividerSpaced -import SectionItemView import SectionSpacer import SectionTextFooter import SectionView @@ -35,7 +34,7 @@ import chat.simplex.common.views.newchat.* import chat.simplex.common.views.usersettings.SettingsActionItem import chat.simplex.common.model.GroupInfo import chat.simplex.common.platform.* -import chat.simplex.common.views.chatlist.openChat +import chat.simplex.common.views.chatlist.openLoadedChat import chat.simplex.res.MR import kotlinx.datetime.Clock @@ -73,6 +72,7 @@ fun GroupMemberInfoView( chatModel.addChat(c) } chatModel.chatItems.clear() + chatModel.chatItemStatuses.clear() chatModel.chatItems.addAll(c.chatItems) chatModel.chatId.value = c.id closeAll() @@ -86,7 +86,7 @@ fun GroupMemberInfoView( if (memberContact != null) { val memberChat = Chat(ChatInfo.Direct(memberContact), chatItems = arrayListOf()) chatModel.addChat(memberChat) - openChat(memberChat, chatModel) + openLoadedChat(memberChat, chatModel) closeAll() chatModel.setContactNetworkStatus(memberContact, NetworkStatus.Connected()) } @@ -96,6 +96,8 @@ fun GroupMemberInfoView( connectViaAddress = { connReqUri -> connectViaMemberAddressAlert(connReqUri) }, + blockMember = { blockMemberAlert(groupInfo, member) }, + unblockMember = { unblockMemberAlert(groupInfo, member) }, removeMember = { removeMemberDialog(groupInfo, member, chatModel, close) }, onRoleSelected = { if (it == newRole.value) return@GroupMemberInfoLayout @@ -162,7 +164,7 @@ fun GroupMemberInfoView( }, verifyClicked = { ModalManager.end.showModalCloseable { close -> - remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem -> + remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> VerifyCodeView( mem.displayName, connectionCode, @@ -224,6 +226,8 @@ fun GroupMemberInfoLayout( openDirectChat: (Long) -> Unit, createMemberContact: () -> Unit, connectViaAddress: (String) -> Unit, + blockMember: () -> Unit, + unblockMember: () -> Unit, removeMember: () -> Unit, onRoleSelected: (GroupMemberRole) -> Unit, switchMemberAddress: () -> Unit, @@ -283,9 +287,9 @@ fun GroupMemberInfoLayout( if (member.contactLink != null) { SectionView(stringResource(MR.strings.address_section_title).uppercase()) { - QRCode(member.contactLink, Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF).aspectRatio(1f)) + SimpleXLinkQRCode(member.contactLink, Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF).aspectRatio(1f)) val clipboard = LocalClipboardManager.current - ShareAddressButton { clipboard.shareText(member.contactLink) } + ShareAddressButton { clipboard.shareText(simplexChatLink(member.contactLink)) } if (contactId != null) { if (knownDirectChat(contactId) == null && !groupInfo.fullGroupPreferences.directMessages.on) { ConnectViaAddressButton(onClick = { connectViaAddress(member.contactLink) }) @@ -338,9 +342,14 @@ fun GroupMemberInfoLayout( } } - if (member.canBeRemoved(groupInfo)) { - SectionDividerSpaced(maxBottomPadding = false) - SectionView { + SectionDividerSpaced(maxBottomPadding = false) + SectionView { + if (member.memberSettings.showMessages) { + BlockMemberButton(blockMember) + } else { + UnblockMemberButton(unblockMember) + } + if (member.canBeRemoved(groupInfo)) { RemoveMemberButton(removeMember) } } @@ -396,6 +405,26 @@ fun GroupMemberInfoHeader(member: GroupMember) { } } +@Composable +fun BlockMemberButton(onClick: () -> Unit) { + SettingsActionItem( + painterResource(MR.images.ic_back_hand), + stringResource(MR.strings.block_member_button), + click = onClick, + textColor = Color.Red, + iconColor = Color.Red, + ) +} + +@Composable +fun UnblockMemberButton(onClick: () -> Unit) { + SettingsActionItem( + painterResource(MR.images.ic_do_not_touch), + stringResource(MR.strings.unblock_member_button), + click = onClick + ) +} + @Composable fun RemoveMemberButton(onClick: () -> Unit) { SettingsActionItem( @@ -472,45 +501,56 @@ private fun updateMemberRoleDialog( } fun connectViaMemberAddressAlert(connReqUri: String) { - AlertManager.shared.showAlertDialogButtonsColumn( - title = generalGetString(MR.strings.connect_via_member_address_alert_title), - 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) - } - } + try { + val uri = URI(connReqUri) + withApi { + planAndConnect(chatModel, uri, incognito = null, close = { ModalManager.closeAllModalsEverywhere() }) } + } catch (e: RuntimeException) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.invalid_connection_link), + text = generalGetString(MR.strings.this_string_is_not_a_connection_link) + ) + } +} + +fun blockMemberAlert(gInfo: GroupInfo, mem: GroupMember) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.block_member_question), + text = generalGetString(MR.strings.block_member_desc).format(mem.chatViewName), + confirmText = generalGetString(MR.strings.block_member_confirmation), + onConfirm = { + toggleShowMemberMessages(gInfo, mem, false) + }, + destructive = true, ) } +fun unblockMemberAlert(gInfo: GroupInfo, mem: GroupMember) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.unblock_member_question), + text = generalGetString(MR.strings.unblock_member_desc).format(mem.chatViewName), + confirmText = generalGetString(MR.strings.unblock_member_confirmation), + onConfirm = { + toggleShowMemberMessages(gInfo, mem, true) + }, + ) +} + +fun toggleShowMemberMessages(gInfo: GroupInfo, member: GroupMember, showMessages: Boolean) { + val updatedMemberSettings = member.memberSettings.copy(showMessages = showMessages) + updateMemberSettings(gInfo, member, updatedMemberSettings) +} + +fun updateMemberSettings(gInfo: GroupInfo, member: GroupMember, memberSettings: GroupMemberSettings) { + withBGApi { + val success = ChatController.apiSetMemberSettings(gInfo.groupId, member.groupMemberId, memberSettings) + if (success) { + ChatModel.upsertGroupMember(gInfo, member.copy(memberSettings = memberSettings)) + } + } +} + @Preview @Composable fun PreviewGroupMemberInfoLayout() { @@ -526,6 +566,8 @@ fun PreviewGroupMemberInfoLayout() { openDirectChat = {}, createMemberContact = {}, connectViaAddress = {}, + blockMember = {}, + unblockMember = {}, removeMember = {}, onRoleSelected = {}, switchMemberAddress = {}, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt index d95d2a4cff..4571a38c13 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt @@ -103,12 +103,11 @@ private fun GroupPreferencesLayout( FeatureSection(GroupFeature.Voice, allowVoice, groupInfo, preferences, onTTLUpdated) { applyPrefs(preferences.copy(voice = GroupPreference(enable = it))) } -// TODO uncomment in 5.3 -// SectionDividerSpaced(true, maxBottomPadding = false) -// val allowFiles = remember(preferences) { mutableStateOf(preferences.files.enable) } -// FeatureSection(GroupFeature.Files, allowFiles, groupInfo, preferences, onTTLUpdated) { -// applyPrefs(preferences.copy(files = GroupPreference(enable = it))) -// } + SectionDividerSpaced(true, maxBottomPadding = false) + val allowFiles = remember(preferences) { mutableStateOf(preferences.files.enable) } + FeatureSection(GroupFeature.Files, allowFiles, groupInfo, preferences, onTTLUpdated) { + applyPrefs(preferences.copy(files = GroupPreference(enable = it))) + } if (groupInfo.canEdit) { SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) ResetSaveButtons( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt index 3e4dce7f4a..6f165515ec 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt @@ -68,7 +68,7 @@ fun AcceptCallButton(cInfo: ChatInfo, acceptCall: (Contact) -> Unit) { // sharedKey: invitation.sharedKey // ) // m.showCallView = true -// m.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey, useWorker: true) +// m.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey: true) // } else { // AlertManager.shared.showAlertMsg(title: "Call already ended!") // } @@ -141,7 +141,7 @@ fun AcceptCallButton(cInfo: ChatInfo, acceptCall: (Contact) -> Unit) { // sharedKey: invitation.sharedKey // ) // m.showCallView = true -// m.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey, useWorker: true) +// m.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey: true) // } else { // AlertManager.shared.showAlertMsg(title: "Call already ended!") // } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt index e919ab1aa8..a9a4963c96 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt @@ -1,19 +1,119 @@ package chat.simplex.common.views.chat.item +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.material.* -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import chat.simplex.common.model.ChatItem -import chat.simplex.common.model.Feature +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatModel.getChatItemIndexOrNull +import chat.simplex.common.platform.onRightClick @Composable fun CIChatFeatureView( + chatItem: ChatItem, + feature: Feature, + iconColor: Color, + icon: Painter? = null, + revealed: MutableState, + showMenu: MutableState, +) { + val merged = if (!revealed.value) mergedFeatures(chatItem) else emptyList() + Box( + Modifier + .combinedClickable( + onLongClick = { showMenu.value = true }, + onClick = {} + ) + .onRightClick { showMenu.value = true } + ) { + if (!revealed.value && merged != null) { + Row( + Modifier.padding(horizontal = 6.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + merged.forEach { + FeatureIconView(it) + } + } + } else { + FullFeatureView(chatItem, feature, iconColor, icon) + } + } +} + +private data class FeatureInfo( + val icon: PainterBox, + val color: Color, + val param: String? +) + +private class PainterBox( + val featureName: String, + val icon: Painter, +) { + override fun hashCode(): Int = featureName.hashCode() + override fun equals(other: Any?): Boolean = other is PainterBox && featureName == other.featureName +} + +@Composable +private fun Feature.toFeatureInfo(color: Color, param: Int?, type: String): FeatureInfo = + FeatureInfo( + icon = PainterBox(type, iconFilled()), + color = color, + param = if (this.hasParam && param != null) timeText(param) else null + ) + +@Composable +private fun mergedFeatures(chatItem: ChatItem): List? { + val m = ChatModel + val fs: ArrayList = arrayListOf() + val icons: MutableSet = mutableSetOf() + var i = getChatItemIndexOrNull(chatItem) + if (i != null) { + val reversedChatItems = m.chatItems.asReversed() + while (i < reversedChatItems.size) { + val f = featureInfo(reversedChatItems[i]) ?: break + if (!icons.contains(f.icon)) { + fs.add(0, f) + icons.add(f.icon) + } + i++ + } + } + return if (fs.size > 1) fs else null +} + +@Composable +private fun featureInfo(ci: ChatItem): FeatureInfo? = + when (ci.content) { + is CIContent.RcvChatFeature -> ci.content.feature.toFeatureInfo(ci.content.enabled.iconColor, ci.content.param, ci.content.feature.name) + is CIContent.SndChatFeature -> ci.content.feature.toFeatureInfo(ci.content.enabled.iconColor, ci.content.param, ci.content.feature.name) + is CIContent.RcvGroupFeature -> ci.content.groupFeature.toFeatureInfo(ci.content.preference.enable.iconColor, ci.content.param, ci.content.groupFeature.name) + is CIContent.SndGroupFeature -> ci.content.groupFeature.toFeatureInfo(ci.content.preference.enable.iconColor, ci.content.param, ci.content.groupFeature.name) + else -> null + } + +@Composable +private fun FeatureIconView(f: FeatureInfo) { + val icon = @Composable { Icon(f.icon.icon, null, Modifier.size(20.dp), tint = f.color) } + if (f.param != null) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + icon() + Text(chatEventText(f.param, ""), maxLines = 1) + } + } else { + icon() + } +} + +@Composable +private fun FullFeatureView( chatItem: ChatItem, feature: Feature, iconColor: Color, @@ -24,7 +124,7 @@ fun CIChatFeatureView( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) ) { - Icon(icon ?: feature.iconFilled(), feature.text, Modifier.size(18.dp), tint = iconColor) + Icon(icon ?: feature.iconFilled(), feature.text, Modifier.size(20.dp), tint = iconColor) Text( chatEventText(chatItem), Modifier, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIEventView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIEventView.kt index 508116f7e3..1e20a372ec 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIEventView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIEventView.kt @@ -1,11 +1,9 @@ package chat.simplex.common.views.chat.item import androidx.compose.desktop.ui.tooling.preview.Preview -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.material.* import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.* import androidx.compose.ui.unit.dp @@ -14,12 +12,7 @@ import chat.simplex.common.ui.theme.* @Composable fun CIEventView(text: AnnotatedString) { - Row( - Modifier.padding(horizontal = 6.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text(text, style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp)) - } + Text(text, Modifier.padding(horizontal = 6.dp, vertical = 6.dp), style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp)) } @Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt index 6ee29b8304..56dd7a360a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import dev.icerock.moko.resources.compose.stringResource @@ -17,6 +18,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.model.* import chat.simplex.res.MR +import kotlinx.coroutines.delay @Composable fun CIGroupInvitationView( @@ -24,16 +26,26 @@ fun CIGroupInvitationView( groupInvitation: CIGroupInvitation, memberRole: GroupMemberRole, chatIncognito: Boolean = false, - joinGroup: (Long) -> Unit + joinGroup: (Long, () -> Unit) -> Unit ) { val sent = ci.chatDir.sent val action = !sent && groupInvitation.status == CIGroupInvitationStatus.Pending + val inProgress = remember { mutableStateOf(false) } + var progressByTimeout by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(inProgress.value) { + progressByTimeout = if (inProgress.value) { + delay(1000) + inProgress.value + } else { + false + } + } @Composable fun groupInfoView() { val p = groupInvitation.groupProfile val iconColor = - if (action) if (chatIncognito) Indigo else MaterialTheme.colors.primary + if (action && !inProgress.value) if (chatIncognito) Indigo else MaterialTheme.colors.primary else if (isInDarkTheme()) FileDark else FileLight Row( @@ -70,8 +82,9 @@ fun CIGroupInvitationView( val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage Surface( - modifier = if (action) Modifier.clickable(onClick = { - joinGroup(groupInvitation.groupId) + modifier = if (action && !inProgress.value) Modifier.clickable(onClick = { + inProgress.value = true + joinGroup(groupInvitation.groupId) { inProgress.value = false } }) else Modifier, shape = RoundedCornerShape(18.dp), color = if (sent) sentColor else receivedColor, @@ -83,26 +96,45 @@ fun CIGroupInvitationView( .padding(start = 8.dp, end = 12.dp), contentAlignment = Alignment.BottomEnd ) { - Column( - Modifier - .defaultMinSize(minWidth = 220.dp) - .padding(bottom = 4.dp), + Box( + contentAlignment = Alignment.Center ) { - groupInfoView() - Column(Modifier.padding(top = 2.dp, start = 5.dp)) { - Divider(Modifier.fillMaxWidth().padding(bottom = 4.dp)) - if (action) { - groupInvitationText() - Text(stringResource( - if (chatIncognito) MR.strings.group_invitation_tap_to_join_incognito else MR.strings.group_invitation_tap_to_join), - color = if (chatIncognito) Indigo else MaterialTheme.colors.primary) - } else { - Box(Modifier.padding(end = 48.dp)) { + Column( + Modifier + .defaultMinSize(minWidth = 220.dp) + .padding(bottom = 4.dp), + ) { + groupInfoView() + Column(Modifier.padding(top = 2.dp, start = 5.dp)) { + Divider(Modifier.fillMaxWidth().padding(bottom = 4.dp)) + if (action) { groupInvitationText() + Text( + stringResource( + if (chatIncognito) MR.strings.group_invitation_tap_to_join_incognito else MR.strings.group_invitation_tap_to_join + ), + color = if (inProgress.value) + MaterialTheme.colors.secondary + else + if (chatIncognito) Indigo else MaterialTheme.colors.primary + ) + } else { + Box(Modifier.padding(end = 48.dp)) { + groupInvitationText() + } } } } + + if (progressByTimeout) { + CircularProgressIndicator( + Modifier.size(32.dp), + color = if (isInDarkTheme()) FileDark else FileLight, + strokeWidth = 3.dp + ) + } } + Text( ci.timestampText, color = MaterialTheme.colors.secondary, @@ -124,7 +156,7 @@ fun PendingCIGroupInvitationViewPreview() { ci = ChatItem.getGroupInvitationSample(), groupInvitation = CIGroupInvitation.getSample(), memberRole = GroupMemberRole.Admin, - joinGroup = {} + joinGroup = { _, _ -> } ) } } @@ -140,7 +172,7 @@ fun CIGroupInvitationViewAcceptedPreview() { ci = ChatItem.getGroupInvitationSample(), groupInvitation = CIGroupInvitation.getSample(status = CIGroupInvitationStatus.Accepted), memberRole = GroupMemberRole.Admin, - joinGroup = {} + joinGroup = { _, _ -> } ) } } @@ -156,7 +188,7 @@ fun CIGroupInvitationViewLongNamePreview() { status = CIGroupInvitationStatus.Accepted ), memberRole = GroupMemberRole.Admin, - joinGroup = {} + joinGroup = { _, _ -> } ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index dd07a3fc19..d2dcfcaeec 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -21,9 +21,8 @@ import androidx.compose.ui.unit.* 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.helpers.* -import chat.simplex.common.views.chat.ComposeContextItem -import chat.simplex.common.views.chat.ComposeState import chat.simplex.res.MR import kotlinx.datetime.Clock @@ -47,10 +46,13 @@ fun ChatItemView( imageProvider: (() -> ImageGalleryProvider)? = null, useLinkPreviews: Boolean, linkMode: SimplexLinkMode, + revealed: MutableState, + range: IntRange?, deleteMessage: (Long, CIDeleteMode) -> Unit, + deleteMessages: (List) -> Unit, receiveFile: (Long, Boolean) -> Unit, cancelFile: (Long) -> Unit, - joinGroup: (Long) -> Unit, + joinGroup: (Long, () -> Unit) -> Unit, acceptCall: (Contact) -> Unit, scrollToItem: (Long) -> Unit, acceptFeature: (Contact, ChatFeature, Int?) -> Unit, @@ -63,14 +65,12 @@ fun ChatItemView( findModelMember: (String) -> GroupMember?, setReaction: (ChatInfo, ChatItem, Boolean, MsgReaction) -> Unit, showItemDetails: (ChatInfo, ChatItem) -> Unit, - getConnectedMemberNames: (() -> List)? = null, developerTools: Boolean, ) { val uriHandler = LocalUriHandler.current val sent = cItem.chatDir.sent val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart val showMenu = remember { mutableStateOf(false) } - val revealed = remember { mutableStateOf(false) } val fullDeleteAllowed = remember(cInfo) { cInfo.featureEnabled(ChatFeature.FullDelete) } val onLinkLongClick = { _: String -> showMenu.value = true } val live = composeState.value.liveMessage != null @@ -178,61 +178,75 @@ fun ChatItemView( fun MsgContentItemDropdownMenu() { val saveFileLauncher = rememberSaveFileLauncher(ciFile = cItem.file) DefaultDropdownMenu(showMenu) { - if (cInfo.featureEnabled(ChatFeature.Reactions) && cItem.allowAddReaction) { - MsgReactionsMenu() - } - if (cItem.meta.itemDeleted == null && !live) { - ItemAction(stringResource(MR.strings.reply_verb), painterResource(MR.images.ic_reply), onClick = { - if (composeState.value.editing) { - composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews) - } else { - composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem)) - } - showMenu.value = false - }) - } - val clipboard = LocalClipboardManager.current - ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = { - val fileSource = getLoadedFileSource(cItem.file) - when { - fileSource != null -> shareFile(cItem.text, fileSource) - else -> clipboard.shareText(cItem.content.text) + if (cItem.content.msgContent != null) { + if (cInfo.featureEnabled(ChatFeature.Reactions) && cItem.allowAddReaction) { + MsgReactionsMenu() } - showMenu.value = false - }) - ItemAction(stringResource(MR.strings.copy_verb), painterResource(MR.images.ic_content_copy), onClick = { - copyItemToClipboard(cItem, clipboard) - showMenu.value = false - }) - if ((cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) && getLoadedFilePath(cItem.file) != null) { - SaveContentItemAction(cItem, saveFileLauncher, showMenu) - } - if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) { - ItemAction(stringResource(MR.strings.edit_verb), painterResource(MR.images.ic_edit_filled), onClick = { - composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews) + if (cItem.meta.itemDeleted == null && !live) { + ItemAction(stringResource(MR.strings.reply_verb), painterResource(MR.images.ic_reply), onClick = { + if (composeState.value.editing) { + composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews) + } else { + composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem)) + } + showMenu.value = false + }) + } + val clipboard = LocalClipboardManager.current + ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = { + val fileSource = getLoadedFileSource(cItem.file) + when { + fileSource != null -> shareFile(cItem.text, fileSource) + else -> clipboard.shareText(cItem.content.text) + } showMenu.value = false }) - } - if (cItem.meta.itemDeleted != null && revealed.value) { - ItemAction( - stringResource(MR.strings.hide_verb), - painterResource(MR.images.ic_visibility_off), - onClick = { - revealed.value = false + ItemAction(stringResource(MR.strings.copy_verb), painterResource(MR.images.ic_content_copy), onClick = { + copyItemToClipboard(cItem, clipboard) + showMenu.value = false + }) + if ((cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) && getLoadedFilePath(cItem.file) != null) { + SaveContentItemAction(cItem, saveFileLauncher, showMenu) + } + if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) { + ItemAction(stringResource(MR.strings.edit_verb), painterResource(MR.images.ic_edit_filled), onClick = { + composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews) showMenu.value = false - } - ) - } - ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - if (cItem.meta.itemDeleted == null && cItem.file != null && cItem.file.cancelAction != null) { - CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction) - } - if (!(live && cItem.meta.isLive)) { - DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage) - } - val groupInfo = cItem.memberToModerate(cInfo)?.first - if (groupInfo != null) { - ModerateItemAction(cItem, questionText = moderateMessageQuestionText(), showMenu, deleteMessage) + }) + } + ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) + if (revealed.value) { + HideItemAction(revealed, showMenu) + } + if (cItem.meta.itemDeleted == null && cItem.file != null && cItem.file.cancelAction != null) { + CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction) + } + if (!(live && cItem.meta.isLive)) { + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + } + val groupInfo = cItem.memberToModerate(cInfo)?.first + if (groupInfo != null) { + ModerateItemAction(cItem, questionText = moderateMessageQuestionText(), showMenu, deleteMessage) + } + } else if (cItem.meta.itemDeleted != null) { + if (revealed.value) { + HideItemAction(revealed, showMenu) + } else if (!cItem.isDeletedContent) { + RevealItemAction(revealed, showMenu) + } else if (range != null) { + ExpandItemAction(revealed, showMenu) + } + ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + } else if (cItem.isDeletedContent) { + ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + } else if (cItem.mergeCategory != null) { + if (revealed.value) { + ShrinkItemAction(revealed, showMenu) + } else { + ExpandItemAction(revealed, showMenu) + } } } } @@ -241,25 +255,18 @@ fun ChatItemView( fun MarkedDeletedItemDropdownMenu() { DefaultDropdownMenu(showMenu) { if (!cItem.isDeletedContent) { - ItemAction( - stringResource(MR.strings.reveal_verb), - painterResource(MR.images.ic_visibility), - onClick = { - revealed.value = true - showMenu.value = false - } - ) + RevealItemAction(revealed, showMenu) } ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) } } @Composable fun ContentItem() { val mc = cItem.content.msgContent - if (cItem.meta.itemDeleted != null && !revealed.value) { - MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL) + if (cItem.meta.itemDeleted != null && (!revealed.value || cItem.isDeletedContent)) { + MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, revealed) MarkedDeletedItemDropdownMenu() } else { if (cItem.quotedItem == null && cItem.meta.itemDeleted == null && !cItem.meta.isLive) { @@ -281,7 +288,7 @@ fun ChatItemView( DeletedItemView(cItem, cInfo.timedMessagesTTL) DefaultDropdownMenu(showMenu) { ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage) + DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) } } @@ -289,9 +296,32 @@ fun ChatItemView( CICallItemView(cInfo, cItem, status, duration, acceptCall) } + fun mergedGroupEventText(chatItem: ChatItem): String? { + val (count, ns) = chatModel.getConnectedMemberNames(chatItem) + val members = when { + ns.size == 1 -> String.format(generalGetString(MR.strings.rcv_group_event_1_member_connected), ns[0]) + ns.size == 2 -> String.format(generalGetString(MR.strings.rcv_group_event_2_members_connected), ns[0], ns[1]) + ns.size == 3 -> String.format(generalGetString(MR.strings.rcv_group_event_3_members_connected), ns[0], ns[1], ns[2]) + ns.size > 3 -> String.format(generalGetString(MR.strings.rcv_group_event_n_members_connected), ns[0], ns[1], ns.size - 2) + else -> "" + } + return if (count <= 1) { + null + } else if (ns.isEmpty()) { + generalGetString(MR.strings.rcv_group_events_count).format(count) + } else if (count > ns.size) { + members + " " + generalGetString(MR.strings.rcv_group_and_other_events).format(count - ns.size) + } else { + members + } + } + fun eventItemViewText(): AnnotatedString { val memberDisplayName = cItem.memberDisplayName - return if (memberDisplayName != null) { + val t = mergedGroupEventText(cItem) + return if (!revealed.value && t != null) { + chatEventText(t, cItem.timestampText) + } else if (memberDisplayName != null) { buildAnnotatedString { withStyle(chatEventStyle) { append(memberDisplayName) } append(" ") @@ -305,35 +335,12 @@ fun ChatItemView( CIEventView(eventItemViewText()) } - fun membersConnectedText(): String? { - return if (getConnectedMemberNames != null) { - val ns = getConnectedMemberNames() - when { - ns.size > 3 -> String.format(generalGetString(MR.strings.rcv_group_event_n_members_connected), ns[0], ns[1], ns.size - 2) - ns.size == 3 -> String.format(generalGetString(MR.strings.rcv_group_event_3_members_connected), ns[0], ns[1], ns[2]) - ns.size == 2 -> String.format(generalGetString(MR.strings.rcv_group_event_2_members_connected), ns[0], ns[1]) - else -> null - } - } else { - null - } - } - - fun membersConnectedItemText(): AnnotatedString { - val t = membersConnectedText() - return if (t != null) { - chatEventText(t, cItem.timestampText) - } else { - eventItemViewText() - } - } - @Composable fun ModeratedItem() { - MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL) + MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, revealed) DefaultDropdownMenu(showMenu) { ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(cItem, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage) + DeleteItemAction(cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage, deleteMessages) } } @@ -352,26 +359,61 @@ fun ChatItemView( is CIContent.RcvDecryptionError -> CIRcvDecryptionError(c.msgDecryptError, c.msgCount, cInfo, cItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember) is CIContent.RcvGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito) is CIContent.SndGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito) - is CIContent.RcvDirectEventContent -> EventItemView() - is CIContent.RcvGroupEventContent -> when (c.rcvGroupEvent) { - is RcvGroupEvent.MemberConnected -> CIEventView(membersConnectedItemText()) - is RcvGroupEvent.MemberCreatedContact -> CIMemberCreatedContactView(cItem, openDirectChat) - else -> EventItemView() + is CIContent.RcvDirectEventContent -> { + EventItemView() + MsgContentItemDropdownMenu() + } + is CIContent.RcvGroupEventContent -> { + when (c.rcvGroupEvent) { + is RcvGroupEvent.MemberCreatedContact -> CIMemberCreatedContactView(cItem, openDirectChat) + else -> EventItemView() + } + MsgContentItemDropdownMenu() + } + is CIContent.SndGroupEventContent -> { + EventItemView() + MsgContentItemDropdownMenu() + } + is CIContent.RcvConnEventContent -> { + EventItemView() + MsgContentItemDropdownMenu() + } + is CIContent.SndConnEventContent -> { + EventItemView() + MsgContentItemDropdownMenu() + } + is CIContent.RcvChatFeature -> { + CIChatFeatureView(cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.SndChatFeature -> { + CIChatFeatureView(cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() } - is CIContent.SndGroupEventContent -> EventItemView() - is CIContent.RcvConnEventContent -> EventItemView() - is CIContent.SndConnEventContent -> EventItemView() - is CIContent.RcvChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor) - is CIContent.SndChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor) is CIContent.RcvChatPreference -> { val ct = if (cInfo is ChatInfo.Direct) cInfo.contact else null CIFeaturePreferenceView(cItem, ct, c.feature, c.allowed, acceptFeature) } - is CIContent.SndChatPreference -> CIChatFeatureView(cItem, c.feature, MaterialTheme.colors.secondary, icon = c.feature.icon,) - is CIContent.RcvGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor) - is CIContent.SndGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor) - is CIContent.RcvChatFeatureRejected -> CIChatFeatureView(cItem, c.feature, Color.Red) - is CIContent.RcvGroupFeatureRejected -> CIChatFeatureView(cItem, c.groupFeature, Color.Red) + is CIContent.SndChatPreference -> { + CIChatFeatureView(cItem, c.feature, MaterialTheme.colors.secondary, icon = c.feature.icon, revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.RcvGroupFeature -> { + CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.SndGroupFeature -> { + CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.RcvChatFeatureRejected -> { + CIChatFeatureView(cItem, c.feature, Color.Red, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.RcvGroupFeatureRejected -> { + CIChatFeatureView(cItem, c.groupFeature, Color.Red, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } is CIContent.SndModerated -> ModeratedItem() is CIContent.RcvModerated -> ModeratedItem() is CIContent.InvalidJSON -> CIInvalidJSONView(c.json) @@ -430,16 +472,38 @@ fun ItemInfoAction( @Composable fun DeleteItemAction( cItem: ChatItem, + revealed: MutableState, showMenu: MutableState, questionText: String, - deleteMessage: (Long, CIDeleteMode) -> Unit + deleteMessage: (Long, CIDeleteMode) -> Unit, + deleteMessages: (List) -> Unit, ) { ItemAction( stringResource(MR.strings.delete_verb), painterResource(MR.images.ic_delete), onClick = { showMenu.value = false - deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage) + if (!revealed.value && cItem.meta.itemDeleted != null) { + val currIndex = chatModel.getChatItemIndexOrNull(cItem) + val ciCategory = cItem.mergeCategory + if (currIndex != null && ciCategory != null) { + val (prevHidden, _) = chatModel.getPrevShownChatItem(currIndex, ciCategory) + val range = chatViewItemsRange(currIndex, prevHidden) + if (range != null) { + val itemIds: ArrayList = arrayListOf() + for (i in range) { + itemIds.add(chatModel.chatItems.asReversed()[i].id) + } + deleteMessagesAlertDialog(itemIds, generalGetString(MR.strings.delete_message_mark_deleted_warning), deleteMessages = deleteMessages) + } else { + deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage) + } + } else { + deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage) + } + } else { + deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage) + } }, color = Color.Red ) @@ -463,6 +527,54 @@ fun ModerateItemAction( ) } +@Composable +private fun RevealItemAction(revealed: MutableState, showMenu: MutableState) { + ItemAction( + stringResource(MR.strings.reveal_verb), + painterResource(MR.images.ic_visibility), + onClick = { + revealed.value = true + showMenu.value = false + } + ) +} + +@Composable +private fun HideItemAction(revealed: MutableState, showMenu: MutableState) { + ItemAction( + stringResource(MR.strings.hide_verb), + painterResource(MR.images.ic_visibility_off), + onClick = { + revealed.value = false + showMenu.value = false + } + ) +} + +@Composable +private fun ExpandItemAction(revealed: MutableState, showMenu: MutableState) { + ItemAction( + stringResource(MR.strings.expand_verb), + painterResource(MR.images.ic_expand_all), + onClick = { + revealed.value = true + showMenu.value = false + }, + ) +} + +@Composable +private fun ShrinkItemAction(revealed: MutableState, showMenu: MutableState) { + ItemAction( + stringResource(MR.strings.hide_verb), + painterResource(MR.images.ic_collapse_all), + onClick = { + revealed.value = false + showMenu.value = false + }, + ) +} + @Composable fun ItemAction(text: String, icon: Painter, onClick: () -> Unit, color: Color = Color.Unspecified) { val finalColor = if (color == Color.Unspecified) { @@ -542,6 +654,26 @@ fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMes ) } +fun deleteMessagesAlertDialog(itemIds: List, questionText: String, deleteMessages: (List) -> Unit) { + AlertManager.shared.showAlertDialogButtons( + title = generalGetString(MR.strings.delete_messages__question).format(itemIds.size), + text = questionText, + buttons = { + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 2.dp), + horizontalArrangement = Arrangement.Center, + ) { + TextButton(onClick = { + deleteMessages(itemIds) + AlertManager.shared.hideAlert() + }) { Text(stringResource(MR.strings.for_me_only), color = MaterialTheme.colors.error) } + } + } + ) +} + fun moderateMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.delete_member_message__question), @@ -575,10 +707,13 @@ fun PreviewChatItemView() { useLinkPreviews = true, linkMode = SimplexLinkMode.DESCRIPTION, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, + revealed = remember { mutableStateOf(false) }, + range = 0..1, deleteMessage = { _, _ -> }, + deleteMessages = { _ -> }, receiveFile = { _, _ -> }, cancelFile = {}, - joinGroup = {}, + joinGroup = { _, _ -> }, acceptCall = { _ -> }, scrollToItem = {}, acceptFeature = { _, _, _ -> }, @@ -606,10 +741,13 @@ fun PreviewChatItemViewDeletedContent() { useLinkPreviews = true, linkMode = SimplexLinkMode.DESCRIPTION, composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) }, + revealed = remember { mutableStateOf(false) }, + range = 0..1, deleteMessage = { _, _ -> }, + deleteMessages = { _ -> }, receiveFile = { _, _ -> }, cancelFile = {}, - joinGroup = {}, + joinGroup = { _, _ -> }, acceptCall = { _ -> }, scrollToItem = {}, acceptFeature = { _, _, _ -> }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index 122e54c3b2..c391200c2d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -20,10 +20,9 @@ import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* import chat.simplex.common.model.* -import chat.simplex.common.platform.appPlatform +import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* -import chat.simplex.common.platform.base64ToBitmap import chat.simplex.common.views.chat.MEMBER_IMAGE_SIZE import chat.simplex.res.MR import kotlin.math.min @@ -202,10 +201,16 @@ fun FramedItemView( Column(Modifier.width(IntrinsicSize.Max)) { PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID) { if (ci.meta.itemDeleted != null) { - if (ci.meta.itemDeleted is CIDeleted.Moderated) { - FramedItemHeader(String.format(stringResource(MR.strings.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName), true, painterResource(MR.images.ic_flag)) - } else { - FramedItemHeader(stringResource(MR.strings.marked_deleted_description), true, painterResource(MR.images.ic_delete)) + when (ci.meta.itemDeleted) { + is CIDeleted.Moderated -> { + FramedItemHeader(String.format(stringResource(MR.strings.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName), true, painterResource(MR.images.ic_flag)) + } + is CIDeleted.Blocked -> { + FramedItemHeader(stringResource(MR.strings.blocked_item_description), true, painterResource(MR.images.ic_back_hand)) + } + else -> { + FramedItemHeader(stringResource(MR.strings.marked_deleted_description), true, painterResource(MR.images.ic_delete)) + } } } else if (ci.meta.isLive) { FramedItemHeader(stringResource(MR.strings.live), false) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt index 4d4d847cc7..c7268592bf 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt @@ -32,7 +32,12 @@ interface ImageGalleryProvider { @Composable fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () -> Unit) { val provider = remember { imageProvider() } - val pagerState = rememberPagerState(provider.initialIndex) + val pagerState = rememberPagerState( + initialPage = provider.initialIndex, + initialPageOffsetFraction = 0f + ) { + provider.totalMediaSize.value + } val goBack = { provider.onDismiss(pagerState.currentPage); close() } BackHandler(onBack = goBack) // Pager doesn't ask previous page at initialization step who knows why. By not doing this, prev page is not checked and can be blank, @@ -138,7 +143,7 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () -> } } if (appPlatform.isAndroid) { - HorizontalPager(pageCount = remember { provider.totalMediaSize }.value, state = pagerState) { index -> Content(index) } + HorizontalPager(state = pagerState) { index -> Content(index) } } else { Content(pagerState.currentPage) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt index 84675a09b4..50d905ef76 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt @@ -2,25 +2,25 @@ package chat.simplex.common.views.chat.item import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextOverflow import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.runtime.* import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import chat.simplex.common.model.CIDeleted -import chat.simplex.common.model.ChatItem +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatModel.getChatItemIndexOrNull import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.generalGetString import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.stringResource import kotlinx.datetime.Clock @Composable -fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?) { +fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, revealed: MutableState) { val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage Surface( @@ -32,11 +32,7 @@ fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?) { verticalAlignment = Alignment.CenterVertically ) { Box(Modifier.weight(1f, false)) { - if (ci.meta.itemDeleted is CIDeleted.Moderated) { - MarkedDeletedText(String.format(generalGetString(MR.strings.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName)) - } else { - MarkedDeletedText(generalGetString(MR.strings.marked_deleted_description)) - } + MergedMarkedDeletedText(ci, revealed) } CIMetaView(ci, timedMessagesTTL) } @@ -44,7 +40,41 @@ fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?) { } @Composable -private fun MarkedDeletedText(text: String) { +private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: MutableState) { + var i = getChatItemIndexOrNull(chatItem) + val ciCategory = chatItem.mergeCategory + val text = if (!revealed.value && ciCategory != null && i != null) { + val reversedChatItems = ChatModel.chatItems.asReversed() + var moderated = 0 + var blocked = 0 + var deleted = 0 + val moderatedBy: MutableSet = mutableSetOf() + while (i < reversedChatItems.size) { + val ci = reversedChatItems.getOrNull(i) + if (ci?.mergeCategory != ciCategory) break + when (val itemDeleted = ci.meta.itemDeleted ?: break) { + is CIDeleted.Moderated -> { + moderated += 1 + moderatedBy.add(itemDeleted.byGroupMember.displayName) + } + is CIDeleted.Blocked -> blocked += 1 + is CIDeleted.Deleted -> deleted += 1 + } + i++ + } + val total = moderated + blocked + deleted + if (total <= 1) + markedDeletedText(chatItem.meta) + else if (total == moderated) + stringResource(MR.strings.moderated_items_description).format(total, moderatedBy.joinToString(", ")) + else if (total == blocked) + stringResource(MR.strings.blocked_items_description).format(total) + else + stringResource(MR.strings.marked_deleted_items_description).format(total) + } else { + markedDeletedText(chatItem.meta) + } + Text( buildAnnotatedString { withStyle(SpanStyle(fontSize = 12.sp, fontStyle = FontStyle.Italic, color = MaterialTheme.colors.secondary)) { append(text) } @@ -56,6 +86,16 @@ private fun MarkedDeletedText(text: String) { ) } +private fun markedDeletedText(meta: CIMeta): String = + when (meta.itemDeleted) { + is CIDeleted.Moderated -> + String.format(generalGetString(MR.strings.moderated_item_description), meta.itemDeleted.byGroupMember.displayName) + is CIDeleted.Blocked -> + generalGetString(MR.strings.blocked_item_description) + else -> + generalGetString(MR.strings.marked_deleted_description) + } + @Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, name = "Dark Mode" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt index eabab138ba..ff1267d0fa 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt @@ -136,12 +136,8 @@ fun MarkdownText ( val link = ft.link(linkMode) if (link != null) { hasLinks = true - val ftStyle = if (ft.format is Format.SimplexLink && !ft.format.trustedUri && linkMode == SimplexLinkMode.BROWSER) { - SpanStyle(color = Color.Red, textDecoration = TextDecoration.Underline) - } else { - ft.format.style - } - withAnnotation(tag = if (ft.format is Format.SimplexLink && linkMode != SimplexLinkMode.BROWSER) "SIMPLEX_URL" else "URL", annotation = link) { + val ftStyle = ft.format.style + withAnnotation(tag = if (ft.format is Format.SimplexLink) "SIMPLEX_URL" else "URL", annotation = link) { withStyle(ftStyle) { append(ft.viewText(linkMode)) } } } else { 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 41c94f21d9..bcabb7cfd4 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 @@ -4,7 +4,6 @@ import SectionItemView import androidx.compose.foundation.layout.* 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.LocalDensity @@ -13,10 +12,7 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.desktop.ui.tooling.preview.Preview -import androidx.compose.foundation.* -import androidx.compose.foundation.interaction.InteractionSource -import androidx.compose.ui.graphics.drawscope.ContentDrawScope -import androidx.compose.ui.platform.LocalViewConfiguration +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -49,11 +45,22 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { } val selectedChat = remember(chat.id) { derivedStateOf { chat.id == ChatModel.chatId.value } } val showChatPreviews = chatModel.showChatPreviews.value + val inProgress = remember { mutableStateOf(false) } + var progressByTimeout by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(inProgress.value) { + progressByTimeout = if (inProgress.value) { + delay(1000) + inProgress.value + } else { + false + } + } + when (chat.chatInfo) { is ChatInfo.Direct -> { val contactNetworkStatus = chatModel.contactNetworkStatus(chat.chatInfo.contact) ChatListNavLinkLayout( - chatLinkPreview = { ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, stopped, linkMode) }, + chatLinkPreview = { ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, stopped, linkMode, inProgress = false, progressByTimeout = false) }, click = { directChatAction(chat.chatInfo, chatModel) }, dropdownMenuItems = { ContactMenuItems(chat, chatModel, showMenu, showMarkRead) }, showMenu, @@ -63,9 +70,9 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) { } is ChatInfo.Group -> ChatListNavLinkLayout( - chatLinkPreview = { ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, stopped, linkMode) }, - click = { groupChatAction(chat.chatInfo.groupInfo, chatModel) }, - dropdownMenuItems = { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, showMarkRead) }, + chatLinkPreview = { ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, stopped, linkMode, inProgress.value, progressByTimeout) }, + click = { if (!inProgress.value) groupChatAction(chat.chatInfo.groupInfo, chatModel, inProgress) }, + dropdownMenuItems = { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, inProgress, showMarkRead) }, showMenu, stopped, selectedChat @@ -115,9 +122,9 @@ fun directChatAction(chatInfo: ChatInfo, chatModel: ChatModel) { withBGApi { openChat(chatInfo, chatModel) } } -fun groupChatAction(groupInfo: GroupInfo, chatModel: ChatModel) { +fun groupChatAction(groupInfo: GroupInfo, chatModel: ChatModel, inProgress: MutableState? = null) { when (groupInfo.membership.memberStatus) { - GroupMemberStatus.MemInvited -> acceptGroupInvitationAlertDialog(groupInfo, chatModel) + GroupMemberStatus.MemInvited -> acceptGroupInvitationAlertDialog(groupInfo, chatModel, inProgress) GroupMemberStatus.MemAccepted -> groupInvitationAcceptedAlert() else -> withBGApi { openChat(ChatInfo.Group(groupInfo), chatModel) } } @@ -126,7 +133,14 @@ fun groupChatAction(groupInfo: GroupInfo, chatModel: ChatModel) { suspend fun openDirectChat(contactId: Long, chatModel: ChatModel) { val chat = chatModel.controller.apiGetChat(ChatType.Direct, contactId) if (chat != null) { - openChat(chat, chatModel) + openLoadedChat(chat, chatModel) + } +} + +suspend fun openGroupChat(groupId: Long, chatModel: ChatModel) { + val chat = chatModel.controller.apiGetChat(ChatType.Group, groupId) + if (chat != null) { + openLoadedChat(chat, chatModel) } } @@ -134,13 +148,14 @@ suspend fun openChat(chatInfo: ChatInfo, chatModel: ChatModel) { Log.d(TAG, "TODOCHAT: openChat: opening ${chatInfo.id}, current chatId ${ChatModel.chatId.value}, size ${ChatModel.chatItems.size}") val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId) if (chat != null) { - openChat(chat, chatModel) + openLoadedChat(chat, chatModel) Log.d(TAG, "TODOCHAT: openChat: opened ${chatInfo.id}, current chatId ${ChatModel.chatId.value}, size ${ChatModel.chatItems.size}") } } -suspend fun openChat(chat: Chat, chatModel: ChatModel) { +fun openLoadedChat(chat: Chat, chatModel: ChatModel) { chatModel.chatItems.clear() + chatModel.chatItemStatuses.clear() chatModel.chatItems.addAll(chat.chatItems) chatModel.chatId.value = chat.chatInfo.id } @@ -190,10 +205,19 @@ fun ContactMenuItems(chat: Chat, chatModel: ChatModel, showMenu: MutableState, showMarkRead: Boolean) { +fun GroupMenuItems( + chat: Chat, + groupInfo: GroupInfo, + chatModel: ChatModel, + showMenu: MutableState, + inProgress: MutableState, + showMarkRead: Boolean +) { when (groupInfo.membership.memberStatus) { GroupMemberStatus.MemInvited -> { - JoinGroupAction(chat, groupInfo, chatModel, showMenu) + if (!inProgress.value) { + JoinGroupAction(chat, groupInfo, chatModel, showMenu, inProgress) + } if (groupInfo.canDelete) { DeleteGroupAction(chat, groupInfo, chatModel, showMenu) } @@ -314,8 +338,20 @@ fun DeleteGroupAction(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, sh } @Composable -fun JoinGroupAction(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState) { - val joinGroup: () -> Unit = { withApi { chatModel.controller.apiJoinGroup(groupInfo.groupId) } } +fun JoinGroupAction( + chat: Chat, + groupInfo: GroupInfo, + chatModel: ChatModel, + showMenu: MutableState, + inProgress: MutableState +) { + val joinGroup: () -> Unit = { + withApi { + inProgress.value = true + chatModel.controller.apiJoinGroup(groupInfo.groupId) + inProgress.value = false + } + } ItemAction( if (chat.chatInfo.incognito) stringResource(MR.strings.join_group_incognito_button) else stringResource(MR.strings.join_group_button), if (chat.chatInfo.incognito) painterResource(MR.images.ic_theater_comedy_filled) else painterResource(MR.images.ic_login), @@ -555,12 +591,18 @@ fun pendingContactAlertDialog(chatInfo: ChatInfo, chatModel: ChatModel) { ) } -fun acceptGroupInvitationAlertDialog(groupInfo: GroupInfo, chatModel: ChatModel) { +fun acceptGroupInvitationAlertDialog(groupInfo: GroupInfo, chatModel: ChatModel, inProgress: MutableState? = null) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.join_group_question), text = generalGetString(MR.strings.you_are_invited_to_group_join_to_connect_with_group_members), confirmText = if (groupInfo.membership.memberIncognito) generalGetString(MR.strings.join_group_incognito_button) else generalGetString(MR.strings.join_group_button), - onConfirm = { withApi { chatModel.controller.apiJoinGroup(groupInfo.groupId) } }, + onConfirm = { + withApi { + inProgress?.value = true + chatModel.controller.apiJoinGroup(groupInfo.groupId) + inProgress?.value = false + } + }, dismissText = generalGetString(MR.strings.delete_verb), onDismiss = { deleteGroup(groupInfo, chatModel) } ) @@ -595,8 +637,8 @@ fun groupInvitationAcceptedAlert() { ) } -fun toggleNotifications(chat: Chat, enableNtfs: Boolean, chatModel: ChatModel, currentState: MutableState? = null) { - val chatSettings = (chat.chatInfo.chatSettings ?: ChatSettings.defaults).copy(enableNtfs = enableNtfs) +fun toggleNotifications(chat: Chat, enableAllNtfs: Boolean, chatModel: ChatModel, currentState: MutableState? = null) { + val chatSettings = (chat.chatInfo.chatSettings ?: ChatSettings.defaults).copy(enableNtfs = if (enableAllNtfs) MsgFilter.All else MsgFilter.None) updateChatSettings(chat, chatSettings, chatModel, currentState) } @@ -627,7 +669,7 @@ fun updateChatSettings(chat: Chat, chatSettings: ChatSettings, chatModel: ChatMo } if (res && newChatInfo != null) { chatModel.updateChatInfo(newChatInfo) - if (!chatSettings.enableNtfs) { + if (chatSettings.enableNtfs != MsgFilter.All) { ntfManager.cancelNotificationsForChat(chat.id) } val current = currentState?.value @@ -677,7 +719,9 @@ fun PreviewChatListNavLinkDirect() { null, null, stopped = false, - linkMode = SimplexLinkMode.DESCRIPTION + linkMode = SimplexLinkMode.DESCRIPTION, + inProgress = false, + progressByTimeout = false ) }, click = {}, @@ -718,7 +762,9 @@ fun PreviewChatListNavLinkGroup() { null, null, stopped = false, - linkMode = SimplexLinkMode.DESCRIPTION + linkMode = SimplexLinkMode.DESCRIPTION, + inProgress = false, + progressByTimeout = false ) }, click = {}, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 6d7450a213..4df62e12e3 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 @@ -12,6 +12,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.* import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.AnnotatedString @@ -29,6 +30,9 @@ import chat.simplex.common.views.onboarding.shouldShowWhatsNew import chat.simplex.common.views.usersettings.SettingsView import chat.simplex.common.views.usersettings.simplexTeamUri import chat.simplex.common.platform.* +import chat.simplex.common.views.call.Call +import chat.simplex.common.views.call.CallMediaType +import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.newchat.* import chat.simplex.res.MR import kotlinx.coroutines.* @@ -121,6 +125,7 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf } } if (searchInList.isEmpty()) { + DesktopActiveCallOverlayLayout(newChatSheetState) NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet) } if (appPlatform.isAndroid) { @@ -311,51 +316,16 @@ private fun ProgressIndicator() { ) } +@Composable +expect fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow) + fun connectIfOpenedViaUri(uri: URI, chatModel: ChatModel) { Log.d(TAG, "connectIfOpenedViaUri: opened via link") if (chatModel.currentUser.value == null) { chatModel.appOpenUrl.value = uri } else { - withUriAction(uri) { linkType -> - val title = when (linkType) { - ConnectionLinkType.CONTACT -> generalGetString(MR.strings.connect_via_contact_link) - ConnectionLinkType.INVITATION -> generalGetString(MR.strings.connect_via_invitation_link) - ConnectionLinkType.GROUP -> generalGetString(MR.strings.connect_via_group_link) - } - AlertManager.shared.showAlertDialogButtonsColumn( - title = title, - text = if (linkType == ConnectionLinkType.GROUP) - AnnotatedString(generalGetString(MR.strings.you_will_join_group)) - else - 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) - } - } - } - ) + withApi { + planAndConnect(chatModel, uri, incognito = null, close = null) } } } 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 a5775d369b..d3413e2e08 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt @@ -37,7 +37,9 @@ fun ChatPreviewView( currentUserProfileDisplayName: String?, contactNetworkStatus: NetworkStatus?, stopped: Boolean, - linkMode: SimplexLinkMode + linkMode: SimplexLinkMode, + inProgress: Boolean, + progressByTimeout: Boolean ) { val cInfo = chat.chatInfo @@ -135,7 +137,12 @@ fun ChatPreviewView( } is ChatInfo.Group -> when (cInfo.groupInfo.membership.memberStatus) { - GroupMemberStatus.MemInvited -> chatPreviewTitleText(if (chat.chatInfo.incognito) Indigo else MaterialTheme.colors.primary) + GroupMemberStatus.MemInvited -> chatPreviewTitleText( + if (inProgress) + MaterialTheme.colors.secondary + else + if (chat.chatInfo.incognito) Indigo else MaterialTheme.colors.primary + ) GroupMemberStatus.MemAccepted -> chatPreviewTitleText(MaterialTheme.colors.secondary) else -> chatPreviewTitleText() } @@ -194,6 +201,17 @@ fun ChatPreviewView( } } + @Composable + fun progressView() { + CircularProgressIndicator( + Modifier + .padding(horizontal = 2.dp) + .size(15.dp), + color = MaterialTheme.colors.secondary, + strokeWidth = 1.5.dp + ) + } + @Composable fun chatStatusImage() { if (cInfo is ChatInfo.Direct) { @@ -213,17 +231,17 @@ fun ChatPreviewView( ) else -> - CircularProgressIndicator( - Modifier - .padding(horizontal = 2.dp) - .size(15.dp), - color = MaterialTheme.colors.secondary, - strokeWidth = 1.5.dp - ) + progressView() } } else { IncognitoIcon(chat.chatInfo.incognito) } + } else if (cInfo is ChatInfo.Group) { + if (progressByTimeout) { + progressView() + } else { + IncognitoIcon(chat.chatInfo.incognito) + } } else { IncognitoIcon(chat.chatInfo.incognito) } @@ -351,6 +369,6 @@ fun unreadCountStr(n: Int): String { @Composable fun PreviewChatPreviewView() { SimpleXTheme { - ChatPreviewView(Chat.sampleData, true, null, null, "", contactNetworkStatus = NetworkStatus.Connected(), stopped = false, linkMode = SimplexLinkMode.DESCRIPTION) + ChatPreviewView(Chat.sampleData, true, null, null, "", contactNetworkStatus = NetworkStatus.Connected(), stopped = false, linkMode = SimplexLinkMode.DESCRIPTION, inProgress = false, progressByTimeout = false) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt index fa0f8f54d1..0cca354746 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt @@ -20,11 +20,13 @@ import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import chat.simplex.common.model.* +import chat.simplex.common.model.ChatModel.updatingChatsMutex import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* import chat.simplex.common.platform.* import chat.simplex.res.MR +import kotlinx.coroutines.sync.withLock import kotlinx.datetime.* import java.io.* import java.net.URI @@ -620,8 +622,10 @@ private fun afterSetCiTTL( appFilesCountAndSize.value = directoryFileCountAndSize(appFilesDir.absolutePath) withApi { try { - val chats = m.controller.apiGetChats() - m.updateChats(chats) + updatingChatsMutex.withLock { + val chats = m.controller.apiGetChats() + m.updateChats(chats) + } } catch (e: Exception) { Log.e(TAG, "apiGetChats error: ${e.message}") } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt index d8466e9d96..35d5b8b3ef 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt @@ -50,11 +50,12 @@ class AlertManager { fun showAlertDialogButtonsColumn( title: String, text: AnnotatedString? = null, + onDismissRequest: (() -> Unit)? = null, buttons: @Composable () -> Unit, ) { showAlert { AlertDialog( - onDismissRequest = ::hideAlert, + onDismissRequest = { onDismissRequest?.invoke(); hideAlert() }, title = { Text( title, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt index 3a799eddf8..abc894942f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt @@ -81,6 +81,35 @@ fun ProfileImage( } } +/** [AccountCircleFilled] has its inner padding which leads to visible border if there is background underneath. + * This is workaround + * */ +@Composable +fun ProfileImageForActiveCall( + size: Dp, + image: String? = null, + color: Color = MaterialTheme.colors.secondaryVariant, +) { + if (image == null) { + Box(Modifier.requiredSize(size).clip(CircleShape)) { + Icon( + AccountCircleFilled, + contentDescription = stringResource(MR.strings.icon_descr_profile_image_placeholder), + tint = color, + modifier = Modifier.requiredSize(size + 14.dp) + ) + } + } else { + val imageBitmap = base64ToBitmap(image) + Image( + imageBitmap, + stringResource(MR.strings.image_descr_profile_image), + contentScale = ContentScale.Crop, + modifier = Modifier.size(size).clip(CircleShape) + ) + } +} + @Preview @Composable diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultDropdownMenu.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultDropdownMenu.kt index f3aec77e0b..267fc86462 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultDropdownMenu.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DefaultDropdownMenu.kt @@ -11,26 +11,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp -expect fun Modifier.onRightClick(action: () -> Unit): Modifier - -expect interface DefaultExposedDropdownMenuBoxScope { - @Composable - open fun DefaultExposedDropdownMenu( - expanded: Boolean, - onDismissRequest: () -> Unit, - modifier: Modifier = Modifier, - content: @Composable ColumnScope.() -> Unit - ) -} - -@Composable -expect fun DefaultExposedDropdownMenuBox( - expanded: Boolean, - onExpandedChange: (Boolean) -> Unit, - modifier: Modifier = Modifier, - content: @Composable DefaultExposedDropdownMenuBoxScope.() -> Unit -) - @Composable fun DefaultDropdownMenu( showMenu: MutableState, @@ -55,7 +35,7 @@ fun DefaultDropdownMenu( } @Composable -fun DefaultExposedDropdownMenuBoxScope.DefaultExposedDropdownMenu( +fun ExposedDropdownMenuBoxScope.DefaultExposedDropdownMenu( expanded: MutableState, modifier: Modifier = Modifier, dropdownMenuItems: (@Composable () -> Unit)? @@ -63,7 +43,7 @@ fun DefaultExposedDropdownMenuBoxScope.DefaultExposedDropdownMenu( MaterialTheme( shapes = MaterialTheme.shapes.copy(medium = RoundedCornerShape(corner = CornerSize(25.dp))) ) { - DefaultExposedDropdownMenu( + ExposedDropdownMenu( modifier = Modifier .widthIn(min = 200.dp) .background(MaterialTheme.colors.surface) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ExposedDropDownSettingRow.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ExposedDropDownSettingRow.kt index 2f24ff4144..72a8aaf10a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ExposedDropDownSettingRow.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ExposedDropDownSettingRow.kt @@ -29,7 +29,7 @@ fun ExposedDropDownSettingRow( ) { SettingsActionItemWithContent(icon, title, iconColor = iconTint, disabled = !enabled.value) { val expanded = remember { mutableStateOf(false) } - DefaultExposedDropdownMenuBox( + ExposedDropdownMenuBox( expanded = expanded.value, onExpandedChange = { expanded.value = !expanded.value && enabled.value diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt index 6adbfed76c..16d7e88d6f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt @@ -12,6 +12,7 @@ import dev.icerock.moko.resources.compose.painterResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* +import chat.simplex.common.platform.onRightClick import chat.simplex.common.platform.windowWidth import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* @@ -98,6 +99,34 @@ fun SectionItemView( } } +@Composable +fun SectionItemViewLongClickable( + click: () -> Unit, + longClick: () -> Unit, + minHeight: Dp = 46.dp, + disabled: Boolean = false, + extraPadding: Boolean = false, + padding: PaddingValues = if (extraPadding) + PaddingValues(start = DEFAULT_PADDING * 1.7f, end = DEFAULT_PADDING) + else + PaddingValues(horizontal = DEFAULT_PADDING), + content: (@Composable RowScope.() -> Unit) +) { + val modifier = Modifier + .fillMaxWidth() + .sizeIn(minHeight = minHeight) + Row( + if (disabled) { + modifier.padding(padding) + } else { + modifier.combinedClickable(onClick = click, onLongClick = longClick).onRightClick(longClick).padding(padding) + }, + verticalAlignment = Alignment.CenterVertically + ) { + content() + } +} + @Composable fun SectionItemViewWithIcon( click: (() -> Unit)? = null, 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 8542ea52a4..ef3633d1fa 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 @@ -34,7 +34,6 @@ fun AddContactView( incognitoPref = chatModel.controller.appPrefs.incognito, connReq = connReqInvitation, contactConnection = contactConnection, - share = { clipboard.shareText(connReqInvitation) }, learnMore = { ModalManager.center.showModal { Column( @@ -56,7 +55,6 @@ fun AddContactLayout( incognitoPref: SharedPreference, connReq: String, contactConnection: MutableState, - share: () -> Unit, learnMore: () -> Unit ) { val incognito = remember { mutableStateOf(incognitoPref.get()) } @@ -82,7 +80,7 @@ fun AddContactLayout( SectionView(stringResource(MR.strings.one_time_link_short).uppercase()) { if (connReq.isNotEmpty()) { - QRCode( + SimpleXLinkQRCode( connReq, Modifier .padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF) .aspectRatio(1f) @@ -99,7 +97,7 @@ fun AddContactLayout( } IncognitoToggle(incognitoPref, incognito) { ModalManager.start.showModal { IncognitoView() } } - ShareLinkButton(share) + ShareLinkButton(connReq) OneTimeLinkLearnMoreButton(learnMore) } SectionTextFooter(sharedProfileInfo(chatModel, incognito.value)) @@ -109,11 +107,12 @@ fun AddContactLayout( } @Composable -fun ShareLinkButton(onClick: () -> Unit) { +fun ShareLinkButton(connReqInvitation: String) { + val clipboard = LocalClipboardManager.current SettingsActionItem( painterResource(MR.images.ic_share), stringResource(MR.strings.share_invitation_link), - onClick, + click = { clipboard.shareText(simplexChatLink(connReqInvitation)) }, iconColor = MaterialTheme.colors.primary, textColor = MaterialTheme.colors.primary, ) @@ -177,7 +176,6 @@ fun PreviewAddContactView() { 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", contactConnection = mutableStateOf(PendingContactConnection.getSampleData()), - share = {}, learnMore = {}, ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt index 6ad919c27d..9b2cedefaa 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt @@ -1,5 +1,6 @@ package chat.simplex.common.views.newchat +import SectionTextFooter import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.* import androidx.compose.foundation.layout.* @@ -11,10 +12,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.buildAnnotatedString import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* @@ -22,11 +22,10 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.group.AddGroupMembersView import chat.simplex.common.views.chatlist.setGroupMembers import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.onboarding.ReadableText -import chat.simplex.common.views.usersettings.DeleteImageButton -import chat.simplex.common.views.usersettings.EditImageButton import chat.simplex.common.platform.* import chat.simplex.common.views.* +import chat.simplex.common.views.chat.group.GroupLinkView +import chat.simplex.common.views.usersettings.* import chat.simplex.res.MR import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -35,33 +34,46 @@ import java.net.URI @Composable fun AddGroupView(chatModel: ChatModel, close: () -> Unit) { AddGroupLayout( - createGroup = { groupProfile -> + createGroup = { incognito, groupProfile -> withApi { - val groupInfo = chatModel.controller.apiNewGroup(groupProfile) + val groupInfo = chatModel.controller.apiNewGroup(incognito, groupProfile) if (groupInfo != null) { chatModel.addChat(Chat(chatInfo = ChatInfo.Group(groupInfo), chatItems = listOf())) chatModel.chatItems.clear() + chatModel.chatItemStatuses.clear() chatModel.chatId.value = groupInfo.id setGroupMembers(groupInfo, chatModel) close.invoke() - ModalManager.end.showModalCloseable(true) { close -> - AddGroupMembersView(groupInfo, true, chatModel, close) + if (!groupInfo.incognito) { + ModalManager.end.showModalCloseable(true) { close -> + AddGroupMembersView(groupInfo, creatingGroup = true, chatModel, close) + } + } else { + ModalManager.end.showModalCloseable(true) { close -> + GroupLinkView(chatModel, groupInfo, connReqContact = null, memberRole = null, onGroupLinkUpdated = null, creatingGroup = true, close) + } } } } }, + incognitoPref = chatModel.controller.appPrefs.incognito, close ) } @Composable -fun AddGroupLayout(createGroup: (GroupProfile) -> Unit, close: () -> Unit) { +fun AddGroupLayout( + createGroup: (Boolean, GroupProfile) -> Unit, + incognitoPref: SharedPreference, + close: () -> Unit +) { val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) val scope = rememberCoroutineScope() val displayName = rememberSaveable { mutableStateOf("") } val chosenImage = rememberSaveable { mutableStateOf(null) } val profileImage = rememberSaveable { mutableStateOf(null) } val focusRequester = remember { FocusRequester() } + val incognito = remember { mutableStateOf(incognitoPref.get()) } ProvideWindowInsets(windowInsetsAnimationsEnabled = true) { ModalBottomSheetLayout( @@ -86,7 +98,6 @@ fun AddGroupLayout(createGroup: (GroupProfile) -> Unit, close: () -> Unit) { .padding(horizontal = DEFAULT_PADDING) ) { AppBarTitle(stringResource(MR.strings.create_secret_group_title)) - ReadableText(MR.strings.group_is_decentralized, TextAlign.Center) Box( Modifier .fillMaxWidth() @@ -117,20 +128,32 @@ fun AddGroupLayout(createGroup: (GroupProfile) -> Unit, close: () -> Unit) { } ProfileNameField(displayName, "", { isValidDisplayName(it.trim()) }, focusRequester) Spacer(Modifier.height(8.dp)) - val enabled = canCreateProfile(displayName.value) - if (enabled) { - CreateGroupButton(MaterialTheme.colors.primary, Modifier - .clickable { - createGroup(GroupProfile( - displayName = displayName.value.trim(), - fullName = "", - image = profileImage.value - )) - } - .padding(8.dp)) - } else { - CreateGroupButton(MaterialTheme.colors.secondary, Modifier.padding(8.dp)) - } + + SettingsActionItem( + painterResource(MR.images.ic_check), + stringResource(MR.strings.create_group_button), + click = { + createGroup(incognito.value, GroupProfile( + displayName = displayName.value.trim(), + fullName = "", + image = profileImage.value + )) + }, + textColor = MaterialTheme.colors.primary, + iconColor = MaterialTheme.colors.primary, + disabled = !canCreateProfile(displayName.value) + ) + + IncognitoToggle(incognitoPref, incognito) { ModalManager.start.showModal { IncognitoView() } } + + SectionTextFooter( + buildAnnotatedString { + append(sharedProfileInfo(chatModel, incognito.value)) + append("\n") + append(annotatedStringResource(MR.strings.group_is_decentralized)) + } + ) + LaunchedEffect(Unit) { delay(300) focusRequester.requestFocus() @@ -141,21 +164,6 @@ fun AddGroupLayout(createGroup: (GroupProfile) -> Unit, close: () -> Unit) { } } -@Composable -fun CreateGroupButton(color: Color, modifier: Modifier) { - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End - ) { - Surface(shape = RoundedCornerShape(20.dp), color = Color.Transparent) { - Row(modifier, verticalAlignment = Alignment.CenterVertically) { - Text(stringResource(MR.strings.create_profile_button), style = MaterialTheme.typography.caption, color = color, fontWeight = FontWeight.Bold) - Icon(painterResource(MR.images.ic_arrow_forward_ios), stringResource(MR.strings.create_profile_button), tint = color) - } - } - } -} - fun canCreateProfile(displayName: String): Boolean = displayName.trim().isNotEmpty() && isValidDisplayName(displayName.trim()) @Preview @@ -163,7 +171,8 @@ fun canCreateProfile(displayName: String): Boolean = displayName.trim().isNotEmp fun PreviewAddGroupLayout() { SimpleXTheme { AddGroupLayout( - createGroup = {}, + createGroup = { _, _ -> }, + incognitoPref = SharedPreference({ false }, {}), close = {} ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt index 934c050d8a..d04a85d905 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 @@ -131,13 +131,13 @@ private fun ContactConnectionInfoLayout( SectionView { if (!connReq.isNullOrEmpty() && contactConnection.initiated) { - QRCode( + SimpleXLinkQRCode( connReq, Modifier .padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF) .aspectRatio(1f) ) incognitoEnabled() - ShareLinkButton(share) + ShareLinkButton(connReq) OneTimeLinkLearnMoreButton(learnMore) } else { incognitoEnabled() 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 0c13bd4f68..b142b8e16a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/PasteToConnect.kt @@ -3,10 +3,10 @@ package chat.simplex.common.views.newchat import SectionBottomSpacer import SectionTextFooter import androidx.compose.desktop.ui.tooling.preview.Preview -import chat.simplex.common.platform.Log import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material.MaterialTheme import androidx.compose.runtime.* import androidx.compose.ui.Modifier import dev.icerock.moko.resources.compose.painterResource @@ -14,7 +14,6 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.unit.dp -import chat.simplex.common.platform.TAG import chat.simplex.common.model.ChatModel import chat.simplex.common.model.SharedPreference import chat.simplex.common.ui.theme.* @@ -23,7 +22,6 @@ import chat.simplex.common.views.usersettings.IncognitoView import chat.simplex.common.views.usersettings.SettingsActionItem import chat.simplex.res.MR import java.net.URI -import java.net.URISyntaxException @Composable fun PasteToConnectView(chatModel: ChatModel, close: () -> Unit) { @@ -53,32 +51,14 @@ fun PasteToConnectLayout( 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() + withApi { + planAndConnect(chatModel, uri, incognito = incognito.value, close) } } catch (e: RuntimeException) { AlertManager.shared.showAlertMsg( title = generalGetString(MR.strings.invalid_connection_link), text = generalGetString(MR.strings.this_string_is_not_a_connection_link) ) - } catch (e: URISyntaxException) { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.invalid_connection_link), - text = generalGetString(MR.strings.this_string_is_not_a_connection_link) - ) } } @@ -115,6 +95,9 @@ fun PasteToConnectLayout( painterResource(MR.images.ic_link), stringResource(MR.strings.connect_button), click = { connectViaLink(connectionLink.value) }, + textColor = MaterialTheme.colors.primary, + iconColor = MaterialTheme.colors.primary, + disabled = connectionLink.value.isEmpty() || connectionLink.value.trim().contains(" ") ) IncognitoToggle(incognitoPref, incognito) { ModalManager.start.showModal { IncognitoView() } } 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 6632925964..763addae66 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 @@ -19,6 +19,29 @@ import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import kotlinx.coroutines.launch +@Composable +fun SimpleXLinkQRCode( + connReq: String, + modifier: Modifier = Modifier, + tintColor: Color = Color(0xff062d56), + withLogo: Boolean = true +) { + QRCode( + simplexChatLink(connReq), + modifier, + tintColor, + withLogo + ) +} + +fun simplexChatLink(uri: String): String { + return if (uri.startsWith("simplex:/")) { + uri.replace("simplex:/", "https://simplex.chat/") + } else { + uri + } +} + @Composable fun QRCode( connReq: String, 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 e3fa922755..2f52c2cacf 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,6 +1,7 @@ package chat.simplex.common.views.newchat import SectionBottomSpacer +import SectionItemView import SectionTextFooter import androidx.compose.desktop.ui.tooling.preview.Preview import chat.simplex.common.platform.Log @@ -10,66 +11,265 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp import chat.simplex.common.model.* import chat.simplex.common.platform.TAG import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chatlist.openDirectChat +import chat.simplex.common.views.chatlist.openGroupChat 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 import java.net.URI @Composable expect fun ScanToConnectView(chatModel: ChatModel, close: () -> Unit) enum class ConnectionLinkType { - CONTACT, INVITATION, GROUP + INVITATION, CONTACT, GROUP } -@Serializable -sealed class CReqClientData { - @Serializable @SerialName("group") data class Group(val groupLinkId: String): CReqClientData() -} - -fun withUriAction(uri: URI, run: suspend (ConnectionLinkType) -> Unit) { - val action = uri.path?.drop(1)?.replace("/", "") - val data = URI(uri.toString().replaceFirst("#/", "/")).getQueryParameter("data") - val type = when { - data != null -> { - val parsed = runCatching { - json.decodeFromString(CReqClientData.serializer(), data) +suspend fun planAndConnect( + chatModel: ChatModel, + uri: URI, + incognito: Boolean?, + close: (() -> Unit)? +) { + val connectionPlan = chatModel.controller.apiConnectPlan(uri.toString()) + if (connectionPlan != null) { + when (connectionPlan) { + is ConnectionPlan.InvitationLink -> when (connectionPlan.invitationLinkPlan) { + InvitationLinkPlan.Ok -> { + Log.d(TAG, "planAndConnect, .InvitationLink, .Ok, incognito=$incognito") + if (incognito != null) { + connectViaUri(chatModel, uri, incognito, connectionPlan, close) + } else { + askCurrentOrIncognitoProfileAlert( + chatModel, uri, connectionPlan, close, + title = generalGetString(MR.strings.connect_via_invitation_link), + text = AnnotatedString(generalGetString(MR.strings.profile_will_be_sent_to_contact_sending_link)), + connectDestructive = false + ) + } + } + InvitationLinkPlan.OwnLink -> { + Log.d(TAG, "planAndConnect, .InvitationLink, .OwnLink, incognito=$incognito") + if (incognito != null) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.connect_plan_connect_to_yourself), + text = generalGetString(MR.strings.connect_plan_this_is_your_own_one_time_link), + confirmText = if (incognito) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), + onConfirm = { withApi { connectViaUri(chatModel, uri, incognito, connectionPlan, close) } }, + destructive = true, + ) + } else { + askCurrentOrIncognitoProfileAlert( + chatModel, uri, connectionPlan, close, + title = generalGetString(MR.strings.connect_plan_connect_to_yourself), + text = AnnotatedString(generalGetString(MR.strings.connect_plan_this_is_your_own_one_time_link)), + connectDestructive = true + ) + } + } + is InvitationLinkPlan.Connecting -> { + Log.d(TAG, "planAndConnect, .InvitationLink, .Connecting, incognito=$incognito") + val contact = connectionPlan.invitationLinkPlan.contact_ + if (contact != null) { + openKnownContact(chatModel, close, contact) + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.contact_already_exists), + String.format(generalGetString(MR.strings.connect_plan_you_are_already_connecting_to_vName), contact.displayName) + ) + } else { + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.connect_plan_already_connecting), + generalGetString(MR.strings.connect_plan_you_are_already_connecting_via_this_one_time_link) + ) + } + } + is InvitationLinkPlan.Known -> { + Log.d(TAG, "planAndConnect, .InvitationLink, .Known, incognito=$incognito") + val contact = connectionPlan.invitationLinkPlan.contact + openKnownContact(chatModel, close, contact) + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.contact_already_exists), + String.format(generalGetString(MR.strings.you_are_already_connected_to_vName_via_this_link), contact.displayName) + ) + } } - when { - parsed.getOrNull() is CReqClientData.Group -> ConnectionLinkType.GROUP - else -> null + is ConnectionPlan.ContactAddress -> when (connectionPlan.contactAddressPlan) { + ContactAddressPlan.Ok -> { + Log.d(TAG, "planAndConnect, .ContactAddress, .Ok, incognito=$incognito") + if (incognito != null) { + connectViaUri(chatModel, uri, incognito, connectionPlan, close) + } else { + askCurrentOrIncognitoProfileAlert( + chatModel, uri, connectionPlan, close, + title = generalGetString(MR.strings.connect_via_contact_link), + text = AnnotatedString(generalGetString(MR.strings.profile_will_be_sent_to_contact_sending_link)), + connectDestructive = false + ) + } + } + ContactAddressPlan.OwnLink -> { + Log.d(TAG, "planAndConnect, .ContactAddress, .OwnLink, incognito=$incognito") + if (incognito != null) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.connect_plan_connect_to_yourself), + text = generalGetString(MR.strings.connect_plan_this_is_your_own_simplex_address), + confirmText = if (incognito) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), + onConfirm = { withApi { connectViaUri(chatModel, uri, incognito, connectionPlan, close) } }, + destructive = true, + ) + } else { + askCurrentOrIncognitoProfileAlert( + chatModel, uri, connectionPlan, close, + title = generalGetString(MR.strings.connect_plan_connect_to_yourself), + text = AnnotatedString(generalGetString(MR.strings.connect_plan_this_is_your_own_simplex_address)), + connectDestructive = true + ) + } + } + ContactAddressPlan.ConnectingConfirmReconnect -> { + Log.d(TAG, "planAndConnect, .ContactAddress, .ConnectingConfirmReconnect, incognito=$incognito") + if (incognito != null) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.connect_plan_repeat_connection_request), + text = generalGetString(MR.strings.connect_plan_you_have_already_requested_connection_via_this_address), + confirmText = if (incognito) generalGetString(MR.strings.connect_via_link_incognito) else generalGetString(MR.strings.connect_via_link_verb), + onConfirm = { withApi { connectViaUri(chatModel, uri, incognito, connectionPlan, close) } }, + destructive = true, + ) + } else { + askCurrentOrIncognitoProfileAlert( + chatModel, uri, connectionPlan, close, + title = generalGetString(MR.strings.connect_plan_repeat_connection_request), + text = AnnotatedString(generalGetString(MR.strings.connect_plan_you_have_already_requested_connection_via_this_address)), + connectDestructive = true + ) + } + } + is ContactAddressPlan.ConnectingProhibit -> { + Log.d(TAG, "planAndConnect, .ContactAddress, .ConnectingProhibit, incognito=$incognito") + val contact = connectionPlan.contactAddressPlan.contact + openKnownContact(chatModel, close, contact) + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.contact_already_exists), + String.format(generalGetString(MR.strings.connect_plan_you_are_already_connecting_to_vName), contact.displayName) + ) + } + is ContactAddressPlan.Known -> { + Log.d(TAG, "planAndConnect, .ContactAddress, .Known, incognito=$incognito") + val contact = connectionPlan.contactAddressPlan.contact + openKnownContact(chatModel, close, contact) + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.contact_already_exists), + String.format(generalGetString(MR.strings.you_are_already_connected_to_vName_via_this_link), contact.displayName) + ) + } + } + is ConnectionPlan.GroupLink -> when (connectionPlan.groupLinkPlan) { + GroupLinkPlan.Ok -> { + Log.d(TAG, "planAndConnect, .GroupLink, .Ok, incognito=$incognito") + if (incognito != null) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.connect_via_group_link), + text = generalGetString(MR.strings.you_will_join_group), + confirmText = if (incognito) generalGetString(MR.strings.join_group_incognito_button) else generalGetString(MR.strings.join_group_button), + onConfirm = { withApi { connectViaUri(chatModel, uri, incognito, connectionPlan, close) } } + ) + } else { + askCurrentOrIncognitoProfileAlert( + chatModel, uri, connectionPlan, close, + title = generalGetString(MR.strings.connect_via_group_link), + text = AnnotatedString(generalGetString(MR.strings.you_will_join_group)), + connectDestructive = false + ) + } + } + is GroupLinkPlan.OwnLink -> { + Log.d(TAG, "planAndConnect, .GroupLink, .OwnLink, incognito=$incognito") + val groupInfo = connectionPlan.groupLinkPlan.groupInfo + ownGroupLinkConfirmConnect(chatModel, uri, incognito, connectionPlan, groupInfo, close) + } + GroupLinkPlan.ConnectingConfirmReconnect -> { + Log.d(TAG, "planAndConnect, .GroupLink, .ConnectingConfirmReconnect, incognito=$incognito") + if (incognito != null) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.connect_plan_repeat_join_request), + text = generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_via_this_link), + confirmText = if (incognito) generalGetString(MR.strings.join_group_incognito_button) else generalGetString(MR.strings.join_group_button), + onConfirm = { withApi { connectViaUri(chatModel, uri, incognito, connectionPlan, close) } }, + destructive = true, + ) + } else { + askCurrentOrIncognitoProfileAlert( + chatModel, uri, connectionPlan, close, + title = generalGetString(MR.strings.connect_plan_repeat_join_request), + text = AnnotatedString(generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_via_this_link)), + connectDestructive = true + ) + } + } + is GroupLinkPlan.ConnectingProhibit -> { + Log.d(TAG, "planAndConnect, .GroupLink, .ConnectingProhibit, incognito=$incognito") + val groupInfo = connectionPlan.groupLinkPlan.groupInfo_ + if (groupInfo != null) { + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.connect_plan_group_already_exists), + String.format(generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_vName), groupInfo.displayName) + ) + } else { + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.connect_plan_already_joining_the_group), + generalGetString(MR.strings.connect_plan_you_are_already_joining_the_group_via_this_link) + ) + } + } + is GroupLinkPlan.Known -> { + Log.d(TAG, "planAndConnect, .GroupLink, .Known, incognito=$incognito") + val groupInfo = connectionPlan.groupLinkPlan.groupInfo + openKnownGroup(chatModel, close, groupInfo) + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.connect_plan_group_already_exists), + String.format(generalGetString(MR.strings.connect_plan_you_are_already_in_group_vName), groupInfo.displayName) + ) + } } } - - action == "contact" -> ConnectionLinkType.CONTACT - action == "invitation" -> ConnectionLinkType.INVITATION - else -> null - } - if (type != null) { - withApi { run(type) } } else { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.invalid_contact_link), - text = generalGetString(MR.strings.this_link_is_not_a_valid_connection_link) - ) + Log.d(TAG, "planAndConnect, plan error") + if (incognito != null) { + connectViaUri(chatModel, uri, incognito, connectionPlan = null, close) + } else { + askCurrentOrIncognitoProfileAlert( + chatModel, uri, connectionPlan = null, close, + title = generalGetString(MR.strings.connect_plan_connect_via_link), + connectDestructive = false + ) + } } } -suspend fun connectViaUri(chatModel: ChatModel, action: ConnectionLinkType, uri: URI, incognito: Boolean): Boolean { +suspend fun connectViaUri( + chatModel: ChatModel, + uri: URI, + incognito: Boolean, + connectionPlan: ConnectionPlan?, + close: (() -> Unit)? +): Boolean { val r = chatModel.controller.apiConnect(incognito, uri.toString()) + val connLinkType = if (connectionPlan != null) planToConnectionLinkType(connectionPlan) else ConnectionLinkType.INVITATION if (r) { + close?.invoke() AlertManager.shared.showAlertMsg( title = generalGetString(MR.strings.connection_request_sent), text = - when (action) { + when (connLinkType) { ConnectionLinkType.CONTACT -> generalGetString(MR.strings.you_will_be_connected_when_your_connection_request_is_accepted) ConnectionLinkType.INVITATION -> generalGetString(MR.strings.you_will_be_connected_when_your_contacts_device_is_online) ConnectionLinkType.GROUP -> generalGetString(MR.strings.you_will_be_connected_when_group_host_device_is_online) @@ -79,6 +279,139 @@ suspend fun connectViaUri(chatModel: ChatModel, action: ConnectionLinkType, uri: return r } +fun planToConnectionLinkType(connectionPlan: ConnectionPlan): ConnectionLinkType { + return when(connectionPlan) { + is ConnectionPlan.InvitationLink -> ConnectionLinkType.INVITATION + is ConnectionPlan.ContactAddress -> ConnectionLinkType.CONTACT + is ConnectionPlan.GroupLink -> ConnectionLinkType.GROUP + } +} + +fun askCurrentOrIncognitoProfileAlert( + chatModel: ChatModel, + uri: URI, + connectionPlan: ConnectionPlan?, + close: (() -> Unit)?, + title: String, + text: AnnotatedString? = null, + connectDestructive: Boolean, +) { + AlertManager.shared.showAlertDialogButtonsColumn( + title = title, + text = text, + buttons = { + Column { + val connectColor = if (connectDestructive) MaterialTheme.colors.error else MaterialTheme.colors.primary + SectionItemView({ + AlertManager.shared.hideAlert() + withApi { + connectViaUri(chatModel, uri, incognito = false, connectionPlan, close) + } + }) { + Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = connectColor) + } + SectionItemView({ + AlertManager.shared.hideAlert() + withApi { + connectViaUri(chatModel, uri, incognito = true, connectionPlan, close) + } + }) { + Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = connectColor) + } + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + } + ) +} + +fun openKnownContact(chatModel: ChatModel, close: (() -> Unit)?, contact: Contact) { + withApi { + val c = chatModel.getContactChat(contact.contactId) + if (c != null) { + close?.invoke() + openDirectChat(contact.contactId, chatModel) + } + } +} + +fun ownGroupLinkConfirmConnect( + chatModel: ChatModel, + uri: URI, + incognito: Boolean?, + connectionPlan: ConnectionPlan?, + groupInfo: GroupInfo, + close: (() -> Unit)?, +) { + AlertManager.shared.showAlertDialogButtonsColumn( + title = generalGetString(MR.strings.connect_plan_join_your_group), + text = AnnotatedString(String.format(generalGetString(MR.strings.connect_plan_this_is_your_link_for_group_vName), groupInfo.displayName)), + buttons = { + Column { + // Open group + SectionItemView({ + AlertManager.shared.hideAlert() + openKnownGroup(chatModel, close, groupInfo) + }) { + Text(generalGetString(MR.strings.connect_plan_open_group), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + if (incognito != null) { + // Join incognito / Join with current profile + SectionItemView({ + AlertManager.shared.hideAlert() + withApi { + connectViaUri(chatModel, uri, incognito, connectionPlan, close) + } + }) { + Text( + if (incognito) generalGetString(MR.strings.join_group_incognito_button) else generalGetString(MR.strings.join_group_button), + Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error + ) + } + } else { + // Use current profile + SectionItemView({ + AlertManager.shared.hideAlert() + withApi { + connectViaUri(chatModel, uri, incognito = false, connectionPlan, close) + } + }) { + Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) + } + // Use new incognito profile + SectionItemView({ + AlertManager.shared.hideAlert() + withApi { + connectViaUri(chatModel, uri, incognito = true, connectionPlan, close) + } + }) { + Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) + } + } + // Cancel + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + } + ) +} + +fun openKnownGroup(chatModel: ChatModel, close: (() -> Unit)?, groupInfo: GroupInfo) { + withApi { + val g = chatModel.getGroupChat(groupInfo.groupId) + if (g != null) { + close?.invoke() + openGroupChat(groupInfo.groupId, chatModel) + } + } +} + @Composable fun ConnectContactLayout( chatModel: ChatModel, @@ -92,21 +425,8 @@ fun ConnectContactLayout( 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() + withApi { + planAndConnect(chatModel, uri, incognito = incognito.value, close) } } catch (e: RuntimeException) { AlertManager.shared.showAlertMsg( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/CreateSimpleXAddress.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/CreateSimpleXAddress.kt index 72cbc3a628..0132223383 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/CreateSimpleXAddress.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/CreateSimpleXAddress.kt @@ -18,7 +18,8 @@ import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.newchat.QRCode +import chat.simplex.common.views.newchat.SimpleXLinkQRCode +import chat.simplex.common.views.newchat.simplexChatLink import chat.simplex.res.MR @Composable @@ -38,7 +39,7 @@ fun CreateSimpleXAddress(m: ChatModel) { sendEmail = { address -> uriHandler.sendEmail( generalGetString(MR.strings.email_invite_subject), - generalGetString(MR.strings.email_invite_body).format(address.connReqContact) + generalGetString(MR.strings.email_invite_body).format(simplexChatLink(address.connReqContact)) ) }, createAddress = { @@ -91,8 +92,8 @@ private fun CreateSimpleXAddressLayout( Spacer(Modifier.weight(1f)) if (userAddress != null) { - QRCode(userAddress.connReqContact, Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF).aspectRatio(1f)) - ShareAddressButton { share(userAddress.connReqContact) } + SimpleXLinkQRCode(userAddress.connReqContact, Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF).aspectRatio(1f)) + ShareAddressButton { share(simplexChatLink(userAddress.connReqContact)) } Spacer(Modifier.weight(1f)) ShareViaEmailButton { sendEmail(userAddress) } Spacer(Modifier.weight(1f)) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt index eedf604a7f..5849178202 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt @@ -254,7 +254,7 @@ fun IntSettingRow(title: String, selection: MutableState, values: List Text(title) - DefaultExposedDropdownMenuBox( + ExposedDropdownMenuBox( expanded = expanded.value, onExpandedChange = { expanded.value = !expanded.value @@ -313,7 +313,7 @@ fun TimeoutSettingRow(title: String, selection: MutableState, values: List Text(title) - DefaultExposedDropdownMenuBox( + ExposedDropdownMenuBox( expanded = expanded.value, onExpandedChange = { expanded.value = !expanded.value diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt index ef0940b2a0..84ab87c653 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt @@ -64,7 +64,9 @@ fun PrivacySettingsView( SectionDividerSpaced() SectionView(stringResource(MR.strings.settings_section_title_chats)) { - SettingsPreferenceItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.encrypt_local_files), chatModel.controller.appPrefs.privacyEncryptLocalFiles) + SettingsPreferenceItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.encrypt_local_files), chatModel.controller.appPrefs.privacyEncryptLocalFiles, onChange = { enable -> + withBGApi { chatModel.controller.apiSetEncryptLocalFiles(enable) } + }) SettingsPreferenceItem(painterResource(MR.images.ic_image), stringResource(MR.strings.auto_accept_images), chatModel.controller.appPrefs.privacyAcceptImages) SettingsPreferenceItem(painterResource(MR.images.ic_travel_explore), stringResource(MR.strings.send_link_previews), chatModel.controller.appPrefs.privacyLinkPreviews) SettingsPreferenceItem( @@ -90,9 +92,6 @@ fun PrivacySettingsView( chatModel.simplexLinkMode.value = it }) } - if (chatModel.simplexLinkMode.value == SimplexLinkMode.BROWSER) { - SectionTextFooter(stringResource(MR.strings.simplex_link_mode_browser_warning)) - } SectionDividerSpaced() val currentUser = chatModel.currentUser.value @@ -183,8 +182,10 @@ fun PrivacySettingsView( @Composable private fun SimpleXLinkOptions(simplexLinkModeState: State, onSelected: (SimplexLinkMode) -> Unit) { + val modeValues = listOf(SimplexLinkMode.DESCRIPTION, SimplexLinkMode.FULL) + val pickerValues = modeValues + if (modeValues.contains(simplexLinkModeState.value)) emptyList() else listOf(simplexLinkModeState.value) val values = remember { - SimplexLinkMode.values().map { + pickerValues.map { when (it) { SimplexLinkMode.DESCRIPTION -> it to generalGetString(MR.strings.simplex_link_mode_description) SimplexLinkMode.FULL -> it to generalGetString(MR.strings.simplex_link_mode_full) @@ -316,7 +317,7 @@ private fun showUserGroupsReceiptsAlert( ) } -private val laDelays = listOf(10, 30, 60, 180, 0) +private val laDelays = listOf(10, 30, 60, 180, 600, 0) @Composable fun SimplexLockView( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt index 63f06a2aec..d03b758565 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt @@ -24,10 +24,10 @@ import chat.simplex.common.model.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.ShareAddressButton import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.newchat.QRCode import chat.simplex.common.model.ChatModel import chat.simplex.common.model.MsgContent import chat.simplex.common.platform.* +import chat.simplex.common.views.newchat.* import chat.simplex.res.MR @Composable @@ -100,7 +100,7 @@ fun UserAddressView( sendEmail = { userAddress -> uriHandler.sendEmail( generalGetString(MR.strings.email_invite_subject), - generalGetString(MR.strings.email_invite_body).format(userAddress.connReqContact) + generalGetString(MR.strings.email_invite_body).format(simplexChatLink( userAddress.connReqContact)) ) }, setProfileAddress = ::setProfileAddress, @@ -201,8 +201,8 @@ private fun UserAddressLayout( val autoAcceptState = remember { mutableStateOf(AutoAcceptState(userAddress)) } val autoAcceptStateSaved = remember { mutableStateOf(autoAcceptState.value) } SectionView(stringResource(MR.strings.address_section_title).uppercase()) { - QRCode(userAddress.connReqContact, Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF).aspectRatio(1f)) - ShareAddressButton { share(userAddress.connReqContact) } + SimpleXLinkQRCode(userAddress.connReqContact, Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF).aspectRatio(1f)) + ShareAddressButton { share(simplexChatLink(userAddress.connReqContact)) } ShareViaEmailButton { sendEmail(userAddress) } ShareWithContactsButton(shareViaProfile, setProfileAddress) AutoAcceptToggle(autoAcceptState) { saveAas(autoAcceptState.value, autoAcceptStateSaved) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt index 38c9e58ad2..ea4ef79d42 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt @@ -42,8 +42,8 @@ fun UserProfileView(chatModel: ChatModel, close: () -> Unit) { val (newProfile, _) = updated chatModel.updateCurrentUser(newProfile) profile = newProfile + close() } - close() } } ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/VersionInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/VersionInfoView.kt index 8528414771..010b94e034 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/VersionInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/VersionInfoView.kt @@ -24,6 +24,7 @@ fun VersionInfoView(info: CoreVersionInfo) { Text(String.format(stringResource(MR.strings.app_version_code), BuildConfigCommon.ANDROID_VERSION_CODE)) } else { Text(String.format(stringResource(MR.strings.app_version_name), BuildConfigCommon.DESKTOP_VERSION_NAME)) + Text(String.format(stringResource(MR.strings.app_version_code), BuildConfigCommon.DESKTOP_VERSION_CODE)) } Text(String.format(stringResource(MR.strings.core_version), info.version)) val simplexmqCommit = if (info.simplexmqCommit.length >= 7) info.simplexmqCommit.substring(startIndex = 0, endIndex = 7) else info.simplexmqCommit 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 7912a5ad1b..9df3f9d621 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -4,18 +4,20 @@ k - Connect via contact link? - Connect via invitation link? - Connect via group link? + Connect via contact address? + Connect via one-time link? + Join group? 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. + You will connect to all group members. Connect Connect incognito Opening database… + Invalid file path + You shared an invalid file path. Report the issue to the app developers. connected @@ -28,7 +30,11 @@ deleted marked deleted + %d messages marked deleted moderated by %s + %d messages moderated by %s + blocked + %d messages blocked sending files is not supported yet receiving files is not supported yet you @@ -113,6 +119,7 @@ Server requires authorization to upload, check password Possibly, certificate fingerprint in server address is incorrect Error setting address + Error Connect Disconnect Create queue @@ -240,7 +247,9 @@ Hide Allow Moderate + Expand Delete message? + Delete %d messages? Message will be deleted - this cannot be undone! Message will be marked for deletion. The recipient(s) will be able to reveal this message. Delete member message? @@ -349,6 +358,7 @@ Delete contact? Contact and all messages will be deleted - this cannot be undone! + Delete and notify contact Delete contact Set contact name… Connected @@ -1128,9 +1138,14 @@ you left group profile updated + %s connected %s and %s connected %s, %s and %s connected %s, %s and %d other members connected + %d group events + and %d other events + %s and %s + %s, %s and %d members Open @@ -1246,10 +1261,21 @@ %s: %s + Remove member? Remove member + Send direct message Member will be removed from group - this cannot be undone! Remove + Remove member + Block member? + Block member + Block + All new messages from %s will be hidden! + Unblock member? + Unblock member + Unblock + Messages from %s will be shown! MEMBER Role Change role @@ -1290,11 +1316,11 @@ Create secret group - The group is fully decentralized – it is visible only to the members. + Fully decentralized – visible only to members. Enter group name: Group full name: Your chat profile will be sent to group members - + Create group Group profile is stored on members\' devices, not on the servers. @@ -1599,4 +1625,25 @@ Coming soon! This feature is not yet supported. Try the next release. + + + Connect to yourself? + This is your own one-time link! + You are already connecting to %1$s. + Already connecting! + You are already connecting via this one-time link! + This is your own SimpleX address! + Repeat connection request? + You have already requested connection via this address! + Join your group? + This is your link for group %1$s! + Open group + Repeat join request? + You are already joining the group via this link! + Group already exists! + You are already joining the group %1$s. + Already joining the group! + You are already joining the group via this link. + You are already in group %1$s. + Connect via link? \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_back_hand.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_back_hand.svg new file mode 100644 index 0000000000..41013ff66c --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_back_hand.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_collapse_all.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_collapse_all.svg new file mode 100644 index 0000000000..a380594e79 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_collapse_all.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_do_not_touch.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_do_not_touch.svg new file mode 100644 index 0000000000..5ea1a5f2e3 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_do_not_touch.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_expand_all.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_expand_all.svg new file mode 100644 index 0000000000..75b2874d5b --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_expand_all.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/android/src/main/assets/www/README.md b/apps/multiplatform/common/src/commonMain/resources/assets/www/README.md similarity index 100% rename from apps/multiplatform/android/src/main/assets/www/README.md rename to apps/multiplatform/common/src/commonMain/resources/assets/www/README.md diff --git a/packages/simplex-chat-webrtc/src/call.html b/apps/multiplatform/common/src/commonMain/resources/assets/www/android/call.html similarity index 88% rename from packages/simplex-chat-webrtc/src/call.html rename to apps/multiplatform/common/src/commonMain/resources/assets/www/android/call.html index d7b3f6cefc..46910bfaf1 100644 --- a/packages/simplex-chat-webrtc/src/call.html +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/android/call.html @@ -3,7 +3,7 @@ - +
- +
diff --git a/apps/multiplatform/android/src/main/assets/www/style.css b/apps/multiplatform/common/src/commonMain/resources/assets/www/android/style.css similarity index 100% rename from apps/multiplatform/android/src/main/assets/www/style.css rename to apps/multiplatform/common/src/commonMain/resources/assets/www/android/style.css diff --git a/apps/multiplatform/android/src/main/assets/www/call.html b/apps/multiplatform/common/src/commonMain/resources/assets/www/call.html similarity index 100% rename from apps/multiplatform/android/src/main/assets/www/call.html rename to apps/multiplatform/common/src/commonMain/resources/assets/www/call.html diff --git a/apps/multiplatform/android/src/main/assets/www/call.js b/apps/multiplatform/common/src/commonMain/resources/assets/www/call.js similarity index 92% rename from apps/multiplatform/android/src/main/assets/www/call.js rename to apps/multiplatform/common/src/commonMain/resources/assets/www/call.js index c7cf4a9324..62c9ca7fbe 100644 --- a/apps/multiplatform/android/src/main/assets/www/call.js +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/call.js @@ -23,6 +23,9 @@ var TransformOperation; })(TransformOperation || (TransformOperation = {})); let activeCall; let answerTimeout = 30000; +var useWorker = false; +var localizedState = ""; +var localizedDescription = ""; const processCommand = (function () { const defaultIceServers = [ { urls: ["stun:stun.simplex.im:443"] }, @@ -38,9 +41,9 @@ const processCommand = (function () { iceTransportPolicy: relay ? "relay" : "all", }, iceCandidates: { - delay: 3000, - extrasInterval: 2000, - extrasTimeout: 8000, + delay: 750, + extrasInterval: 1500, + extrasTimeout: 12000, }, }; } @@ -81,6 +84,8 @@ const processCommand = (function () { if (delay) clearTimeout(delay); resolved = true; + // console.log("resolveIceCandidates", JSON.stringify(candidates)) + console.log("resolveIceCandidates"); const iceCandidates = serialize(candidates); candidates = []; resolve(iceCandidates); @@ -88,19 +93,21 @@ const processCommand = (function () { function sendIceCandidates() { if (candidates.length === 0) return; + // console.log("sendIceCandidates", JSON.stringify(candidates)) + console.log("sendIceCandidates"); const iceCandidates = serialize(candidates); candidates = []; sendMessageToNative({ resp: { type: "ice", iceCandidates } }); } }); } - async function initializeCall(config, mediaType, aesKey, useWorker) { + async function initializeCall(config, mediaType, aesKey) { const pc = new RTCPeerConnection(config.peerConnectionConfig); const remoteStream = new MediaStream(); const localCamera = VideoCamera.User; const localStream = await getLocalMediaStream(mediaType, localCamera); const iceCandidates = getIceCandidates(pc, config); - const call = { connection: pc, iceCandidates, localMedia: mediaType, localCamera, localStream, remoteStream, aesKey, useWorker }; + const call = { connection: pc, iceCandidates, localMedia: mediaType, localCamera, localStream, remoteStream, aesKey }; await setupMediaStreams(call); let connectionTimeout = setTimeout(connectionHandler, answerTimeout); pc.addEventListener("connectionstatechange", connectionStateChange); @@ -178,17 +185,17 @@ const processCommand = (function () { // This request for local media stream is made to prompt for camera/mic permissions on call start if (command.media) await getLocalMediaStream(command.media, VideoCamera.User); - const encryption = supportsInsertableStreams(command.useWorker); + const encryption = supportsInsertableStreams(useWorker); resp = { type: "capabilities", capabilities: { encryption } }; break; case "start": { console.log("starting incoming call - create webrtc session"); if (activeCall) endCall(); - const { media, useWorker, iceServers, relay } = command; + const { media, iceServers, relay } = command; const encryption = supportsInsertableStreams(useWorker); const aesKey = encryption ? command.aesKey : undefined; - activeCall = await initializeCall(getCallConfig(encryption && !!aesKey, iceServers, relay), media, aesKey, useWorker); + activeCall = await initializeCall(getCallConfig(encryption && !!aesKey, iceServers, relay), media, aesKey); const pc = activeCall.connection; const offer = await pc.createOffer(); await pc.setLocalDescription(offer); @@ -202,7 +209,6 @@ const processCommand = (function () { // iceServers, // relay, // aesKey, - // useWorker, // } resp = { type: "offer", @@ -210,21 +216,23 @@ const processCommand = (function () { iceCandidates: await activeCall.iceCandidates, capabilities: { encryption }, }; + // console.log("offer response", JSON.stringify(resp)) break; } case "offer": if (activeCall) { resp = { type: "error", message: "accept: call already started" }; } - else if (!supportsInsertableStreams(command.useWorker) && command.aesKey) { + else if (!supportsInsertableStreams(useWorker) && command.aesKey) { resp = { type: "error", message: "accept: encryption is not supported" }; } else { const offer = parse(command.offer); const remoteIceCandidates = parse(command.iceCandidates); - const { media, aesKey, useWorker, iceServers, relay } = command; - activeCall = await initializeCall(getCallConfig(!!aesKey, iceServers, relay), media, aesKey, useWorker); + const { media, aesKey, iceServers, relay } = command; + activeCall = await initializeCall(getCallConfig(!!aesKey, iceServers, relay), media, aesKey); const pc = activeCall.connection; + // console.log("offer remoteIceCandidates", JSON.stringify(remoteIceCandidates)) await pc.setRemoteDescription(new RTCSessionDescription(offer)); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); @@ -236,6 +244,7 @@ const processCommand = (function () { iceCandidates: await activeCall.iceCandidates, }; } + // console.log("answer response", JSON.stringify(resp)) break; case "answer": if (!pc) { @@ -250,6 +259,7 @@ const processCommand = (function () { else { const answer = parse(command.answer); const remoteIceCandidates = parse(command.iceCandidates); + // console.log("answer remoteIceCandidates", JSON.stringify(remoteIceCandidates)) await pc.setRemoteDescription(new RTCSessionDescription(answer)); addIceCandidates(pc, remoteIceCandidates); resp = { type: "ok" }; @@ -286,6 +296,11 @@ const processCommand = (function () { resp = { type: "ok" }; } break; + case "description": + localizedState = command.state; + localizedDescription = command.description; + resp = { type: "ok" }; + break; case "end": endCall(); resp = { type: "ok" }; @@ -310,12 +325,14 @@ const processCommand = (function () { catch (e) { console.log(e); } + shutdownCameraAndMic(); activeCall = undefined; resetVideoElements(); } function addIceCandidates(conn, iceCandidates) { for (const c of iceCandidates) { conn.addIceCandidate(new RTCIceCandidate(c)); + // console.log("addIceCandidates", JSON.stringify(c)) } } async function setupMediaStreams(call) { @@ -335,11 +352,11 @@ const processCommand = (function () { if (call.aesKey) { if (!call.key) call.key = await callCrypto.decodeAesKey(call.aesKey); - if (call.useWorker && !call.worker) { + if (useWorker && !call.worker) { const workerCode = `const callCrypto = (${callCryptoFunction.toString()})(); (${workerFunction.toString()})()`; call.worker = new Worker(URL.createObjectURL(new Blob([workerCode], { type: "text/javascript" }))); - call.worker.onerror = ({ error, filename, lineno, message }) => console.log(JSON.stringify({ error, filename, lineno, message })); - call.worker.onmessage = ({ data }) => console.log(JSON.stringify({ message: data })); + call.worker.onerror = ({ error, filename, lineno, message }) => console.log({ error, filename, lineno, message }); + // call.worker.onmessage = ({data}) => console.log(JSON.stringify({message: data})) } } } @@ -479,6 +496,11 @@ const processCommand = (function () { return (("createEncodedStreams" in RTCRtpSender.prototype && "createEncodedStreams" in RTCRtpReceiver.prototype) || (!!useWorker && "RTCRtpScriptTransform" in window)); } + function shutdownCameraAndMic() { + if (activeCall === null || activeCall === void 0 ? void 0 : activeCall.localStream) { + activeCall.localStream.getTracks().forEach((track) => track.stop()); + } + } function resetVideoElements() { const videos = getVideoElements(); if (!videos) @@ -507,6 +529,15 @@ const processCommand = (function () { } return processCommand; })(); +function toggleMedia(s, media) { + let res = false; + const tracks = media == CallMediaType.Video ? s.getVideoTracks() : s.getAudioTracks(); + for (const t of tracks) { + t.enabled = !t.enabled; + res = t.enabled; + } + return res; +} // Cryptography function - it is loaded both in the main window and in worker context (if the worker is used) function callCryptoFunction() { const initialPlainTextRequired = { diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/call.html b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/call.html new file mode 100644 index 0000000000..5e945ffe64 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/call.html @@ -0,0 +1,50 @@ + + + + SimpleX Chat WebRTC call + + + + + + + +
+
+

+

+
+
+ +
+

+ + + + +

+ +
+ + +
+ diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_call_end_filled.svg b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_call_end_filled.svg new file mode 100644 index 0000000000..34c409818a --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_call_end_filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_mic.svg b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_mic.svg new file mode 100644 index 0000000000..afebf258d3 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_mic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_mic_off.svg b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_mic_off.svg new file mode 100644 index 0000000000..941dc182a0 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_mic_off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_phone_in_talk.svg b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_phone_in_talk.svg new file mode 100644 index 0000000000..43cfd7cb9f --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_phone_in_talk.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_videocam_filled.svg b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_videocam_filled.svg new file mode 100644 index 0000000000..def80d4719 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_videocam_filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_videocam_off.svg b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_videocam_off.svg new file mode 100644 index 0000000000..07557e277e --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_videocam_off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_volume_down.svg b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_volume_down.svg new file mode 100644 index 0000000000..19999e82af --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_volume_down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_volume_up.svg b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_volume_up.svg new file mode 100644 index 0000000000..2857a913f9 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ic_volume_up.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/style.css b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/style.css new file mode 100644 index 0000000000..24c31fa6f7 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/style.css @@ -0,0 +1,127 @@ +html, +body { + padding: 0; + margin: 0; + background-color: black; +} + +#remote-video-stream { + position: absolute; + width: 100%; + height: 100%; + object-fit: cover; +} + +#local-video-stream { + position: absolute; + width: 20%; + max-width: 20%; + object-fit: cover; + margin: 16px; + border-radius: 16px; + top: 0; + right: 0; +} + +*::-webkit-media-controls { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-panel { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-play-button { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-start-playback-button { + display: none !important; + -webkit-appearance: none !important; +} + +#manage-call { + position: absolute; + width: fit-content; + top: 90%; + left: 50%; + transform: translate(-50%, 0); + display: grid; + grid-auto-flow: column; + grid-column-gap: 30px; +} + +#manage-call button { + border: none; + cursor: pointer; + appearance: none; + background-color: inherit; +} + +#progress { + position: absolute; + left: 50%; + top: 50%; + margin-left: -52px; + margin-top: -52px; + border-radius: 50%; + border-top: 5px solid white; + border-right: 5px solid white; + border-bottom: 5px solid white; + border-left: 5px solid black; + width: 100px; + height: 100px; + -webkit-animation: spin 2s linear infinite; + animation: spin 2s linear infinite; +} + +@-webkit-keyframes spin { + 0% { + -webkit-transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + } +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +#info-block { + position: absolute; + color: white; + line-height: 10px; + opacity: 0.8; + width: 200px; + font-family: Arial, Helvetica, sans-serif; +} + +#info-block.audio { + text-align: center; + left: 50%; + top: 50%; + margin-left: -100px; + margin-top: 100px; +} + +#info-block.video { + left: 16px; + top: 2px; +} + +#audio-call-icon { + position: absolute; + display: none; + left: 50%; + top: 50%; + margin-left: -50px; + margin-top: -44px; + width: 100px; + height: 100px; +} diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js new file mode 100644 index 0000000000..2514a24117 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js @@ -0,0 +1,80 @@ +"use strict"; +// Override defaults to enable worker on Chrome and Safari +useWorker = typeof window.Worker !== "undefined"; +// Create WebSocket connection. +const socket = new WebSocket(`ws://${location.host}`); +socket.addEventListener("open", (_event) => { + console.log("Opened socket"); + sendMessageToNative = (msg) => { + console.log("Message to server"); + socket.send(JSON.stringify(msg)); + }; +}); +socket.addEventListener("message", (event) => { + const parsed = JSON.parse(event.data); + reactOnMessageFromServer(parsed); + processCommand(parsed); + console.log("Message from server"); +}); +socket.addEventListener("close", (_event) => { + console.log("Closed socket"); + sendMessageToNative = (_msg) => { + console.log("Tried to send message to native but the socket was closed already"); + }; + window.close(); +}); +function endCallManually() { + sendMessageToNative({ resp: { type: "end" } }); +} +function toggleAudioManually() { + if (activeCall === null || activeCall === void 0 ? void 0 : activeCall.localMedia) { + document.getElementById("toggle-audio").innerHTML = toggleMedia(activeCall.localStream, CallMediaType.Audio) + ? '' + : ''; + } +} +function toggleSpeakerManually() { + if (activeCall === null || activeCall === void 0 ? void 0 : activeCall.remoteStream) { + document.getElementById("toggle-speaker").innerHTML = toggleMedia(activeCall.remoteStream, CallMediaType.Audio) + ? '' + : ''; + } +} +function toggleVideoManually() { + if (activeCall === null || activeCall === void 0 ? void 0 : activeCall.localMedia) { + document.getElementById("toggle-video").innerHTML = toggleMedia(activeCall.localStream, CallMediaType.Video) + ? '' + : ''; + } +} +function reactOnMessageFromServer(msg) { + var _a; + switch ((_a = msg.command) === null || _a === void 0 ? void 0 : _a.type) { + case "capabilities": + document.getElementById("info-block").className = msg.command.media; + break; + case "offer": + case "start": + document.getElementById("toggle-audio").style.display = "inline-block"; + document.getElementById("toggle-speaker").style.display = "inline-block"; + if (msg.command.media == "video") { + document.getElementById("toggle-video").style.display = "inline-block"; + } + document.getElementById("info-block").className = msg.command.media; + break; + case "description": + updateCallInfoView(msg.command.state, msg.command.description); + if ((activeCall === null || activeCall === void 0 ? void 0 : activeCall.connection.connectionState) == "connected") { + document.getElementById("progress").style.display = "none"; + if (document.getElementById("info-block").className == CallMediaType.Audio) { + document.getElementById("audio-call-icon").style.display = "block"; + } + } + break; + } +} +function updateCallInfoView(state, description) { + document.getElementById("state").innerText = state; + document.getElementById("description").innerText = description; +} +//# sourceMappingURL=ui.js.map \ No newline at end of file diff --git a/apps/multiplatform/android/src/main/assets/www/lz-string.min.js b/apps/multiplatform/common/src/commonMain/resources/assets/www/lz-string.min.js similarity index 100% rename from apps/multiplatform/android/src/main/assets/www/lz-string.min.js rename to apps/multiplatform/common/src/commonMain/resources/assets/www/lz-string.min.js diff --git a/packages/simplex-chat-webrtc/src/style.css b/apps/multiplatform/common/src/commonMain/resources/assets/www/style.css similarity index 100% rename from packages/simplex-chat-webrtc/src/style.css rename to apps/multiplatform/common/src/commonMain/resources/assets/www/style.css diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Modifier.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Modifier.desktop.kt index 0185a50fcf..fa9f311d1b 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Modifier.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Modifier.desktop.kt @@ -1,5 +1,6 @@ package chat.simplex.common.platform +import androidx.compose.foundation.contextMenuOpenDetector import androidx.compose.runtime.Composable import androidx.compose.ui.* import androidx.compose.ui.graphics.painter.Painter @@ -29,3 +30,5 @@ onExternalDrag(enabled) { is DragData.Text -> onText(data.readText()) } } + +actual fun Modifier.onRightClick(action: () -> Unit): Modifier = contextMenuOpenDetector { action() } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt index 4439680c66..25fc9ec8d5 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt @@ -1,16 +1,15 @@ package chat.simplex.common.platform -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.* import chat.simplex.common.model.* -import chat.simplex.common.views.helpers.AlertManager -import chat.simplex.common.views.helpers.generalGetString +import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import kotlinx.coroutines.* import uk.co.caprica.vlcj.player.base.MediaPlayer import uk.co.caprica.vlcj.player.base.State import uk.co.caprica.vlcj.player.component.AudioPlayerComponent import java.io.File +import java.util.* import kotlin.math.max actual class RecorderNative: RecorderInterface { @@ -38,7 +37,7 @@ actual object AudioPlayer: AudioPlayerInterface { // Returns real duration of the track private fun start(fileSource: CryptoFile, seek: Int? = null, onProgressUpdate: (position: Int?, state: TrackState) -> Unit): Int? { - val absoluteFilePath = getAppFilePath(fileSource.filePath) + val absoluteFilePath = if (fileSource.isAbsolutePath) fileSource.filePath else getAppFilePath(fileSource.filePath) if (!File(absoluteFilePath).exists()) { Log.e(TAG, "No such file: ${fileSource.filePath}") return null @@ -208,6 +207,25 @@ val MediaPlayer.duration: Int get() = media().info().duration().toInt() actual object SoundPlayer: SoundPlayerInterface { - override fun start(scope: CoroutineScope, sound: Boolean) { /*LALAL*/ } - override fun stop() { /*LALAL*/ } + var playing = false + + override fun start(scope: CoroutineScope, sound: Boolean) { + withBGApi { + val tmpFile = File(tmpDir, UUID.randomUUID().toString()) + tmpFile.deleteOnExit() + SoundPlayer::class.java.getResource("/media/ring_once.mp3").openStream()!!.use { it.copyTo(tmpFile.outputStream()) } + playing = true + while (playing) { + if (sound) { + AudioPlayer.play(CryptoFile.plain(tmpFile.absolutePath), mutableStateOf(true), mutableStateOf(0), mutableStateOf(0), true) + } + delay(3500) + } + } + } + + override fun stop() { + playing = false + AudioPlayer.stop() + } } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/UI.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/UI.desktop.kt index 1ea5018fcc..94d42ba792 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/UI.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/UI.desktop.kt @@ -17,3 +17,5 @@ actual fun LocalMultiplatformView(): Any? = null @Composable actual fun getKeyboardState(): State = remember { mutableStateOf(KeyboardState.Opened) } actual fun hideKeyboard(view: Any?) {} + +actual fun androidIsFinishingMainActivity(): Boolean = false diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt index df45a8acb0..42def0c75f 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt @@ -1,8 +1,243 @@ package chat.simplex.common.views.call -import androidx.compose.runtime.Composable +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.item.ItemAction +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.* +import kotlinx.datetime.Clock +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import org.nanohttpd.protocols.http.IHTTPSession +import org.nanohttpd.protocols.http.response.Response +import org.nanohttpd.protocols.http.response.Response.newFixedLengthResponse +import org.nanohttpd.protocols.http.response.Status +import org.nanohttpd.protocols.websockets.* +import java.io.IOException +import java.net.URI + +private const val SERVER_HOST = "localhost" +private const val SERVER_PORT = 50395 +val connections = ArrayList() @Composable actual fun ActiveCallView() { - // LALAL + val endCall = { + val call = chatModel.activeCall.value + if (call != null) withBGApi { chatModel.callManager.endCall(call) } + } + BackHandler(onBack = endCall) + WebRTCController(chatModel.callCommand) { apiMsg -> + Log.d(TAG, "received from WebRTCController: $apiMsg") + val call = chatModel.activeCall.value + if (call != null) { + Log.d(TAG, "has active call $call") + when (val r = apiMsg.resp) { + is WCallResponse.Capabilities -> withBGApi { + val callType = CallType(call.localMedia, r.capabilities) + chatModel.controller.apiSendCallInvitation(call.contact, callType) + chatModel.activeCall.value = call.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities) + } + is WCallResponse.Offer -> withBGApi { + chatModel.controller.apiSendCallOffer(call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities) + chatModel.activeCall.value = call.copy(callState = CallState.OfferSent, localCapabilities = r.capabilities) + } + is WCallResponse.Answer -> withBGApi { + chatModel.controller.apiSendCallAnswer(call.contact, r.answer, r.iceCandidates) + chatModel.activeCall.value = call.copy(callState = CallState.Negotiated) + } + is WCallResponse.Ice -> withBGApi { + chatModel.controller.apiSendCallExtraInfo(call.contact, r.iceCandidates) + } + is WCallResponse.Connection -> + try { + val callStatus = json.decodeFromString("\"${r.state.connectionState}\"") + if (callStatus == WebRTCCallStatus.Connected) { + chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectedAt = Clock.System.now()) + } + withBGApi { chatModel.controller.apiCallStatus(call.contact, callStatus) } + } catch (e: Error) { + Log.d(TAG, "call status ${r.state.connectionState} not used") + } + is WCallResponse.Connected -> { + chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectionInfo = r.connectionInfo) + } + is WCallResponse.End -> { + withBGApi { chatModel.callManager.endCall(call) } + } + is WCallResponse.Ended -> { + chatModel.activeCall.value = call.copy(callState = CallState.Ended) + withBGApi { chatModel.callManager.endCall(call) } + chatModel.showCallView.value = false + } + is WCallResponse.Ok -> when (val cmd = apiMsg.command) { + is WCallCommand.Answer -> + chatModel.activeCall.value = call.copy(callState = CallState.Negotiated) + is WCallCommand.Media -> { + when (cmd.media) { + CallMediaType.Video -> chatModel.activeCall.value = call.copy(videoEnabled = cmd.enable) + CallMediaType.Audio -> chatModel.activeCall.value = call.copy(audioEnabled = cmd.enable) + } + } + is WCallCommand.Camera -> { + chatModel.activeCall.value = call.copy(localCamera = cmd.camera) + if (!call.audioEnabled) { + chatModel.callCommand.add(WCallCommand.Media(CallMediaType.Audio, enable = false)) + } + } + is WCallCommand.End -> + chatModel.showCallView.value = false + else -> {} + } + is WCallResponse.Error -> { + Log.e(TAG, "ActiveCallView: command error ${r.message}") + } + } + } + } + + SendStateUpdates() + DisposableEffect(Unit) { + chatModel.activeCallViewIsVisible.value = true + // After the first call, End command gets added to the list which prevents making another calls + chatModel.callCommand.removeAll { it is WCallCommand.End } + onDispose { + chatModel.activeCallViewIsVisible.value = false + chatModel.callCommand.clear() + } + } +} + +@Composable +private fun SendStateUpdates() { + LaunchedEffect(Unit) { + snapshotFlow { chatModel.activeCall.value } + .distinctUntilChanged() + .filterNotNull() + .collect { call -> + val state = call.callState.text + val connInfo = call.connectionInfo + // val connInfoText = if (connInfo == null) "" else " (${connInfo.text}, ${connInfo.protocolText})" + val connInfoText = if (connInfo == null) "" else " (${connInfo.text})" + val description = call.encryptionStatus + connInfoText + chatModel.callCommand.add(WCallCommand.Description(state, description)) + } + } +} + +@Composable +fun WebRTCController(callCommand: SnapshotStateList, onResponse: (WVAPIMessage) -> Unit) { + val uriHandler = LocalUriHandler.current + val server = remember { + uriHandler.openUri("http://${SERVER_HOST}:$SERVER_PORT/simplex/call/") + startServer(onResponse) + } + fun processCommand(cmd: WCallCommand) { + val apiCall = WVAPICall(command = cmd) + for (connection in connections.toList()) { + try { + connection.send(json.encodeToString(apiCall)) + break + } catch (e: Exception) { + Log.e(TAG, "Failed to send message to browser: ${e.stackTraceToString()}") + } + } + } + DisposableEffect(Unit) { + onDispose { + processCommand(WCallCommand.End) + server.stop() + connections.clear() + } + } + LaunchedEffect(Unit) { + snapshotFlow { callCommand.firstOrNull() } + .distinctUntilChanged() + .filterNotNull() + .collect { + while (connections.isEmpty()) { + delay(100) + } + while (callCommand.isNotEmpty()) { + val cmd = callCommand.removeFirst() + Log.d(TAG, "WebRTCController LaunchedEffect executing $cmd") + processCommand(cmd) + } + } + } +} + +fun startServer(onResponse: (WVAPIMessage) -> Unit): NanoWSD { + val server = object: NanoWSD(SERVER_HOST, SERVER_PORT) { + override fun openWebSocket(session: IHTTPSession): WebSocket = MyWebSocket(onResponse, session) + + @Suppress("NewApi") + fun resourcesToResponse(path: String): Response { + val uri = Class.forName("chat.simplex.common.AppKt").getResource("/assets/www$path") ?: return resourceNotFound + val response = newFixedLengthResponse( + Status.OK, getMimeTypeForFile(uri.file), + uri.openStream().readAllBytes() + ) + response.setKeepAlive(true) + response.setUseGzip(true) + return response + } + + val resourceNotFound = newFixedLengthResponse(Status.NOT_FOUND, "text/plain", "This page couldn't be found") + + override fun handle(session: IHTTPSession): Response { + return when { + session.headers["upgrade"] == "websocket" -> super.handle(session) + session.uri.contains("/simplex/call/") -> resourcesToResponse("/desktop/call.html") + else -> resourcesToResponse(URI.create(session.uri).path) + } + } + } + server.start(60_000_000) + return server +} + +class MyWebSocket(val onResponse: (WVAPIMessage) -> Unit, handshakeRequest: IHTTPSession) : WebSocket(handshakeRequest) { + override fun onOpen() { + connections.add(this) + } + + override fun onClose(closeCode: CloseCode?, reason: String?, initiatedByRemote: Boolean) { + onResponse(WVAPIMessage(null, WCallResponse.End)) + } + + override fun onMessage(message: WebSocketFrame) { + Log.d(TAG, "MyWebSocket.onMessage") + try { + // for debugging + // onResponse(message.textPayload) + onResponse(json.decodeFromString(message.textPayload)) + } catch (e: Exception) { + Log.e(TAG, "failed parsing browser message: $message") + } + } + + override fun onPong(pong: WebSocketFrame?) = Unit + + override fun onException(exception: IOException) { + Log.e(TAG, "WebSocket exception: ${exception.stackTraceToString()}") + } } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.desktop.kt index 7a46873f7d..8dac39199f 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.desktop.kt @@ -6,9 +6,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp -import chat.simplex.common.platform.VideoPlayer -import chat.simplex.common.platform.isPlaying -import chat.simplex.common.views.helpers.onRightClick +import chat.simplex.common.platform.* @Composable actual fun PlayerView(player: VideoPlayer, width: Dp, onClick: () -> Unit, onLongClick: () -> Unit, stop: () -> Unit) { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt index 0ad69d1c04..6c37c93ccc 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt @@ -11,9 +11,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.ContentDrawScope import androidx.compose.ui.unit.dp +import chat.simplex.common.platform.onRightClick import chat.simplex.common.views.helpers.* -private object NoIndication : Indication { +object NoIndication : Indication { private object NoIndicationInstance : IndicationInstance { override fun ContentDrawScope.drawIndication() { drawContent() diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt new file mode 100644 index 0000000000..d2fa97f860 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.desktop.kt @@ -0,0 +1,75 @@ +package chat.simplex.common.views.chatlist + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.call.CallMediaType +import chat.simplex.common.views.chat.item.ItemAction +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.flow.MutableStateFlow + +@Composable +actual fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow) { + val call = remember { chatModel.activeCall}.value + // if (call?.callState == CallState.Connected && !newChatSheetState.collectAsState().value.isVisible()) { + if (call != null && !newChatSheetState.collectAsState().value.isVisible()) { + val showMenu = remember { mutableStateOf(false) } + val media = call.peerMedia ?: call.localMedia + CompositionLocalProvider( + LocalIndication provides NoIndication + ) { + Box( + Modifier + .fillMaxSize(), + contentAlignment = Alignment.BottomEnd + ) { + Box( + Modifier + .padding(end = 71.dp, bottom = 92.dp) + .size(67.dp) + .combinedClickable(onClick = { + val chat = chatModel.getChat(call.contact.id) + if (chat != null) { + withApi { + openChat(chat.chatInfo, chatModel) + } + } + }, + onLongClick = { showMenu.value = true }) + .onRightClick { showMenu.value = true }, + contentAlignment = Alignment.Center + ) { + Box(Modifier.background(MaterialTheme.colors.background, CircleShape)) { + ProfileImageForActiveCall(size = 56.dp, image = call.contact.profile.image) + } + Box(Modifier.padding().background(SimplexGreen, CircleShape).padding(4.dp).align(Alignment.TopEnd)) { + if (media == CallMediaType.Video) { + Icon(painterResource(MR.images.ic_videocam_filled), stringResource(MR.strings.icon_descr_video_call), Modifier.size(18.dp), tint = Color.White) + } else { + Icon(painterResource(MR.images.ic_call_filled), stringResource(MR.strings.icon_descr_audio_call), Modifier.size(18.dp), tint = Color.White) + } + } + DefaultDropdownMenu(showMenu) { + ItemAction(stringResource(MR.strings.icon_descr_hang_up), painterResource(MR.images.ic_call_end_filled), color = MaterialTheme.colors.error, onClick = { + withBGApi { chatModel.callManager.endCall(call) } + showMenu.value = false + }) + } + } + } + } + } +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDropDownMenu.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDropDownMenu.desktop.kt deleted file mode 100644 index 5dfd44ba6b..0000000000 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/DefaultDropDownMenu.desktop.kt +++ /dev/null @@ -1,44 +0,0 @@ -package chat.simplex.common.views.helpers - -import androidx.compose.foundation.* -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.* -import androidx.compose.material.DropdownMenu -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.DpOffset -import androidx.compose.ui.unit.dp - -actual fun Modifier.onRightClick(action: () -> Unit): Modifier = contextMenuOpenDetector { action() } - -actual interface DefaultExposedDropdownMenuBoxScope { - @Composable - actual fun DefaultExposedDropdownMenu( - expanded: Boolean, - onDismissRequest: () -> Unit, - modifier: Modifier, - content: @Composable ColumnScope.() -> Unit - ) { - DropdownMenu(expanded, onDismissRequest, offset = DpOffset(0.dp, (-40).dp)) { - Column { - content() - } - } - } -} - -@Composable -actual fun DefaultExposedDropdownMenuBox( - expanded: Boolean, - onExpandedChange: (Boolean) -> Unit, - modifier: Modifier, - content: @Composable DefaultExposedDropdownMenuBoxScope.() -> Unit -) { - val obj = remember { object : DefaultExposedDropdownMenuBoxScope {} } - Box(Modifier - .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { onExpandedChange(!expanded) }) - ) { - obj.content() - } -} diff --git a/apps/multiplatform/common/src/desktopMain/resources/media/ring_once.mp3 b/apps/multiplatform/common/src/desktopMain/resources/media/ring_once.mp3 new file mode 100644 index 0000000000..9583277336 Binary files /dev/null and b/apps/multiplatform/common/src/desktopMain/resources/media/ring_once.mp3 differ diff --git a/apps/multiplatform/desktop/build.gradle.kts b/apps/multiplatform/desktop/build.gradle.kts index a7dab78ee8..ea808a32db 100644 --- a/apps/multiplatform/desktop/build.gradle.kts +++ b/apps/multiplatform/desktop/build.gradle.kts @@ -88,7 +88,7 @@ compose { notarization { this.appleID.set(appleId) this.password.set(password) - this.ascProvider.set(teamId) + this.teamID.set(teamId) } } } diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index e37dbca8eb..d1ce585346 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -25,12 +25,12 @@ android.nonTransitiveRClass=true android.enableJetifier=true kotlin.mpp.androidSourceSetLayoutVersion=2 -android.version_name=5.4-beta.0 -android.version_code=156 +android.version_name=5.4-beta.2 +android.version_code=159 -desktop.version_name=5.4-beta.0 -desktop.version_code=12 +desktop.version_name=5.4-beta.2 +desktop.version_code=15 kotlin.version=1.8.20 gradle.plugin.version=7.4.2 -compose.version=1.4.3 +compose.version=1.5.10 diff --git a/apps/simplex-bot-advanced/Main.hs b/apps/simplex-bot-advanced/Main.hs index 04d8e4ffa1..2af76d9961 100644 --- a/apps/simplex-bot-advanced/Main.hs +++ b/apps/simplex-bot-advanced/Main.hs @@ -24,7 +24,7 @@ import Text.Read main :: IO () main = do opts <- welcomeGetOpts - simplexChatCore terminalChatConfig opts Nothing mySquaringBot + simplexChatCore terminalChatConfig opts mySquaringBot welcomeGetOpts :: IO ChatOpts welcomeGetOpts = do diff --git a/apps/simplex-bot/Main.hs b/apps/simplex-bot/Main.hs index d4ad9f9079..c24f9c251f 100644 --- a/apps/simplex-bot/Main.hs +++ b/apps/simplex-bot/Main.hs @@ -13,7 +13,7 @@ import Text.Read main :: IO () main = do opts <- welcomeGetOpts - simplexChatCore terminalChatConfig opts Nothing $ + simplexChatCore terminalChatConfig opts $ chatBotRepl welcomeMessage $ \_contact msg -> pure $ case readMaybe msg :: Maybe Integer of Just n -> msg <> " * " <> msg <> " = " <> show (n * n) diff --git a/apps/simplex-broadcast-bot/Main.hs b/apps/simplex-broadcast-bot/Main.hs index 15bb743b56..3130437e0f 100644 --- a/apps/simplex-broadcast-bot/Main.hs +++ b/apps/simplex-broadcast-bot/Main.hs @@ -8,4 +8,4 @@ import Simplex.Chat.Terminal (terminalChatConfig) main :: IO () main = do opts <- welcomeGetOpts - simplexChatCore terminalChatConfig (mkChatOpts opts) Nothing $ broadcastBot opts + simplexChatCore terminalChatConfig (mkChatOpts opts) $ broadcastBot opts diff --git a/apps/simplex-chat/Main.hs b/apps/simplex-chat/Main.hs index 8dd02623e2..f5d95e57f0 100644 --- a/apps/simplex-chat/Main.hs +++ b/apps/simplex-chat/Main.hs @@ -27,7 +27,7 @@ main = do welcome opts t <- withTerminal pure simplexChatTerminal terminalChatConfig opts t - else simplexChatCore terminalChatConfig opts Nothing $ \user cc -> do + else simplexChatCore terminalChatConfig opts $ \user cc -> do r <- sendChatCmdStr cc chatCmd ts <- getCurrentTime tz <- getCurrentTimeZone diff --git a/apps/simplex-chat/Server.hs b/apps/simplex-chat/Server.hs index 6f198340f8..d96350cd29 100644 --- a/apps/simplex-chat/Server.hs +++ b/apps/simplex-chat/Server.hs @@ -30,7 +30,7 @@ import UnliftIO.STM simplexChatServer :: ChatServerConfig -> ChatConfig -> ChatOpts -> IO () simplexChatServer srvCfg cfg opts = - simplexChatCore cfg opts Nothing . const $ runChatServer srvCfg + simplexChatCore cfg opts . const $ runChatServer srvCfg data ChatServerConfig = ChatServerConfig { chatPort :: ServiceName, diff --git a/apps/simplex-directory-service/Main.hs b/apps/simplex-directory-service/Main.hs index 434e42d851..af9c9dd252 100644 --- a/apps/simplex-directory-service/Main.hs +++ b/apps/simplex-directory-service/Main.hs @@ -12,4 +12,4 @@ main :: IO () main = do opts@DirectoryOpts {directoryLog} <- welcomeGetOpts st <- restoreDirectoryStore directoryLog - simplexChatCore terminalChatConfig (mkChatOpts opts) Nothing $ directoryService st opts + simplexChatCore terminalChatConfig (mkChatOpts opts) $ directoryService st opts diff --git a/cabal.project b/cabal.project index d38e96fd56..631ed487ba 100644 --- a/cabal.project +++ b/cabal.project @@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 9cb8616d4648c9df7ca58fee7249c9cc611f1b4b + tag: 7ebb63025cc70d0649830b31846deba2348c3c38 source-repository-package type: git @@ -19,7 +19,7 @@ source-repository-package source-repository-package type: git location: https://github.com/kazu-yamamoto/http2.git - tag: b5a1b7200cf5bc7044af34ba325284271f6dff25 + tag: 804fa283f067bd3fd89b8c5f8d25b3047813a517 source-repository-package type: git diff --git a/docs/DOWNLOADS.md b/docs/DOWNLOADS.md index 64b76e7fec..94b8d9197e 100644 --- a/docs/DOWNLOADS.md +++ b/docs/DOWNLOADS.md @@ -7,7 +7,7 @@ revision: 01.10.2023 | Updated 01.10.2023 | Languages: EN | # Download SimpleX apps -The latest stable version is v5.3.1. +The latest stable version is v5.3.2. You can get the latest beta releases from [GitHub](https://github.com/simplex-chat/simplex-chat/releases). @@ -21,9 +21,9 @@ You can get the latest beta releases from [GitHub](https://github.com/simplex-ch Using the same profile as on mobile device is not yet supported – you need to create a separate profile to use desktop apps. -**Linux**: [AppImage](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-x86_64.AppImage) (most Linux distros), [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-ubuntu-20_04-x86_64.deb) (and Debian-based distros), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-ubuntu-22_04-x86_64.deb). +**Linux**: [AppImage](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-desktop-x86_64.AppImage) (most Linux distros), [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-desktop-ubuntu-20_04-x86_64.deb) (and Debian-based distros), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-desktop-ubuntu-22_04-x86_64.deb). -**Mac**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-macos-x86_64.dmg) (Intel), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-macos-aarch64.dmg) (Apple Silicon). +**Mac**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-desktop-macos-x86_64.dmg) (Intel), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-macos-aarch64.dmg) (Apple Silicon). **Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0-beta.0/simplex-desktop-windows-x86-64.msi) (BETA). @@ -31,14 +31,14 @@ Using the same profile as on mobile device is not yet supported – you need to **iOS**: [App store](https://apps.apple.com/us/app/simplex-chat/id1605771084), [TestFlight](https://testflight.apple.com/join/DWuT2LQu). -**Android**: [Play store](https://play.google.com/store/apps/details?id=chat.simplex.app), [F-Droid](https://simplex.chat/fdroid/), [APK aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex.apk), [APK armv7](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-armv7a.apk). +**Android**: [Play store](https://play.google.com/store/apps/details?id=chat.simplex.app), [F-Droid](https://simplex.chat/fdroid/), [APK aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex.apk), [APK armv7](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-armv7a.apk). ## Terminal (console) app See [Using terminal app](/docs/CLI.md). -**Linux**: [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-chat-ubuntu-20_04-x86-64), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-chat-ubuntu-22_04-x86-64). +**Linux**: [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-chat-ubuntu-20_04-x86-64), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-chat-ubuntu-22_04-x86-64). -**Mac** [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-chat-macos-x86-64), aarch64 - [compile from source](./CLI.md#). +**Mac** [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-chat-macos-x86-64), aarch64 - [compile from source](./CLI.md#). -**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-chat-windows-x86-64). +**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.2/simplex-chat-windows-x86-64). diff --git a/docs/rfcs/2023-09-25-groups-integrity.md b/docs/rfcs/2023-09-25-groups-integrity.md new file mode 100644 index 0000000000..c7f0d99f93 --- /dev/null +++ b/docs/rfcs/2023-09-25-groups-integrity.md @@ -0,0 +1,124 @@ +# Groups integrity + +## Problems + +- Inconsistency of group state: + - group profile including group wide preferences, + - list of members and their roles. +- Lack of group messages integrity - group member can send different messages to different members. + +Lack of group consistency leads to group federation both in terms of members list and content visible to different members, which leads to user frustration and lack of trust. + +Improvements to group design should provide: + +- Consistent group state. +- Group messages integrity: + - integrity violations (different message sent to different members) should be identified and shown to users, + - missed messages should be requested to fill in gaps. + +## Design ideas and questions + +### Group messages integrity + +A message container to include member's message ID (ordered?), and list of IDs and hashes of parent messages. + +```haskell +data MsgParentId = MsgParentId + { memberId :: MemberId, + msgId :: Int64, -- sequential message ID for parent message (among memberId member messages) + msgHash :: ByteString + } + +data MsgIds = MsgIds + { msgId :: Int64, -- sequential message ID for member's message + parentIds :: [MsgParentId] + } +``` + +Questions: + - What level of protocol should include MsgIds, and what messages should be included into integrity graph? + - Having it on AppMessage level would allow to include all protocol messages. But some protocol messages are sent with different content per member (XGrpMemIntro, XGrpMemFwd, probe messages) and would have different hash. Also they contain sensitive data such as invitation links and should not be forwarded anyway. + - If MsgIds is MsgContainer level, only XMsgNew would have it. This excludes other content messages such as updates, deletes, etc. + - Include it into specific "content" chat events - XMsgNew, XMsgFileCancel (unused), XMsgUpdate, XMsgDel, XMsgReact, XFile (not used anymore but was never fully deprecated), XFileCancel. + - Some new protocol level container, uniting above events? + - Should msgId be sequential integer? (It leaks metadata about member's previous activity in the group) Can SharedMsgId be used instead? + - Depending on number of parent messages, parentIds can become arbitrarily long and not fit into 16KB block, especially for messages containing profiles pictures. + +When receiving a message with unknown parent identifiers, client should request missing messages from the sender by sending XGrpRequestSkipped, including last seen message reference for each missing parent. When receiving XGrpRequestSkipped, member should forward requested messages up to last seen parent using XGrpRequested. + +```haskell +-- include received parentId? +XGrpRequestSkipped :: [MsgParentId] -> ChatMsgEvent 'Json + +data MsgRequestedParent = MsgRequested + { parentId :: MsgParentId, + msg :: MsgContainer -- content TBD based on scope of messages included into integrity graph. Full event? + } + +XGrpRequested :: MsgRequestedParent -> ChatMsgEvent 'Json +``` + +Questions: + - Depending on number of missing parents, XGrpRequestSkipped may not fit into 16KB block. + - There may be multiple skipped messages for a given member, should they be sent sequentially from oldest (following the one known to requesting member) to newest? + - XGrpRequested may not fit into 16KB block even if original MsgContainer / chat event did fit. On the other hand multiple XGrpRequested messages can be batched. + - Malicious group member may arbitrarily request (at any time or in response to a new message) any number of skipped messages by sending parentIds from the past and trigger receiving member to send a lot of traffic. There are already some automatic response events in protocol, but they are harder to abuse: XGrpMemFwd - requires cooperation with other member, or creating connection; receipts - can be turned off; probes - requires member having matching contact and being non incognito in group. Should the member receiving XGrpRequestSkipped protect from such abuse by limiting number of requested messages? Limiting number or requests from a specific member in time? + - By the time member requests skipped messages, sender may be offline. Should the requester send XGrpRequestSkipped to other members? + - together with the request to sender or after some period? + - to which members? - fraction of admins? all admins? + - Member receiving XGrpRequestSkipped may not have requested messages, for example: + - request is for the older parent id, and member never received it himself (was not part of the group then or has gap in place of this message), or has gap between sent message parent and requested parent. + - member deleted parent(s), e.g. via periodic cleanup, or by deleting specific messages. + - don't fully delete group message records while in group? instead only overwrite content? + +Message integrity is computed for received messages, can be updated on receiving requested message parents. + +```haskell +data GroupMsgIntegrity + = GMIOk + | GMISkippedParents {skippedParents :: [MsgParentId]} + | GMIBadParentHash {knownParent :: MsgParentId, badParent :: MsgParentId} -- list? +``` + +```sql +CREATE TABLE message_integrity_records( -- message_hashes? group_messages? + message_integrity_record_id INTEGER PRIMARY KEY, + message_id INTEGER NOT NULL REFERENCES messages ON DELETE CASCADE, -- SET NULL? + group_id INTEGER NOT NULL REFERENCES groups ON DELETE CASCADE, + group_member_id INTEGER NOT NULL REFERENCES group_members ON DELETE CASCADE, + member_id BLOB NOT NULL, + member_msg_id INTEGER NOT NULL, -- shared_msg_id? + msg_hash BLOB NOT NULL, + msg_integrity TEXT NOT NULL, -- computed for received messages, for sent always Ok? + created_at TEXT NOT NULL DEFAULT(datetime('now')), + updated_at TEXT NOT NULL DEFAULT(datetime('now')) +); + +-- many to many table for message_integrity_records table +-- (parent can have multiple children, child can have multiple parents) +-- parent can be null if it wasn't received +CREATE TABLE message_parents( + message_parent_id INTEGER PRIMARY KEY, + message_integrity_record_id INTEGER NOT NULL REFERENCES message_integrity_record_id ON DELETE CASCADE, + message_parent_integrity_record_id INTEGER REFERENCES message_integrity_record_id ON DELETE CASCADE, + msg_parent_member_id BLOB NOT NULL, + msg_parent_member_msg_id INTEGER NOT NULL, + msg_hash BLOB NOT NULL, + created_at TEXT NOT NULL DEFAULT(datetime('now')), + updated_at TEXT NOT NULL DEFAULT(datetime('now')) +); +``` + +How should message integrity errors be displayed in UI? + - Displaying skipped parent errors would clutter UI due to delays in delivery. Probably they shouldn't be displayed. + - Integrity violations (hashes not matching) should be displayed on respective chat items. + - if integrity is on AppMessage level for all chat events - not all messages have corresponding chat items, create internal chat items? + - if it's on the level of content messages, updates / etc. can be high above in message history, deletes can be not visible at all (full delete). + - how to get reference to message via chat item when loading chat items? Integrity violation can be on a message different than chat item's created_by_msg_id message. For each chat item load integrity of all messages via chat_item_messages? + - If integrity errors are only displayed on integrity violations, for malicious member to work around it and send different message to different group members could he specify unknown (far into future or past) message id, instead of incorrect one? Sender then wouldn't respond with skipped parents (and other members wouldn't be able to) - how to differentiate between this case and skipper parent error that is to be ignored in UI? + - Should it be prohibited to not send MsgIds (to avoid message integrity check) if member protocol version supports it? Should it be prohibited at all and group with integrity be separated? How to distinguish between messages sent without integrity fields and messages with skipped parents in UI? + - Not showing skipped parents integrity error in UI would lead user to believe integrity is preserved, and integrity violation can be revealed later. If conversation is time sensitive member may react to message considering it conversation integrity wasn't breached, and integrity violation may be revealed later. Having eventual integrity may not be better than having no integrity at all, and may even be worse because it produces false assumptions regarding conversation integrity. The goal can be narrowed to only restoring missed messages (gaps), without calculating integrity. + +### Consistent group state + +TODO diff --git a/docs/rfcs/2023-09-29-merge-scenarios.md b/docs/rfcs/2023-09-29-merge-scenarios.md new file mode 100644 index 0000000000..42a7425f05 --- /dev/null +++ b/docs/rfcs/2023-09-29-merge-scenarios.md @@ -0,0 +1,99 @@ +# Merge scenarios + +## Problem + +Chat client allows multiple contact and group members records referring to the same "identity" be disassociated from one another. In some cases initially there is knowledge about the fact, and in some other cases it could be established via probe mechanism. + +There are cases already addressing this problem: +- Contact merge or contact and group member merge (depending on creation of direct connection between members) when new member joins group, via probe mechanism. +- Contact merge when connecting via group link. +- Contact and group member association when sending direct message to a new contact. +- Existing / deleted contact being preserved when receiving invitation direct message (XGrpDirectInv) from a group member. +- Repeat contact requests to the already connected contact being prohibited; repeat contact requests being squashed on the receiving side (XContactId mechanism). + +Cases ignoring this problem: +- Duplicate contacts on repeat connections via invitation links. +- Duplicate contacts on repeat connection via contact request, when the existing contact was not created via a contact request to the same address (it could be created via any other means - via invitation link, via request to an old address, via group member). +- Contact and group member records (possibly for many groups) not being merged when contact connects and group member records already exist. +- Group member records in different groups not being merged if contact doesn't exist. +- Duplicate contacts, or contact and group member records not being merged when connecting via contact address present in contact/group member profile. + +This problem is a direct consequence of lack of user identity in the platform, and in some cases we even consider it a feature. For example, duplicate contacts via repeat connections can be used for having conversation scopes. Though in general it seems to bring more confusion. It also limits some interactions, such as sending direct message to group members, or viewing a list of groups contact is member of (not implemented). + +On the other hand, solving all these cases reduces the privacy of the main profile, since the client cooperates with other probing clients blindly. To keep this property, we can add an opt-out "Merge contacts" client setting which would affect existing and new probe mechanisms. (It would act as an Incognito mode currently does - launch probes w/t launching probe hashes, and never confirm received probes) We can also ignore it, since this can be worked around via Incognito profiles or multiple user profiles. + +There is one more problem that could be addressed in the same scope - repeat group join via group link fails if the contact with host wasn't deleted. This happens due to group links re-using contact address machinery together with prohibiting repeat connections. This could be treated in a similar way to how it is treated for contact addresses: instead of simply prohibiting repeat connection, if group exists, it would be opened; if group doesn't exist, client would send host a request to re-invite them. + +## Solution + +### Duplicate contacts on repeat connections via invitation links + +Can be solved by probing contacts. + +Check connection with oneself and ask for confirmation. + +### Duplicate contacts on repeat connection via contact request + +Can be solved by probing contacts. + +Special case - records can be directly associated w/t probing when address is known in a contact / group member profile, see below. + +### Contact and group member records not merged when contact connects and group member records already exist + +Can be solved by probing group members. + +Send probe hashes to all viable group members? Or should group member records already have been merged between each other? (see below) + +In all cases above - who should initiate probing? It doesn't matter much, but possibly the contact that started connection. + +### Group member records in different groups not being merged if contact doesn't exist + +Currently multiple group members share the same "identity" by being associated to the same contact. However, it is allowed to have a group member record not associated contact. It can happen if group member's associated contact is deleted, or, with the latest changes that enable skipping creation of direct connections between group members, it could never exist in the first place. + +Can be solved by probing group members, merge could be done in one of the following ways: +- Have surrogate contact records always associated to group member records, not available for use as regular contacts and using group member connections for probing. +- Merge on the level of contact profiles. + +The latter seems more straightforward. + +Some more factors to consider: +- Group member record may have both associated contact to probe via, and matching group member records in other groups. +- Matching group member records in other groups may have associated contact records, which in turn may have associated contact records different from contact record associated to group member in question. +- Client to which probes are sent may have contact record deleted, but have group member connection - in this case merge would be possible only if probe is sent to group member. +- Member connections shouldn't be merged, because generally they're established via hosts, and hosts of different groups may have had different level of trust. + +Probably the solution is to: +- send probe to associated contact if it exists (implemented currently) +- if associated contact doesn't exist send probe to group member (not implemented?) +- send probe hashes to all matching contacts (implemented) +- send probe hashes to all matching group members in other groups that don't have associated contact records (not implemented) +- merge all contacts that confirmed (currently only the first confirming contact is merged) +- merge all group members that confirmed (not implemented, merge profiles?) + +Check: if both group member and associated confirm probe, will they be properly merged? + +### Connecting via profile address + +Having contact address in profile is not proof that it belongs that user (malicious client can put arbitrary address in profile), so it shouldn't be used to directly associate contact or group member records. + +When connecting via contact address, having it associated with a contact record can be used as a sufficient condition to send probe hash, even if profile doesn't match. (index on contact_profiles.contact_link, lookup contact by contact_profile_id) + +To consider: + +Currently group member address is shown even if direct messages are prohibited in group. There're two reasons this preference is usually enabled in a group: +- to prevent abuse (in public groups), +- to prevent members from direct communication. + +Member address being shown regardless of this preference undermines the second use case. + +### Repeat group join via group link + +**If group still exists:** + +Open group. + +How to check for group existence? Currently we save group_link_id on host contact's connection. It may have been deleted by the time of repeated connection via group link. Probably we should also save group_link_id or even the full contact address on the group record itself. + +**If group doesn't exist:** + +For group links allow repeat contact request even if host contact exists (unlike for regular contact requests). diff --git a/docs/rfcs/2023-10-05-contact-merge-improvement.md b/docs/rfcs/2023-10-05-contact-merge-improvement.md new file mode 100644 index 0000000000..e0588998ec --- /dev/null +++ b/docs/rfcs/2023-10-05-contact-merge-improvement.md @@ -0,0 +1,92 @@ +# Contact merge issues, eradication, improvement + +## Problem + +Contact merge (see mergeContactRecords) keeps connections of both contacts by assigning both connections to the merged contact. + +When contact is read, last ready connection is selected as contact's active connection (see, for example, getContact - connections are ordered by connection_id in descending order). This may lead to contacts reading different connections, and as a result message delivery failing. Consider following scenarios: +- connection IDs may be in different order for each side, if one of sides inserted connection in gap of IDs after an older (different) connection was deleted. If contacts consider different connections as active for each other, they would subscribe to and send to different connections, completely destabilizing delivery. +- XInfoProbeOk may never be delivered due to server error or processed due to client error. In this case probe sender has 2 separate contacts and user may still use old contact for sending; other side now has a single merged contact record with the latest connection as active - if they restart or resubscribe they would stop receiving messages from the old connection. Perhaps it's less of an issue because that second side would at least send to a new connection which first side (probe sender) considers active for a separate contact, and would receive messages to that new contact. Also if they're establishing a second contact-pair, they may be more likely to use this new contact-pair anyway. + +## Solution ideas + +### Don't merge contacts + +Still merge members and contacts. + +Pros: +- no risk of destabilizing existing conversation. +- eventually (paired with a migration for legacy merged contacts) contact reading queries may be simplified to read a single connection, as well several chat logic flows accounting for multiple contact connections. +- direct connection not being replaced with a new connection established via group (in case of "send direct message" feature, or pre 5.3 in case of direct connections established in group). +- connection verification status not being reset (same + duplicate direct connections). +- feature of having separate parallel conversations with same contact is kept. +- easy to implement: + - in probeMatchingContactsAndMembers only match members (getMatchingMembers), don't match contacts (remove getMatchingContacts). + - in probeMatch prohibit merging contacts (cgm1 is COMContact, cgm2 is COMContact case; also member with associated contact case). + - in xInfoProbeOk - same. + +Cons: +- "split identity" of contacts - though in many cases it's status quo without latest change (https://github.com/simplex-chat/simplex-chat/pull/3173). It may be not a big enough issue to justify risking connection stability. +- members may be matched to arbitrary contacts - need to consider consequences more thoroughly. Though at least they should match to same contact-pair on both sides. "Send direct message" member contacts will not be matched to existing contacts, though going forward with contact-member merge working it shouldn't be an issue until contact is deleted on one of sides. +- group link host not being merged - member knowing host as a contact would have him as a separate contact now. Perhaps we could rework group link protocol to avoid contact creation - new connection would already be created for host-invitee group connection, with some new connection entity "pending group member" created on both sides (starting with next protocol version support on both sides). This member would then be merged to the existing contact. This would be the most expensive part of this change, if we choose not to ignore it. + +### Improve contact merge protocol + +- instead of relying on ordering when selecting active contact connection, add flag active_contact_conn, set it on mergeContactRecords: + + ~~verified connection >~~ direct connection > newer connection + + we can't set to verified connection because it's not necessarily symmetric + + ~~direct connection >~~ newer connection + + for backwards compatibility we can't set to direct - other side may still set to newer. Unless we bump protocol version and choose based on that. + + choose "newer" connection based on created_at instead of connection_id - it should be more likely to have the same order. + +- initial probe sender to send new message XInfoProbeComplete after processing XInfoProbeOk to signal that old connection is good to delete. + +- probe receiver to keep subscribing to both connections until they receive XInfoProbeComplete. + +- negotiate connection to use - shared connection id? + +``` + Alice Bob + 2 contacts | | 2 contacts + 2 connections | | 2 connections + | XInfoProbe | + |------------------------>| + | XInfoProbeCheck | + |------------------------>| + | | match probe and hash; + | | merge contacts + | | 1 contact + | | 2 connections + | | (1 active, subscribe to both) + | XInfoProbeOk | + |<------------------------| + merge contacts | | + 1 contact | | + 1 connection | | + (delete non active) | | + | XInfoProbeComplete | + |------------------------>| + | | 1 contact + | | 1 connection + | | (delete non active) + * * +``` + +Pros: +- contact merge is already implemented. +- maintains contact "identity". +- members are matched to a single contact. +- no need to rework group links. + +Cons: +- more complex logic - more prone to error. +- likely still risks destabilizing connection. +- continue to maintain and add new chat logic flows accounting for multiple connections (for instance, subscription). +- newer connection replace verified and/or direct connections. These new connections are still possible to establish via indirect and unverified group connection ("send direct message"). +- removes "parallel conversations" feature. It's not a hard loss though. +- existing contacts with aliases still not to be merged - to be implemented. Not a con, but a special case for contact merge. diff --git a/docs/rfcs/2023-10-12-desktop-calls.md b/docs/rfcs/2023-10-12-desktop-calls.md new file mode 100644 index 0000000000..c956ed0115 --- /dev/null +++ b/docs/rfcs/2023-10-12-desktop-calls.md @@ -0,0 +1,34 @@ +# Desktop calls + +To make audio and video calls on desktop there are some options: +- adapt [libwebrtc](webrtc.googlesource.com/) from Google which would be the most reliable, performant and seamless solution; +- include some kind of WebView to the app via libraries; +- implement a signaling server in the app and let users to use HTML page with WebRTC code that already exist for Android. + +## WebRTC lib + +To adapt libwebrtc we need to make SDK that is compatible with Java (JNI layer + desktop implementation of VideoCodecs and other features). There are two SDKs exist already: for Android and for Objective-C. Making Android SDK compatible with Java-only SDK gives a lot of problems and requires to have 5+ professional C++ developers with weeks/months for developing. Which is not something good. + +Another considered option was adopting Jitsi Java SDK, but it's state is not very clear, and in any case it is much more effort. + +## WebView + +Including WebView is possible but requires a lot of megabytes of storage to waste on such libs. Because only Chromium-like WebViews support WebRTC features. Which means 100+ MB to the package on top of 200+ MB now. + +## Standalone browser + WebRTC HTML page + +The last solution is what can give the most useful result: the same package size as before + already existent code which can be reused with small modifications + quality of result will depend on Chromium/Firefox/Safari devs (which is good, since they are interested in making all features working for everyone). + +# Details of implementation + +Since the code for WebView has already written (https://github.com/simplex-chat/simplex-chat/tree/0e4376bada2d0c4ec2ade7f30b0048dc8b13abd8/apps/multiplatform/android/src/main/assets/www) it can be used to make calls on a desktop browser too. The only differences are these: +- UI needs to be changed - buttons controlling calls should be added. For example, end call, disable camera/mic. This will be added to separate HTML and JS files that would communication with the existing WebRTC JS code via the existing functional API. +- signaling websocket server should be started in order to allow Haskell backend to talk with HTML page and to exchange messages between all parties. It is bidirectional communication between server and webpage since both parties have to send messages to each other. + +There is no need to have TLS-secured connection between server and webpage since it's only used locally. And browser will not be happy with self-signed certificate too. + +After accepting the call, webpage will be opened in the default browser. URL will look like: `https://localhost:123/simplex/call/` (to reduce questions and to namespace other files - the main UI page will be index.html, that would load via folder path) After that internal machinary will connect both parties together. Same as on Android but with a different signaling channel, in this case it's websockets. + +Ending the call by the user will also send a new action to signaling server to allow the backend to notify other party. + +The solution will also allow to make screen sharing in the future that will be supported on every OS and graphics environment where the user's browser supports it. \ No newline at end of file diff --git a/docs/rfcs/2023-10-20-group-integrity.md b/docs/rfcs/2023-10-20-group-integrity.md new file mode 100644 index 0000000000..0a0e7ef295 --- /dev/null +++ b/docs/rfcs/2023-10-20-group-integrity.md @@ -0,0 +1,229 @@ +# Group integrity + +3 level of DAGs: + +Owner + - group profile and permissions, admin invites and removals + - in case of gap vote before applying event + +Admin + - member invites and removals + - prohibit to add and remove admins + - in case of gap most destructive wins + - link to owner dag + +Messages + - in case of gap show history according to local graph, correct when owner or admin dag changes + - link to both admin and owner dags + +```haskell +-- protocol +data MsgParent = MsgParent + { memberId :: MemberId, + memberName :: String, -- recipient can use to display message if they don't have member introduced; + -- optional? + sharedMsgId :: SharedMsgId, + msgHash :: ByteString, + msgBody :: String? -- recipient can use to display message in case parent wasn't yet received; + -- sender can pack as many parents as fits into block + stored :: Bool -- whether sender has message stored, and it can be requested + } + +data MsgIds = MsgIds -- include into chat event + { sharedMsgId :: SharedMsgId, + ownerDAGMsgId :: SharedMsgId, -- list of parents? + adminDAGMsgId :: SharedMsgId, + parents :: [MsgParent] + } + +-- model +data OwnerDAGEventParent + = ODEPKnown {eventId :: ?} -- DB id? sharedMsgId? + | ODEPUnknown {eventId :: ?} + +data OwnerDAGEvent = DAGEvent + { eventId :: ?, + parents :: [OwnerDAGEventParent] + } + +data AdminDAGEventParent + = ADEPKnown {eventId :: ?} + | ADEPUnknown {eventId :: ?} + +data AdminDAGEvent = DAGEvent + { eventId :: ?, + ownerDAGEventId :: ?, -- [OwnerDAGEventParent] - parentIds? ? + parents :: [AdminDAGEventParent] + } + +data MessagesDAGEventParent + = MDEPKnown {eventId :: ?} + | MDEPUnknown {eventId :: ?} + +data MessagesDAGEvent = DAGEvent + { eventId :: ?, + ownerDAGEventId :: ?, -- [OwnerDAGEventParent] - parentIds? ? + adminDAGEventId :: ?, -- [AdminDAGEventParent] - parentIds? ? + parents :: [MessagesDAGEventParent] + } +``` + +How to restore from destructive messages? +Even if all message parents are known, destructive logic of message should be applied after other members refer it. + +How to workaround members maliciously referring non-existent parents? +For example, this can lead to an owner preventing group updates. + +``` +-- should dag be maintained in memory? older events to be removed +-- read on event? +-- how long into past to get dag? + +ClassifiedEvent = OwnerEvent | AdminEvent | MsgEvent + +def processEvent(e: Event) = + classifiedEvent <- classifyEvent(e) + case classifiedEvent of + OwnerEvent oe -> processOwnerEvent(oe) + AdminEvent ae -> processAdminEvent(ae) + MsgEvent me -> processMsgEvent(me) + +def classifyEvent(e: Event) -> ClassifiedEvent? = + case e of + XMsgNew -> MsgEvent + XMsgFileDescr -> Nothing -- different per member + XMsgFileCancel -> MsgEvent + XMsgUpdate -> MsgEvent + XMsgDel -> MsgEvent + XMsgReact -> MsgEvent + XFile -> MsgEvent + XFileCancel -> MsgEvent + XFileAcptInv -> Nothing -- different per member + XGrpMemNew -> OwnerEvent -- sent by owner, new member is admin or owner + or AdminEvent -- sent by admin (or by owner and new member role is less than admin?) + -- problem: if member role changes, members can add event to different dags + -- what should define member role? + XGrpMemIntro -> Nothing -- received only by invitee + XGrpMemInv -> Nothing -- received only by host + XGrpMemFwd -> Nothing -- different per member; not received by invitee + XGrpMemRole -> OwnerEvent -- sent by owner about owner or admin + or AdminEvent -- sent by admin (or by owner about member with role less than admin?) + XGrpMemDel -> OwnerEvent -- sent by owner about owner or admin + or AdminEvent -- sent by admin (or by owner about member with role less than admin?) + XGrpLeave -> MsgEvent + XGrpDel -> OwnerEvent + XGrpInfo -> OwnerEvent + XGrpDirectInv -> Nothing -- received by single member + XInfoProbe -> Nothing -- per member + XInfoProbeCheck -> Nothing -- per member + XInfoProbeOk -> Nothing -- per member + BFileChunk -> Nothing -- could be MsgEvent? + _ -> Nothing -- not supported in groups + +-- # owner events + +def processOwnerEvent(oe: OwnerEvent) = + process every owner event after owners reach consensus + +// def processOwnerEvent(oe: OwnerEvent) = +// addOwnerDagEvent(oe) +// applyOwnerDagEvent(oe) +// +// def addOwnerDagEvent(oe: OwnerEvent) = +// if (any parent of oe not in dag): +// buffer until all parents are in ownerDag +// else +// add oe to ownerDag +// +// def applyOwnerDagEvent(oe: OwnerEvent) = +// case oe of +// -- process XGrpMemNew, XGrpMemRole, XGrpMemDel same as for admin dag (see below), or should vote for all events? +// XGrpMemNew -> ... +// XGrpMemRole -> ... +// XGrpMemDel -> ... +// -- how to vote - to depend on action (group - manual, update - automatic?); +// -- wait for voting always, or if event has unknown parents? (gaps in dag) +// -- how to treat delayed integrity violation - owner sending message to select members +// XGrpDel -> +// -- create "pending group deletion", wait for confirmation from majority of owners? +// -- new protocol requiring user action from other owners? +// XGrpInfo -> +// -- create "unconfirmed group profile update", remember prev group profile +// -- remove from "unconfirmed group profile update" when this event is in dag and not a leaf? +// -- if another group profile update event is received, revert "unconfirmed" event, don't apply new +// -- so if more than one update is received while dag is not merged to single vertice, all updates are not applied +// -- - this would likely lock out owners from any future updates +// -- - merge to new starting point after some time passes? +// -- - mark parents that are never received and so always block graph merging as special type? + +-- # admin events + +def processAdminEvent(ae: AdminEvent) = + lookup in owner dag - does member still have permission? + addAdminDagEvent(ae) + applyAdminDagEvent(ae) + +def addAdminDagEvent(ae: AdminEvent) = + if (any parent of ae not in dag): + buffer until all parents are in adminDag + else + add ae to adminDag + +def applyAdminDagEvent(ae: AdminEvent) = + case ae of + XGrpMemNew -> + -- handles case where messages from 2 admins about member addition and deletion arrive out of order + if member is not in "unconfirmed member deletions": + add member + XGrpMemRole -> + add role change to "unconfirmed role change" + -- remove from "unconfirmed role change" when this event is in dag and not a leaf? + if another role change already in "unconfirmed role change": + if new role is less than role in "unconfirmed role change": + change role -- role change applies in direction of lower role + XGrpMemDel -> + add member to "unconfirmed member deletions" + -- remove from "unconfirmed member deletions" when this event is in dag and not a leaf? + if member found by memberId: + delete member + +-- ^ problem: if later admin event turns out to fail integrity check, how to revert it? +-- member deletion: don't apply until in graph and not a leaf +-- role change: remember previous role and revert +-- member addition: delete member + +-- # message events + +def processMsgEvent(me: MsgEvent) = + lookup points in owner and admin dag? + - does member have permission to send event? (role changed/removed) + addMsgDagEvent(me) + applyMsgEvent(me) + +def addMsgDagEvent(me: MsgEvent) = + for me.parents not in msgDag: + add MDEPUnknown parent to msgDag + add me to msgDag + +def applyMsgEvent(me: MsgEvent) = + case me of + XMsgNew -> message to view + -- start process waiting for missing parents; if parents are not received: + -- can be shown as integrity violation if parents are not received + -- can be shown as integrity violation if other members don't refer it? + XMsgFileCancel -> cancel file immediately + -- wait for missing parents / referrals similarly to XMsgNew + -- restart file reception on integrity violation? + XMsgUpdate -> update to view -- same as XMsgNew + XMsgDel -> mark deleted, don't apply full delete until parents/referrals are received? + XMsgReact -> to view -- same as XMsgNew + XFile -> -- deprecate? + XFileCancel -> cancel -- same as XMsgFileCancel + XGrpLeave -> mark member as left, don't delete member connection immediately + -- member may try to maliciously remove connections selectively + -- wait for integrity check +``` + +# Admin blockchain + +Suppose admin DAG is replaced with blockchain, with a conflict resolution protocol to provide consistency of membership changes. Take Simplex (not to confuse with SimpleX chat) protocol (https://simplex.blog/). To reach BFT consensus and make progress, 2n/3 votes on block proposals are required, and it's assumed `f < n/3` where f is number of malicious actors. In a highly asynchronous setting of decentralized groups operated by mobile devices, progress seems unlikely or very slow. Should "admin participation" be hosted? diff --git a/flake.lock b/flake.lock index 2c133a9b63..81b3f6f6b8 100644 --- a/flake.lock +++ b/flake.lock @@ -288,11 +288,11 @@ "hackage": { "flake": false, "locked": { - "lastModified": 1676679913, - "narHash": "sha256-nW7ApRgiA9uChV/UrW89HK75rIToLt7XtSrkodO0Nbc=", + "lastModified": 1696724662, + "narHash": "sha256-jV2ugSjZE0FjMYR2YIx0p2cDBqd+xxhZrRxp5BmieYk=", "owner": "input-output-hk", "repo": "hackage.nix", - "rev": "c37cffd51315d8e27dd8d3faf75abf897e39c8c8", + "rev": "df603bff8606d8653a0876ae0c3fd1f9014882f2", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index f7e86fbb6f..44a2e287ea 100644 --- a/flake.nix +++ b/flake.nix @@ -31,7 +31,7 @@ let pkgs = haskellNix.legacyPackages.${system}.appendOverlays [android26]; in let drv' = { extra-modules, pkgs', ... }: pkgs'.haskell-nix.project { compiler-nix-name = "ghc8107"; - index-state = "2022-06-20T00:00:00Z"; + index-state = "2023-10-06T00:00:00Z"; # We need this, to specify we want the cabal project. # If the stack.yaml was dropped, this would not be necessary. projectFileName = "cabal.project"; @@ -287,7 +287,7 @@ extra-modules = [{ packages.simplexmq.flags.swift = true; packages.direct-sqlcipher.flags.commoncrypto = true; - packages.entropy.patches = [ ./scripts/nix/entropy.patch ]; + packages.entropy.flags.DoNotGetEntropy = true; }]; }).simplex-chat.components.library.override ( iosOverrides "pkg-ios-aarch64-swift-json" @@ -297,7 +297,7 @@ pkgs' = pkgs; extra-modules = [{ packages.direct-sqlcipher.flags.commoncrypto = true; - packages.entropy.patches = [ ./scripts/nix/entropy.patch ]; + packages.entropy.flags.DoNotGetEntropy = true; }]; }).simplex-chat.components.library.override ( iosOverrides "pkg-ios-aarch64-tagged-json" @@ -310,7 +310,7 @@ extra-modules = [{ packages.simplexmq.flags.swift = true; packages.direct-sqlcipher.flags.commoncrypto = true; - packages.entropy.patches = [ ./scripts/nix/entropy.patch ]; + packages.entropy.flags.DoNotGetEntropy = true; }]; }).simplex-chat.components.library.override ( iosOverrides "pkg-ios-x86_64-swift-json" @@ -320,7 +320,7 @@ pkgs' = pkgs; extra-modules = [{ packages.direct-sqlcipher.flags.commoncrypto = true; - packages.entropy.patches = [ ./scripts/nix/entropy.patch ]; + packages.entropy.flags.DoNotGetEntropy = true; }]; }).simplex-chat.components.library.override ( iosOverrides "pkg-ios-x86_64-tagged-json" diff --git a/package.yaml b/package.yaml index 907a2a0686..6f333d9bb1 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 5.4.0.0 +version: 5.4.0.2 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/packages/simplex-chat-webrtc/copy b/packages/simplex-chat-webrtc/copy index 4991cdef45..770547b2cd 100755 --- a/packages/simplex-chat-webrtc/copy +++ b/packages/simplex-chat-webrtc/copy @@ -1,14 +1,24 @@ #!/bin/sh # it can be tested in the browser from dist folder -cp ./src/call.html ./dist/call.html -cp ./src/style.css ./dist/style.css +mkdir -p dist/{android,desktop,desktop/images} 2>/dev/null +cp ./src/android/call.html ./dist/android/call.html +cp ./src/android/style.css ./dist/android/style.css +cp ./src/desktop/call.html ./dist/desktop/call.html +cp ./src/desktop/style.css ./dist/desktop/style.css +cp ./src/desktop/images/* ./dist/desktop/images/ cp ./node_modules/lz-string/libs/lz-string.min.js ./dist/lz-string.min.js cp ./src/webcall.html ./dist/webcall.html cp ./src/ui.js ./dist/ui.js -# copy to android app -cp ./src/call.html ../../apps/multiplatform/android/src/main/assets/www/call.html -cp ./src/style.css ../../apps/multiplatform/android/src/main/assets/www/style.css -cp ./dist/call.js ../../apps/multiplatform/android/src/main/assets/www/call.js -cp ./node_modules/lz-string/libs/lz-string.min.js ../../apps/multiplatform/android/src/main/assets/www/lz-string.min.js +# copy to android and desktop apps +mkdir -p ../../apps/multiplatform/common/src/commonMain/resources/assets/www/{android,desktop,desktop/images} 2>/dev/null +cp ./src/android/call.html ../../apps/multiplatform/common/src/commonMain/resources/assets/www/android/call.html +cp ./src/android/style.css ../../apps/multiplatform/common/src/commonMain/resources/assets/www/android/style.css +cp ./src/desktop/call.html ../../apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/call.html +cp ./src/desktop/style.css ../../apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/style.css +cp ./src/desktop/images/* ../../apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/images/ + +cp ./dist/desktop/ui.js ../../apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js +cp ./dist/call.js ../../apps/multiplatform/common/src/commonMain/resources/assets/www/call.js +cp ./node_modules/lz-string/libs/lz-string.min.js ../../apps/multiplatform/common/src/commonMain/resources/assets/www/lz-string.min.js diff --git a/packages/simplex-chat-webrtc/package.json b/packages/simplex-chat-webrtc/package.json index d1fc60b5a4..f11ea36343 100644 --- a/packages/simplex-chat-webrtc/package.json +++ b/packages/simplex-chat-webrtc/package.json @@ -40,4 +40,4 @@ "dependencies": { "lz-string": "^1.4.4" } -} \ No newline at end of file +} diff --git a/packages/simplex-chat-webrtc/src/android/call.html b/packages/simplex-chat-webrtc/src/android/call.html new file mode 100644 index 0000000000..46910bfaf1 --- /dev/null +++ b/packages/simplex-chat-webrtc/src/android/call.html @@ -0,0 +1,26 @@ + + + + + + + + + + + +
+ +
+ diff --git a/packages/simplex-chat-webrtc/src/android/style.css b/packages/simplex-chat-webrtc/src/android/style.css new file mode 100644 index 0000000000..3d2941c71e --- /dev/null +++ b/packages/simplex-chat-webrtc/src/android/style.css @@ -0,0 +1,41 @@ +html, +body { + padding: 0; + margin: 0; + background-color: black; +} + +#remote-video-stream { + position: absolute; + width: 100%; + height: 100%; + object-fit: cover; +} + +#local-video-stream { + position: absolute; + width: 30%; + max-width: 30%; + object-fit: cover; + margin: 16px; + border-radius: 16px; + top: 0; + right: 0; +} + +*::-webkit-media-controls { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-panel { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-play-button { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-start-playback-button { + display: none !important; + -webkit-appearance: none !important; +} diff --git a/packages/simplex-chat-webrtc/src/call.ts b/packages/simplex-chat-webrtc/src/call.ts index 7b0b51ea6d..2a55b26717 100644 --- a/packages/simplex-chat-webrtc/src/call.ts +++ b/packages/simplex-chat-webrtc/src/call.ts @@ -15,6 +15,7 @@ type WCallCommand = | WCallIceCandidates | WCEnableMedia | WCToggleCamera + | WCDescription | WCEndCall type WCallResponse = @@ -24,14 +25,15 @@ type WCallResponse = | WCallIceCandidates | WRConnection | WRCallConnected + | WRCallEnd | WRCallEnded | WROk | WRError | WCAcceptOffer -type WCallCommandTag = "capabilities" | "start" | "offer" | "answer" | "ice" | "media" | "camera" | "end" +type WCallCommandTag = "capabilities" | "start" | "offer" | "answer" | "ice" | "media" | "camera" | "description" | "end" -type WCallResponseTag = "capabilities" | "offer" | "answer" | "ice" | "connection" | "connected" | "ended" | "ok" | "error" +type WCallResponseTag = "capabilities" | "offer" | "answer" | "ice" | "connection" | "connected" | "end" | "ended" | "ok" | "error" enum CallMediaType { Audio = "audio", @@ -53,15 +55,13 @@ interface IWCallResponse { interface WCCapabilities extends IWCallCommand { type: "capabilities" - media?: CallMediaType - useWorker?: boolean + media: CallMediaType } interface WCStartCall extends IWCallCommand { type: "start" media: CallMediaType aesKey?: string - useWorker?: boolean iceServers?: RTCIceServer[] relay?: boolean } @@ -76,7 +76,6 @@ interface WCAcceptOffer extends IWCallCommand { iceCandidates: string // JSON strings for RTCIceCandidateInit media: CallMediaType aesKey?: string - useWorker?: boolean iceServers?: RTCIceServer[] relay?: boolean } @@ -110,6 +109,12 @@ interface WCToggleCamera extends IWCallCommand { camera: VideoCamera } +interface WCDescription extends IWCallCommand { + type: "description" + state: string + description: string +} + interface WRCapabilities extends IWCallResponse { type: "capabilities" capabilities: CallCapabilities @@ -134,6 +139,10 @@ interface WRCallConnected extends IWCallResponse { connectionInfo: ConnectionInfo } +interface WRCallEnd extends IWCallResponse { + type: "end" +} + interface WRCallEnded extends IWCallResponse { type: "ended" } @@ -185,13 +194,15 @@ interface Call { localStream: MediaStream remoteStream: MediaStream aesKey?: string - useWorker?: boolean worker?: Worker key?: CryptoKey } let activeCall: Call | undefined let answerTimeout = 30_000 +var useWorker = false +var localizedState = "" +var localizedDescription = "" const processCommand = (function () { type RTCRtpSenderWithEncryption = RTCRtpSender & { @@ -232,9 +243,9 @@ const processCommand = (function () { iceTransportPolicy: relay ? "relay" : "all", }, iceCandidates: { - delay: 3000, - extrasInterval: 2000, - extrasTimeout: 8000, + delay: 750, + extrasInterval: 1500, + extrasTimeout: 12000, }, } } @@ -274,6 +285,8 @@ const processCommand = (function () { function resolveIceCandidates() { if (delay) clearTimeout(delay) resolved = true + // console.log("resolveIceCandidates", JSON.stringify(candidates)) + console.log("resolveIceCandidates") const iceCandidates = serialize(candidates) candidates = [] resolve(iceCandidates) @@ -281,6 +294,8 @@ const processCommand = (function () { function sendIceCandidates() { if (candidates.length === 0) return + // console.log("sendIceCandidates", JSON.stringify(candidates)) + console.log("sendIceCandidates") const iceCandidates = serialize(candidates) candidates = [] sendMessageToNative({resp: {type: "ice", iceCandidates}}) @@ -288,13 +303,13 @@ const processCommand = (function () { }) } - async function initializeCall(config: CallConfig, mediaType: CallMediaType, aesKey?: string, useWorker?: boolean): Promise { + async function initializeCall(config: CallConfig, mediaType: CallMediaType, aesKey?: string): Promise { const pc = new RTCPeerConnection(config.peerConnectionConfig) const remoteStream = new MediaStream() const localCamera = VideoCamera.User const localStream = await getLocalMediaStream(mediaType, localCamera) const iceCandidates = getIceCandidates(pc, config) - const call = {connection: pc, iceCandidates, localMedia: mediaType, localCamera, localStream, remoteStream, aesKey, useWorker} + const call = {connection: pc, iceCandidates, localMedia: mediaType, localCamera, localStream, remoteStream, aesKey} await setupMediaStreams(call) let connectionTimeout: number | undefined = setTimeout(connectionHandler, answerTimeout) pc.addEventListener("connectionstatechange", connectionStateChange) @@ -374,16 +389,16 @@ const processCommand = (function () { if (activeCall) endCall() // This request for local media stream is made to prompt for camera/mic permissions on call start if (command.media) await getLocalMediaStream(command.media, VideoCamera.User) - const encryption = supportsInsertableStreams(command.useWorker) + const encryption = supportsInsertableStreams(useWorker) resp = {type: "capabilities", capabilities: {encryption}} break case "start": { console.log("starting incoming call - create webrtc session") if (activeCall) endCall() - const {media, useWorker, iceServers, relay} = command + const {media, iceServers, relay} = command const encryption = supportsInsertableStreams(useWorker) const aesKey = encryption ? command.aesKey : undefined - activeCall = await initializeCall(getCallConfig(encryption && !!aesKey, iceServers, relay), media, aesKey, useWorker) + activeCall = await initializeCall(getCallConfig(encryption && !!aesKey, iceServers, relay), media, aesKey) const pc = activeCall.connection const offer = await pc.createOffer() await pc.setLocalDescription(offer) @@ -397,7 +412,6 @@ const processCommand = (function () { // iceServers, // relay, // aesKey, - // useWorker, // } resp = { type: "offer", @@ -405,19 +419,21 @@ const processCommand = (function () { iceCandidates: await activeCall.iceCandidates, capabilities: {encryption}, } + // console.log("offer response", JSON.stringify(resp)) break } case "offer": if (activeCall) { resp = {type: "error", message: "accept: call already started"} - } else if (!supportsInsertableStreams(command.useWorker) && command.aesKey) { + } else if (!supportsInsertableStreams(useWorker) && command.aesKey) { resp = {type: "error", message: "accept: encryption is not supported"} } else { const offer: RTCSessionDescriptionInit = parse(command.offer) const remoteIceCandidates: RTCIceCandidateInit[] = parse(command.iceCandidates) - const {media, aesKey, useWorker, iceServers, relay} = command - activeCall = await initializeCall(getCallConfig(!!aesKey, iceServers, relay), media, aesKey, useWorker) + const {media, aesKey, iceServers, relay} = command + activeCall = await initializeCall(getCallConfig(!!aesKey, iceServers, relay), media, aesKey) const pc = activeCall.connection + // console.log("offer remoteIceCandidates", JSON.stringify(remoteIceCandidates)) await pc.setRemoteDescription(new RTCSessionDescription(offer)) const answer = await pc.createAnswer() await pc.setLocalDescription(answer) @@ -429,6 +445,7 @@ const processCommand = (function () { iceCandidates: await activeCall.iceCandidates, } } + // console.log("answer response", JSON.stringify(resp)) break case "answer": if (!pc) { @@ -440,6 +457,7 @@ const processCommand = (function () { } else { const answer: RTCSessionDescriptionInit = parse(command.answer) const remoteIceCandidates: RTCIceCandidateInit[] = parse(command.iceCandidates) + // console.log("answer remoteIceCandidates", JSON.stringify(remoteIceCandidates)) await pc.setRemoteDescription(new RTCSessionDescription(answer)) addIceCandidates(pc, remoteIceCandidates) resp = {type: "ok"} @@ -472,6 +490,11 @@ const processCommand = (function () { resp = {type: "ok"} } break + case "description": + localizedState = command.state + localizedDescription = command.description + resp = {type: "ok"} + break case "end": endCall() resp = {type: "ok"} @@ -494,6 +517,7 @@ const processCommand = (function () { } catch (e) { console.log(e) } + shutdownCameraAndMic() activeCall = undefined resetVideoElements() } @@ -501,6 +525,7 @@ const processCommand = (function () { function addIceCandidates(conn: RTCPeerConnection, iceCandidates: RTCIceCandidateInit[]) { for (const c of iceCandidates) { conn.addIceCandidate(new RTCIceCandidate(c)) + // console.log("addIceCandidates", JSON.stringify(c)) } } @@ -520,12 +545,11 @@ const processCommand = (function () { async function setupEncryptionWorker(call: Call) { if (call.aesKey) { if (!call.key) call.key = await callCrypto.decodeAesKey(call.aesKey) - if (call.useWorker && !call.worker) { + if (useWorker && !call.worker) { const workerCode = `const callCrypto = (${callCryptoFunction.toString()})(); (${workerFunction.toString()})()` call.worker = new Worker(URL.createObjectURL(new Blob([workerCode], {type: "text/javascript"}))) - call.worker.onerror = ({error, filename, lineno, message}: ErrorEvent) => - console.log(JSON.stringify({error, filename, lineno, message})) - call.worker.onmessage = ({data}) => console.log(JSON.stringify({message: data})) + call.worker.onerror = ({error, filename, lineno, message}: ErrorEvent) => console.log({error, filename, lineno, message}) + // call.worker.onmessage = ({data}) => console.log(JSON.stringify({message: data})) } } } @@ -680,6 +704,12 @@ const processCommand = (function () { remote: HTMLMediaElement } + function shutdownCameraAndMic() { + if (activeCall?.localStream) { + activeCall.localStream.getTracks().forEach((track) => track.stop()) + } + } + function resetVideoElements() { const videos = getVideoElements() if (!videos) return @@ -706,10 +736,19 @@ const processCommand = (function () { const tracks = media == CallMediaType.Video ? s.getVideoTracks() : s.getAudioTracks() for (const t of tracks) t.enabled = enable } - return processCommand })() +function toggleMedia(s: MediaStream, media: CallMediaType): boolean { + let res = false + const tracks = media == CallMediaType.Video ? s.getVideoTracks() : s.getAudioTracks() + for (const t of tracks) { + t.enabled = !t.enabled + res = t.enabled + } + return res +} + type TransformFrameFunc = (key: CryptoKey) => (frame: RTCEncodedVideoFrame, controller: TransformStreamDefaultController) => Promise interface CallCrypto { diff --git a/packages/simplex-chat-webrtc/src/desktop/call.html b/packages/simplex-chat-webrtc/src/desktop/call.html new file mode 100644 index 0000000000..5e945ffe64 --- /dev/null +++ b/packages/simplex-chat-webrtc/src/desktop/call.html @@ -0,0 +1,50 @@ + + + + SimpleX Chat WebRTC call + + + + + + + +
+
+

+

+
+
+ +
+

+ + + + +

+ +
+ + +
+ diff --git a/packages/simplex-chat-webrtc/src/desktop/images/ic_call_end_filled.svg b/packages/simplex-chat-webrtc/src/desktop/images/ic_call_end_filled.svg new file mode 100644 index 0000000000..34c409818a --- /dev/null +++ b/packages/simplex-chat-webrtc/src/desktop/images/ic_call_end_filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/simplex-chat-webrtc/src/desktop/images/ic_mic.svg b/packages/simplex-chat-webrtc/src/desktop/images/ic_mic.svg new file mode 100644 index 0000000000..afebf258d3 --- /dev/null +++ b/packages/simplex-chat-webrtc/src/desktop/images/ic_mic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/simplex-chat-webrtc/src/desktop/images/ic_mic_off.svg b/packages/simplex-chat-webrtc/src/desktop/images/ic_mic_off.svg new file mode 100644 index 0000000000..941dc182a0 --- /dev/null +++ b/packages/simplex-chat-webrtc/src/desktop/images/ic_mic_off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/simplex-chat-webrtc/src/desktop/images/ic_phone_in_talk.svg b/packages/simplex-chat-webrtc/src/desktop/images/ic_phone_in_talk.svg new file mode 100644 index 0000000000..43cfd7cb9f --- /dev/null +++ b/packages/simplex-chat-webrtc/src/desktop/images/ic_phone_in_talk.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/simplex-chat-webrtc/src/desktop/images/ic_videocam_filled.svg b/packages/simplex-chat-webrtc/src/desktop/images/ic_videocam_filled.svg new file mode 100644 index 0000000000..def80d4719 --- /dev/null +++ b/packages/simplex-chat-webrtc/src/desktop/images/ic_videocam_filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/simplex-chat-webrtc/src/desktop/images/ic_videocam_off.svg b/packages/simplex-chat-webrtc/src/desktop/images/ic_videocam_off.svg new file mode 100644 index 0000000000..07557e277e --- /dev/null +++ b/packages/simplex-chat-webrtc/src/desktop/images/ic_videocam_off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/simplex-chat-webrtc/src/desktop/images/ic_volume_down.svg b/packages/simplex-chat-webrtc/src/desktop/images/ic_volume_down.svg new file mode 100644 index 0000000000..19999e82af --- /dev/null +++ b/packages/simplex-chat-webrtc/src/desktop/images/ic_volume_down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/simplex-chat-webrtc/src/desktop/images/ic_volume_up.svg b/packages/simplex-chat-webrtc/src/desktop/images/ic_volume_up.svg new file mode 100644 index 0000000000..2857a913f9 --- /dev/null +++ b/packages/simplex-chat-webrtc/src/desktop/images/ic_volume_up.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/simplex-chat-webrtc/src/desktop/style.css b/packages/simplex-chat-webrtc/src/desktop/style.css new file mode 100644 index 0000000000..24c31fa6f7 --- /dev/null +++ b/packages/simplex-chat-webrtc/src/desktop/style.css @@ -0,0 +1,127 @@ +html, +body { + padding: 0; + margin: 0; + background-color: black; +} + +#remote-video-stream { + position: absolute; + width: 100%; + height: 100%; + object-fit: cover; +} + +#local-video-stream { + position: absolute; + width: 20%; + max-width: 20%; + object-fit: cover; + margin: 16px; + border-radius: 16px; + top: 0; + right: 0; +} + +*::-webkit-media-controls { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-panel { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-play-button { + display: none !important; + -webkit-appearance: none !important; +} +*::-webkit-media-controls-start-playback-button { + display: none !important; + -webkit-appearance: none !important; +} + +#manage-call { + position: absolute; + width: fit-content; + top: 90%; + left: 50%; + transform: translate(-50%, 0); + display: grid; + grid-auto-flow: column; + grid-column-gap: 30px; +} + +#manage-call button { + border: none; + cursor: pointer; + appearance: none; + background-color: inherit; +} + +#progress { + position: absolute; + left: 50%; + top: 50%; + margin-left: -52px; + margin-top: -52px; + border-radius: 50%; + border-top: 5px solid white; + border-right: 5px solid white; + border-bottom: 5px solid white; + border-left: 5px solid black; + width: 100px; + height: 100px; + -webkit-animation: spin 2s linear infinite; + animation: spin 2s linear infinite; +} + +@-webkit-keyframes spin { + 0% { + -webkit-transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + } +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +#info-block { + position: absolute; + color: white; + line-height: 10px; + opacity: 0.8; + width: 200px; + font-family: Arial, Helvetica, sans-serif; +} + +#info-block.audio { + text-align: center; + left: 50%; + top: 50%; + margin-left: -100px; + margin-top: 100px; +} + +#info-block.video { + left: 16px; + top: 2px; +} + +#audio-call-icon { + position: absolute; + display: none; + left: 50%; + top: 50%; + margin-left: -50px; + margin-top: -44px; + width: 100px; + height: 100px; +} diff --git a/packages/simplex-chat-webrtc/src/desktop/ui.ts b/packages/simplex-chat-webrtc/src/desktop/ui.ts new file mode 100644 index 0000000000..f72adffd9f --- /dev/null +++ b/packages/simplex-chat-webrtc/src/desktop/ui.ts @@ -0,0 +1,87 @@ +// Override defaults to enable worker on Chrome and Safari +useWorker = typeof window.Worker !== "undefined" + +// Create WebSocket connection. +const socket = new WebSocket(`ws://${location.host}`) + +socket.addEventListener("open", (_event) => { + console.log("Opened socket") + sendMessageToNative = (msg: WVApiMessage) => { + console.log("Message to server") + socket.send(JSON.stringify(msg)) + } +}) + +socket.addEventListener("message", (event) => { + const parsed = JSON.parse(event.data) + reactOnMessageFromServer(parsed) + processCommand(parsed) + console.log("Message from server") +}) + +socket.addEventListener("close", (_event) => { + console.log("Closed socket") + sendMessageToNative = (_msg: WVApiMessage) => { + console.log("Tried to send message to native but the socket was closed already") + } + window.close() +}) + +function endCallManually() { + sendMessageToNative({resp: {type: "end"}}) +} + +function toggleAudioManually() { + if (activeCall?.localMedia) { + document.getElementById("toggle-audio")!!.innerHTML = toggleMedia(activeCall.localStream, CallMediaType.Audio) + ? '' + : '' + } +} + +function toggleSpeakerManually() { + if (activeCall?.remoteStream) { + document.getElementById("toggle-speaker")!!.innerHTML = toggleMedia(activeCall.remoteStream, CallMediaType.Audio) + ? '' + : '' + } +} + +function toggleVideoManually() { + if (activeCall?.localMedia) { + document.getElementById("toggle-video")!!.innerHTML = toggleMedia(activeCall.localStream, CallMediaType.Video) + ? '' + : '' + } +} + +function reactOnMessageFromServer(msg: WVApiMessage) { + switch (msg.command?.type) { + case "capabilities": + document.getElementById("info-block")!!.className = msg.command.media + break + case "offer": + case "start": + document.getElementById("toggle-audio")!!.style.display = "inline-block" + document.getElementById("toggle-speaker")!!.style.display = "inline-block" + if (msg.command.media == "video") { + document.getElementById("toggle-video")!!.style.display = "inline-block" + } + document.getElementById("info-block")!!.className = msg.command.media + break + case "description": + updateCallInfoView(msg.command.state, msg.command.description) + if (activeCall?.connection.connectionState == "connected") { + document.getElementById("progress")!.style.display = "none" + if (document.getElementById("info-block")!!.className == CallMediaType.Audio) { + document.getElementById("audio-call-icon")!.style.display = "block" + } + } + break + } +} + +function updateCallInfoView(state: string, description: string) { + document.getElementById("state")!!.innerText = state + document.getElementById("description")!!.innerText = description +} diff --git a/scripts/desktop/build-lib-mac.sh b/scripts/desktop/build-lib-mac.sh index 3680a4a2aa..c33f59253f 100755 --- a/scripts/desktop/build-lib-mac.sh +++ b/scripts/desktop/build-lib-mac.sh @@ -112,6 +112,11 @@ if [ -n "$LIBCRYPTO_PATH" ]; then install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.1.1.$LIB_EXT libHSsmplxmq*.$LIB_EXT fi +LIBCRYPTO_PATH=$(otool -l libHSsqlcphr-*.$LIB_EXT | grep libcrypto | cut -d' ' -f11) +if [ -n "$LIBCRYPTO_PATH" ]; then + install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.1.1.$LIB_EXT libHSsqlcphr-*.$LIB_EXT +fi + for lib in $(find . -type f -name "*.$LIB_EXT"); do RPATHS=`otool -l $lib | grep -E "path /Users/|path /usr/local|path /opt/" | cut -d' ' -f11` for RPATH in $RPATHS; do diff --git a/scripts/nix/entropy.patch b/scripts/nix/entropy.patch deleted file mode 100644 index 2add42acb3..0000000000 --- a/scripts/nix/entropy.patch +++ /dev/null @@ -1,30 +0,0 @@ -diff --git a/cbits/random_initialized.c b/cbits/random_initialized.c -index 36ac968..ab708b0 100644 ---- a/cbits/random_initialized.c -+++ b/cbits/random_initialized.c -@@ -5,14 +5,6 @@ - #include - #include - --#ifdef HAVE_GETENTROPY --static int ensure_pool_initialized_getentropy() --{ -- char tmp; -- return getentropy(&tmp, sizeof(tmp)); --} --#endif -- - // Poll /dev/random to wait for randomness. This is a proxy for the /dev/urandom - // pool being initialized. - static int ensure_pool_initialized_poll() -@@ -45,10 +37,5 @@ static int ensure_pool_initialized_poll() - // Returns 0 on success, non-zero on failure. - int ensure_pool_initialized() - { --#ifdef HAVE_GETENTROPY -- if (ensure_pool_initialized_getentropy() == 0) -- return 0; --#endif -- - return ensure_pool_initialized_poll(); - } diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 982d0d513a..1efee7db01 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,7 +1,7 @@ { - "https://github.com/simplex-chat/simplexmq.git"."9cb8616d4648c9df7ca58fee7249c9cc611f1b4b" = "0rdk14bi3f0ny9wmc4rmb80pvimhijh3a21y2fq22amiy380d043"; + "https://github.com/simplex-chat/simplexmq.git"."7ebb63025cc70d0649830b31846deba2348c3c38" = "151lpqvbc04ql6xxyjrp0l06hp2l4pf0hyhqp654gz0xbfp5s40j"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; - "https://github.com/kazu-yamamoto/http2.git"."b5a1b7200cf5bc7044af34ba325284271f6dff25" = "0dqb50j57an64nf4qcf5vcz4xkd1vzvghvf8bk529c1k30r9nfzb"; + "https://github.com/kazu-yamamoto/http2.git"."804fa283f067bd3fd89b8c5f8d25b3047813a517" = "1j67wp7rfybfx3ryx08z6gqmzj85j51hmzhgx47ihgmgr47sl895"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "0kiwhvml42g9anw4d2v0zd1fpc790pj9syg5x3ik4l97fnkbbwpp"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; "https://github.com/simplex-chat/aeson.git"."aab7b5a14d6c5ea64c64dcaee418de1bb00dcc2b" = "0jz7kda8gai893vyvj96fy962ncv8dcsx71fbddyy8zrvc88jfrr"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 0b613310f7..96c56fdbb7 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 5.4.0.0 +version: 5.4.0.2 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat @@ -114,6 +114,11 @@ library Simplex.Chat.Migrations.M20230913_member_contacts Simplex.Chat.Migrations.M20230914_member_probes Simplex.Chat.Migrations.M20230926_contact_status + Simplex.Chat.Migrations.M20231002_conn_initiated + Simplex.Chat.Migrations.M20231009_via_group_link_uri_hash + Simplex.Chat.Migrations.M20231010_member_settings + Simplex.Chat.Migrations.M20231019_indexes + Simplex.Chat.Migrations.M20231030_xgrplinkmem_received Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index db78d10a44..12ef26fa36 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -143,13 +143,17 @@ defaultChatConfig = initialCleanupManagerDelay = 30 * 1000000, -- 30 seconds cleanupManagerInterval = 30 * 60, -- 30 minutes cleanupManagerStepDelay = 3 * 1000000, -- 3 seconds - ciExpirationInterval = 30 * 60 * 1000000 -- 30 minutes + ciExpirationInterval = 30 * 60 * 1000000, -- 30 minutes + coreApi = False } _defaultSMPServers :: NonEmpty SMPServerWithAuth _defaultSMPServers = L.fromList - [ "smp://h--vW7ZSkXPeOUpfxlFGgauQmXNFOzGoizak7Ult7cw=@smp15.simplex.im,oauu4bgijybyhczbnxtlggo6hiubahmeutaqineuyy23aojpih3dajad.onion", + [ "smp://1OwYGt-yqOfe2IyVHhxz3ohqo3aCCMjtB-8wn4X_aoY=@smp11.simplex.im,6ioorbm6i3yxmuoezrhjk6f6qgkc4syabh7m3so74xunb5nzr4pwgfqd.onion", + "smp://UkMFNAXLXeAAe0beCa4w6X_zp18PwxSaSjY17BKUGXQ=@smp12.simplex.im,ie42b5weq7zdkghocs3mgxdjeuycheeqqmksntj57rmejagmg4eor5yd.onion", + "smp://enEkec4hlR3UtKx2NMpOUK_K4ZuDxjWBO1d9Y4YXVaA=@smp14.simplex.im,aspkyu2sopsnizbyfabtsicikr2s4r3ti35jogbcekhm3fsoeyjvgrid.onion", + "smp://h--vW7ZSkXPeOUpfxlFGgauQmXNFOzGoizak7Ult7cw=@smp15.simplex.im,oauu4bgijybyhczbnxtlggo6hiubahmeutaqineuyy23aojpih3dajad.onion", "smp://hejn2gVIqNU6xjtGM3OwQeuk8ZEbDXVJXAlnSBJBWUA=@smp16.simplex.im,p3ktngodzi6qrf7w64mmde3syuzrv57y55hxabqcq3l5p6oi7yzze6qd.onion", "smp://ZKe4uxF4Z_aLJJOEsC-Y6hSkXgQS5-oc442JQGkyP8M=@smp17.simplex.im,ogtwfxyi3h2h5weftjjpjmxclhb5ugufa5rcyrmg7j4xlch7qsr5nuqd.onion", "smp://PtsqghzQKU83kYTlQ1VKg996dW4Cw4x_bvpKmiv8uns=@smp18.simplex.im,lyqpnwbs2zqfr45jqkncwpywpbtq7jrhxnib5qddtr6npjyezuwd3nqd.onion", @@ -183,13 +187,11 @@ createChatDatabase filePrefix key confirmMigrations = runExceptT $ do agentStore <- ExceptT $ createAgentStore (agentStoreFile filePrefix) key confirmMigrations pure ChatDatabase {chatStore, agentStore} -newChatController :: ChatDatabase -> Maybe User -> ChatConfig -> ChatOpts -> Maybe (Notification -> IO ()) -> IO ChatController -newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agentConfig = aCfg, defaultServers, inlineFiles, tempDir} ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, networkConfig, logLevel, logConnections, logServerHosts, logFile, tbqSize}, optFilesFolder, showReactions, allowInstantFiles, autoAcceptFileSize} sendToast = do +newChatController :: ChatDatabase -> Maybe User -> ChatConfig -> ChatOpts -> IO ChatController +newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agentConfig = aCfg, defaultServers, inlineFiles, tempDir} ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, networkConfig, logLevel, logConnections, logServerHosts, logFile, tbqSize}, optFilesFolder, showReactions, allowInstantFiles, autoAcceptFileSize} = do let inlineFiles' = if allowInstantFiles || autoAcceptFileSize > 0 then inlineFiles else inlineFiles {sendChunks = 0, receiveInstant = False} config = cfg {logLevel, showReactions, tbqSize, subscriptionEvents = logConnections, hostEvents = logServerHosts, defaultServers = configServers, inlineFiles = inlineFiles', autoAcceptFileSize} - sendNotification = fromMaybe (const $ pure ()) sendToast firstTime = dbNew chatStore - activeTo <- newTVarIO ActiveNone currentUser <- newTVarIO user servers <- agentServers config smpAgent <- getSMPAgentClient aCfg {tbqSize} servers agentStore @@ -197,7 +199,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen idsDrg <- newTVarIO =<< liftIO drgNew inputQ <- newTBQueueIO tbqSize outputQ <- newTBQueueIO tbqSize - notifyQ <- newTBQueueIO tbqSize + connNetworkStatuses <- atomically TM.empty subscriptionMode <- newTVarIO SMSubscribe chatLock <- newEmptyTMVarIO sndFiles <- newTVarIO M.empty @@ -210,9 +212,40 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen cleanupManagerAsync <- newTVarIO Nothing timedItemThreads <- atomically TM.empty showLiveItems <- newTVarIO False + encryptLocalFiles <- newTVarIO False userXFTPFileConfig <- newTVarIO $ xftpFileConfig cfg tempDirectory <- newTVarIO tempDir - pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, chatStoreChanged, idsDrg, inputQ, outputQ, notifyQ, subscriptionMode, chatLock, sndFiles, rcvFiles, currentCalls, config, sendNotification, filesFolder, expireCIThreads, expireCIFlags, cleanupManagerAsync, timedItemThreads, showLiveItems, userXFTPFileConfig, tempDirectory, logFilePath = logFile} + contactMergeEnabled <- newTVarIO True + pure + ChatController + { firstTime, + currentUser, + smpAgent, + agentAsync, + chatStore, + chatStoreChanged, + idsDrg, + inputQ, + outputQ, + connNetworkStatuses, + subscriptionMode, + chatLock, + sndFiles, + rcvFiles, + currentCalls, + config, + filesFolder, + expireCIThreads, + expireCIFlags, + cleanupManagerAsync, + timedItemThreads, + showLiveItems, + encryptLocalFiles, + userXFTPFileConfig, + tempDirectory, + logFilePath = logFile, + contactMergeEnabled + } where configServers :: DefaultAgentServers configServers = @@ -259,7 +292,7 @@ startChatController subConns enableExpireCIs startXFTPWorkers = do readTVarIO s >>= maybe (start s users) (pure . fst) where start s users = do - a1 <- async $ race_ notificationSubscriber agentSubscriber + a1 <- async agentSubscriber a2 <- if subConns then Just <$> async (subscribeUsers False users) @@ -378,7 +411,6 @@ processChatCommand = \case user <- withStore $ \db -> createUserRecordAt db (AgentUserId auId) p True ts storeServers user smpServers storeServers user xftpServers - setActive ActiveNone atomically . writeTVar u $ Just user pure $ CRActiveUser user where @@ -404,7 +436,6 @@ processChatCommand = \case user' <- privateGetUser userId' validateUserPassword user user' viewPwd_ withStoreCtx' (Just "APISetActiveUser, setActiveUser") $ \db -> setActiveUser db userId' - setActive ActiveNone let user'' = user' {activeUser = True} asks currentUser >>= atomically . (`writeTVar` Just user'') pure $ CRActiveUser user'' @@ -492,6 +523,10 @@ processChatCommand = \case APISetXFTPConfig cfg -> do asks userXFTPFileConfig >>= atomically . (`writeTVar` cfg) ok_ + APISetEncryptLocalFiles on -> chatWriteVar encryptLocalFiles on >> ok_ + SetContactMergeEnabled onOff -> do + asks contactMergeEnabled >>= atomically . (`writeTVar` onOff) + ok_ APIExportArchive cfg -> checkChatStopped $ exportArchive cfg >> ok_ ExportArchive -> do ts <- liftIO getCurrentTime @@ -541,7 +576,7 @@ processChatCommand = \case CTContactConnection -> pure $ chatCmdError (Just user) "not supported" APIGetChatItems pagination search -> withUser $ \user -> do chatItems <- withStore $ \db -> getAllChatItems db user pagination search - pure $ CRChatItems user chatItems + pure $ CRChatItems user Nothing chatItems APIGetChatItemInfo chatRef itemId -> withUser $ \user -> do (aci@(AChatItem cType dir _ ci), versions) <- withStore $ \db -> (,) <$> getAChatItem db user chatRef itemId <*> liftIO (getChatItemVersions db itemId) @@ -555,7 +590,7 @@ processChatCommand = \case pure $ CRChatItemInfo user aci ChatItemInfo {itemVersions, memberDeliveryStatuses} APISendMessage (ChatRef cType chatId) live itemTTL (ComposedMessage file_ quotedItemId_ mc) -> withUser $ \user@User {userId} -> withChatLock "sendMessage" $ case cType of CTDirect -> do - ct@Contact {contactId, localDisplayName = c, contactUsed} <- withStore $ \db -> getContact db user chatId + ct@Contact {contactId, contactUsed} <- withStore $ \db -> getContact db user chatId assertDirectAllowed user MDSnd ct XMsgNew_ unless contactUsed $ withStore' $ \db -> updateContactUsed db user ct if isVoice mc && not (featureAllowed SCFVoice forUser ct) @@ -565,14 +600,13 @@ processChatCommand = \case timed_ <- sndContactCITimed live ct itemTTL (msgContainer, quotedItem_) <- prepareMsg fInv_ timed_ (msg@SndMessage {sharedMsgId}, _) <- sendDirectContactMessage ct (XMsgNew msgContainer) + ci <- saveSndChatItem' user (CDDirectSnd ct) msg (CISndMsgContent mc) ciFile_ quotedItem_ timed_ live case ft_ of Just ft@FileTransferMeta {fileInline = Just IFMSent} -> sendDirectFileInline ct ft sharedMsgId _ -> pure () - ci <- saveSndChatItem' user (CDDirectSnd ct) msg (CISndMsgContent mc) ciFile_ quotedItem_ timed_ live forM_ (timed_ >>= timedDeleteAt') $ startProximateTimedItemThread user (ChatRef CTDirect contactId, chatItemId' ci) - setActive $ ActiveC c pure $ CRNewChatItem user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci) where setupSndFileTransfer :: Contact -> m (Maybe (FileInvitation, CIFile 'MDSnd, FileTransferMeta)) @@ -623,7 +657,7 @@ processChatCommand = \case assertUserGroupRole gInfo GRAuthor send g where - send g@(Group gInfo@GroupInfo {groupId, membership, localDisplayName = gName} ms) + send g@(Group gInfo@GroupInfo {groupId, membership} ms) | isVoice mc && not (groupFeatureAllowed SGFVoice gInfo) = notAllowedError GFVoice | not (isVoice mc) && isJust file_ && not (groupFeatureAllowed SGFFiles gInfo) = notAllowedError GFFiles | otherwise = do @@ -631,14 +665,13 @@ processChatCommand = \case timed_ <- sndGroupCITimed live gInfo itemTTL (msgContainer, quotedItem_) <- prepareMsg fInv_ timed_ membership (msg@SndMessage {sharedMsgId}, sentToMembers) <- sendGroupMessage user gInfo ms (XMsgNew msgContainer) - mapM_ (sendGroupFileInline ms sharedMsgId) ft_ ci <- saveSndChatItem' user (CDGroupSnd gInfo) msg (CISndMsgContent mc) ciFile_ quotedItem_ timed_ live withStore' $ \db -> forM_ sentToMembers $ \GroupMember {groupMemberId} -> createGroupSndStatus db (chatItemId' ci) groupMemberId CISSndNew + mapM_ (sendGroupFileInline ms sharedMsgId) ft_ forM_ (timed_ >>= timedDeleteAt') $ startProximateTimedItemThread user (ChatRef CTGroup groupId, chatItemId' ci) - setActive $ ActiveG gName pure $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci) notAllowedError f = pure $ chatCmdError (Just user) ("feature not allowed " <> T.unpack (groupFeatureNameText f)) setupSndFileTransfer :: Group -> Int -> m (Maybe (FileInvitation, CIFile 'MDSnd, FileTransferMeta)) @@ -743,8 +776,9 @@ processChatCommand = \case unzipMaybe3 _ = (Nothing, Nothing, Nothing) APIUpdateChatItem (ChatRef cType chatId) itemId live mc -> withUser $ \user -> withChatLock "updateChatItem" $ case cType of CTDirect -> do - (ct@Contact {contactId, localDisplayName = c}, cci) <- withStore $ \db -> (,) <$> getContact db user chatId <*> getDirectChatItem db user chatId itemId + ct@Contact {contactId} <- withStore $ \db -> getContact db user chatId assertDirectAllowed user MDSnd ct XMsgUpdate_ + cci <- withStore $ \db -> getDirectCIWithReactions db user ct itemId case cci of CChatItem SMDSnd ci@ChatItem {meta = CIMeta {itemSharedMsgId, itemTimed, itemLive, editable}, content = ciContent} -> do case (ciContent, itemSharedMsgId, editable) of @@ -759,15 +793,14 @@ processChatCommand = \case addInitialAndNewCIVersions db itemId (chatItemTs' ci, oldMC) (currentTs, mc) updateDirectChatItem' db user contactId ci (CISndMsgContent mc) live $ Just msgId startUpdatedTimedItemThread user (ChatRef CTDirect contactId) ci ci' - setActive $ ActiveC c pure $ CRChatItemUpdated user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci') else pure $ CRChatItemNotChanged user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci) _ -> throwChatError CEInvalidChatItemUpdate CChatItem SMDRcv _ -> throwChatError CEInvalidChatItemUpdate CTGroup -> do - Group gInfo@GroupInfo {groupId, localDisplayName = gName} ms <- withStore $ \db -> getGroup db user chatId + Group gInfo@GroupInfo {groupId} ms <- withStore $ \db -> getGroup db user chatId assertUserGroupRole gInfo GRAuthor - cci <- withStore $ \db -> getGroupChatItem db user chatId itemId + cci <- withStore $ \db -> getGroupCIWithReactions db user gInfo itemId case cci of CChatItem SMDSnd ci@ChatItem {meta = CIMeta {itemSharedMsgId, itemTimed, itemLive, editable}, content = ciContent} -> do case (ciContent, itemSharedMsgId, editable) of @@ -782,7 +815,6 @@ processChatCommand = \case addInitialAndNewCIVersions db itemId (chatItemTs' ci, oldMC) (currentTs, mc) updateGroupChatItem db user groupId ci (CISndMsgContent mc) live $ Just msgId startUpdatedTimedItemThread user (ChatRef CTGroup groupId) ci ci' - setActive $ ActiveG gName pure $ CRChatItemUpdated user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci') else pure $ CRChatItemNotChanged user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci) _ -> throwChatError CEInvalidChatItemUpdate @@ -791,20 +823,19 @@ processChatCommand = \case CTContactConnection -> pure $ chatCmdError (Just user) "not supported" APIDeleteChatItem (ChatRef cType chatId) itemId mode -> withUser $ \user -> withChatLock "deleteChatItem" $ case cType of CTDirect -> do - (ct@Contact {localDisplayName = c}, ci@(CChatItem msgDir ChatItem {meta = CIMeta {itemSharedMsgId, editable}})) <- withStore $ \db -> (,) <$> getContact db user chatId <*> getDirectChatItem db user chatId itemId + (ct, CChatItem msgDir ci@ChatItem {meta = CIMeta {itemSharedMsgId, editable}}) <- withStore $ \db -> (,) <$> getContact db user chatId <*> getDirectChatItem db user chatId itemId case (mode, msgDir, itemSharedMsgId, editable) of (CIDMInternal, _, _, _) -> deleteDirectCI user ct ci True False (CIDMBroadcast, SMDSnd, Just itemSharedMId, True) -> do assertDirectAllowed user MDSnd ct XMsgDel_ (SndMessage {msgId}, _) <- sendDirectContactMessage ct (XMsgDel itemSharedMId Nothing) - setActive $ ActiveC c if featureAllowed SCFFullDelete forUser ct then deleteDirectCI user ct ci True False else markDirectCIDeleted user ct ci msgId True =<< liftIO getCurrentTime (CIDMBroadcast, _, _, _) -> throwChatError CEInvalidChatItemDelete CTGroup -> do Group gInfo ms <- withStore $ \db -> getGroup db user chatId - ci@(CChatItem msgDir ChatItem {meta = CIMeta {itemSharedMsgId, editable}}) <- withStore $ \db -> getGroupChatItem db user chatId itemId + CChatItem msgDir ci@ChatItem {meta = CIMeta {itemSharedMsgId, editable}} <- withStore $ \db -> getGroupChatItem db user chatId itemId case (mode, msgDir, itemSharedMsgId, editable) of (CIDMInternal, _, _, _) -> deleteGroupCI user gInfo ci True False Nothing =<< liftIO getCurrentTime (CIDMBroadcast, SMDSnd, Just itemSharedMId, True) -> do @@ -816,7 +847,7 @@ processChatCommand = \case CTContactConnection -> pure $ chatCmdError (Just user) "not supported" APIDeleteMemberChatItem gId mId itemId -> withUser $ \user -> withChatLock "deleteChatItem" $ do Group gInfo@GroupInfo {membership} ms <- withStore $ \db -> getGroup db user gId - ci@(CChatItem _ ChatItem {chatDir, meta = CIMeta {itemSharedMsgId}}) <- withStore $ \db -> getGroupChatItem db user gId itemId + CChatItem _ ci@ChatItem {chatDir, meta = CIMeta {itemSharedMsgId}} <- withStore $ \db -> getGroupChatItem db user gId itemId case (chatDir, itemSharedMsgId) of (CIGroupRcv GroupMember {groupMemberId, memberRole, memberId}, Just itemSharedMId) -> do when (groupMemberId /= mId) $ throwChatError CEInvalidChatItemDelete @@ -907,11 +938,11 @@ processChatCommand = \case _ -> pure $ chatCmdError (Just user) "not supported" APIDeleteChat (ChatRef cType chatId) notify -> withUser $ \user@User {userId} -> case cType of CTDirect -> do - ct@Contact {localDisplayName} <- withStore $ \db -> getContact db user chatId + ct <- withStore $ \db -> getContact db user chatId filesInfo <- withStore' $ \db -> getContactFileInfo db user ct withChatLock "deleteChat direct" . procCmd $ do deleteFilesAndConns user filesInfo - when (isReady ct && contactActive ct && notify) $ + when (contactReady ct && contactActive ct && notify) $ void (sendDirectContactMessage ct XDirectDel) `catchChatError` const (pure ()) contactConnIds <- map aConnId <$> withStore (\db -> getContactConnections db userId ct) deleteAgentConnectionsAsync user contactConnIds @@ -919,7 +950,6 @@ processChatCommand = \case -- (possibly, race condition on integrity check?) withStore' $ \db -> deleteContactConnectionsAndFiles db userId ct withStore' $ \db -> deleteContact db user ct - unsetActive $ ActiveC localDisplayName pure $ CRContactDeleted user ct CTContactConnection -> withChatLock "deleteChat contactConnection" . procCmd $ do conn@PendingContactConnection {pccAgentConnId = AgentConnId acId} <- withStore $ \db -> getPendingContactConnection db userId chatId @@ -1079,6 +1109,8 @@ processChatCommand = \case user <- getUserByContactId db contactId contact <- getContact db user contactId pure RcvCallInvitation {user, contact, callType = peerCallType, sharedKey, callTs} + APIGetNetworkStatuses -> withUser $ \_ -> + CRNetworkStatuses Nothing . map (uncurry ConnNetworkStatus) . M.toList <$> chatReadVar connNetworkStatuses APICallStatus contactId receivedStatus -> withCurrentCall contactId $ \user ct call -> updateCallItemStatus user ct call receivedStatus Nothing $> Just call @@ -1171,7 +1203,7 @@ processChatCommand = \case ct <- getContact db user chatId liftIO $ updateContactSettings db user chatId chatSettings pure ct - withAgent $ \a -> toggleConnectionNtfs a (contactConnId ct) (enableNtfs chatSettings) + withAgent $ \a -> toggleConnectionNtfs a (contactConnId ct) (chatHasNtfs chatSettings) ok user CTGroup -> do ms <- withStore $ \db -> do @@ -1179,9 +1211,17 @@ processChatCommand = \case liftIO $ updateGroupSettings db user chatId chatSettings pure ms forM_ (filter memberActive ms) $ \m -> forM_ (memberConnId m) $ \connId -> - withAgent (\a -> toggleConnectionNtfs a connId $ enableNtfs chatSettings) `catchChatError` (toView . CRChatError (Just user)) + withAgent (\a -> toggleConnectionNtfs a connId $ chatHasNtfs chatSettings) `catchChatError` (toView . CRChatError (Just user)) ok user _ -> pure $ chatCmdError (Just user) "not supported" + APISetMemberSettings gId gMemberId settings -> withUser $ \user -> do + m <- withStore $ \db -> do + liftIO $ updateGroupMemberSettings db user gId gMemberId settings + getGroupMember db user gId gMemberId + when (memberActive m) $ forM_ (memberConnId m) $ \connId -> do + let ntfOn = showMessages $ memberSettings m + withAgent (\a -> toggleConnectionNtfs a connId ntfOn) `catchChatError` (toView . CRChatError (Just user)) + ok user APIContactInfo contactId -> withUser $ \user@User {userId} -> do -- [incognito] print user's incognito profile for this contact ct@Contact {activeConn = Connection {customUserProfileId}} <- withStore $ \db -> getContact db user contactId @@ -1276,6 +1316,11 @@ processChatCommand = \case _ -> throwChatError CEGroupMemberNotActive SetShowMessages cName ntfOn -> updateChatSettings cName (\cs -> cs {enableNtfs = ntfOn}) SetSendReceipts cName rcptsOn_ -> updateChatSettings cName (\cs -> cs {sendRcpts = rcptsOn_}) + SetShowMemberMessages gName mName showMessages -> withUser $ \user -> do + (gId, mId) <- getGroupAndMemberId user gName mName + m <- withStore $ \db -> getGroupMember db user gId mId + let settings = (memberSettings m) {showMessages} + processChatCommand $ APISetMemberSettings gId mId settings ContactInfo cName -> withContactName cName APIContactInfo ShowGroupInfo gName -> withUser $ \user -> do groupId <- withStore $ \db -> getGroupIdByName db user gName @@ -1320,6 +1365,8 @@ processChatCommand = \case case conn'_ of Just conn' -> pure $ CRConnectionIncognitoUpdated user conn' Nothing -> throwChatError CEConnectionIncognitoChangeProhibited + APIConnectPlan userId cReqUri -> withUserId userId $ \user -> withChatLock "connectPlan" . procCmd $ + CRConnectionPlan user <$> connectPlan user cReqUri APIConnect userId incognito (Just (ACR SCMInvitation cReq)) -> withUserId userId $ \user -> withChatLock "connect" . procCmd $ do subMode <- chatReadVar subscriptionMode -- [incognito] generate profile to send @@ -1332,11 +1379,16 @@ processChatCommand = \case pure $ CRSentConfirmation 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 incognito adminContactReq + Connect incognito aCReqUri@(Just cReqUri) -> withUser $ \user@User {userId} -> do + plan <- connectPlan user cReqUri `catchChatError` const (pure $ CPInvitationLink ILPOk) + unless (connectionPlanProceed plan) $ throwChatError (CEConnectionPlan plan) + processChatCommand $ APIConnect userId incognito aCReqUri + Connect _ Nothing -> throwChatError CEInvalidConnReq + ConnectSimplex incognito -> withUser $ \user@User {userId} -> do + let cReqUri = ACR SCMContact adminContactReq + plan <- connectPlan user cReqUri `catchChatError` const (pure $ CPInvitationLink ILPOk) + unless (connectionPlanProceed plan) $ throwChatError (CEConnectionPlan plan) + processChatCommand $ APIConnect userId incognito (Just cReqUri) DeleteContact cName -> withContactName cName $ \ctId -> APIDeleteChat (ChatRef CTDirect ctId) True ClearContact cName -> withContactName cName $ APIClearChat . ChatRef CTDirect APIListContacts userId -> withUserId userId $ \user -> @@ -1432,7 +1484,7 @@ processChatCommand = \case processChatCommand . APISendMessage chatRef True Nothing $ ComposedMessage Nothing Nothing mc SendMessageBroadcast msg -> withUser $ \user -> do contacts <- withStore' (`getUserContacts` user) - let cts = filter (\ct -> isReady ct && contactActive ct && directOrUsed ct) contacts + let cts = filter (\ct -> contactReady ct && contactActive ct && directOrUsed ct) contacts ChatConfig {logLevel} <- asks config withChatLock "sendMessageBroadcast" . procCmd $ do (successes, failures) <- foldM (sendAndCount user logLevel) (0, 0) cts @@ -1471,13 +1523,15 @@ processChatCommand = \case chatRef <- getChatRef user chatName chatItemId <- getChatItemIdByText user chatRef msg processChatCommand $ APIChatItemReaction chatRef chatItemId add reaction - APINewGroup userId gProfile@GroupProfile {displayName} -> withUserId userId $ \user -> do + APINewGroup userId incognito gProfile@GroupProfile {displayName} -> withUserId userId $ \user -> do checkValidName displayName gVar <- asks idsDrg - groupInfo <- withStore $ \db -> createNewGroup db gVar user gProfile + -- [incognito] generate incognito profile for group membership + incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing + groupInfo <- withStore $ \db -> createNewGroup db gVar user gProfile incognitoProfile pure $ CRGroupCreated user groupInfo - NewGroup gProfile -> withUser $ \User {userId} -> - processChatCommand $ APINewGroup userId gProfile + NewGroup incognito gProfile -> withUser $ \User {userId} -> + processChatCommand $ APINewGroup userId incognito gProfile APIAddMember groupId contactId memRole -> withUser $ \user -> withChatLock "addMember" $ do -- TODO for large groups: no need to load all members to determine if contact is a member (group, contact) <- withStore $ \db -> (,) <$> getGroup db user groupId <*> getContact db user contactId @@ -1508,12 +1562,12 @@ processChatCommand = \case Nothing -> throwChatError $ CEGroupCantResendInvitation gInfo cName | otherwise -> throwChatError $ CEGroupDuplicateMember cName APIJoinGroup groupId -> withUser $ \user@User {userId} -> do - (invitation, ct) <- withStore $ \db -> do - inv@ReceivedGroupInvitation {fromMember} <- getGroupInvitation db user groupId - (inv,) <$> getContactViaMember db user fromMember - let ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g@GroupInfo {membership}} = invitation - Contact {activeConn = Connection {peerChatVRange}} = ct withChatLock "joinGroup" . procCmd $ do + (invitation, ct) <- withStore $ \db -> do + inv@ReceivedGroupInvitation {fromMember} <- getGroupInvitation db user groupId + (inv,) <$> getContactViaMember db user fromMember + let ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g@GroupInfo {membership}} = invitation + Contact {activeConn = Connection {peerChatVRange}} = ct subMode <- chatReadVar subscriptionMode dm <- directMessage $ XGrpAcpt membership.memberId agentConnId <- withAgent $ \a -> joinConnection a (aUserId user) True connRequest dm subMode @@ -1661,6 +1715,8 @@ processChatCommand = \case (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing subMode -- [incognito] reuse membership incognito profile ct <- withStore' $ \db -> createMemberContact db user connId cReq g m mConn subMode + -- TODO not sure it is correct to set connections status here? + setContactNetworkStatus ct NSConnected pure $ CRNewMemberContact user ct g m _ -> throwChatError CEGroupMemberNotActive APISendMemberContactInvitation contactId msgContent_ -> withUser $ \user -> do @@ -1700,11 +1756,10 @@ processChatCommand = \case LastMessages (Just chatName) count search -> withUser $ \user -> do chatRef <- getChatRef user chatName chatResp <- processChatCommand $ APIGetChat chatRef (CPLast count) search - setActive $ chatActiveTo chatName - pure $ CRChatItems user (aChatItems . chat $ chatResp) + pure $ CRChatItems user (Just chatName) (aChatItems . chat $ chatResp) LastMessages Nothing count search -> withUser $ \user -> do chatItems <- withStore $ \db -> getAllChatItems db user (CPLast count) search - pure $ CRChatItems user chatItems + pure $ CRChatItems user Nothing chatItems LastChatItemId (Just chatName) index -> withUser $ \user -> do chatRef <- getChatRef user chatName chatResp <- processChatCommand (APIGetChat chatRef (CPLast $ index + 1) Nothing) @@ -1716,10 +1771,10 @@ processChatCommand = \case chatItem <- withStore $ \db -> do chatRef <- getChatRefViaItemId db user itemId getAChatItem db user chatRef itemId - pure $ CRChatItems user ((: []) chatItem) + pure $ CRChatItems user Nothing ((: []) chatItem) ShowChatItem Nothing -> withUser $ \user -> do chatItems <- withStore $ \db -> getAllChatItems db user (CPLast 1) Nothing - pure $ CRChatItems user chatItems + pure $ CRChatItems user Nothing chatItems ShowChatItemInfo chatName msg -> withUser $ \user -> do chatRef <- getChatRef user chatName itemId <- getChatItemIdByText user chatRef msg @@ -1740,19 +1795,16 @@ processChatCommand = \case ForwardFile chatName fileId -> forwardFile chatName fileId SendFile ForwardImage chatName fileId -> forwardFile chatName fileId SendImage SendFileDescription _chatName _f -> pure $ chatCmdError Nothing "TODO" - ReceiveFile fileId encrypted rcvInline_ filePath_ -> withUser $ \_ -> + ReceiveFile fileId encrypted_ rcvInline_ filePath_ -> withUser $ \_ -> withChatLock "receiveFile" . procCmd $ do (user, ft) <- withStore (`getRcvFileTransferById` fileId) - ft' <- if encrypted then encryptLocalFile ft else pure ft + encrypt <- (`fromMaybe` encrypted_) <$> chatReadVar encryptLocalFiles + ft' <- (if encrypt then setFileToEncrypt else pure) ft receiveFile' user ft' rcvInline_ filePath_ - where - encryptLocalFile ft = do - cfArgs <- liftIO $ CF.randomArgs - withStore' $ \db -> setFileCryptoArgs db fileId cfArgs - pure (ft :: RcvFileTransfer) {cryptoArgs = Just cfArgs} - SetFileToReceive fileId encrypted -> withUser $ \_ -> do + SetFileToReceive fileId encrypted_ -> withUser $ \_ -> do withChatLock "setFileToReceive" . procCmd $ do - cfArgs <- if encrypted then Just <$> liftIO CF.randomArgs else pure Nothing + encrypt <- (`fromMaybe` encrypted_) <$> chatReadVar encryptLocalFiles + cfArgs <- if encrypt then Just <$> liftIO CF.randomArgs else pure Nothing withStore' $ \db -> setRcvFileToReceive db fileId cfArgs ok_ CancelFile fileId -> withUser $ \user@User {userId} -> @@ -1933,19 +1985,36 @@ processChatCommand = \case _ -> throwChatError $ CECommandError "not supported" 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 - (_, xContactId_) -> procCmd $ do - let randomXContactId = XContactId <$> drgRandomBytes 16 - xContactId <- maybe randomXContactId pure xContactId_ - subMode <- chatReadVar subscriptionMode + let groupLinkId = crClientData >>= decodeJSON >>= \(CRDataGroup gli) -> Just gli + cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq + case groupLinkId of + -- contact address + Nothing -> + withStore' (\db -> getConnReqContactXContactId db user cReqHash) >>= \case + (Just contact, _) -> pure $ CRContactAlreadyExists user contact + (_, xContactId_) -> procCmd $ do + let randomXContactId = XContactId <$> drgRandomBytes 16 + xContactId <- maybe randomXContactId pure xContactId_ + connect' Nothing cReqHash xContactId + -- group link + Just gLinkId -> + withStore' (\db -> getConnReqContactXContactId db user cReqHash) >>= \case + (Just _contact, _) -> procCmd $ do + -- allow repeat contact request + newXContactId <- XContactId <$> drgRandomBytes 16 + connect' (Just gLinkId) cReqHash newXContactId + (_, xContactId_) -> procCmd $ do + let randomXContactId = XContactId <$> drgRandomBytes 16 + xContactId <- maybe randomXContactId pure xContactId_ + connect' (Just gLinkId) cReqHash xContactId + where + connect' groupLinkId cReqHash xContactId = do -- [incognito] generate profile to send incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing let profileToSend = userProfileToSend user incognitoProfile Nothing dm <- directMessage (XContact profileToSend $ Just xContactId) + subMode <- chatReadVar subscriptionMode connId <- withAgent $ \a -> joinConnection a (aUserId user) True cReq dm subMode - let groupLinkId = crClientData >>= decodeJSON >>= \(CRDataGroup gli) -> Just gli conn <- withStore' $ \db -> createConnReqConnection db userId connId cReqHash xContactId incognitoProfile groupLinkId subMode toView $ CRNewContactConnection user conn pure $ CRSentInvitation user incognitoProfile @@ -1984,7 +2053,7 @@ processChatCommand = \case -- read contacts before user update to correctly merge preferences -- [incognito] filter out contacts with whom user has incognito connections contacts <- - filter (\ct -> isReady ct && contactActive ct && not (contactConnIncognito ct)) + filter (\ct -> contactReady ct && contactActive ct && not (contactConnIncognito ct)) <$> withStore' (`getUserContacts` user) user' <- updateUser asks currentUser >>= atomically . (`writeTVar` Just user') @@ -2043,9 +2112,8 @@ processChatCommand = \case when (memberStatus membership == GSMemInvited) $ throwChatError (CEGroupNotJoined g) when (memberRemoved membership) $ throwChatError CEGroupMemberUserRemoved unless (memberActive membership) $ throwChatError CEGroupMemberNotActive - delGroupChatItem :: User -> GroupInfo -> CChatItem 'CTGroup -> MessageId -> Maybe GroupMember -> m ChatResponse - delGroupChatItem user gInfo@GroupInfo {localDisplayName = gName} ci msgId byGroupMember = do - setActive $ ActiveG gName + delGroupChatItem :: MsgDirectionI d => User -> GroupInfo -> ChatItem 'CTGroup d -> MessageId -> Maybe GroupMember -> m ChatResponse + delGroupChatItem user gInfo ci msgId byGroupMember = do deletedTs <- liftIO getCurrentTime if groupFeatureAllowed SGFFullDelete gInfo then deleteGroupCI user gInfo ci True False byGroupMember deletedTs @@ -2055,10 +2123,6 @@ processChatCommand = \case g@(Group GroupInfo {groupProfile = p} _) <- withStore $ \db -> getGroupIdByName db user gName >>= getGroup db user runUpdateGroupProfile user g $ update p - isReady :: Contact -> Bool - isReady ct = - let s = connStatus $ ct.activeConn - in s == ConnReady || s == ConnSndReady withCurrentCall :: ContactId -> (User -> Contact -> Call -> m (Maybe Call)) -> m ChatResponse withCurrentCall ctId action = do (user, ct) <- withStore $ \db -> do @@ -2106,7 +2170,6 @@ processChatCommand = \case let content = CISndGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole ci <- saveSndChatItem user (CDDirectSnd ct) msg content toView $ CRNewChatItem user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci) - setActive $ ActiveG localDisplayName sndContactCITimed :: Bool -> Contact -> Maybe Int -> m (Maybe CITimed) sndContactCITimed live = sndCITimed_ live . contactTimedTTL sndGroupCITimed :: Bool -> GroupInfo -> Maybe Int -> m (Maybe CITimed) @@ -2156,7 +2219,6 @@ processChatCommand = \case users <- withStore' getUsers unless (length users > 1 && (isJust (viewPwdHash user) || length (filter (isNothing . viewPwdHash) users) > 1)) $ throwChatError (CECantDeleteLastUser userId) - setActive ActiveNone deleteChatUser :: User -> Bool -> m ChatResponse deleteChatUser user delSMPQueues = do filesInfo <- withStore' (`getUserFileInfo` user) @@ -2177,6 +2239,74 @@ processChatCommand = \case pure (gId, chatSettings) _ -> throwChatError $ CECommandError "not supported" processChatCommand $ APISetChatSettings (ChatRef cType chatId) $ updateSettings chatSettings + connectPlan :: User -> AConnectionRequestUri -> m ConnectionPlan + connectPlan user (ACR SCMInvitation cReq) = do + withStore' (\db -> getConnectionEntityByConnReq db user cReqSchemas) >>= \case + Nothing -> pure $ CPInvitationLink ILPOk + Just (RcvDirectMsgConnection conn ct_) -> do + let Connection {connStatus, contactConnInitiated} = conn + if + | connStatus == ConnNew && contactConnInitiated -> + pure $ CPInvitationLink ILPOwnLink + | not (connReady conn) -> + pure $ CPInvitationLink (ILPConnecting ct_) + | otherwise -> case ct_ of + Just ct -> pure $ CPInvitationLink (ILPKnown ct) + Nothing -> throwChatError $ CEInternalError "ready RcvDirectMsgConnection connection should have associated contact" + Just _ -> throwChatError $ CECommandError "found connection entity is not RcvDirectMsgConnection" + where + cReqSchemas :: (ConnReqInvitation, ConnReqInvitation) + cReqSchemas = case cReq of + (CRInvitationUri crData e2e) -> + ( CRInvitationUri crData {crScheme = CRSSimplex} e2e, + CRInvitationUri crData {crScheme = simplexChat} e2e + ) + connectPlan user (ACR SCMContact cReq) = do + let CRContactUri ConnReqUriData {crClientData} = cReq + groupLinkId = crClientData >>= decodeJSON >>= \(CRDataGroup gli) -> Just gli + case groupLinkId of + -- contact address + Nothing -> + withStore' (\db -> getUserContactLinkByConnReq db user cReqSchemas) >>= \case + Just _ -> pure $ CPContactAddress CAPOwnLink + Nothing -> do + withStore' (\db -> getContactConnEntityByConnReqHash db user cReqHashes) >>= \case + Nothing -> pure $ CPContactAddress CAPOk + Just (RcvDirectMsgConnection _conn Nothing) -> pure $ CPContactAddress CAPConnectingConfirmReconnect + Just (RcvDirectMsgConnection _ (Just ct)) + | not (contactReady ct) && contactActive ct -> pure $ CPContactAddress (CAPConnectingProhibit ct) + | contactDeleted ct -> pure $ CPContactAddress CAPOk + | otherwise -> pure $ CPContactAddress (CAPKnown ct) + Just _ -> throwChatError $ CECommandError "found connection entity is not RcvDirectMsgConnection" + -- group link + Just _ -> + withStore' (\db -> getGroupInfoByUserContactLinkConnReq db user cReqSchemas) >>= \case + Just g -> pure $ CPGroupLink (GLPOwnLink g) + Nothing -> do + connEnt_ <- withStore' $ \db -> getContactConnEntityByConnReqHash db user cReqHashes + gInfo_ <- withStore' $ \db -> getGroupInfoByGroupLinkHash db user cReqHashes + case (gInfo_, connEnt_) of + (Nothing, Nothing) -> pure $ CPGroupLink GLPOk + (Nothing, Just (RcvDirectMsgConnection _conn Nothing)) -> pure $ CPGroupLink GLPConnectingConfirmReconnect + (Nothing, Just (RcvDirectMsgConnection _ (Just ct))) + | not (contactReady ct) && contactActive ct -> pure $ CPGroupLink (GLPConnectingProhibit gInfo_) + | otherwise -> pure $ CPGroupLink GLPOk + (Nothing, Just _) -> throwChatError $ CECommandError "found connection entity is not RcvDirectMsgConnection" + (Just gInfo@GroupInfo {membership}, _) + | not (memberActive membership) && not (memberRemoved membership) -> + pure $ CPGroupLink (GLPConnectingProhibit gInfo_) + | memberActive membership -> pure $ CPGroupLink (GLPKnown gInfo) + | otherwise -> pure $ CPGroupLink GLPOk + where + cReqSchemas :: (ConnReqContact, ConnReqContact) + cReqSchemas = case cReq of + (CRContactUri crData) -> + ( CRContactUri crData {crScheme = CRSSimplex}, + CRContactUri crData {crScheme = simplexChat} + ) + cReqHashes :: (ConnReqUriHash, ConnReqUriHash) + cReqHashes = bimap hash hash cReqSchemas + hash = ConnReqUriHash . C.sha256Hash . strEncode assertDirectAllowed :: ChatMonad m => User -> MsgDirection -> Contact -> CMEventTag e -> m () assertDirectAllowed user dir ct event = @@ -2279,8 +2409,8 @@ updateCallItemStatus user ct Call {chatItemId} receivedStatus msgId_ = do forM_ aciContent_ $ \aciContent -> updateDirectChatItemView user ct chatItemId aciContent False msgId_ updateDirectChatItemView :: ChatMonad m => User -> Contact -> ChatItemId -> ACIContent -> Bool -> Maybe MessageId -> m () -updateDirectChatItemView user ct@Contact {contactId} chatItemId (ACIContent msgDir ciContent) live msgId_ = do - ci' <- withStore $ \db -> updateDirectChatItem db user contactId chatItemId ciContent live msgId_ +updateDirectChatItemView user ct chatItemId (ACIContent msgDir ciContent) live msgId_ = do + ci' <- withStore $ \db -> updateDirectChatItem db user ct chatItemId ciContent live msgId_ toView $ CRChatItemUpdated user (AChatItem SCTDirect msgDir (DirectChat ct) ci') callStatusItemContent :: ChatMonad m => User -> Contact -> ChatItemId -> WebRTCCallStatus -> m (Maybe ACIContent) @@ -2319,6 +2449,12 @@ toFSFilePath :: ChatMonad' m => FilePath -> m FilePath toFSFilePath f = maybe f ( f) <$> (readTVarIO =<< asks filesFolder) +setFileToEncrypt :: ChatMonad m => RcvFileTransfer -> m RcvFileTransfer +setFileToEncrypt ft@RcvFileTransfer {fileId} = do + cfArgs <- liftIO CF.randomArgs + withStore' $ \db -> setFileCryptoArgs db fileId cfArgs + pure (ft :: RcvFileTransfer) {cryptoArgs = Just cfArgs} + receiveFile' :: ChatMonad m => User -> RcvFileTransfer -> Maybe Bool -> Maybe FilePath -> m ChatResponse receiveFile' user ft rcvInline_ filePath_ = do (CRRcvFileAccepted user <$> acceptFileReceive user ft rcvInline_ filePath_) `catchChatError` processError @@ -2476,6 +2612,24 @@ acceptContactRequestAsync user UserContactRequest {agentInvitationId = AgentInvI setCommandConnId db user cmdId connId pure ct +acceptGroupJoinRequestAsync :: ChatMonad m => User -> GroupInfo -> UserContactRequest -> GroupMemberRole -> Maybe IncognitoProfile -> m GroupMember +acceptGroupJoinRequestAsync + user + gInfo@GroupInfo {groupProfile, membership} + ucr@UserContactRequest {agentInvitationId = AgentInvId invId} + gLinkMemRole + incognitoProfile = do + gVar <- asks idsDrg + (groupMemberId, memberId) <- withStore $ \db -> createAcceptedMember db gVar user gInfo ucr gLinkMemRole + let Profile {displayName} = profileToSendOnAccept user incognitoProfile + GroupMember {memberRole = userRole, memberId = userMemberId} = membership + msg = XGrpLinkInv $ GroupLinkInvitation (MemberIdRole userMemberId userRole) displayName (MemberIdRole memberId gLinkMemRole) groupProfile + subMode <- chatReadVar subscriptionMode + connIds <- agentAcceptContactAsync user True invId msg subMode + withStore $ \db -> do + liftIO $ createAcceptedMemberConnection db user connIds ucr groupMemberId subMode + getGroupMemberById db user groupMemberId + profileToSendOnAccept :: User -> Maybe IncognitoProfile -> Profile profileToSendOnAccept user ip = userProfileToSend user (getIncognitoProfile <$> ip) Nothing where @@ -2543,6 +2697,7 @@ subscribeUserConnections onlyNeeded agentBatchSubscribe user@User {userId} = do rs <- withAgent $ \a -> agentBatchSubscribe a conns -- send connection events to view contactSubsToView rs cts ce +-- TODO possibly, we could either disable these events or replace with less noisy for API contactLinkSubsToView rs ucs groupSubsToView rs gs ms ce sndFileSubsToView rs sfts @@ -2603,12 +2758,30 @@ subscribeUserConnections onlyNeeded agentBatchSubscribe user@User {userId} = do let connIds = map aConnId' pcs pure (connIds, M.fromList $ zip connIds pcs) contactSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId Contact -> Bool -> m () - contactSubsToView rs cts ce = do - toView . CRContactSubSummary user $ map (uncurry ContactSubStatus) cRs - when ce $ mapM_ (toView . uncurry (CRContactSubError user)) cErrors + contactSubsToView rs cts ce = ifM (asks $ coreApi . config) notifyAPI notifyCLI where - cRs = resultsFor rs cts - cErrors = sortOn (\(Contact {localDisplayName = n}, _) -> n) $ filterErrors cRs + notifyCLI = do + let cRs = resultsFor rs cts + cErrors = sortOn (\(Contact {localDisplayName = n}, _) -> n) $ filterErrors cRs + toView . CRContactSubSummary user $ map (uncurry ContactSubStatus) cRs + when ce $ mapM_ (toView . uncurry (CRContactSubError user)) cErrors + notifyAPI = do + let statuses = M.foldrWithKey' addStatus [] cts + chatModifyVar connNetworkStatuses $ M.union (M.fromList statuses) + toView $ CRNetworkStatuses (Just user) $ map (uncurry ConnNetworkStatus) statuses + where + addStatus :: ConnId -> Contact -> [(AgentConnId, NetworkStatus)] -> [(AgentConnId, NetworkStatus)] + addStatus connId ct = + let ns = (contactAgentConnId ct, netStatus $ resultErr connId rs) + in (ns :) + netStatus :: Maybe ChatError -> NetworkStatus + netStatus = maybe NSConnected $ NSError . errorNetworkStatus + errorNetworkStatus :: ChatError -> String + errorNetworkStatus = \case + ChatErrorAgent (BROKER _ NETWORK) _ -> "network" + ChatErrorAgent (SMP SMP.AUTH) _ -> "contact deleted" + e -> show e +-- TODO possibly below could be replaced with less noisy events for API contactLinkSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId UserContact -> m () contactLinkSubsToView rs = toView . CRUserContactSubSummary user . map (uncurry UserContactSubStatus) . resultsFor rs groupSubsToView :: Map ConnId (Either AgentErrorType ()) -> [Group] -> Map ConnId GroupMember -> Bool -> m () @@ -2658,12 +2831,12 @@ subscribeUserConnections onlyNeeded agentBatchSubscribe user@User {userId} = do resultsFor rs = M.foldrWithKey' addResult [] where addResult :: ConnId -> a -> [(a, Maybe ChatError)] -> [(a, Maybe ChatError)] - addResult connId = (:) . (,err) - where - err = case M.lookup connId rs of - Just (Left e) -> Just $ ChatErrorAgent e Nothing - Just _ -> Nothing - _ -> Just . ChatError . CEAgentNoSubResult $ AgentConnId connId + addResult connId = (:) . (,resultErr connId rs) + resultErr :: ConnId -> Map ConnId (Either AgentErrorType ()) -> Maybe ChatError + resultErr connId rs = case M.lookup connId rs of + Just (Left e) -> Just $ ChatErrorAgent e Nothing + Just _ -> Nothing + _ -> Just . ChatError . CEAgentNoSubResult $ AgentConnId connId cleanupManager :: forall m. ChatMonad m => m () cleanupManager = do @@ -2680,6 +2853,7 @@ cleanupManager = do forM_ us $ cleanupUser interval stepDelay forM_ us' $ cleanupUser interval stepDelay cleanupMessages `catchChatError` (toView . CRChatError Nothing) + cleanupProbes `catchChatError` (toView . CRChatError Nothing) liftIO $ threadDelay' $ diffToMicroseconds interval where runWithoutInitialDelay cleanupInterval = flip catchChatError (toView . CRChatError Nothing) $ do @@ -2707,6 +2881,10 @@ cleanupManager = do ts <- liftIO getCurrentTime let cutoffTs = addUTCTime (- (30 * nominalDay)) ts withStoreCtx' (Just "cleanupManager, deleteOldMessages") (`deleteOldMessages` cutoffTs) + cleanupProbes = do + ts <- liftIO getCurrentTime + let cutoffTs = addUTCTime (- (14 * nominalDay)) ts + withStore' (`deleteOldProbes` cutoffTs) startProximateTimedItemThread :: ChatMonad m => User -> (ChatRef, ChatItemId) -> UTCTime -> m () startProximateTimedItemThread user itemRef deleteAt = do @@ -2737,10 +2915,10 @@ deleteTimedItem user (ChatRef cType chatId, itemId) deleteAt = do waitChatStarted case cType of CTDirect -> do - (ct, ci) <- withStoreCtx (Just "deleteTimedItem, getContact ...") $ \db -> (,) <$> getContact db user chatId <*> getDirectChatItem db user chatId itemId + (ct, CChatItem _ ci) <- withStore $ \db -> (,) <$> getContact db user chatId <*> getDirectChatItem db user chatId itemId deleteDirectCI user ct ci True True >>= toView CTGroup -> do - (gInfo, ci) <- withStoreCtx (Just "deleteTimedItem, getGroupInfo ...") $ \db -> (,) <$> getGroupInfo db user chatId <*> getGroupChatItem db user chatId itemId + (gInfo, CChatItem _ ci) <- withStore $ \db -> (,) <$> getGroupInfo db user chatId <*> getGroupChatItem db user chatId itemId deletedTs <- liftIO getCurrentTime deleteGroupCI user gInfo ci True True Nothing deletedTs >>= toView _ -> toView . CRChatError (Just user) . ChatError $ CEInternalError "bad deleteTimedItem cType" @@ -2803,17 +2981,22 @@ processAgentMessageNoConn :: forall m. ChatMonad m => ACommand 'Agent 'AENone -> processAgentMessageNoConn = \case CONNECT p h -> hostEvent $ CRHostConnected p h DISCONNECT p h -> hostEvent $ CRHostDisconnected p h - DOWN srv conns -> serverEvent srv conns CRContactsDisconnected "disconnected" - UP srv conns -> serverEvent srv conns CRContactsSubscribed "connected" + DOWN srv conns -> serverEvent srv conns NSDisconnected CRContactsDisconnected + UP srv conns -> serverEvent srv conns NSConnected CRContactsSubscribed SUSPENDED -> toView CRChatSuspended DEL_USER agentUserId -> toView $ CRAgentUserDeleted agentUserId where hostEvent :: ChatResponse -> m () hostEvent = whenM (asks $ hostEvents . config) . toView - serverEvent srv@(SMPServer host _ _) conns event str = do - cs <- withStore' $ \db -> getConnectionsContacts db conns - toView $ event srv cs - showToast ("server " <> str) (safeDecodeUtf8 $ strEncode host) + serverEvent srv conns nsStatus event = ifM (asks $ coreApi . config) notifyAPI notifyCLI + where + notifyAPI = do + let connIds = map AgentConnId conns + chatModifyVar connNetworkStatuses $ \m -> foldl' (\m' cId -> M.insert cId nsStatus m') m connIds + toView $ CRNetworkStatus nsStatus connIds + notifyCLI = do + cs <- withStore' (`getConnectionsContacts` conns) + toView $ event srv cs processAgentMsgSndFile :: forall m. ChatMonad m => ACorrId -> SndFileId -> ACommand 'Agent 'AESndFile -> m () processAgentMsgSndFile _corrId aFileId msg = @@ -2950,10 +3133,7 @@ processAgentMsgRcvFile _corrId aFileId msg = processAgentMessageConn :: forall m. ChatMonad m => User -> ACorrId -> ConnId -> ACommand 'Agent 'AEConn -> m () processAgentMessageConn user _ agentConnId END = withStore (\db -> getConnectionEntity db user $ AgentConnId agentConnId) >>= \case - RcvDirectMsgConnection _ (Just ct@Contact {localDisplayName = c}) -> do - toView $ CRContactAnotherClient user ct - whenUserNtfs user $ showToast (c <> "> ") "connected to another client" - unsetActive $ ActiveC c + RcvDirectMsgConnection _ (Just ct) -> toView $ CRContactAnotherClient user ct entity -> toView $ CRSubscriptionEnd user entity processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do entity <- withStore (\db -> getConnectionEntity db user $ AgentConnId agentConnId) >>= updateConnStatus @@ -2989,7 +3169,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do _ -> Nothing processDirectMessage :: ACommand 'Agent e -> ConnectionEntity -> Connection -> Maybe Contact -> m () - processDirectMessage agentMsg connEntity conn@Connection {connId, peerChatVRange, viaUserContactLink, groupLinkId, customUserProfileId, connectionCode} = \case + processDirectMessage agentMsg connEntity conn@Connection {connId, peerChatVRange, viaUserContactLink, customUserProfileId, connectionCode} = \case Nothing -> case agentMsg of CONF confId _ connInfo -> do -- [incognito] send saved profile @@ -3020,7 +3200,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () -- TODO add debugging output _ -> pure () - Just ct@Contact {localDisplayName = c, contactId} -> case agentMsg of + Just ct@Contact {contactId} -> case agentMsg of INV (ACR _ cReq) -> -- [async agent commands] XGrpMemIntro continuation on receiving INV withCompletedCommand conn agentMsg $ \_ -> @@ -3053,9 +3233,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do XInfo p -> xInfo ct' p XDirectDel -> xDirectDel ct' msg msgMeta XGrpInv gInv -> processGroupInvitation ct' gInv msg msgMeta - XInfoProbe probe -> xInfoProbe (CGMContact ct') probe - XInfoProbeCheck probeHash -> xInfoProbeCheck ct' probeHash - XInfoProbeOk probe -> xInfoProbeOk ct' probe + XInfoProbe probe -> xInfoProbe (COMContact ct') probe + XInfoProbeCheck probeHash -> xInfoProbeCheck (COMContact ct') probeHash + XInfoProbeOk probe -> xInfoProbeOk (COMContact ct') probe XCallInv callId invitation -> xCallInv ct' callId invitation msg msgMeta XCallOffer callId offer -> xCallOffer ct' callId offer msg msgMeta XCallAnswer callId answer -> xCallAnswer ct' callId answer msg msgMeta @@ -3103,12 +3283,14 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do Nothing -> do -- [incognito] print incognito profile used for this contact incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) + setContactNetworkStatus ct NSConnected toView $ CRContactConnected user ct (fmap fromLocalProfile incognitoProfile) when (directOrUsed ct) $ createFeatureEnabledItems ct - whenUserNtfs user $ do - setActive $ ActiveC c - showToast (c <> "> ") "connected" - forM_ groupLinkId $ \_ -> probeMatchingContacts ct $ contactConnIncognito ct + when (contactConnInitiated conn) $ do + let Connection {groupLinkId} = conn + doProbeContacts = isJust groupLinkId + probeMatchingContactsAndMembers ct (contactConnIncognito ct) doProbeContacts + withStore' $ \db -> resetContactConnInitiated db user conn forM_ viaUserContactLink $ \userContactLinkId -> withStore' (\db -> getUserContactLinkById db userId userContactLinkId) >>= \case Just (UserContactLink {autoAccept = Just AutoAccept {autoReply = mc_}}, groupId_, gLinkMemRole) -> do @@ -3126,7 +3308,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do when (maybe False ((== ConnReady) . connStatus) activeConn) $ do notifyMemberConnected gInfo m $ Just ct let connectedIncognito = contactConnIncognito ct || incognitoMembership gInfo - when (memberCategory m == GCPreMember) $ probeMatchingContacts ct connectedIncognito + when (memberCategory m == GCPreMember) $ probeMatchingContactsAndMembers ct connectedIncognito True SENT msgId -> do sentMsgDeliveryEvent conn msgId checkSndInlineFTComplete conn msgId @@ -3144,8 +3326,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do withStore' $ \db -> setConnectionVerified db user connId Nothing let ct' = ct {activeConn = conn {connectionCode = Nothing}} :: Contact ratchetSyncEventItem ct' - toView $ CRContactVerificationReset user ct' - createInternalChatItem user (CDDirectRcv ct') (CIRcvConnEvent RCEVerificationCodeReset) Nothing + securityCodeChanged ct' _ -> ratchetSyncEventItem ct where processErr cryptoErr = do @@ -3181,7 +3362,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do _ -> pure () processGroupMessage :: ACommand 'Agent e -> ConnectionEntity -> Connection -> GroupInfo -> GroupMember -> m () - processGroupMessage agentMsg connEntity conn@Connection {connId, connectionCode} gInfo@GroupInfo {groupId, localDisplayName = gName, groupProfile, membership, chatSettings} m = case agentMsg of + processGroupMessage agentMsg connEntity conn@Connection {connId, connectionCode} gInfo@GroupInfo {groupId, groupProfile, membership, chatSettings} m = case agentMsg of INV (ACR _ cReq) -> withCompletedCommand conn agentMsg $ \CommandData {cmdFunction} -> case cReq of @@ -3252,8 +3433,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do -- TODO update member profile pure () | otherwise -> messageError "x.grp.mem.info: memberId is different from expected" + XInfo _ -> pure () -- sent when connecting via group link XOk -> pure () - _ -> messageError "INFO from member must have x.grp.mem.info" + _ -> messageError "INFO from member must have x.grp.mem.info, x.info or x.ok" pure () CON -> do members <- withStore' $ \db -> getGroupMembers db user gInfo @@ -3263,7 +3445,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do updateGroupMemberStatus db userId membership GSMemConnected -- possible improvement: check for each pending message, requires keeping track of connection state unless (connDisabled conn) $ sendPendingGroupMessages user m conn - withAgent $ \a -> toggleConnectionNtfs a (aConnId conn) $ enableNtfs chatSettings + withAgent $ \a -> toggleConnectionNtfs a (aConnId conn) $ chatHasNtfs chatSettings case memberCategory m of GCHostMember -> do toView $ CRUserJoinedGroup user gInfo {membership = membership {memberStatus = GSMemConnected}} m {memberStatus = GSMemConnected} @@ -3271,20 +3453,20 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do let GroupInfo {groupProfile = GroupProfile {description}} = gInfo memberConnectedChatItem gInfo m forM_ description $ groupDescriptionChatItem gInfo m - whenUserNtfs user $ do - setActive $ ActiveG gName - showToast ("#" <> gName) "you are connected to group" GCInviteeMember -> do memberConnectedChatItem gInfo m toView $ CRJoinedGroupMember user gInfo m {memberStatus = GSMemConnected} - whenGroupNtfs user gInfo $ do - setActive $ ActiveG gName - showToast ("#" <> gName) $ "member " <> m.localDisplayName <> " is connected" + let Connection {viaUserContactLink} = conn + when (isJust viaUserContactLink && isNothing (memberContactId m)) sendXGrpLinkMem intros <- withStore' $ \db -> createIntroductions db members m void . sendGroupMessage user gInfo members . XGrpMemNew $ memberInfo m forM_ intros $ \intro -> processIntro intro `catchChatError` (toView . CRChatError (Just user)) where + sendXGrpLinkMem = do + let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo + profileToSend = profileToSendOnAccept user profileMode + void $ sendDirectMessage conn (XGrpLinkMem profileToSend) (GroupId groupId) processIntro intro@GroupMemberIntro {introId} = do void $ sendDirectMessage conn (XGrpMemIntro $ memberInfo (reMember intro)) (GroupId groupId) withStore' $ \db -> updateIntroStatus db introId GMIntroSent @@ -3294,12 +3476,12 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do Nothing -> do notifyMemberConnected gInfo m Nothing let connectedIncognito = memberIncognito membership - when (memberCategory m == GCPreMember) $ probeMatchingMemberContact gInfo m connectedIncognito + when (memberCategory m == GCPreMember) $ probeMatchingMemberContact m connectedIncognito Just ct@Contact {activeConn = Connection {connStatus}} -> when (connStatus == ConnReady) $ do notifyMemberConnected gInfo m $ Just ct let connectedIncognito = contactConnIncognito ct || incognitoMembership gInfo - when (memberCategory m == GCPreMember) $ probeMatchingContacts ct connectedIncognito + when (memberCategory m == GCPreMember) $ probeMatchingContactsAndMembers ct connectedIncognito True MSG msgMeta _msgFlags msgBody -> do cmdId <- createAckCmd conn withAckMessage agentConnId cmdId msgMeta $ do @@ -3317,6 +3499,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do XFile fInv -> processGroupFileInvitation' gInfo m' fInv msg msgMeta XFileCancel sharedMsgId -> xFileCancelGroup gInfo m' sharedMsgId msgMeta XFileAcptInv sharedMsgId fileConnReq_ fName -> xFileAcptInvGroup gInfo m' sharedMsgId fileConnReq_ fName msgMeta + -- XInfo p -> xInfoMember gInfo m' p -- TODO use for member profile update + XGrpLinkMem p -> xGrpLinkMem gInfo m' conn' p XGrpMemNew memInfo -> xGrpMemNew gInfo m' memInfo msg msgMeta XGrpMemIntro memInfo -> xGrpMemIntro gInfo m' memInfo XGrpMemInv memId introInv -> xGrpMemInv gInfo m' memId introInv @@ -3327,9 +3511,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do XGrpDel -> xGrpDel gInfo m' msg msgMeta XGrpInfo p' -> xGrpInfo gInfo m' p' msg msgMeta XGrpDirectInv connReq mContent_ -> canSend m' $ xGrpDirectInv gInfo m' conn' connReq mContent_ msg msgMeta - XInfoProbe probe -> xInfoProbe (CGMGroupMember gInfo m') probe - -- XInfoProbeCheck -- TODO merge members? - -- XInfoProbeOk -- TODO merge members? + XInfoProbe probe -> xInfoProbe (COMGroupMember m') probe + XInfoProbeCheck probeHash -> xInfoProbeCheck (COMGroupMember m') probeHash + XInfoProbeOk probe -> xInfoProbeOk (COMGroupMember m') probe BFileChunk sharedMsgId chunk -> bFileChunkGroup gInfo sharedMsgId chunk msgMeta _ -> messageError $ "unsupported message: " <> T.pack (show event) currentMemCount <- withStore' $ \db -> getGroupCurrentMembersCount db user gInfo @@ -3575,9 +3759,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do profileContactRequest invId chatVRange p xContactId_ = do withStore (\db -> createOrUpdateContactRequest db user userContactLinkId invId chatVRange p xContactId_) >>= \case CORContact contact -> toView $ CRContactRequestAlreadyAccepted user contact - CORRequest cReq@UserContactRequest {localDisplayName} -> do + CORRequest cReq -> do withStore' (\db -> getUserContactLinkById db userId userContactLinkId) >>= \case - Just (UserContactLink {autoAccept}, groupId_, _) -> + Just (UserContactLink {autoAccept}, groupId_, gLinkMemRole) -> case autoAccept of Just AutoAccept {acceptIncognito} -> case groupId_ of Nothing -> do @@ -3588,12 +3772,15 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do Just groupId -> do gInfo <- withStore $ \db -> getGroupInfo db user groupId let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo - ct <- acceptContactRequestAsync user cReq profileMode - toView $ CRAcceptingGroupJoinRequest user gInfo ct - _ -> do - toView $ CRReceivedContactRequest user cReq - whenUserNtfs user $ - showToast (localDisplayName <> "> ") "wants to connect to you" + if isCompatibleRange chatVRange groupLinkNoContactVRange + then do + mem <- acceptGroupJoinRequestAsync user gInfo cReq gLinkMemRole profileMode + createInternalChatItem user (CDGroupRcv gInfo mem) (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing + toView $ CRAcceptingGroupJoinRequestMember user gInfo mem + else do + ct <- acceptContactRequestAsync user cReq profileMode + toView $ CRAcceptingGroupJoinRequest user gInfo ct + _ -> toView $ CRReceivedContactRequest user cReq _ -> pure () incAuthErrCounter :: ConnectionEntity -> Connection -> AgentErrorType -> m () @@ -3684,53 +3871,63 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do createInternalChatItem user (CDGroupRcv gInfo m) (CIRcvMsgContent $ MCText descr) Nothing notifyMemberConnected :: GroupInfo -> GroupMember -> Maybe Contact -> m () - notifyMemberConnected gInfo m@GroupMember {localDisplayName = c} ct_ = do + notifyMemberConnected gInfo m ct_ = do memberConnectedChatItem gInfo m + mapM_ (`setContactNetworkStatus` NSConnected) ct_ toView $ CRConnectedToGroupMember user gInfo m ct_ - let g = groupName' gInfo - whenGroupNtfs user gInfo $ do - setActive $ ActiveG g - showToast ("#" <> g) $ "member " <> c <> " is connected" - probeMatchingContacts :: Contact -> IncognitoEnabled -> m () - probeMatchingContacts ct connectedIncognito = do + probeMatchingContactsAndMembers :: Contact -> IncognitoEnabled -> Bool -> m () + probeMatchingContactsAndMembers ct connectedIncognito doProbeContacts = do gVar <- asks idsDrg - if connectedIncognito - then sendProbe . Probe =<< liftIO (encodedRandomBytes gVar 32) - else do - (probe, probeId) <- withStore $ \db -> createSentProbe db gVar userId (CGMContact ct) + contactMerge <- readTVarIO =<< asks contactMergeEnabled + if contactMerge && not connectedIncognito + then do + (probe, probeId) <- withStore $ \db -> createSentProbe db gVar userId (COMContact ct) + -- ! when making changes to probe-and-merge mechanism, + -- ! test scenario in which recipient receives probe after probe hashes (not covered in tests): + -- sendProbe -> sendProbeHashes (currently) + -- sendProbeHashes -> sendProbe (reversed - change order in code, may add delay) sendProbe probe - cs <- withStore' $ \db -> getMatchingContacts db user ct - sendProbeHashes cs probe probeId + cs <- if doProbeContacts + then map COMContact <$> withStore' (\db -> getMatchingContacts db user ct) + else pure [] + ms <- map COMGroupMember <$> withStore' (\db -> getMatchingMembers db user ct) + sendProbeHashes (cs <> ms) probe probeId + else sendProbe . Probe =<< liftIO (encodedRandomBytes gVar 32) where sendProbe :: Probe -> m () sendProbe probe = void . sendDirectContactMessage ct $ XInfoProbe probe - probeMatchingMemberContact :: GroupInfo -> GroupMember -> IncognitoEnabled -> m () - probeMatchingMemberContact _ GroupMember {activeConn = Nothing} _ = pure () - probeMatchingMemberContact g m@GroupMember {groupId, activeConn = Just conn} connectedIncognito = do + probeMatchingMemberContact :: GroupMember -> IncognitoEnabled -> m () + probeMatchingMemberContact GroupMember {activeConn = Nothing} _ = pure () + probeMatchingMemberContact m@GroupMember {groupId, activeConn = Just conn} connectedIncognito = do gVar <- asks idsDrg - if connectedIncognito - then sendProbe . Probe =<< liftIO (encodedRandomBytes gVar 32) - else do - (probe, probeId) <- withStore $ \db -> createSentProbe db gVar userId $ CGMGroupMember g m + contactMerge <- readTVarIO =<< asks contactMergeEnabled + if contactMerge && not connectedIncognito + then do + (probe, probeId) <- withStore $ \db -> createSentProbe db gVar userId $ COMGroupMember m sendProbe probe - cs <- withStore' $ \db -> getMatchingMemberContacts db user m + cs <- map COMContact <$> withStore' (\db -> getMatchingMemberContacts db user m) sendProbeHashes cs probe probeId + else sendProbe . Probe =<< liftIO (encodedRandomBytes gVar 32) where sendProbe :: Probe -> m () sendProbe probe = void $ sendDirectMessage conn (XInfoProbe probe) (GroupId groupId) - -- TODO currently we only send probe hashes to contacts - sendProbeHashes :: [Contact] -> Probe -> Int64 -> m () - sendProbeHashes cs probe probeId = - forM_ cs $ \c -> sendProbeHash c `catchChatError` \_ -> pure () + sendProbeHashes :: [ContactOrMember] -> Probe -> Int64 -> m () + sendProbeHashes cgms probe probeId = + forM_ cgms $ \cgm -> sendProbeHash cgm `catchChatError` \_ -> pure () where probeHash = ProbeHash $ C.sha256Hash (unProbe probe) - sendProbeHash :: Contact -> m () - sendProbeHash c = do + sendProbeHash :: ContactOrMember -> m () + sendProbeHash cgm@(COMContact c) = do void . sendDirectContactMessage c $ XInfoProbeCheck probeHash - withStore' $ \db -> createSentProbeHash db userId probeId $ CGMContact c + withStore' $ \db -> createSentProbeHash db userId probeId cgm + sendProbeHash (COMGroupMember GroupMember {activeConn = Nothing}) = pure () + sendProbeHash cgm@(COMGroupMember m@GroupMember {groupId, activeConn = Just conn}) = + when (memberCurrent m) $ do + void $ sendDirectMessage conn (XInfoProbeCheck probeHash) (GroupId groupId) + withStore' $ \db -> createSentProbeHash db userId probeId cgm messageWarning :: Text -> m () messageWarning = toView . CRMessageError user "warning" @@ -3739,7 +3936,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do messageError = toView . CRMessageError user "error" newContentMessage :: Contact -> MsgContainer -> RcvMessage -> MsgMeta -> m () - newContentMessage ct@Contact {localDisplayName = c, contactUsed} mc msg@RcvMessage {sharedMsgId_} msgMeta = do + newContentMessage ct@Contact {contactUsed} mc msg@RcvMessage {sharedMsgId_} msgMeta = do unless contactUsed $ withStore' $ \db -> updateContactUsed db user ct checkIntegrityCreateItem (CDDirectRcv ct) msgMeta let ExtMsgContent content fInv_ _ _ = mcExtMsgContent mc @@ -3752,23 +3949,18 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do if isVoice content && not (featureAllowed SCFVoice forContact ct) then do void $ newChatItem (CIRcvChatFeatureRejected CFVoice) Nothing Nothing False - setActive $ ActiveC c else do let ExtMsgContent _ _ itemTTL live_ = mcExtMsgContent mc timed_ = rcvContactCITimed ct itemTTL live = fromMaybe False live_ file_ <- processFileInvitation fInv_ content $ \db -> createRcvFileTransfer db userId ct - ChatItem {formattedText} <- newChatItem (CIRcvMsgContent content) (snd <$> file_) timed_ live + newChatItem (CIRcvMsgContent content) (snd <$> file_) timed_ live autoAcceptFile file_ - whenContactNtfs user ct $ do - showMsgToast (c <> "> ") content formattedText - setActive $ ActiveC c where newChatItem ciContent ciFile_ timed_ live = do ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ msgMeta ciContent ciFile_ timed_ live reactions <- maybe (pure []) (\sharedMsgId -> withStore' $ \db -> getDirectCIReactions db ct sharedMsgId) sharedMsgId_ toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci {reactions}) - pure ci autoAcceptFile :: Maybe (RcvFileTransfer, CIFile 'MDRcv) -> m () autoAcceptFile = mapM_ $ \(ft, CIFile {fileSize}) -> do @@ -3817,17 +4009,20 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do inline <- receiveInlineMode fInv (Just mc) fileChunkSize ft@RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvFT db fInv inline fileChunkSize let fileProtocol = if isJust xftpRcvFile then FPXFTP else FPSMP - (filePath, fileStatus) <- case inline of + (filePath, fileStatus, ft') <- case inline of Just IFMSent -> do + encrypt <- chatReadVar encryptLocalFiles + ft' <- (if encrypt then setFileToEncrypt else pure) ft fPath <- getRcvFilePath fileId Nothing fileName True - withStore' $ \db -> startRcvInlineFT db user ft fPath inline - pure (Just fPath, CIFSRcvAccepted) - _ -> pure (Nothing, CIFSRcvInvitation) - let fileSource = CF.plain <$> filePath - pure (ft, CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol}) + withStore' $ \db -> startRcvInlineFT db user ft' fPath inline + pure (Just fPath, CIFSRcvAccepted, ft') + _ -> pure (Nothing, CIFSRcvInvitation, ft) + let RcvFileTransfer {cryptoArgs} = ft' + fileSource = (`CryptoFile` cryptoArgs) <$> filePath + pure (ft', CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol}) messageUpdate :: Contact -> SharedMsgId -> MsgContent -> RcvMessage -> MsgMeta -> Maybe Int -> Maybe Bool -> m () - messageUpdate ct@Contact {contactId, localDisplayName = c} sharedMsgId mc msg@RcvMessage {msgId} msgMeta ttl live_ = do + messageUpdate ct@Contact {contactId} sharedMsgId mc msg@RcvMessage {msgId} msgMeta ttl live_ = do checkIntegrityCreateItem (CDDirectRcv ct) msgMeta updateRcvChatItem `catchCINotFound` \_ -> do -- This patches initial sharedMsgId into chat item when locally deleted chat item @@ -3839,7 +4034,6 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do createChatItemVersion db (chatItemId' ci) brokerTs mc updateDirectChatItem' db user contactId ci content live Nothing toView $ CRChatItemUpdated user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci') - setActive $ ActiveC c where MsgMeta {broker = (_, brokerTs)} = msgMeta content = CIRcvMsgContent mc @@ -3854,7 +4048,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do ci' <- withStore' $ \db -> do when changed $ addInitialAndNewCIVersions db (chatItemId' ci) (chatItemTs' ci, oldMC) (brokerTs, mc) - updateDirectChatItem' db user contactId ci content live $ Just msgId + reactions <- getDirectCIReactions db ct sharedMsgId + updateDirectChatItem' db user contactId ci {reactions} content live $ Just msgId toView $ CRChatItemUpdated user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci') startUpdatedTimedItemThread user (ChatRef CTDirect contactId) ci ci' else toView $ CRChatItemNotChanged user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci) @@ -3866,7 +4061,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do deleteRcvChatItem `catchCINotFound` (toView . CRChatItemDeletedNotFound user ct) where deleteRcvChatItem = do - ci@(CChatItem msgDir _) <- withStore $ \db -> getDirectChatItemBySharedMsgId db user contactId sharedMsgId + CChatItem msgDir ci <- withStore $ \db -> getDirectChatItemBySharedMsgId db user contactId sharedMsgId case msgDir of SMDRcv -> if featureAllowed SCFFullDelete forContact ct @@ -3926,7 +4121,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do e -> throwError e newGroupContentMessage :: GroupInfo -> GroupMember -> MsgContainer -> RcvMessage -> MsgMeta -> m () - newGroupContentMessage gInfo m@GroupMember {localDisplayName = c, memberId, memberRole} mc msg@RcvMessage {sharedMsgId_} msgMeta + newGroupContentMessage gInfo m@GroupMember {memberId, memberRole} mc msg@RcvMessage {sharedMsgId_} msgMeta | isVoice content && not (groupFeatureAllowed SGFVoice gInfo) = rejected GFVoice | not (isVoice content) && isJust fInv_ && not (groupFeatureAllowed SGFFiles gInfo) = rejected GFFiles | otherwise = do @@ -3947,29 +4142,24 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do createItem timed_ live | groupFeatureAllowed SGFFullDelete gInfo = do ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ msgMeta CIRcvModerated Nothing timed_ False - ci' <- withStore' $ \db -> updateGroupChatItemModerated db user gInfo (CChatItem SMDRcv ci) moderator moderatedAt - toView $ CRNewChatItem user ci' + ci' <- withStore' $ \db -> updateGroupChatItemModerated db user gInfo ci moderator moderatedAt + toView $ CRNewChatItem user $ AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci' | otherwise = do file_ <- processFileInvitation fInv_ content $ \db -> createRcvGroupFileTransfer db userId m ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ msgMeta (CIRcvMsgContent content) (snd <$> file_) timed_ False - cr <- markGroupCIDeleted user gInfo (CChatItem SMDRcv ci) createdByMsgId False (Just moderator) moderatedAt - toView cr + toView =<< markGroupCIDeleted user gInfo ci createdByMsgId False (Just moderator) moderatedAt createItem timed_ live = do file_ <- processFileInvitation fInv_ content $ \db -> createRcvGroupFileTransfer db userId m - ChatItem {formattedText} <- newChatItem (CIRcvMsgContent content) (snd <$> file_) timed_ live - autoAcceptFile file_ - let g = groupName' gInfo - whenGroupNtfs user gInfo $ do - showMsgToast ("#" <> g <> " " <> c <> "> ") content formattedText - setActive $ ActiveG g + newChatItem (CIRcvMsgContent content) (snd <$> file_) timed_ live + when (showMessages $ memberSettings m) $ autoAcceptFile file_ newChatItem ciContent ciFile_ timed_ live = do ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ msgMeta ciContent ciFile_ timed_ live + ci' <- blockedMember m ci $ withStore' $ \db -> markGroupChatItemBlocked db user gInfo ci reactions <- maybe (pure []) (\sharedMsgId -> withStore' $ \db -> getGroupCIReactions db gInfo memberId sharedMsgId) sharedMsgId_ - groupMsgToView gInfo m ci {reactions} msgMeta - pure ci + groupMsgToView gInfo m ci' {reactions} msgMeta groupMessageUpdate :: GroupInfo -> GroupMember -> SharedMsgId -> MsgContent -> RcvMessage -> MsgMeta -> Maybe Int -> Maybe Bool -> m () - groupMessageUpdate gInfo@GroupInfo {groupId, localDisplayName = g} m@GroupMember {groupMemberId, memberId} sharedMsgId mc msg@RcvMessage {msgId} msgMeta ttl_ live_ = + groupMessageUpdate gInfo@GroupInfo {groupId} m@GroupMember {groupMemberId, memberId} sharedMsgId mc msg@RcvMessage {msgId} msgMeta ttl_ live_ = updateRcvChatItem `catchCINotFound` \_ -> do -- This patches initial sharedMsgId into chat item when locally deleted chat item -- received an update from the sender, so that it can be referenced later (e.g. by broadcast delete). @@ -3978,9 +4168,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg (Just sharedMsgId) msgMeta content Nothing timed_ live ci' <- withStore' $ \db -> do createChatItemVersion db (chatItemId' ci) brokerTs mc - updateGroupChatItem db user groupId ci content live Nothing + ci' <- updateGroupChatItem db user groupId ci content live Nothing + blockedMember m ci' $ markGroupChatItemBlocked db user gInfo ci' toView $ CRChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci') - setActive $ ActiveG g where MsgMeta {broker = (_, brokerTs)} = msgMeta content = CIRcvMsgContent mc @@ -3997,9 +4187,9 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do ci' <- withStore' $ \db -> do when changed $ addInitialAndNewCIVersions db (chatItemId' ci) (chatItemTs' ci, oldMC) (brokerTs, mc) - updateGroupChatItem db user groupId ci content live $ Just msgId + reactions <- getGroupCIReactions db gInfo memberId sharedMsgId + updateGroupChatItem db user groupId ci {reactions} content live $ Just msgId toView $ CRChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci') - setActive $ ActiveG g startUpdatedTimedItemThread user (ChatRef CTGroup groupId) ci ci' else toView $ CRChatItemNotChanged user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci) else messageError "x.msg.update: group member attempted to update a message of another member" @@ -4009,7 +4199,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do groupMessageDelete gInfo@GroupInfo {groupId, membership} m@GroupMember {memberId, memberRole = senderRole} sharedMsgId sndMemberId_ RcvMessage {msgId} MsgMeta {broker = (_, brokerTs)} = do let msgMemberId = fromMaybe memberId sndMemberId_ withStore' (\db -> runExceptT $ getGroupMemberCIBySharedMsgId db user groupId msgMemberId sharedMsgId) >>= \case - Right ci@(CChatItem _ ChatItem {chatDir}) -> case chatDir of + Right (CChatItem _ ci@ChatItem {chatDir}) -> case chatDir of CIGroupRcv mem | sameMemberId memberId mem && msgMemberId == memberId -> delete ci Nothing >>= toView | otherwise -> deleteMsg mem ci @@ -4019,7 +4209,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do | senderRole < GRAdmin -> messageError $ "x.msg.del: message not found, message of another member with insufficient member permissions, " <> tshow e | otherwise -> withStore' $ \db -> createCIModeration db gInfo m msgMemberId sharedMsgId msgId brokerTs where - deleteMsg :: GroupMember -> CChatItem 'CTGroup -> m () + deleteMsg :: MsgDirectionI d => GroupMember -> ChatItem 'CTGroup d -> m () deleteMsg mem ci = case sndMemberId_ of Just sndMemberId | sameMemberId sndMemberId mem -> checkRole mem $ delete ci (Just m) >>= toView @@ -4029,13 +4219,14 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do | senderRole < GRAdmin || senderRole < memberRole = messageError "x.msg.del: message of another member with insufficient member permissions" | otherwise = a + delete :: MsgDirectionI d => ChatItem 'CTGroup d -> Maybe GroupMember -> m ChatResponse delete ci byGroupMember | groupFeatureAllowed SGFFullDelete gInfo = deleteGroupCI user gInfo ci False False byGroupMember brokerTs | otherwise = markGroupCIDeleted user gInfo ci msgId False byGroupMember brokerTs -- TODO remove once XFile is discontinued processFileInvitation' :: Contact -> FileInvitation -> RcvMessage -> MsgMeta -> m () - processFileInvitation' ct@Contact {localDisplayName = c} fInv@FileInvitation {fileName, fileSize} msg@RcvMessage {sharedMsgId_} msgMeta = do + processFileInvitation' ct fInv@FileInvitation {fileName, fileSize} msg@RcvMessage {sharedMsgId_} msgMeta = do checkIntegrityCreateItem (CDDirectRcv ct) msgMeta ChatConfig {fileChunkSize} <- asks config inline <- receiveInlineMode fInv Nothing fileChunkSize @@ -4044,24 +4235,23 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol} ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ msgMeta (CIRcvMsgContent $ MCFile "") ciFile Nothing False toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci) - whenContactNtfs user ct $ do - showToast (c <> "> ") "wants to send a file" - setActive $ ActiveC c -- TODO remove once XFile is discontinued processGroupFileInvitation' :: GroupInfo -> GroupMember -> FileInvitation -> RcvMessage -> MsgMeta -> m () - processGroupFileInvitation' gInfo m@GroupMember {localDisplayName = c} fInv@FileInvitation {fileName, fileSize} msg@RcvMessage {sharedMsgId_} msgMeta = do + processGroupFileInvitation' gInfo m fInv@FileInvitation {fileName, fileSize} msg@RcvMessage {sharedMsgId_} msgMeta = do ChatConfig {fileChunkSize} <- asks config inline <- receiveInlineMode fInv Nothing fileChunkSize RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvGroupFileTransfer db userId m fInv inline fileChunkSize let fileProtocol = if isJust xftpRcvFile then FPXFTP else FPSMP ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol} ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ msgMeta (CIRcvMsgContent $ MCFile "") ciFile Nothing False - groupMsgToView gInfo m ci msgMeta - let g = groupName' gInfo - whenGroupNtfs user gInfo $ do - showToast ("#" <> g <> " " <> c <> "> ") "wants to send a file" - setActive $ ActiveG g + ci' <- blockedMember m ci $ withStore' $ \db -> markGroupChatItemBlocked db user gInfo ci + groupMsgToView gInfo m ci' msgMeta + + blockedMember :: Monad m' => GroupMember -> ChatItem c d -> m' (ChatItem c d) -> m' (ChatItem c d) + blockedMember m ci blockedCI + | showMessages (memberSettings m) = pure ci + | otherwise = blockedCI receiveInlineMode :: FileInvitation -> Maybe MsgContent -> Integer -> m (Maybe InlineFileMode) receiveInlineMode FileInvitation {fileSize, fileInline, fileDescr} mc_ chSize = case (fileInline, fileDescr) of @@ -4218,7 +4408,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do processGroupInvitation :: Contact -> GroupInvitation -> RcvMessage -> MsgMeta -> m () processGroupInvitation ct inv msg msgMeta = do - let Contact {localDisplayName = c, activeConn = Connection {peerChatVRange, customUserProfileId, groupLinkId = groupLinkId'}} = ct + let Contact {localDisplayName = c, activeConn = Connection {connId, peerChatVRange, customUserProfileId, groupLinkId = groupLinkId'}} = ct GroupInvitation {fromMember = (MemberIdRole fromMemId fromRole), invitedMember = (MemberIdRole memId memRole), connRequest, groupLinkId} = inv checkIntegrityCreateItem (CDDirectRcv ct) msgMeta when (fromRole < GRAdmin || fromRole < memRole) $ throwChatError (CEGroupContactRole c) @@ -4231,6 +4421,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do dm <- directMessage $ XGrpAcpt memberId connIds <- joinAgentConnectionAsync user True connRequest dm subMode withStore' $ \db -> do + setViaGroupLinkHash db groupId connId createMemberConnectionAsync db user hostId connIds (fromJVersionRange peerChatVRange) subMode updateGroupMemberStatusById db userId hostId GSMemAccepted updateGroupMemberStatus db userId membership GSMemAccepted @@ -4241,8 +4432,6 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci) toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci) toView $ CRReceivedGroupInvitation {user, groupInfo = gInfo, contact = ct, fromMemberRole = fromRole, memberRole = memRole} - whenContactNtfs user ct $ - showToast ("#" <> localDisplayName <> " " <> c <> "> ") "invited you to join the group" where sameGroupLinkId :: Maybe GroupLinkId -> Maybe GroupLinkId -> Bool sameGroupLinkId (Just gli) (Just gli') = gli == gli' @@ -4303,6 +4492,33 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do | otherwise -> Nothing in setPreference_ SCFTimedMessages ctUserTMPref' ctUserPrefs + -- TODO use for member profile update + -- xInfoMember :: GroupInfo -> GroupMember -> Profile -> m () + -- xInfoMember gInfo m p' = void $ processMemberProfileUpdate gInfo m p' + + xGrpLinkMem :: GroupInfo -> GroupMember -> Connection -> Profile -> m () + xGrpLinkMem gInfo@GroupInfo {membership} m@GroupMember {groupMemberId, memberCategory} Connection {viaGroupLink} p' = do + xGrpLinkMemReceived <- withStore $ \db -> getXGrpLinkMemReceived db groupMemberId + if viaGroupLink && isNothing (memberContactId m) && memberCategory == GCHostMember && not xGrpLinkMemReceived + then do + m' <- processMemberProfileUpdate gInfo m p' + withStore' $ \db -> setXGrpLinkMemReceived db groupMemberId True + let connectedIncognito = memberIncognito membership + probeMatchingMemberContact m' connectedIncognito + else messageError "x.grp.link.mem error: invalid group link host profile update" + + processMemberProfileUpdate :: GroupInfo -> GroupMember -> Profile -> m GroupMember + processMemberProfileUpdate gInfo m@GroupMember {memberContactId} p' = + case memberContactId of + Nothing -> do + m' <- withStore $ \db -> updateMemberProfile db user m p' + toView $ CRGroupMemberUpdated user gInfo m m' + pure m' + Just mContactId -> do + mCt <- withStore $ \db -> getContact db user mContactId + Contact {profile} <- processContactProfileUpdate mCt p' True + pure m {memberProfile = profile} + createFeatureEnabledItems :: Contact -> m () createFeatureEnabledItems ct@Contact {mergedPreferences} = forM_ allChatFeatures $ \(ACF f) -> do @@ -4316,48 +4532,77 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do (_, param) = groupFeatureState p createInternalChatItem user (CDGroupRcv g m) (CIRcvGroupFeature (toGroupFeature f) (toGroupPreference p) param) Nothing - xInfoProbe :: ContactOrGroupMember -> Probe -> m () - xInfoProbe cgm2 probe = + xInfoProbe :: ContactOrMember -> Probe -> m () + xInfoProbe cgm2 probe = do + contactMerge <- readTVarIO =<< asks contactMergeEnabled -- [incognito] unless connected incognito - unless (contactOrGroupMemberIncognito cgm2) $ do - r <- withStore' $ \db -> matchReceivedProbe db user cgm2 probe - forM_ r $ \case - CGMContact c1 -> probeMatch c1 cgm2 probe - CGMGroupMember _ _ -> messageWarning "xInfoProbe ignored: matched member (no probe hashes sent to members)" + when (contactMerge && not (contactOrMemberIncognito cgm2)) $ do + cgm1s <- withStore' $ \db -> matchReceivedProbe db user cgm2 probe + let cgm1s' = filter (not . contactOrMemberIncognito) cgm1s + probeMatches cgm1s' cgm2 + where + probeMatches :: [ContactOrMember] -> ContactOrMember -> m () + probeMatches [] _ = pure () + probeMatches (cgm1' : cgm1s') cgm2' = do + cgm2''_ <- probeMatch cgm1' cgm2' probe `catchChatError` \_ -> pure (Just cgm2') + let cgm2'' = fromMaybe cgm2' cgm2''_ + probeMatches cgm1s' cgm2'' - -- TODO currently we send probe hashes only to contacts - xInfoProbeCheck :: Contact -> ProbeHash -> m () - xInfoProbeCheck c1 probeHash = + xInfoProbeCheck :: ContactOrMember -> ProbeHash -> m () + xInfoProbeCheck cgm1 probeHash = do + contactMerge <- readTVarIO =<< asks contactMergeEnabled -- [incognito] unless connected incognito - unless (contactConnIncognito c1) $ do - r <- withStore' $ \db -> matchReceivedProbeHash db user (CGMContact c1) probeHash - forM_ r . uncurry $ probeMatch c1 + when (contactMerge && not (contactOrMemberIncognito cgm1)) $ do + cgm2Probe_ <- withStore' $ \db -> matchReceivedProbeHash db user cgm1 probeHash + forM_ cgm2Probe_ $ \(cgm2, probe) -> + unless (contactOrMemberIncognito cgm2) $ + void $ probeMatch cgm1 cgm2 probe - probeMatch :: Contact -> ContactOrGroupMember -> Probe -> m () - probeMatch c1@Contact {contactId = cId1, profile = p1} cgm2 probe = - case cgm2 of - CGMContact c2@Contact {contactId = cId2, profile = p2} - | cId1 /= cId2 && profilesMatch p1 p2 -> do - void . sendDirectContactMessage c1 $ XInfoProbeOk probe - mergeContacts c1 c2 - | otherwise -> messageWarning "probeMatch ignored: profiles don't match or same contact id" - CGMGroupMember g m2@GroupMember {memberProfile = p2, memberContactId} - | isNothing memberContactId && profilesMatch p1 p2 -> do - void . sendDirectContactMessage c1 $ XInfoProbeOk probe - connectContactToMember c1 g m2 - | otherwise -> messageWarning "probeMatch ignored: profiles don't match or member already has contact" + probeMatch :: ContactOrMember -> ContactOrMember -> Probe -> m (Maybe ContactOrMember) + probeMatch cgm1 cgm2 probe = + case cgm1 of + COMContact c1@Contact {contactId = cId1, profile = p1} -> + case cgm2 of + COMContact c2@Contact {contactId = cId2, profile = p2} + | cId1 /= cId2 && profilesMatch p1 p2 -> do + void . sendDirectContactMessage c1 $ XInfoProbeOk probe + COMContact <$$> mergeContacts c1 c2 + | otherwise -> messageWarning "probeMatch ignored: profiles don't match or same contact id" >> pure Nothing + COMGroupMember m2@GroupMember {memberProfile = p2, memberContactId} + | isNothing memberContactId && profilesMatch p1 p2 -> do + void . sendDirectContactMessage c1 $ XInfoProbeOk probe + COMContact <$$> associateMemberAndContact c1 m2 + | otherwise -> messageWarning "probeMatch ignored: profiles don't match or member already has contact" >> pure Nothing + COMGroupMember GroupMember {activeConn = Nothing} -> pure Nothing + COMGroupMember m1@GroupMember {groupId, memberProfile = p1, memberContactId, activeConn = Just conn} -> + case cgm2 of + COMContact c2@Contact {profile = p2} + | memberCurrent m1 && isNothing memberContactId && profilesMatch p1 p2 -> do + void $ sendDirectMessage conn (XInfoProbeOk probe) (GroupId groupId) + COMContact <$$> associateMemberAndContact c2 m1 + | otherwise -> messageWarning "probeMatch ignored: profiles don't match or member already has contact or member not current" >> pure Nothing + COMGroupMember _ -> messageWarning "probeMatch ignored: members are not matched with members" >> pure Nothing - -- TODO currently we send probe hashes only to contacts - xInfoProbeOk :: Contact -> Probe -> m () - xInfoProbeOk c1@Contact {contactId = cId1} probe = - withStore' (\db -> matchSentProbe db user (CGMContact c1) probe) >>= \case - Just (CGMContact c2@Contact {contactId = cId2}) - | cId1 /= cId2 -> mergeContacts c1 c2 - | otherwise -> messageWarning "xInfoProbeOk ignored: same contact id" - Just (CGMGroupMember g m2@GroupMember {memberContactId}) - | isNothing memberContactId -> connectContactToMember c1 g m2 - | otherwise -> messageWarning "xInfoProbeOk ignored: member already has contact" - _ -> pure () + xInfoProbeOk :: ContactOrMember -> Probe -> m () + xInfoProbeOk cgm1 probe = do + cgm2 <- withStore' $ \db -> matchSentProbe db user cgm1 probe + case cgm1 of + COMContact c1@Contact {contactId = cId1} -> + case cgm2 of + Just (COMContact c2@Contact {contactId = cId2}) + | cId1 /= cId2 -> void $ mergeContacts c1 c2 + | otherwise -> messageWarning "xInfoProbeOk ignored: same contact id" + Just (COMGroupMember m2@GroupMember {memberContactId}) + | isNothing memberContactId -> void $ associateMemberAndContact c1 m2 + | otherwise -> messageWarning "xInfoProbeOk ignored: member already has contact" + _ -> pure () + COMGroupMember m1@GroupMember {memberContactId} -> + case cgm2 of + Just (COMContact c2) + | isNothing memberContactId -> void $ associateMemberAndContact c2 m1 + | otherwise -> messageWarning "xInfoProbeOk ignored: member already has contact" + Just (COMGroupMember _) -> messageWarning "xInfoProbeOk ignored: members are not matched with members" + _ -> pure () -- to party accepting call xCallInv :: Contact -> CallId -> CallInvitation -> RcvMessage -> MsgMeta -> m () @@ -4464,15 +4709,67 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do msgCallStateError eventName Call {callState} = messageError $ eventName <> ": wrong call state " <> T.pack (show $ callStateTag callState) - mergeContacts :: Contact -> Contact -> m () + mergeContacts :: Contact -> Contact -> m (Maybe Contact) mergeContacts c1 c2 = do - withStore' $ \db -> mergeContactRecords db userId c1 c2 - toView $ CRContactsMerged user c1 c2 + let Contact {localDisplayName = cLDN1, profile = LocalProfile {displayName}} = c1 + Contact {localDisplayName = cLDN2} = c2 + case (suffixOrd displayName cLDN1, suffixOrd displayName cLDN2) of + (Just cOrd1, Just cOrd2) + | cOrd1 < cOrd2 -> merge c1 c2 + | cOrd2 < cOrd1 -> merge c2 c1 + | otherwise -> pure Nothing + _ -> pure Nothing + where + merge c1' c2' = do + c2'' <- withStore $ \db -> mergeContactRecords db user c1' c2' + toView $ CRContactsMerged user c1' c2' c2'' + when (directOrUsed c2'') $ showSecurityCodeChanged c2'' + pure $ Just c2'' + where + showSecurityCodeChanged mergedCt = do + let sc1_ = contactSecurityCode c1' + sc2_ = contactSecurityCode c2' + scMerged_ = contactSecurityCode mergedCt + case (sc1_, sc2_) of + (Just sc1, Nothing) + | scMerged_ /= Just sc1 -> securityCodeChanged mergedCt + | otherwise -> pure () + (Nothing, Just sc2) + | scMerged_ /= Just sc2 -> securityCodeChanged mergedCt + | otherwise -> pure () + _ -> pure () - connectContactToMember :: Contact -> GroupInfo -> GroupMember -> m () - connectContactToMember c1 g m2 = do - withStore' $ \db -> updateMemberContact db user c1 m2 - toView $ CRMemberContactConnected user c1 g m2 + associateMemberAndContact :: Contact -> GroupMember -> m (Maybe Contact) + associateMemberAndContact c m = do + let Contact {localDisplayName = cLDN, profile = LocalProfile {displayName}} = c + GroupMember {localDisplayName = mLDN} = m + case (suffixOrd displayName cLDN, suffixOrd displayName mLDN) of + (Just cOrd, Just mOrd) + | cOrd < mOrd -> Just <$> associateMemberWithContact c m + | mOrd < cOrd -> Just <$> associateContactWithMember m c + | otherwise -> pure Nothing + _ -> pure Nothing + + suffixOrd :: ContactName -> ContactName -> Maybe Int + suffixOrd displayName localDisplayName + | localDisplayName == displayName = Just 0 + | otherwise = case T.stripPrefix (displayName <> "_") localDisplayName of + Just suffix -> readMaybe $ T.unpack suffix + Nothing -> Nothing + + associateMemberWithContact :: Contact -> GroupMember -> m Contact + associateMemberWithContact c1 m2@GroupMember {groupId} = do + withStore' $ \db -> associateMemberWithContactRecord db user c1 m2 + g <- withStore $ \db -> getGroupInfo db user groupId + toView $ CRContactAndMemberAssociated user c1 g m2 c1 + pure c1 + + associateContactWithMember :: GroupMember -> Contact -> m Contact + associateContactWithMember m1@GroupMember {groupId} c2 = do + c2' <- withStore $ \db -> associateContactWithMemberRecord db user m1 c2 + g <- withStore $ \db -> getGroupInfo db user groupId + toView $ CRContactAndMemberAssociated user c2 g m1 c2' + pure c2' saveConnInfo :: Connection -> ConnInfo -> m Connection saveConnInfo activeConn connInfo = do @@ -4483,6 +4780,10 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do ct <- withStore $ \db -> createDirectContact db user conn' p toView $ CRContactConnecting user ct pure conn' + XGrpLinkInv glInv -> do + (gInfo, host) <- withStore $ \db -> createGroupInvitedViaLink db user conn' glInv + toView $ CRGroupLinkConnecting user gInfo host + pure conn' -- TODO show/log error, other events in SMP confirmation _ -> pure conn' @@ -4500,7 +4801,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do toView $ CRJoinedGroupMemberConnecting user gInfo m newMember xGrpMemIntro :: GroupInfo -> GroupMember -> MemberInfo -> m () - xGrpMemIntro gInfo@GroupInfo {chatSettings = ChatSettings {enableNtfs}} m@GroupMember {memberRole, localDisplayName = c} memInfo@(MemberInfo memId _ memberChatVRange _) = do + xGrpMemIntro gInfo@GroupInfo {chatSettings} m@GroupMember {memberRole, localDisplayName = c} memInfo@(MemberInfo memId _ memberChatVRange _) = do case memberCategory m of GCHostMember -> do members <- withStore' $ \db -> getGroupMembers db user gInfo @@ -4520,7 +4821,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do void $ withStore $ \db -> createIntroReMember db user gInfo m memInfo groupConnIds directConnIds customUserProfileId subMode _ -> messageError "x.grp.mem.intro can be only sent by host member" where - createConn subMode = createAgentConnectionAsync user CFCreateConnGrpMemInv enableNtfs SCMInvitation subMode + createConn subMode = createAgentConnectionAsync user CFCreateConnGrpMemInv (chatHasNtfs chatSettings) SCMInvitation subMode sendXGrpMemInv :: Int64 -> Maybe ConnReqInvitation -> XGrpMemIntroCont -> m () sendXGrpMemInv hostConnId directConnReq XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq} = do @@ -4543,7 +4844,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do _ -> messageError "x.grp.mem.inv can be only sent by invitee member" xGrpMemFwd :: GroupInfo -> GroupMember -> MemberInfo -> IntroInvitation -> m () - xGrpMemFwd gInfo@GroupInfo {membership, chatSettings = ChatSettings {enableNtfs}} m memInfo@(MemberInfo memId memRole memberChatVRange _) introInv@IntroInvitation {groupConnReq, directConnReq} = do + xGrpMemFwd gInfo@GroupInfo {membership, chatSettings} m memInfo@(MemberInfo memId memRole memberChatVRange _) introInv@IntroInvitation {groupConnReq, directConnReq} = do checkHostRole m memRole members <- withStore' $ \db -> getGroupMembers db user gInfo toMember <- case find (sameMemberId memId) members of @@ -4558,8 +4859,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do -- [incognito] send membership incognito profile, create direct connection as incognito dm <- directMessage $ XGrpMemInfo membership.memberId (fromLocalProfile $ memberProfile membership) -- [async agent commands] no continuation needed, but commands should be asynchronous for stability - groupConnIds <- joinAgentConnectionAsync user enableNtfs groupConnReq dm subMode - directConnIds <- forM directConnReq $ \dcr -> joinAgentConnectionAsync user enableNtfs dcr dm subMode + groupConnIds <- joinAgentConnectionAsync user (chatHasNtfs chatSettings) groupConnReq dm subMode + directConnIds <- forM directConnReq $ \dcr -> joinAgentConnectionAsync user True dcr dm subMode let customUserProfileId = localProfileId <$> incognitoMembershipProfile gInfo mcvr = maybe chatInitialVRange fromChatVRange memberChatVRange withStore' $ \db -> createIntroToMemberContact db user m toMember mcvr groupConnIds directConnIds customUserProfileId subMode @@ -4696,9 +4997,11 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do forM_ mContent_ $ \mc -> do ci <- saveRcvChatItem user (CDDirectRcv mCt') msg msgMeta (CIRcvMsgContent mc) toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat mCt') ci) - securityCodeChanged ct = do - toView $ CRContactVerificationReset user ct - createInternalChatItem user (CDDirectRcv ct) (CIRcvConnEvent RCEVerificationCodeReset) Nothing + + securityCodeChanged :: Contact -> m () + securityCodeChanged ct = do + toView $ CRContactVerificationReset user ct + createInternalChatItem user (CDDirectRcv ct) (CIRcvConnEvent RCEVerificationCodeReset) Nothing directMsgReceived :: Contact -> Connection -> MsgMeta -> NonEmpty MsgReceipt -> m () directMsgReceived ct conn@Connection {connId} msgMeta msgRcpts = do @@ -4721,7 +5024,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do Just (CChatItem SMDSnd ChatItem {meta = CIMeta {itemId, itemStatus}}) | itemStatus == newStatus -> pure () | otherwise -> do - chatItem <- withStore $ \db -> updateDirectChatItemStatus db user contactId itemId newStatus + chatItem <- withStore $ \db -> updateDirectChatItemStatus db user ct itemId newStatus toView $ CRChatItemStatusUpdated user (AChatItem SCTDirect SMDSnd (DirectChat ct) chatItem) _ -> pure () @@ -4744,7 +5047,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do memStatusCounts <- withStore' (`getGroupSndStatusCounts` itemId) let newStatus = membersGroupItemStatus memStatusCounts when (newStatus /= itemStatus) $ do - chatItem <- withStore $ \db -> updateGroupChatItemStatus db user groupId itemId newStatus + chatItem <- withStore $ \db -> updateGroupChatItemStatus db user gInfo itemId newStatus toView $ CRChatItemStatusUpdated user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) chatItem) _ -> pure () @@ -4987,9 +5290,7 @@ deliverMessage conn@Connection {connId} cmEventTag msgBody msgId = do let msgFlags = MsgFlags {notification = hasNotification cmEventTag} agentMsgId <- withAgent $ \a -> sendMessage a (aConnId conn) msgFlags msgBody let sndMsgDelivery = SndMsgDelivery {connId, agentMsgId} - withStoreCtx' - (Just $ "createSndMsgDelivery, sndMsgDelivery: " <> show sndMsgDelivery <> ", msgId: " <> show msgId <> ", cmEventTag: " <> show cmEventTag <> ", msgDeliveryStatus: MDSSndAgent") - $ \db -> createSndMsgDelivery db sndMsgDelivery msgId + withStore' $ \db -> createSndMsgDelivery db sndMsgDelivery msgId sendGroupMessage :: (MsgEncodingI e, ChatMonad m) => User -> GroupInfo -> [GroupMember] -> ChatMsgEvent e -> m (SndMessage, [GroupMember]) sendGroupMessage user GroupInfo {groupId} members chatMsgEvent = @@ -5081,20 +5382,22 @@ mkChatItem cd ciId content file quotedItem sharedMsgId itemTimed live itemTs cur meta = mkCIMeta ciId content itemText itemStatus sharedMsgId Nothing False itemTimed (justTrue live) currentTs itemTs currentTs currentTs pure ChatItem {chatDir = toCIDirection cd, meta, content, formattedText = parseMaybeMarkdownList itemText, quotedItem, reactions = [], file} -deleteDirectCI :: ChatMonad m => User -> Contact -> CChatItem 'CTDirect -> Bool -> Bool -> m ChatResponse -deleteDirectCI user ct ci@(CChatItem msgDir deletedItem@ChatItem {file}) byUser timed = do +deleteDirectCI :: (ChatMonad m, MsgDirectionI d) => User -> Contact -> ChatItem 'CTDirect d -> Bool -> Bool -> m ChatResponse +deleteDirectCI user ct ci@ChatItem {file} byUser timed = do deleteCIFile user file withStoreCtx' (Just "deleteDirectCI, deleteDirectChatItem") $ \db -> deleteDirectChatItem db user ct ci - pure $ CRChatItemDeleted user (AChatItem SCTDirect msgDir (DirectChat ct) deletedItem) Nothing byUser timed + pure $ CRChatItemDeleted user (AChatItem SCTDirect msgDirection (DirectChat ct) ci) Nothing byUser timed -deleteGroupCI :: ChatMonad m => User -> GroupInfo -> CChatItem 'CTGroup -> Bool -> Bool -> Maybe GroupMember -> UTCTime -> m ChatResponse -deleteGroupCI user gInfo ci@(CChatItem msgDir deletedItem@ChatItem {file}) byUser timed byGroupMember_ deletedTs = do +deleteGroupCI :: (ChatMonad m, MsgDirectionI d) => User -> GroupInfo -> ChatItem 'CTGroup d -> Bool -> Bool -> Maybe GroupMember -> UTCTime -> m ChatResponse +deleteGroupCI user gInfo ci@ChatItem {file} byUser timed byGroupMember_ deletedTs = do deleteCIFile user file toCi <- withStoreCtx' (Just "deleteGroupCI, deleteGroupChatItem ...") $ \db -> case byGroupMember_ of Nothing -> deleteGroupChatItem db user gInfo ci $> Nothing Just m -> Just <$> updateGroupChatItemModerated db user gInfo ci m deletedTs - pure $ CRChatItemDeleted user (AChatItem SCTGroup msgDir (GroupChat gInfo) deletedItem) toCi byUser timed + pure $ CRChatItemDeleted user (gItem ci) (gItem <$> toCi) byUser timed + where + gItem = AChatItem SCTGroup msgDirection (GroupChat gInfo) deleteCIFile :: (ChatMonad m, MsgDirectionI d) => User -> Maybe (CIFile d) -> m () deleteCIFile user file_ = @@ -5102,25 +5405,21 @@ deleteCIFile user file_ = fileAgentConnIds <- deleteFile' user (mkCIFileInfo file) True deleteAgentConnectionsAsync user fileAgentConnIds -markDirectCIDeleted :: ChatMonad m => User -> Contact -> CChatItem 'CTDirect -> MessageId -> Bool -> UTCTime -> m ChatResponse -markDirectCIDeleted user ct@Contact {contactId} ci@(CChatItem _ ChatItem {file}) msgId byUser deletedTs = do +markDirectCIDeleted :: (ChatMonad m, MsgDirectionI d) => User -> Contact -> ChatItem 'CTDirect d -> MessageId -> Bool -> UTCTime -> m ChatResponse +markDirectCIDeleted user ct ci@ChatItem {file} msgId byUser deletedTs = do cancelCIFile user file - toCi <- withStore $ \db -> do - liftIO $ markDirectChatItemDeleted db user ct ci msgId deletedTs - getDirectChatItem db user contactId (cchatItemId ci) - pure $ CRChatItemDeleted user (ctItem ci) (Just $ ctItem toCi) byUser False + ci' <- withStore' $ \db -> markDirectChatItemDeleted db user ct ci msgId deletedTs + pure $ CRChatItemDeleted user (ctItem ci) (Just $ ctItem ci') byUser False where - ctItem (CChatItem msgDir ci') = AChatItem SCTDirect msgDir (DirectChat ct) ci' + ctItem = AChatItem SCTDirect msgDirection (DirectChat ct) -markGroupCIDeleted :: ChatMonad m => User -> GroupInfo -> CChatItem 'CTGroup -> MessageId -> Bool -> Maybe GroupMember -> UTCTime -> m ChatResponse -markGroupCIDeleted user gInfo@GroupInfo {groupId} ci@(CChatItem _ ChatItem {file}) msgId byUser byGroupMember_ deletedTs = do +markGroupCIDeleted :: (ChatMonad m, MsgDirectionI d) => User -> GroupInfo -> ChatItem 'CTGroup d -> MessageId -> Bool -> Maybe GroupMember -> UTCTime -> m ChatResponse +markGroupCIDeleted user gInfo ci@ChatItem {file} msgId byUser byGroupMember_ deletedTs = do cancelCIFile user file - toCi <- withStore $ \db -> do - liftIO $ markGroupChatItemDeleted db user gInfo ci msgId byGroupMember_ deletedTs - getGroupChatItem db user groupId (cchatItemId ci) - pure $ CRChatItemDeleted user (gItem ci) (Just $ gItem toCi) byUser False + ci' <- withStore' $ \db -> markGroupChatItemDeleted db user gInfo ci msgId byGroupMember_ deletedTs + pure $ CRChatItemDeleted user (gItem ci) (Just $ gItem ci') byUser False where - gItem (CChatItem msgDir ci') = AChatItem SCTGroup msgDir (GroupChat gInfo) ci' + gItem = AChatItem SCTGroup msgDirection (GroupChat gInfo) cancelCIFile :: (ChatMonad m, MsgDirectionI d) => User -> Maybe (CIFile d) -> m () cancelCIFile user file_ = @@ -5266,7 +5565,7 @@ getCreateActiveUser st testView = do where loop = do displayName <- getContactName - withTransaction st (\db -> runExceptT $ createUserRecord db (AgentUserId 1) Profile {displayName, fullName = "", image = Nothing, contactLink = Nothing, preferences = Nothing} True) >>= \case + withTransaction st (\db -> runExceptT $ createUserRecord db (AgentUserId 1) (profileFromName displayName) True) >>= \case Left SEDuplicateName -> do putStrLn "chosen display name is already used by another profile on this device, choose another one" loop @@ -5306,30 +5605,6 @@ getCreateActiveUser st testView = do getWithPrompt :: String -> IO String getWithPrompt s = putStr (s <> ": ") >> hFlush stdout >> getLine -whenUserNtfs :: ChatMonad' m => User -> m () -> m () -whenUserNtfs User {showNtfs, activeUser} = when $ showNtfs || activeUser - -whenContactNtfs :: ChatMonad' m => User -> Contact -> m () -> m () -whenContactNtfs user Contact {chatSettings} = whenUserNtfs user . when (enableNtfs chatSettings) - -whenGroupNtfs :: ChatMonad' m => User -> GroupInfo -> m () -> m () -whenGroupNtfs user GroupInfo {chatSettings} = whenUserNtfs user . when (enableNtfs chatSettings) - -showMsgToast :: ChatMonad' m => Text -> MsgContent -> Maybe MarkdownList -> m () -showMsgToast from mc md_ = showToast from $ maybe (msgContentText mc) (mconcat . map hideSecret) md_ - where - hideSecret :: FormattedText -> Text - hideSecret FormattedText {format = Just Secret} = "..." - hideSecret FormattedText {text} = text - -showToast :: ChatMonad' m => Text -> Text -> m () -showToast title text = atomically . (`writeTBQueue` Notification {title, text}) =<< asks notifyQ - -notificationSubscriber :: ChatMonad' m => m () -notificationSubscriber = do - ChatController {notifyQ, sendNotification} <- ask - forever $ atomically (readTBQueue notifyQ) >>= liftIO . sendNotification - withUser' :: ChatMonad m => (User -> m ChatResponse) -> m ChatResponse withUser' action = asks currentUser @@ -5367,9 +5642,12 @@ withAgent action = chatCommandP :: Parser ChatCommand chatCommandP = choice - [ "/mute " *> ((`SetShowMessages` False) <$> chatNameP), - "/unmute " *> ((`SetShowMessages` True) <$> chatNameP), + [ "/mute " *> ((`SetShowMessages` MFNone) <$> chatNameP), + "/unmute " *> ((`SetShowMessages` MFAll) <$> chatNameP), + "/unmute mentions " *> ((`SetShowMessages` MFMentions) <$> chatNameP), "/receipts " *> (SetSendReceipts <$> chatNameP <* " " <*> ((Just <$> onOffP) <|> ("default" $> Nothing))), + "/block #" *> (SetShowMemberMessages <$> displayName <* A.space <*> (char_ '@' *> displayName) <*> pure False), + "/unblock #" *> (SetShowMemberMessages <$> displayName <* A.space <*> (char_ '@' *> displayName) <*> pure True), "/_create user " *> (CreateActiveUser <$> jsonP), "/create user " *> (CreateActiveUser <$> newUserP), "/users" $> ListUsers, @@ -5401,6 +5679,8 @@ chatCommandP = ("/_files_folder " <|> "/files_folder ") *> (SetFilesFolder <$> filePath), "/_xftp " *> (APISetXFTPConfig <$> ("on " *> (Just <$> jsonP) <|> ("off" $> Nothing))), "/xftp " *> (APISetXFTPConfig <$> ("on" *> (Just <$> xftpCfgP) <|> ("off" $> Nothing))), + "/_files_encrypt " *> (APISetEncryptLocalFiles <$> onOffP), + "/contact_merge " *> (SetContactMergeEnabled <$> onOffP), "/_db export " *> (APIExportArchive <$> jsonP), "/db export" $> ExportArchive, "/_db import " *> (APIImportArchive <$> jsonP), @@ -5437,6 +5717,7 @@ chatCommandP = "/_call end @" *> (APIEndCall <$> A.decimal), "/_call status @" *> (APICallStatus <$> A.decimal <* A.space <*> strP), "/_call get" $> APIGetCallInvitations, + "/_network_statuses" $> APIGetNetworkStatuses, "/_profile " *> (APIUpdateProfile <$> A.decimal <* A.space <*> jsonP), "/_set alias @" *> (APISetContactAlias <$> A.decimal <*> (A.space *> textP <|> pure "")), "/_set alias :" *> (APISetConnectionAlias <$> A.decimal <*> (A.space *> textP <|> pure "")), @@ -5473,6 +5754,7 @@ chatCommandP = ("/network" <|> "/net") $> APIGetNetworkConfig, "/reconnect" $> ReconnectAllServers, "/_settings " *> (APISetChatSettings <$> chatRefP <* A.space <*> jsonP), + "/_member settings #" *> (APISetMemberSettings <$> A.decimal <* A.space <*> A.decimal <* A.space <*> jsonP), "/_info #" *> (APIGroupMemberInfo <$> A.decimal <* A.space <*> A.decimal), "/_info #" *> (APIGroupInfo <$> A.decimal), "/_info @" *> (APIContactInfo <$> A.decimal), @@ -5512,8 +5794,8 @@ chatCommandP = ("/help settings" <|> "/hs") $> ChatHelp HSSettings, ("/help db" <|> "/hd") $> ChatHelp HSDatabase, ("/help" <|> "/h") $> ChatHelp HSMain, - ("/group " <|> "/g ") *> char_ '#' *> (NewGroup <$> groupProfile), - "/_group " *> (APINewGroup <$> A.decimal <* A.space <*> jsonP), + ("/group" <|> "/g") *> (NewGroup <$> incognitoP <* A.space <* char_ '#' <*> groupProfile), + "/_group " *> (APINewGroup <$> A.decimal <*> incognitoOnOffP <* A.space <*> jsonP), ("/add " <|> "/a ") *> char_ '#' *> (AddMember <$> displayName <* A.space <* char_ '@' <*> displayName <*> (memberRole <|> pure GRMember)), ("/join " <|> "/j ") *> char_ '#' *> (JoinGroup <$> displayName), ("/member role " <|> "/mr ") *> char_ '#' *> (MemberRole <$> displayName <* A.space <* char_ '@' <*> displayName <*> memberRole), @@ -5547,6 +5829,7 @@ chatCommandP = (">#" <|> "> #") *> (SendGroupMessageQuote <$> displayName <* A.space <* char_ '@' <*> (Just <$> displayName) <* A.space <*> quotedMsg <*> msgTextP), "/_contacts " *> (APIListContacts <$> A.decimal), "/contacts" $> ListContacts, + "/_connect plan " *> (APIConnectPlan <$> A.decimal <* A.space <*> strP), "/_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), @@ -5574,8 +5857,8 @@ chatCommandP = ("/fforward " <|> "/ff ") *> (ForwardFile <$> chatNameP' <* A.space <*> A.decimal), ("/image_forward " <|> "/imgf ") *> (ForwardImage <$> chatNameP' <* A.space <*> A.decimal), ("/fdescription " <|> "/fd") *> (SendFileDescription <$> chatNameP' <* A.space <*> filePath), - ("/freceive " <|> "/fr ") *> (ReceiveFile <$> A.decimal <*> (" encrypt=" *> onOffP <|> pure False) <*> optional (" inline=" *> onOffP) <*> optional (A.space *> filePath)), - "/_set_file_to_receive " *> (SetFileToReceive <$> A.decimal <*> (" encrypt=" *> onOffP <|> pure False)), + ("/freceive " <|> "/fr ") *> (ReceiveFile <$> A.decimal <*> optional (" encrypt=" *> onOffP) <*> optional (" inline=" *> onOffP) <*> optional (A.space *> filePath)), + "/_set_file_to_receive " *> (SetFileToReceive <$> A.decimal <*> optional (" encrypt=" *> onOffP)), ("/fcancel " <|> "/fc ") *> (CancelFile <$> A.decimal), ("/fstatus " <|> "/fs ") *> (FileStatus <$> A.decimal), "/simplex" *> (ConnectSimplex <$> incognitoP), @@ -5634,7 +5917,7 @@ chatCommandP = mcTextP = MCText . safeDecodeUtf8 <$> A.takeByteString msgContentP = "text " *> mcTextP <|> "json " *> jsonP ciDeleteMode = "broadcast" $> CIDMBroadcast <|> "internal" $> CIDMInternal - displayName = safeDecodeUtf8 <$> (quoted "'\"" <|> takeNameTill isSpace) + displayName = safeDecodeUtf8 <$> (quoted "'" <|> takeNameTill isSpace) where takeNameTill p = A.peekChar' >>= \c -> @@ -5737,7 +6020,7 @@ chatCommandP = adminContactReq :: ConnReqContact adminContactReq = - either error id $ strDecode "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D" + either error id $ strDecode "simplex:/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D" timeItToView :: ChatMonad' m => String -> m a -> m a timeItToView s action = do @@ -5749,14 +6032,20 @@ timeItToView s action = do pure a mkValidName :: String -> String -mkValidName = reverse . dropWhile isSpace . fst . foldl' addChar ("", '\NUL') +mkValidName = reverse . dropWhile isSpace . fst3 . foldl' addChar ("", '\NUL', 0 :: Int) where - addChar (r, prev) c = if notProhibited && validChar then (c' : r, c') else (r, prev) + fst3 (x, _, _) = x + addChar (r, prev, punct) c = if validChar then (c' : r, c', punct') else (r, prev, punct) where c' = if isSpace c then ' ' else c + punct' + | isPunctuation c = punct + 1 + | isSpace c = punct + | otherwise = 0 validChar - | prev == '\NUL' || isSpace prev = validFirstChar - | isPunctuation prev = validFirstChar || isSpace c + | c == '\'' = False + | prev == '\NUL' = c > ' ' && c /= '#' && c /= '@' && validFirstChar + | isSpace prev = validFirstChar || (punct == 0 && isPunctuation c) + | isPunctuation prev = validFirstChar || isSpace c || (punct < 3 && isPunctuation c) | otherwise = validFirstChar || isSpace c || isMark c || isPunctuation c validFirstChar = isLetter c || isNumber c || isSymbol c - notProhibited = c `notElem` ("@#'\"`" :: String) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 1fecc7caa8..ef6acd7e3a 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -32,10 +32,10 @@ import Data.Char (ord) import Data.Int (Int64) import Data.List.NonEmpty (NonEmpty) import Data.Map.Strict (Map) +import qualified Data.Map.Strict as M import Data.String import Data.Text (Text) -import Data.Time (NominalDiffTime) -import Data.Time.Clock (UTCTime) +import Data.Time (NominalDiffTime, UTCTime) import Data.Version (showVersion) import GHC.Generics (Generic) import Language.Haskell.TH (Exp, Q, runIO) @@ -125,7 +125,8 @@ data ChatConfig = ChatConfig initialCleanupManagerDelay :: Int64, cleanupManagerInterval :: NominalDiffTime, cleanupManagerStepDelay :: Int64, - ciExpirationInterval :: Int64 -- microseconds + ciExpirationInterval :: Int64, -- microseconds + coreApi :: Bool } data DefaultAgentServers = DefaultAgentServers @@ -153,20 +154,10 @@ defaultInlineFilesConfig = receiveInstant = True -- allow receiving instant files, within receiveChunks limit } -data ActiveTo = ActiveNone | ActiveC ContactName | ActiveG GroupName - deriving (Eq) - -chatActiveTo :: ChatName -> ActiveTo -chatActiveTo (ChatName cType name) = case cType of - CTDirect -> ActiveC name - CTGroup -> ActiveG name - _ -> ActiveNone - data ChatDatabase = ChatDatabase {chatStore :: SQLiteStore, agentStore :: SQLiteStore} data ChatController = ChatController { currentUser :: TVar (Maybe User), - activeTo :: TVar ActiveTo, firstTime :: Bool, smpAgent :: AgentClient, agentAsync :: TVar (Maybe (Async (), Maybe (Async ()))), @@ -175,8 +166,7 @@ data ChatController = ChatController idsDrg :: TVar ChaChaDRG, inputQ :: TBQueue String, outputQ :: TBQueue (Maybe CorrId, ChatResponse), - notifyQ :: TBQueue Notification, - sendNotification :: Notification -> IO (), + connNetworkStatuses :: TMap AgentConnId NetworkStatus, subscriptionMode :: TVar SubscriptionMode, chatLock :: Lock, sndFiles :: TVar (Map Int64 Handle), @@ -189,9 +179,11 @@ data ChatController = ChatController cleanupManagerAsync :: TVar (Maybe (Async ())), timedItemThreads :: TMap (ChatRef, ChatItemId) (TVar (Maybe (Weak ThreadId))), showLiveItems :: TVar Bool, + encryptLocalFiles :: TVar Bool, userXFTPFileConfig :: TVar (Maybe XFTPFileConfig), tempDirectory :: TVar (Maybe FilePath), - logFilePath :: Maybe FilePath + logFilePath :: Maybe FilePath, + contactMergeEnabled :: TVar Bool } data HelpSection = HSMain | HSFiles | HSGroups | HSContacts | HSMyAddress | HSIncognito | HSMarkdown | HSMessages | HSSettings | HSDatabase @@ -230,6 +222,8 @@ data ChatCommand | SetTempFolder FilePath | SetFilesFolder FilePath | APISetXFTPConfig (Maybe XFTPFileConfig) + | APISetEncryptLocalFiles Bool + | SetContactMergeEnabled Bool | APIExportArchive ArchiveConfig | ExportArchive | APIImportArchive ArchiveConfig @@ -263,6 +257,7 @@ data ChatCommand | APIEndCall ContactId | APIGetCallInvitations | APICallStatus ContactId WebRTCCallStatus + | APIGetNetworkStatuses | APIUpdateProfile UserId Profile | APISetContactPrefs ContactId Preferences | APISetContactAlias ContactId LocalAlias @@ -300,6 +295,7 @@ data ChatCommand | APIGetNetworkConfig | ReconnectAllServers | APISetChatSettings ChatRef ChatSettings + | APISetMemberSettings GroupId GroupMemberId GroupMemberSettings | APIContactInfo ContactId | APIGroupInfo GroupId | APIGroupMemberInfo GroupId GroupMemberId @@ -315,8 +311,9 @@ data ChatCommand | APIVerifyGroupMember GroupId GroupMemberId (Maybe Text) | APIEnableContact ContactId | APIEnableGroupMember GroupId GroupMemberId - | SetShowMessages ChatName Bool + | SetShowMessages ChatName MsgFilter | SetSendReceipts ChatName (Maybe Bool) + | SetShowMemberMessages GroupName ContactName Bool | ContactInfo ContactName | ShowGroupInfo GroupName | GroupMemberInfo GroupName ContactName @@ -337,6 +334,7 @@ data ChatCommand | APIAddContact UserId IncognitoEnabled | AddContact IncognitoEnabled | APISetConnectionIncognito Int64 IncognitoEnabled + | APIConnectPlan UserId AConnectionRequestUri | APIConnect UserId IncognitoEnabled (Maybe AConnectionRequestUri) | Connect IncognitoEnabled (Maybe AConnectionRequestUri) | ConnectSimplex IncognitoEnabled -- UserId (not used in UI) @@ -366,8 +364,8 @@ data ChatCommand | EditMessage {chatName :: ChatName, editedMsg :: Text, message :: Text} | UpdateLiveMessage {chatName :: ChatName, chatItemId :: ChatItemId, liveMessage :: Bool, message :: Text} | ReactToMessage {add :: Bool, reaction :: MsgReaction, chatName :: ChatName, reactToMessage :: Text} - | APINewGroup UserId GroupProfile - | NewGroup GroupProfile + | APINewGroup UserId IncognitoEnabled GroupProfile + | NewGroup IncognitoEnabled GroupProfile | AddMember GroupName ContactName GroupMemberRole | JoinGroup GroupName | MemberRole GroupName ContactName GroupMemberRole @@ -398,8 +396,8 @@ data ChatCommand | ForwardFile ChatName FileTransferId | ForwardImage ChatName FileTransferId | SendFileDescription ChatName FilePath - | ReceiveFile {fileId :: FileTransferId, storeEncrypted :: Bool, fileInline :: Maybe Bool, filePath :: Maybe FilePath} - | SetFileToReceive {fileId :: FileTransferId, storeEncrypted :: Bool} + | ReceiveFile {fileId :: FileTransferId, storeEncrypted :: Maybe Bool, fileInline :: Maybe Bool, filePath :: Maybe FilePath} + | SetFileToReceive {fileId :: FileTransferId, storeEncrypted :: Maybe Bool} | CancelFile FileTransferId | FileStatus FileTransferId | ShowProfile -- UserId (not used in UI) @@ -431,7 +429,7 @@ data ChatResponse | CRApiChats {user :: User, chats :: [AChat]} | CRChats {chats :: [AChat]} | CRApiChat {user :: User, chat :: AChat} - | CRChatItems {user :: User, chatItems :: [AChatItem]} + | CRChatItems {user :: User, chatName_ :: Maybe ChatName, chatItems :: [AChatItem]} | CRChatItemInfo {user :: User, chatItem :: AChatItem, chatItemInfo :: ChatItemInfo} | CRChatItemId User (Maybe ChatItemId) | CRApiParsedMarkdown {formattedText :: Maybe MarkdownList} @@ -477,6 +475,7 @@ data ChatResponse | CRUserContactLinkUpdated {user :: User, contactLink :: UserContactLink} | CRContactRequestRejected {user :: User, contactRequest :: UserContactRequest} | CRUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact} + | CRGroupLinkConnecting {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember} | CRUserDeletedMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRGroupsList {user :: User, groups :: [(GroupInfo, GroupSummary)]} | CRSentGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, member :: GroupMember} @@ -488,10 +487,12 @@ data ChatResponse | CRVersionInfo {versionInfo :: CoreVersionInfo, chatMigrations :: [UpMigration], agentMigrations :: [UpMigration]} | CRInvitation {user :: User, connReqInvitation :: ConnReqInvitation, connection :: PendingContactConnection} | CRConnectionIncognitoUpdated {user :: User, toConnection :: PendingContactConnection} + | CRConnectionPlan {user :: User, connectionPlan :: ConnectionPlan} | CRSentConfirmation {user :: User} | CRSentInvitation {user :: User, customUserProfile :: Maybe Profile} | CRContactUpdated {user :: User, fromContact :: Contact, toContact :: Contact} - | CRContactsMerged {user :: User, intoContact :: Contact, mergedContact :: Contact} + | CRGroupMemberUpdated {user :: User, groupInfo :: GroupInfo, fromMember :: GroupMember, toMember :: GroupMember} + | CRContactsMerged {user :: User, intoContact :: Contact, mergedContact :: Contact, updatedContact :: Contact} | CRContactDeleted {user :: User, contact :: Contact} | CRContactDeletedByContact {user :: User, contact :: Contact} | CRChatCleared {user :: User, chatInfo :: AChatInfo} @@ -536,6 +537,8 @@ data ChatResponse | CRContactSubError {user :: User, contact :: Contact, chatError :: ChatError} | CRContactSubSummary {user :: User, contactSubscriptions :: [ContactSubStatus]} | CRUserContactSubSummary {user :: User, userContactSubscriptions :: [UserContactSubStatus]} + | CRNetworkStatus {networkStatus :: NetworkStatus, connections :: [AgentConnId]} + | CRNetworkStatuses {user_ :: Maybe User, networkStatuses :: [ConnNetworkStatus]} | CRHostConnected {protocol :: AProtocolType, transportHost :: TransportHost} | CRHostDisconnected {protocol :: AProtocolType, transportHost :: TransportHost} | CRGroupInvitation {user :: User, groupInfo :: GroupInfo} @@ -559,11 +562,12 @@ data ChatResponse | CRGroupLink {user :: User, groupInfo :: GroupInfo, connReqContact :: ConnReqContact, memberRole :: GroupMemberRole} | CRGroupLinkDeleted {user :: User, groupInfo :: GroupInfo} | CRAcceptingGroupJoinRequest {user :: User, groupInfo :: GroupInfo, contact :: Contact} + | CRAcceptingGroupJoinRequestMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRNoMemberContactCreating {user :: User, groupInfo :: GroupInfo, member :: GroupMember} -- only used in CLI | CRNewMemberContact {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} | CRNewMemberContactSentInv {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} | CRNewMemberContactReceivedInv {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} - | CRMemberContactConnected {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} + | CRContactAndMemberAssociated {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember, updatedContact :: Contact} | CRMemberSubError {user :: User, groupInfo :: GroupInfo, member :: GroupMember, chatError :: ChatError} | CRMemberSubSummary {user :: User, memberSubscriptions :: [MemberSubStatus]} | CRGroupSubscribed {user :: User, groupInfo :: GroupInfo} @@ -624,6 +628,68 @@ instance ToJSON ChatResponse where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CR" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CR" +data ConnectionPlan + = CPInvitationLink {invitationLinkPlan :: InvitationLinkPlan} + | CPContactAddress {contactAddressPlan :: ContactAddressPlan} + | CPGroupLink {groupLinkPlan :: GroupLinkPlan} + deriving (Show, Generic) + +instance ToJSON ConnectionPlan where + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CP" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CP" + +data InvitationLinkPlan + = ILPOk + | ILPOwnLink + | ILPConnecting {contact_ :: Maybe Contact} + | ILPKnown {contact :: Contact} + deriving (Show, Generic) + +instance ToJSON InvitationLinkPlan where + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "ILP" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "ILP" + +data ContactAddressPlan + = CAPOk + | CAPOwnLink + | CAPConnectingConfirmReconnect + | CAPConnectingProhibit {contact :: Contact} + | CAPKnown {contact :: Contact} + deriving (Show, Generic) + +instance ToJSON ContactAddressPlan where + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "CAP" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CAP" + +data GroupLinkPlan + = GLPOk + | GLPOwnLink {groupInfo :: GroupInfo} + | GLPConnectingConfirmReconnect + | GLPConnectingProhibit {groupInfo_ :: Maybe GroupInfo} + | GLPKnown {groupInfo :: GroupInfo} + deriving (Show, Generic) + +instance ToJSON GroupLinkPlan where + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "GLP" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "GLP" + +connectionPlanProceed :: ConnectionPlan -> Bool +connectionPlanProceed = \case + CPInvitationLink ilp -> case ilp of + ILPOk -> True + ILPOwnLink -> True + _ -> False + CPContactAddress cap -> case cap of + CAPOk -> True + CAPOwnLink -> True + CAPConnectingConfirmReconnect -> True + _ -> False + CPGroupLink glp -> case glp of + GLPOk -> True + GLPOwnLink _ -> True + GLPConnectingConfirmReconnect -> True + _ -> False + newtype UserPwd = UserPwd {unUserPwd :: Text} deriving (Eq, Show) @@ -888,6 +954,7 @@ data ChatErrorType | CEChatNotStarted | CEChatNotStopped | CEChatStoreChanged + | CEConnectionPlan {connectionPlan :: ConnectionPlan} | CEInvalidConnReq | CEInvalidChatMessage {connection :: Connection, msgMeta :: Maybe MsgMetaJSON, messageData :: Text, message :: String} | CEContactNotFound {contactName :: ContactName, suspectedMember :: Maybe (GroupInfo, GroupMember)} @@ -994,6 +1061,13 @@ chatWriteVar :: ChatMonad' m => (ChatController -> TVar a) -> a -> m () chatWriteVar f value = asks f >>= atomically . (`writeTVar` value) {-# INLINE chatWriteVar #-} +chatModifyVar :: ChatMonad' m => (ChatController -> TVar a) -> (a -> a) -> m () +chatModifyVar f newValue = asks f >>= atomically . (`modifyTVar'` newValue) +{-# INLINE chatModifyVar #-} + +setContactNetworkStatus :: ChatMonad' m => Contact -> NetworkStatus -> m () +setContactNetworkStatus ct = chatModifyVar connNetworkStatuses . M.insert (contactAgentConnId ct) + tryChatError :: ChatMonad m => m a -> m (Either ChatError a) tryChatError = tryAllErrors mkChatError {-# INLINE tryChatError #-} @@ -1013,14 +1087,6 @@ mkChatError = ChatError . CEException . show chatCmdError :: Maybe User -> String -> ChatResponse chatCmdError user = CRChatCmdError user . ChatError . CECommandError -setActive :: (MonadUnliftIO m, MonadReader ChatController m) => ActiveTo -> m () -setActive to = asks activeTo >>= atomically . (`writeTVar` to) - -unsetActive :: (MonadUnliftIO m, MonadReader ChatController m) => ActiveTo -> m () -unsetActive a = asks activeTo >>= atomically . (`modifyTVar` unset) - where - unset a' = if a == a' then ActiveNone else a' - toView :: ChatMonad' m => ChatResponse -> m () toView event = do q <- asks outputQ diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs index 4af161ab41..870779cfda 100644 --- a/src/Simplex/Chat/Core.hs +++ b/src/Simplex/Chat/Core.hs @@ -14,8 +14,8 @@ import Simplex.Chat.Types import System.Exit (exitFailure) import UnliftIO.Async -simplexChatCore :: ChatConfig -> ChatOpts -> Maybe (Notification -> IO ()) -> (User -> ChatController -> IO ()) -> IO () -simplexChatCore cfg@ChatConfig {confirmMigrations, testView} opts@ChatOpts {coreOptions = CoreChatOpts {dbFilePrefix, dbKey, logAgent}} sendToast chat = +simplexChatCore :: ChatConfig -> ChatOpts -> (User -> ChatController -> IO ()) -> IO () +simplexChatCore cfg@ChatConfig {confirmMigrations, testView} opts@ChatOpts {coreOptions = CoreChatOpts {dbFilePrefix, dbKey, logAgent}} chat = case logAgent of Just level -> do setLogLevel level @@ -28,7 +28,7 @@ simplexChatCore cfg@ChatConfig {confirmMigrations, testView} opts@ChatOpts {core exitFailure run db@ChatDatabase {chatStore} = do u <- getCreateActiveUser chatStore testView - cc <- newChatController db (Just u) cfg opts sendToast + cc <- newChatController db (Just u) cfg opts runSimplexChat opts u cc chat runSimplexChat :: ChatOpts -> User -> ChatController -> (User -> ChatController -> IO ()) -> IO () diff --git a/src/Simplex/Chat/Markdown.hs b/src/Simplex/Chat/Markdown.hs index d18f28db31..969c7c2b56 100644 --- a/src/Simplex/Chat/Markdown.hs +++ b/src/Simplex/Chat/Markdown.hs @@ -14,7 +14,7 @@ import Data.Aeson (ToJSON) import qualified Data.Aeson as J import Data.Attoparsec.Text (Parser) import qualified Data.Attoparsec.Text as A -import Data.Char (isDigit) +import Data.Char (isDigit, isPunctuation) import Data.Either (fromRight) import Data.Functor (($>)) import Data.List (intercalate, foldl') @@ -32,7 +32,7 @@ import Simplex.Chat.Types.Util import Simplex.Messaging.Agent.Protocol (AConnectionRequestUri (..), ConnReqScheme (..), ConnReqUriData (..), ConnectionRequestUri (..), SMPQueue (..)) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, enumJSON, fstToLower, sumTypeJSON) -import Simplex.Messaging.Protocol (ProtocolServer (..), SrvLoc (..)) +import Simplex.Messaging.Protocol (ProtocolServer (..)) import Simplex.Messaging.Util (safeDecodeUtf8) import System.Console.ANSI.Types import qualified Text.Email.Validate as Email @@ -48,7 +48,7 @@ data Format | Secret | Colored {color :: FormatColor} | Uri - | SimplexLink {linkType :: SimplexLinkType, simplexUri :: Text, trustedUri :: Bool, smpHosts :: NonEmpty Text} + | SimplexLink {linkType :: SimplexLinkType, simplexUri :: Text, smpHosts :: NonEmpty Text} | Email | Phone deriving (Eq, Show, Generic) @@ -217,11 +217,15 @@ markdownP = mconcat <$> A.many' fragmentP wordMD :: Text -> Markdown wordMD s | T.null s = unmarked s - | isUri s = case strDecode $ encodeUtf8 s of - Right cReq -> markdown (simplexUriFormat cReq) s - _ -> markdown Uri s + | isUri s = + let t = T.takeWhileEnd isPunctuation s + uri = uriMarkdown $ T.dropWhileEnd isPunctuation s + in if T.null t then uri else uri :|: unmarked t | isEmail s = markdown Email s | otherwise = unmarked s + uriMarkdown s = case strDecode $ encodeUtf8 s of + Right cReq -> markdown (simplexUriFormat cReq) s + _ -> markdown Uri s isUri s = T.length s >= 10 && any (`T.isPrefixOf` s) ["http://", "https://", "simplex:/"] isEmail s = T.any (== '@') s && Email.isValid (encodeUtf8 s) noFormat = pure . unmarked @@ -229,15 +233,12 @@ markdownP = mconcat <$> A.many' fragmentP simplexUriFormat = \case ACR _ (CRContactUri crData) -> let uri = safeDecodeUtf8 . strEncode $ CRContactUri crData {crScheme = CRSSimplex} - in SimplexLink (linkType' crData) uri (trustedUri' crData) $ uriHosts crData + in SimplexLink (linkType' crData) uri $ uriHosts crData ACR _ (CRInvitationUri crData e2e) -> let uri = safeDecodeUtf8 . strEncode $ CRInvitationUri crData {crScheme = CRSSimplex} e2e - in SimplexLink XLInvitation uri (trustedUri' crData) $ uriHosts crData + in SimplexLink XLInvitation uri $ uriHosts crData where uriHosts ConnReqUriData {crSmpQueues} = L.map (safeDecodeUtf8 . strEncode) $ sconcat $ L.map (host . qServer) crSmpQueues - trustedUri' ConnReqUriData {crScheme} = case crScheme of - CRSSimplex -> True - CRSAppServer (SrvLoc host _) -> host == "simplex.chat" linkType' ConnReqUriData {crClientData} = case crClientData >>= decodeJSON of Just (CRDataGroup _) -> XLGroup Nothing -> XLContact diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 79463d2107..2ddb1e7bcd 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -50,8 +50,10 @@ import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>)) data ChatType = CTDirect | CTGroup | CTContactRequest | CTContactConnection deriving (Eq, Show, Ord, Generic) -data ChatName = ChatName ChatType Text - deriving (Show) +data ChatName = ChatName {chatType :: ChatType, chatName :: Text} + deriving (Show, Generic) + +instance ToJSON ChatName where toEncoding = J.genericToEncoding J.defaultOptions chatTypeStr :: ChatType -> String chatTypeStr = \case @@ -148,6 +150,19 @@ instance MsgDirectionI d => ToJSON (ChatItem c d) where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} +isMention :: ChatItem c d -> Bool +isMention ChatItem {chatDir, quotedItem} = case chatDir of + CIDirectRcv -> userItem quotedItem + CIGroupRcv _ -> userItem quotedItem + _ -> False + where + userItem = \case + Nothing -> False + Just CIQuote {chatDir = cd} -> case cd of + CIQDirectSnd -> True + CIQGroupSnd -> True + _ -> False + data CIDirection (c :: ChatType) (d :: MsgDirection) where CIDirectSnd :: CIDirection 'CTDirect 'MDSnd CIDirectRcv :: CIDirection 'CTDirect 'MDRcv @@ -218,26 +233,6 @@ ciReactionAllowed :: ChatItem c d -> Bool ciReactionAllowed ChatItem {meta = CIMeta {itemDeleted = Just _}} = False ciReactionAllowed ChatItem {content} = isJust $ ciMsgContent content -data CIDeletedState = CIDeletedState - { markedDeleted :: Bool, - deletedByMember :: Maybe GroupMember - } - deriving (Show, Eq) - -chatItemDeletedState :: ChatItem c d -> Maybe CIDeletedState -chatItemDeletedState ChatItem {meta = CIMeta {itemDeleted}, content} = - ciDeletedToDeletedState <$> itemDeleted - where - ciDeletedToDeletedState cid = - case content of - CISndModerated -> CIDeletedState {markedDeleted = False, deletedByMember = byMember cid} - CIRcvModerated -> CIDeletedState {markedDeleted = False, deletedByMember = byMember cid} - _ -> CIDeletedState {markedDeleted = True, deletedByMember = byMember cid} - byMember :: CIDeleted c -> Maybe GroupMember - byMember = \case - CIModerated _ m -> Just m - CIDeleted _ -> Nothing - data ChatDirection (c :: ChatType) (d :: MsgDirection) where CDDirectSnd :: Contact -> ChatDirection 'CTDirect 'MDSnd CDDirectRcv :: Contact -> ChatDirection 'CTDirect 'MDRcv @@ -927,6 +922,7 @@ checkDirection x = case testEquality (msgDirection @d) (msgDirection @d') of data CIDeleted (c :: ChatType) where CIDeleted :: Maybe UTCTime -> CIDeleted c + CIBlocked :: Maybe UTCTime -> CIDeleted c CIModerated :: Maybe UTCTime -> GroupMember -> CIDeleted 'CTGroup deriving instance Show (CIDeleted c) @@ -937,6 +933,7 @@ instance ToJSON (CIDeleted d) where data JSONCIDeleted = JCIDDeleted {deletedTs :: Maybe UTCTime} + | JCIDBlocked {deletedTs :: Maybe UTCTime} | JCIDModerated {deletedTs :: Maybe UTCTime, byGroupMember :: GroupMember} deriving (Show, Generic) @@ -947,11 +944,13 @@ instance ToJSON JSONCIDeleted where jsonCIDeleted :: CIDeleted d -> JSONCIDeleted jsonCIDeleted = \case CIDeleted ts -> JCIDDeleted ts + CIBlocked ts -> JCIDBlocked ts CIModerated ts m -> JCIDModerated ts m itemDeletedTs :: CIDeleted d -> Maybe UTCTime itemDeletedTs = \case CIDeleted ts -> ts + CIBlocked ts -> ts CIModerated ts _ -> ts data ChatItemInfo = ChatItemInfo diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index 9abc8e4644..639093d01b 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -9,22 +9,20 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE StandaloneDeriving #-} +{-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} module Simplex.Chat.Messages.CIContent where import Data.Aeson (FromJSON, ToJSON) import qualified Data.Aeson as J +import qualified Data.Aeson.TH as JQ import Data.Int (Int64) import Data.Text (Text) import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Type.Equality -import Data.Typeable (Typeable) import Data.Word (Word32) -import Database.SQLite.Simple (ResultError (..), SQLData (..)) -import Database.SQLite.Simple.FromField (Field, FromField (..), returnError) -import Database.SQLite.Simple.Internal (Field (..)) -import Database.SQLite.Simple.Ok +import Database.SQLite.Simple.FromField (FromField (..)) import Database.SQLite.Simple.ToField (ToField (..)) import GHC.Generics (Generic) import Simplex.Chat.Protocol @@ -50,14 +48,6 @@ instance FromField AMsgDirection where fromField = fromIntField_ $ fmap fromMsgD instance ToField MsgDirection where toField = toField . msgDirectionInt -fromIntField_ :: Typeable a => (Int64 -> Maybe a) -> Field -> Ok a -fromIntField_ fromInt = \case - f@(Field (SQLInteger i) _) -> - case fromInt i of - Just x -> Ok x - _ -> returnError ConversionFailed f ("invalid integer: " <> show i) - f -> returnError ConversionFailed f "expecting SQLInteger column type" - data SMsgDirection (d :: MsgDirection) where SMDRcv :: SMsgDirection 'MDRcv SMDSnd :: SMsgDirection 'MDSnd @@ -326,11 +316,11 @@ instance ToJSON DBRcvDirectEvent where newtype DBMsgErrorType = DBME MsgErrorType instance FromJSON DBMsgErrorType where - parseJSON v = DBME <$> J.genericParseJSON (singleFieldJSON fstToLower) v + parseJSON v = DBME <$> $(JQ.mkParseJSON (singleFieldJSON fstToLower) ''MsgErrorType) v instance ToJSON DBMsgErrorType where - toJSON (DBME v) = J.genericToJSON (singleFieldJSON fstToLower) v - toEncoding (DBME v) = J.genericToEncoding (singleFieldJSON fstToLower) v + toJSON (DBME v) = $(JQ.mkToJSON (singleFieldJSON fstToLower) ''MsgErrorType) v + toEncoding (DBME v) = $(JQ.mkToEncoding (singleFieldJSON fstToLower) ''MsgErrorType) v data CIGroupInvitation = CIGroupInvitation { groupId :: GroupId, diff --git a/src/Simplex/Chat/Migrations/M20231002_conn_initiated.hs b/src/Simplex/Chat/Migrations/M20231002_conn_initiated.hs new file mode 100644 index 0000000000..a0f6009af2 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20231002_conn_initiated.hs @@ -0,0 +1,28 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20231002_conn_initiated where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20231002_conn_initiated :: Query +m20231002_conn_initiated = + [sql| +ALTER TABLE connections ADD COLUMN contact_conn_initiated INTEGER NOT NULL DEFAULT 0; + +UPDATE connections SET conn_req_inv = NULL WHERE conn_status IN ('ready', 'deleted'); + +CREATE INDEX idx_sent_probes_created_at ON sent_probes(created_at); +CREATE INDEX idx_sent_probe_hashes_created_at ON sent_probe_hashes(created_at); +CREATE INDEX idx_received_probes_created_at ON received_probes(created_at); +|] + +down_m20231002_conn_initiated :: Query +down_m20231002_conn_initiated = + [sql| +DROP INDEX idx_sent_probes_created_at; +DROP INDEX idx_sent_probe_hashes_created_at; +DROP INDEX idx_received_probes_created_at; + +ALTER TABLE connections DROP COLUMN contact_conn_initiated; +|] diff --git a/src/Simplex/Chat/Migrations/M20231009_via_group_link_uri_hash.hs b/src/Simplex/Chat/Migrations/M20231009_via_group_link_uri_hash.hs new file mode 100644 index 0000000000..41c9887a04 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20231009_via_group_link_uri_hash.hs @@ -0,0 +1,24 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20231009_via_group_link_uri_hash where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20231009_via_group_link_uri_hash :: Query +m20231009_via_group_link_uri_hash = + [sql| +CREATE INDEX idx_connections_conn_req_inv ON connections(conn_req_inv); + +ALTER TABLE groups ADD COLUMN via_group_link_uri_hash BLOB; +CREATE INDEX idx_groups_via_group_link_uri_hash ON groups(via_group_link_uri_hash); +|] + +down_m20231009_via_group_link_uri_hash :: Query +down_m20231009_via_group_link_uri_hash = + [sql| +DROP INDEX idx_groups_via_group_link_uri_hash; +ALTER TABLE groups DROP COLUMN via_group_link_uri_hash; + +DROP INDEX idx_connections_conn_req_inv; +|] diff --git a/src/Simplex/Chat/Migrations/M20231010_member_settings.hs b/src/Simplex/Chat/Migrations/M20231010_member_settings.hs new file mode 100644 index 0000000000..e31203e572 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20231010_member_settings.hs @@ -0,0 +1,18 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20231010_member_settings where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20231010_member_settings :: Query +m20231010_member_settings = + [sql| +ALTER TABLE group_members ADD COLUMN show_messages INTEGER NOT NULL DEFAULT 1; +|] + +down_m20231010_member_settings :: Query +down_m20231010_member_settings = + [sql| +ALTER TABLE group_members DROP COLUMN show_messages; +|] diff --git a/src/Simplex/Chat/Migrations/M20231019_indexes.hs b/src/Simplex/Chat/Migrations/M20231019_indexes.hs new file mode 100644 index 0000000000..40412e1778 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20231019_indexes.hs @@ -0,0 +1,32 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20231019_indexes where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20231019_indexes :: Query +m20231019_indexes = + [sql| +DROP INDEX idx_connections_conn_req_inv; +CREATE INDEX idx_connections_conn_req_inv ON connections(user_id, conn_req_inv); + +DROP INDEX idx_groups_via_group_link_uri_hash; +CREATE INDEX idx_groups_via_group_link_uri_hash ON groups(user_id, via_group_link_uri_hash); + +DROP INDEX idx_connections_via_contact_uri_hash; +CREATE INDEX idx_connections_via_contact_uri_hash ON connections(user_id, via_contact_uri_hash); +|] + +down_m20231019_indexes :: Query +down_m20231019_indexes = + [sql| +DROP INDEX idx_connections_conn_req_inv; +CREATE INDEX idx_connections_conn_req_inv ON connections(conn_req_inv); + +DROP INDEX idx_groups_via_group_link_uri_hash; +CREATE INDEX idx_groups_via_group_link_uri_hash ON groups(via_group_link_uri_hash); + +DROP INDEX idx_connections_via_contact_uri_hash; +CREATE INDEX idx_connections_via_contact_uri_hash ON connections(via_contact_uri_hash); +|] diff --git a/src/Simplex/Chat/Migrations/M20231030_xgrplinkmem_received.hs b/src/Simplex/Chat/Migrations/M20231030_xgrplinkmem_received.hs new file mode 100644 index 0000000000..cf4aee2531 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20231030_xgrplinkmem_received.hs @@ -0,0 +1,18 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20231030_xgrplinkmem_received where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20231030_xgrplinkmem_received :: Query +m20231030_xgrplinkmem_received = + [sql| +ALTER TABLE group_members ADD COLUMN xgrplinkmem_received INTEGER NOT NULL DEFAULT 0; +|] + +down_m20231030_xgrplinkmem_received :: Query +down_m20231030_xgrplinkmem_received = + [sql| +ALTER TABLE group_members DROP COLUMN xgrplinkmem_received; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index 65ceb7d19b..8e277a9789 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -117,7 +117,8 @@ CREATE TABLE groups( unread_chat INTEGER DEFAULT 0 CHECK(unread_chat NOT NULL), chat_ts TEXT, favorite INTEGER NOT NULL DEFAULT 0, - send_rcpts INTEGER, -- received + send_rcpts INTEGER, + via_group_link_uri_hash BLOB, -- received FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE @@ -144,6 +145,8 @@ CREATE TABLE group_members( created_at TEXT CHECK(created_at NOT NULL), updated_at TEXT CHECK(updated_at NOT NULL), member_profile_id INTEGER REFERENCES contact_profiles ON DELETE SET NULL, + show_messages INTEGER NOT NULL DEFAULT 1, + xgrplinkmem_received INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE @@ -264,6 +267,7 @@ CREATE TABLE connections( peer_chat_min_version INTEGER NOT NULL DEFAULT 1, peer_chat_max_version INTEGER NOT NULL DEFAULT 1, to_subscribe INTEGER DEFAULT 0 NOT NULL, + contact_conn_initiated INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(snd_file_id, connection_id) REFERENCES snd_files(file_id, connection_id) ON DELETE CASCADE @@ -521,9 +525,6 @@ CREATE INDEX contact_profiles_index ON contact_profiles( full_name ); CREATE INDEX idx_groups_inv_queue_info ON groups(inv_queue_info); -CREATE INDEX idx_connections_via_contact_uri_hash ON connections( - via_contact_uri_hash -); CREATE INDEX idx_contact_requests_xcontact_id ON contact_requests(xcontact_id); CREATE INDEX idx_contacts_xcontact_id ON contacts(xcontact_id); CREATE INDEX idx_messages_shared_msg_id ON messages(shared_msg_id); @@ -732,3 +733,18 @@ CREATE INDEX idx_received_probes_user_id ON received_probes(user_id); CREATE INDEX idx_received_probes_contact_id ON received_probes(contact_id); CREATE INDEX idx_received_probes_probe ON received_probes(probe); CREATE INDEX idx_received_probes_probe_hash ON received_probes(probe_hash); +CREATE INDEX idx_sent_probes_created_at ON sent_probes(created_at); +CREATE INDEX idx_sent_probe_hashes_created_at ON sent_probe_hashes(created_at); +CREATE INDEX idx_received_probes_created_at ON received_probes(created_at); +CREATE INDEX idx_connections_conn_req_inv ON connections( + user_id, + conn_req_inv +); +CREATE INDEX idx_groups_via_group_link_uri_hash ON groups( + user_id, + via_group_link_uri_hash +); +CREATE INDEX idx_connections_via_contact_uri_hash ON connections( + user_id, + via_contact_uri_hash +); diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index edd84f9327..cd9b5f7701 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -177,7 +177,8 @@ defaultMobileConfig :: ChatConfig defaultMobileConfig = defaultChatConfig { confirmMigrations = MCYesUp, - logLevel = CLLError + logLevel = CLLError, + coreApi = True } getActiveUser_ :: SQLiteStore -> IO (Maybe User) @@ -204,7 +205,7 @@ chatMigrateInit dbFilePrefix dbKey confirm = runExceptT $ do where initialize st db = do user_ <- getActiveUser_ st - newChatController db user_ defaultMobileConfig (mobileChatOpts dbFilePrefix dbKey) Nothing + newChatController db user_ defaultMobileConfig (mobileChatOpts dbFilePrefix dbKey) migrate createStore dbFile confirmMigrations = ExceptT $ (first (DBMErrorMigration dbFile) <$> createStore dbFile dbKey confirmMigrations) diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index bbdddf8ce0..58aa26f284 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -51,7 +51,7 @@ import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>)) import Simplex.Messaging.Version hiding (version) currentChatVersion :: Version -currentChatVersion = 2 +currentChatVersion = 3 supportedChatVRange :: VersionRange supportedChatVRange = mkVersionRange 1 currentChatVersion @@ -64,6 +64,10 @@ groupNoDirectVRange = mkVersionRange 2 currentChatVersion xGrpDirectInvVRange :: VersionRange xGrpDirectInvVRange = mkVersionRange 2 currentChatVersion +-- version range that supports joining group via group link without creating direct contact +groupLinkNoContactVRange :: VersionRange +groupLinkNoContactVRange = mkVersionRange 3 currentChatVersion + data ConnectionEntity = RcvDirectMsgConnection {entityConnection :: Connection, contact :: Maybe Contact} | RcvGroupMsgConnection {entityConnection :: Connection, groupInfo :: GroupInfo, groupMember :: GroupMember} @@ -218,6 +222,8 @@ data ChatMsgEvent (e :: MsgEncoding) where XDirectDel :: ChatMsgEvent 'Json XGrpInv :: GroupInvitation -> ChatMsgEvent 'Json XGrpAcpt :: MemberId -> ChatMsgEvent 'Json + XGrpLinkInv :: GroupLinkInvitation -> ChatMsgEvent 'Json + XGrpLinkMem :: Profile -> ChatMsgEvent 'Json XGrpMemNew :: MemberInfo -> ChatMsgEvent 'Json XGrpMemIntro :: MemberInfo -> ChatMsgEvent 'Json XGrpMemInv :: MemberId -> IntroInvitation -> ChatMsgEvent 'Json @@ -378,6 +384,11 @@ mcExtMsgContent = \case MCQuote _ c -> c MCForward c -> c +isQuote :: MsgContainer -> Bool +isQuote = \case + MCQuote {} -> True + _ -> False + data LinkPreview = LinkPreview {uri :: Text, title :: Text, description :: Text, image :: ImageData, content :: Maybe LinkContent} deriving (Eq, Show, Generic) @@ -554,6 +565,8 @@ data CMEventTag (e :: MsgEncoding) where XDirectDel_ :: CMEventTag 'Json XGrpInv_ :: CMEventTag 'Json XGrpAcpt_ :: CMEventTag 'Json + XGrpLinkInv_ :: CMEventTag 'Json + XGrpLinkMem_ :: CMEventTag 'Json XGrpMemNew_ :: CMEventTag 'Json XGrpMemIntro_ :: CMEventTag 'Json XGrpMemInv_ :: CMEventTag 'Json @@ -601,6 +614,8 @@ instance MsgEncodingI e => StrEncoding (CMEventTag e) where XDirectDel_ -> "x.direct.del" XGrpInv_ -> "x.grp.inv" XGrpAcpt_ -> "x.grp.acpt" + XGrpLinkInv_ -> "x.grp.link.inv" + XGrpLinkMem_ -> "x.grp.link.mem" XGrpMemNew_ -> "x.grp.mem.new" XGrpMemIntro_ -> "x.grp.mem.intro" XGrpMemInv_ -> "x.grp.mem.inv" @@ -649,6 +664,8 @@ instance StrEncoding ACMEventTag where "x.direct.del" -> XDirectDel_ "x.grp.inv" -> XGrpInv_ "x.grp.acpt" -> XGrpAcpt_ + "x.grp.link.inv" -> XGrpLinkInv_ + "x.grp.link.mem" -> XGrpLinkMem_ "x.grp.mem.new" -> XGrpMemNew_ "x.grp.mem.intro" -> XGrpMemIntro_ "x.grp.mem.inv" -> XGrpMemInv_ @@ -693,6 +710,8 @@ toCMEventTag msg = case msg of XDirectDel -> XDirectDel_ XGrpInv _ -> XGrpInv_ XGrpAcpt _ -> XGrpAcpt_ + XGrpLinkInv _ -> XGrpLinkInv_ + XGrpLinkMem _ -> XGrpLinkMem_ XGrpMemNew _ -> XGrpMemNew_ XGrpMemIntro _ -> XGrpMemIntro_ XGrpMemInv _ _ -> XGrpMemInv_ @@ -790,6 +809,8 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do XDirectDel_ -> pure XDirectDel XGrpInv_ -> XGrpInv <$> p "groupInvitation" XGrpAcpt_ -> XGrpAcpt <$> p "memberId" + XGrpLinkInv_ -> XGrpLinkInv <$> p "groupLinkInvitation" + XGrpLinkMem_ -> XGrpLinkMem <$> p "profile" XGrpMemNew_ -> XGrpMemNew <$> p "memberInfo" XGrpMemIntro_ -> XGrpMemIntro <$> p "memberInfo" XGrpMemInv_ -> XGrpMemInv <$> p "memberId" <*> p "memberIntro" @@ -848,6 +869,8 @@ chatToAppMessage ChatMessage {chatVRange, msgId, chatMsgEvent} = case encoding @ XDirectDel -> JM.empty XGrpInv groupInv -> o ["groupInvitation" .= groupInv] XGrpAcpt memId -> o ["memberId" .= memId] + XGrpLinkInv groupLinkInv -> o ["groupLinkInvitation" .= groupLinkInv] + XGrpLinkMem profile -> o ["profile" .= profile] XGrpMemNew memInfo -> o ["memberInfo" .= memInfo] XGrpMemIntro memInfo -> o ["memberInfo" .= memInfo] XGrpMemInv memId memIntro -> o ["memberId" .= memId, "memberIntro" .= memIntro] diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 93f3349ca2..d73ac705d3 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -9,6 +9,8 @@ module Simplex.Chat.Store.Connections ( getConnectionEntity, + getConnectionEntityByConnReq, + getContactConnEntityByConnReqHash, getConnectionsToSubscribe, unsetConnectionToSubscribe, ) @@ -31,7 +33,7 @@ import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Messaging.Agent.Protocol (ConnId) -import Simplex.Messaging.Agent.Store.SQLite (firstRow, firstRow') +import Simplex.Messaging.Agent.Store.SQLite (firstRow, firstRow', maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import Simplex.Messaging.Util (eitherToMaybe) @@ -58,7 +60,7 @@ getConnectionEntity db user@User {userId, userContactId} agentConnId = do db [sql| SELECT connection_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, group_link_id, custom_user_profile_id, - conn_status, conn_type, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, security_code, security_code_verified_at, auth_err_counter, + conn_status, conn_type, contact_conn_initiated, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, security_code, security_code_verified_at, auth_err_counter, peer_chat_min_version, peer_chat_max_version FROM connections WHERE user_id = ? AND agent_conn_id = ? @@ -78,10 +80,10 @@ getConnectionEntity db user@User {userId, userContactId} agentConnId = do WHERE c.user_id = ? AND c.contact_id = ? AND c.deleted = 0 |] (userId, contactId) - toContact' :: Int64 -> Connection -> [(ProfileId, ContactName, Text, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Int64, Bool, ContactStatus) :. (Maybe Bool, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime, Maybe GroupMemberId, Bool)] -> Either StoreError Contact + toContact' :: Int64 -> Connection -> [(ProfileId, ContactName, Text, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Int64, Bool, ContactStatus) :. (Maybe MsgFilter, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime, Maybe GroupMemberId, Bool)] -> Either StoreError Contact toContact' contactId activeConn [(profileId, localDisplayName, displayName, fullName, image, contactLink, localAlias, viaGroup, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)] = let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} - chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_, sendRcpts, favorite} + chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts, favorite} mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn in Right Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent} toContact' _ _ _ = Left $ SEInternalError "referenced contact not found" @@ -96,11 +98,11 @@ getConnectionEntity db user@User {userId, userContactId} agentConnId = do g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category, - mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + mu.member_status, mu.show_messages, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, -- from GroupMember - m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, + m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.show_messages, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) @@ -152,6 +154,35 @@ getConnectionEntity db user@User {userId, userContactId} agentConnId = do userContact_ [(cReq, groupId)] = Right UserContact {userContactLinkId, connReqContact = cReq, groupId} userContact_ _ = Left SEUserContactLinkNotFound +getConnectionEntityByConnReq :: DB.Connection -> User -> (ConnReqInvitation, ConnReqInvitation) -> IO (Maybe ConnectionEntity) +getConnectionEntityByConnReq db user@User {userId} (cReqSchema1, cReqSchema2) = do + connId_ <- maybeFirstRow fromOnly $ + DB.query db "SELECT agent_conn_id FROM connections WHERE user_id = ? AND conn_req_inv IN (?,?) LIMIT 1" (userId, cReqSchema1, cReqSchema2) + maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getConnectionEntity db user) connId_ + +-- search connection for connection plan: +-- multiple connections can have same via_contact_uri_hash if request was repeated; +-- this function searches for latest connection with contact so that "known contact" plan would be chosen; +-- deleted connections are filtered out to allow re-connecting via same contact address +getContactConnEntityByConnReqHash :: DB.Connection -> User -> (ConnReqUriHash, ConnReqUriHash) -> IO (Maybe ConnectionEntity) +getContactConnEntityByConnReqHash db user@User {userId} (cReqHash1, cReqHash2) = do + connId_ <- maybeFirstRow fromOnly $ + DB.query + db + [sql| + SELECT agent_conn_id FROM ( + SELECT + agent_conn_id, + (CASE WHEN contact_id IS NOT NULL THEN 1 ELSE 0 END) AS conn_ord + FROM connections + WHERE user_id = ? AND via_contact_uri_hash IN (?,?) AND conn_status != ? + ORDER BY conn_ord DESC, created_at DESC + LIMIT 1 + ) + |] + (userId, cReqHash1, cReqHash2, ConnDeleted) + maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getConnectionEntity db user) connId_ + getConnectionsToSubscribe :: DB.Connection -> IO ([ConnId], [ConnectionEntity]) getConnectionsToSubscribe db = do aConnIds <- map fromOnly <$> DB.query_ db "SELECT agent_conn_id FROM connections where to_subscribe = 1" diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 886c735053..477361acdf 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -25,6 +25,7 @@ module Simplex.Chat.Store.Direct createConnReqConnection, getProfileById, getConnReqContactXContactId, + getContactByConnReqHash, createDirectContact, deleteContactConnectionsAndFiles, deleteContact, @@ -62,6 +63,7 @@ module Simplex.Chat.Store.Direct updateConnectionStatus, updateContactSettings, setConnConnReqInv, + resetContactConnInitiated, ) where @@ -126,42 +128,20 @@ createConnReqConnection db userId acId cReqHash xContactId incognitoProfile grou db [sql| INSERT INTO connections ( - user_id, agent_conn_id, conn_status, conn_type, + user_id, agent_conn_id, conn_status, conn_type, contact_conn_initiated, via_contact_uri_hash, xcontact_id, custom_user_profile_id, via_group_link, group_link_id, created_at, updated_at, to_subscribe - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ((userId, acId, pccConnStatus, ConnContact, cReqHash, xContactId) :. (customUserProfileId, isJust groupLinkId, groupLinkId, createdAt, createdAt, subMode == SMOnlyCreate)) + ((userId, acId, pccConnStatus, ConnContact, True, cReqHash, xContactId) :. (customUserProfileId, isJust groupLinkId, groupLinkId, createdAt, createdAt, subMode == SMOnlyCreate)) pccConnId <- insertedRowId db pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = True, viaUserContactLink = Nothing, groupLinkId, customUserProfileId, connReqInv = Nothing, localAlias = "", createdAt, updatedAt = createdAt} getConnReqContactXContactId :: DB.Connection -> User -> ConnReqUriHash -> IO (Maybe Contact, Maybe XContactId) getConnReqContactXContactId db user@User {userId} cReqHash = do - getContact' >>= \case + getContactByConnReqHash db user cReqHash >>= \case c@(Just _) -> pure (c, Nothing) Nothing -> (Nothing,) <$> getXContactId where - getContact' :: IO (Maybe Contact) - getContact' = - maybeFirstRow (toContact user) $ - DB.query - db - [sql| - SELECT - -- Contact - ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, - cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, - -- Connection - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, - c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, - c.peer_chat_min_version, c.peer_chat_max_version - FROM contacts ct - JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id - JOIN connections c ON c.contact_id = ct.contact_id - WHERE ct.user_id = ? AND c.via_contact_uri_hash = ? AND ct.deleted = 0 - ORDER BY c.connection_id DESC - LIMIT 1 - |] - (userId, cReqHash) getXContactId :: IO (Maybe XContactId) getXContactId = maybeFirstRow fromOnly $ @@ -170,17 +150,41 @@ getConnReqContactXContactId db user@User {userId} cReqHash = do "SELECT xcontact_id FROM connections WHERE user_id = ? AND via_contact_uri_hash = ? LIMIT 1" (userId, cReqHash) +getContactByConnReqHash :: DB.Connection -> User -> ConnReqUriHash -> IO (Maybe Contact) +getContactByConnReqHash db user@User {userId} cReqHash = + maybeFirstRow (toContact user) $ + DB.query + db + [sql| + SELECT + -- Contact + ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, + -- Connection + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, + c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, + c.peer_chat_min_version, c.peer_chat_max_version + FROM contacts ct + JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id + JOIN connections c ON c.contact_id = ct.contact_id + WHERE c.user_id = ? AND c.via_contact_uri_hash = ? AND ct.contact_status = ? AND ct.deleted = 0 + ORDER BY c.created_at DESC + LIMIT 1 + |] + (userId, cReqHash, CSActive) + createDirectConnection :: DB.Connection -> User -> ConnId -> ConnReqInvitation -> ConnStatus -> Maybe Profile -> SubscriptionMode -> IO PendingContactConnection createDirectConnection db User {userId} acId cReq pccConnStatus incognitoProfile subMode = do createdAt <- getCurrentTime customUserProfileId <- mapM (createIncognitoProfile_ db userId createdAt) incognitoProfile + let contactConnInitiated = pccConnStatus == ConnNew DB.execute db [sql| INSERT INTO connections - (user_id, agent_conn_id, conn_req_inv, conn_status, conn_type, custom_user_profile_id, created_at, updated_at, to_subscribe) VALUES (?,?,?,?,?,?,?,?,?) + (user_id, agent_conn_id, conn_req_inv, conn_status, conn_type, contact_conn_initiated, custom_user_profile_id, created_at, updated_at, to_subscribe) VALUES (?,?,?,?,?,?,?,?,?,?) |] - (userId, acId, cReq, pccConnStatus, ConnContact, customUserProfileId, createdAt, createdAt, subMode == SMOnlyCreate) + (userId, acId, cReq, pccConnStatus, ConnContact, contactConnInitiated, customUserProfileId, createdAt, createdAt, subMode == SMOnlyCreate) pccConnId <- insertedRowId db pure PendingContactConnection {pccConnId, pccAgentConnId = AgentConnId acId, pccConnStatus, viaContactUri = False, viaUserContactLink = Nothing, groupLinkId = Nothing, customUserProfileId, connReqInv = Just cReq, localAlias = "", createdAt, updatedAt = createdAt} @@ -189,17 +193,6 @@ 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 - db - [sql| - INSERT INTO contact_profiles (display_name, full_name, image, user_id, incognito, created_at, updated_at) - VALUES (?,?,?,?,?,?,?) - |] - (displayName, fullName, image, userId, Just True, createdAt, createdAt) - insertedRowId db - createDirectContact :: DB.Connection -> User -> Connection -> Profile -> ExceptT StoreError IO Contact createDirectContact db user@User {userId} activeConn@Connection {connId, localAlias} p@Profile {preferences} = do createdAt <- liftIO getCurrentTime @@ -508,14 +501,14 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId (Vers ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, -- Connection - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM contacts ct JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id LEFT JOIN connections c ON c.contact_id = ct.contact_id WHERE ct.user_id = ? AND ct.xcontact_id = ? AND ct.deleted = 0 - ORDER BY c.connection_id DESC + ORDER BY c.created_at DESC LIMIT 1 |] (userId, xContactId) @@ -665,14 +658,14 @@ getContact_ :: DB.Connection -> User -> Int64 -> Bool -> ExceptT StoreError IO C getContact_ db user@User {userId} contactId deleted = ExceptT . fmap join . firstRow (toContactOrError user) (SEContactNotFound contactId) $ DB.query - db + db [sql| SELECT -- Contact ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, -- Connection - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM contacts ct @@ -684,10 +677,11 @@ getContact_ db user@User {userId} contactId deleted = SELECT cc_connection_id FROM ( SELECT cc.connection_id AS cc_connection_id, + cc.created_at AS cc_created_at, (CASE WHEN cc.conn_status = ? OR cc.conn_status = ? THEN 1 ELSE 0 END) AS cc_conn_status_ord FROM connections cc WHERE cc.user_id = ct.user_id AND cc.contact_id = ct.contact_id - ORDER BY cc_conn_status_ord DESC, cc_connection_id DESC + ORDER BY cc_conn_status_ord DESC, cc_created_at DESC LIMIT 1 ) ) @@ -722,7 +716,7 @@ getContactConnections db userId Contact {contactId} = db [sql| SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM connections c JOIN contacts ct ON ct.contact_id = c.contact_id @@ -739,7 +733,7 @@ getConnectionById db User {userId} connId = ExceptT $ do db [sql| SELECT connection_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, group_link_id, custom_user_profile_id, - conn_status, conn_type, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, security_code, security_code_verified_at, auth_err_counter, + conn_status, conn_type, contact_conn_initiated, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, security_code, security_code_verified_at, auth_err_counter, peer_chat_min_version, peer_chat_max_version FROM connections WHERE user_id = ? AND connection_id = ? @@ -773,7 +767,11 @@ getConnectionsContacts db agentConnIds = do updateConnectionStatus :: DB.Connection -> Connection -> ConnStatus -> IO () updateConnectionStatus db Connection {connId} connStatus = do currentTs <- getCurrentTime - DB.execute db "UPDATE connections SET conn_status = ?, updated_at = ? WHERE connection_id = ?" (connStatus, currentTs, connId) + if connStatus == ConnReady + then + DB.execute db "UPDATE connections SET conn_status = ?, updated_at = ?, conn_req_inv = NULL WHERE connection_id = ?" (connStatus, currentTs, connId) + else + DB.execute db "UPDATE connections SET conn_status = ?, updated_at = ? WHERE connection_id = ?" (connStatus, currentTs, connId) updateContactSettings :: DB.Connection -> User -> Int64 -> ChatSettings -> IO () updateContactSettings db User {userId} contactId ChatSettings {enableNtfs, sendRcpts, favorite} = @@ -790,3 +788,16 @@ setConnConnReqInv db User {userId} connId connReq = do WHERE user_id = ? AND connection_id = ? |] (connReq, updatedAt, userId, connId) + +resetContactConnInitiated :: DB.Connection -> User -> Connection -> IO () +resetContactConnInitiated db User {userId} Connection {connId} = do + updatedAt <- getCurrentTime + DB.execute + db + [sql| + UPDATE connections + SET contact_conn_initiated = 0, updated_at = ? + WHERE user_id = ? AND connection_id = ? + |] + (updatedAt, userId, connId) + diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index e72ca8e8ca..bddca0deb4 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -31,9 +31,13 @@ module Simplex.Chat.Store.Groups getGroupAndMember, createNewGroup, createGroupInvitation, + createGroupInvitedViaLink, + setViaGroupLinkHash, setGroupInvitationChatItemId, getGroup, getGroupInfo, + getGroupInfoByUserContactLinkConnReq, + getGroupInfoByGroupLinkHash, updateGroupProfile, getGroupIdByName, getGroupMemberIdByName, @@ -56,6 +60,8 @@ module Simplex.Chat.Store.Groups getGroupInvitation, createNewContactMember, createNewContactMemberAsync, + createAcceptedMember, + createAcceptedMemberConnection, getContactViaMember, setNewContactMemberConnRequest, getMemberInvitation, @@ -77,6 +83,7 @@ module Simplex.Chat.Store.Groups getViaGroupMember, getViaGroupContact, getMatchingContacts, + getMatchingMembers, getMatchingMemberContacts, createSentProbe, createSentProbeHash, @@ -84,8 +91,11 @@ module Simplex.Chat.Store.Groups matchReceivedProbeHash, matchSentProbe, mergeContactRecords, - updateMemberContact, + associateMemberWithContactRecord, + associateContactWithMemberRecord, + deleteOldProbes, updateGroupSettings, + updateGroupMemberSettings, getXGrpMemIntroContDirect, getXGrpMemIntroContGroup, getHostConnId, @@ -95,6 +105,9 @@ module Simplex.Chat.Store.Groups createMemberContactInvited, updateMemberContactInvited, resetMemberContactFields, + updateMemberProfile, + getXGrpLinkMemReceived, + setXGrpLinkMemReceived, ) where @@ -104,8 +117,8 @@ import Control.Monad.IO.Class import Crypto.Random (ChaChaDRG) import Data.Either (rights) import Data.Int (Int64) -import Data.List (sortOn) -import Data.Maybe (fromMaybe, isNothing) +import Data.List (partition, sortOn) +import Data.Maybe (fromMaybe, isNothing, catMaybes, isJust) import Data.Ord (Down (..)) import Data.Text (Text) import Data.Time.Clock (UTCTime (..), getCurrentTime) @@ -125,30 +138,31 @@ import Simplex.Messaging.Util (eitherToMaybe, ($>>=), (<$$>)) import Simplex.Messaging.Version import UnliftIO.STM -type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Maybe ImageData, Maybe ProfileId, Maybe Bool, Maybe Bool, Bool, Maybe GroupPreferences) :. (UTCTime, UTCTime, Maybe UTCTime) :. GroupMemberRow +type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Maybe ImageData, Maybe ProfileId, Maybe MsgFilter, Maybe Bool, Bool, Maybe GroupPreferences) :. (UTCTime, UTCTime, Maybe UTCTime) :. GroupMemberRow -type GroupMemberRow = ((Int64, Int64, MemberId, GroupMemberRole, GroupMemberCategory, GroupMemberStatus) :. (Maybe Int64, ContactName, Maybe ContactId, ProfileId, ProfileId, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Preferences)) +type GroupMemberRow = ((Int64, Int64, MemberId, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, Bool) :. (Maybe Int64, ContactName, Maybe ContactId, ProfileId, ProfileId, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Preferences)) -type MaybeGroupMemberRow = ((Maybe Int64, Maybe Int64, Maybe MemberId, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus) :. (Maybe Int64, Maybe ContactName, Maybe ContactId, Maybe ProfileId, Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe ImageData, Maybe ConnReqContact, Maybe LocalAlias, Maybe Preferences)) +type MaybeGroupMemberRow = ((Maybe Int64, Maybe Int64, Maybe MemberId, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe Bool) :. (Maybe Int64, Maybe ContactName, Maybe ContactId, Maybe ProfileId, Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe ImageData, Maybe ConnReqContact, Maybe LocalAlias, Maybe Preferences)) toGroupInfo :: Int64 -> GroupInfoRow -> GroupInfo toGroupInfo userContactId ((groupId, localDisplayName, displayName, fullName, description, image, hostConnCustomUserProfileId, enableNtfs_, sendRcpts, favorite, groupPreferences) :. (createdAt, updatedAt, chatTs) :. userMemberRow) = let membership = toGroupMember userContactId userMemberRow - chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_, sendRcpts, favorite} + chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts, favorite} fullGroupPreferences = mergeGroupPreferences groupPreferences groupProfile = GroupProfile {displayName, fullName, description, image, groupPreferences} in GroupInfo {groupId, localDisplayName, groupProfile, fullGroupPreferences, membership, hostConnCustomUserProfileId, chatSettings, createdAt, updatedAt, chatTs} toGroupMember :: Int64 -> GroupMemberRow -> GroupMember -toGroupMember userContactId ((groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus) :. (invitedById, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, preferences)) = +toGroupMember userContactId ((groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus, showMessages) :. (invitedById, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, preferences)) = let memberProfile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} + memberSettings = GroupMemberSettings {showMessages} invitedBy = toInvitedBy userContactId invitedById activeConn = Nothing in GroupMember {..} toMaybeGroupMember :: Int64 -> MaybeGroupMemberRow -> Maybe GroupMember -toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, Just memberId, Just memberRole, Just memberCategory, Just memberStatus) :. (invitedById, Just localDisplayName, memberContactId, Just memberContactProfileId, Just profileId, Just displayName, Just fullName, image, contactLink, Just localAlias, contactPreferences)) = - Just $ toGroupMember userContactId ((groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus) :. (invitedById, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, contactPreferences)) +toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, Just memberId, Just memberRole, Just memberCategory, Just memberStatus, Just showMessages) :. (invitedById, Just localDisplayName, memberContactId, Just memberContactProfileId, Just profileId, Just displayName, Just fullName, image, contactLink, Just localAlias, contactPreferences)) = + Just $ toGroupMember userContactId ((groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus, showMessages) :. (invitedById, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, contactPreferences)) toMaybeGroupMember _ _ = Nothing createGroupLink :: DB.Connection -> User -> GroupInfo -> ConnId -> ConnReqContact -> GroupLinkId -> GroupMemberRole -> SubscriptionMode -> ExceptT StoreError IO () @@ -169,7 +183,7 @@ getGroupLinkConnection db User {userId} groupInfo@GroupInfo {groupId} = db [sql| SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM connections c JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id @@ -244,14 +258,14 @@ getGroupAndMember db User {userId, userContactId} groupMemberId = g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category, - mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + mu.member_status, mu.show_messages, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, -- from GroupMember - m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, + m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.show_messages, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) @@ -275,11 +289,12 @@ getGroupAndMember db User {userId, userContactId} groupMemberId = in (groupInfo, (member :: GroupMember) {activeConn = toMaybeConnection connRow}) -- | creates completely new group with a single member - the current user -createNewGroup :: DB.Connection -> TVar ChaChaDRG -> User -> GroupProfile -> ExceptT StoreError IO GroupInfo -createNewGroup db gVar user@User {userId} groupProfile = ExceptT $ do +createNewGroup :: DB.Connection -> TVar ChaChaDRG -> User -> GroupProfile -> Maybe Profile -> ExceptT StoreError IO GroupInfo +createNewGroup db gVar user@User {userId} groupProfile incognitoProfile = ExceptT $ do let GroupProfile {displayName, fullName, description, image, groupPreferences} = groupProfile fullGroupPreferences = mergeGroupPreferences groupPreferences currentTs <- getCurrentTime + customUserProfileId <- mapM (createIncognitoProfile_ db userId currentTs) incognitoProfile withLocalDisplayName db userId displayName $ \ldn -> runExceptT $ do groupId <- liftIO $ do DB.execute @@ -293,8 +308,8 @@ createNewGroup db gVar user@User {userId} groupProfile = ExceptT $ do (ldn, userId, profileId, True, currentTs, currentTs, currentTs) insertedRowId db memberId <- liftIO $ encodedRandomBytes gVar 12 - membership <- createContactMemberInv_ db user groupId user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser Nothing currentTs - let chatSettings = ChatSettings {enableNtfs = True, sendRcpts = Nothing, favorite = False} + membership <- createContactMemberInv_ db user groupId user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser customUserProfileId currentTs + let chatSettings = ChatSettings {enableNtfs = MFAll, sendRcpts = Nothing, favorite = False} pure GroupInfo {groupId, localDisplayName = ldn, groupProfile, fullGroupPreferences, membership, hostConnCustomUserProfileId = Nothing, chatSettings, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs} -- | creates a new group record for the group the current user was invited to, or returns an existing one @@ -339,7 +354,7 @@ createGroupInvitation db user@User {userId} contact@Contact {contactId, activeCo insertedRowId db GroupMember {groupMemberId} <- createContactMemberInv_ db user groupId contact fromMember GCHostMember GSMemInvited IBUnknown Nothing currentTs membership <- createContactMemberInv_ db user groupId user invitedMember GCUserMember GSMemInvited (IBContact contactId) incognitoProfileId currentTs - let chatSettings = ChatSettings {enableNtfs = True, sendRcpts = Nothing, favorite = False} + let chatSettings = ChatSettings {enableNtfs = MFAll, sendRcpts = Nothing, favorite = False} pure (GroupInfo {groupId, localDisplayName, groupProfile, fullGroupPreferences, membership, hostConnCustomUserProfileId = customUserProfileId, chatSettings, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs}, groupMemberId) getHostMemberId_ :: DB.Connection -> User -> GroupId -> ExceptT StoreError IO GroupMemberId @@ -363,6 +378,7 @@ createContactMemberInv_ db User {userId, userContactId} groupId userOrContact Me memberRole, memberCategory, memberStatus, + memberSettings = defaultMemberSettings, invitedBy, localDisplayName, memberProfile, @@ -402,6 +418,65 @@ createContactMemberInv_ db User {userId, userContactId} groupId userOrContact Me ) pure $ Right incognitoLdn +createGroupInvitedViaLink :: DB.Connection -> User -> Connection -> GroupLinkInvitation -> ExceptT StoreError IO (GroupInfo, GroupMember) +createGroupInvitedViaLink + db + user@User {userId, userContactId} + Connection {connId, customUserProfileId} + GroupLinkInvitation {fromMember, fromMemberName, invitedMember, groupProfile} = do + currentTs <- liftIO getCurrentTime + groupId <- insertGroup_ currentTs + hostMemberId <- insertHost_ currentTs groupId + liftIO $ DB.execute db "UPDATE connections SET conn_type = ?, group_member_id = ?, updated_at = ? WHERE connection_id = ?" (ConnMember, hostMemberId, currentTs, connId) + -- using IBUnknown since host is created without contact + void $ createContactMemberInv_ db user groupId user invitedMember GCUserMember GSMemAccepted IBUnknown customUserProfileId currentTs + liftIO $ setViaGroupLinkHash db groupId connId + (,) <$> getGroupInfo db user groupId <*> getGroupMemberById db user hostMemberId + where + insertGroup_ currentTs = ExceptT $ do + let GroupProfile {displayName, fullName, description, image, groupPreferences} = groupProfile + withLocalDisplayName db userId displayName $ \localDisplayName -> runExceptT $ do + liftIO $ do + DB.execute + db + "INSERT INTO group_profiles (display_name, full_name, description, image, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" + (displayName, fullName, description, image, userId, groupPreferences, currentTs, currentTs) + profileId <- insertedRowId db + DB.execute + db + "INSERT INTO groups (group_profile_id, local_display_name, host_conn_custom_user_profile_id, user_id, enable_ntfs, created_at, updated_at, chat_ts) VALUES (?,?,?,?,?,?,?,?)" + (profileId, localDisplayName, customUserProfileId, userId, True, currentTs, currentTs, currentTs) + insertedRowId db + insertHost_ currentTs groupId = ExceptT $ do + let fromMemberProfile = profileFromName fromMemberName + withLocalDisplayName db userId fromMemberName $ \localDisplayName -> runExceptT $ do + (_, profileId) <- createNewMemberProfile_ db user fromMemberProfile currentTs + let MemberIdRole {memberId, memberRole} = fromMember + liftIO $ do + DB.execute + db + [sql| + INSERT INTO group_members + ( group_id, member_id, member_role, member_category, member_status, invited_by, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + |] + ( (groupId, memberId, memberRole, GCHostMember, GSMemAccepted, fromInvitedBy userContactId IBUnknown) + :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs) + ) + insertedRowId db + +setViaGroupLinkHash :: DB.Connection -> GroupId -> Int64 -> IO () +setViaGroupLinkHash db groupId connId = + DB.execute + db + [sql| + UPDATE groups + SET via_group_link_uri_hash = (SELECT via_contact_uri_hash FROM connections WHERE connection_id = ?) + WHERE group_id = ? + |] + (connId, groupId) + setGroupInvitationChatItemId :: DB.Connection -> User -> GroupId -> ChatItemId -> IO () setGroupInvitationChatItemId db User {userId} groupId chatItemId = do currentTs <- getCurrentTime @@ -476,7 +551,7 @@ getUserGroupDetails db User {userId, userContactId} _contactId_ search_ = db [sql| SELECT g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, - mu.group_member_id, g.group_id, mu.member_id, mu.member_role, mu.member_category, mu.member_status, + mu.group_member_id, g.group_id, mu.member_id, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences FROM groups g JOIN group_profiles gp USING (group_profile_id) @@ -541,10 +616,10 @@ groupMemberQuery :: Query groupMemberQuery = [sql| SELECT - m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, + m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.show_messages, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) @@ -648,6 +723,7 @@ createNewContactMember db gVar User {userId, userContactId} groupId Contact {con memberRole, memberCategory = GCInviteeMember, memberStatus = GSMemInvited, + memberSettings = defaultMemberSettings, invitedBy = IBUser, localDisplayName, memberProfile = profile, @@ -691,32 +767,63 @@ createNewContactMemberAsync db gVar user@User {userId, userContactId} groupId Co :. (userId, localDisplayName, contactId, localProfileId profile, createdAt, createdAt) ) -getContactViaMember :: DB.Connection -> User -> GroupMember -> ExceptT StoreError IO Contact -getContactViaMember db user@User {userId} GroupMember {groupMemberId} = - ExceptT $ - firstRow (toContact user) (SEContactNotFoundByMemberId groupMemberId) $ - DB.query +createAcceptedMember :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> UserContactRequest -> GroupMemberRole -> ExceptT StoreError IO (GroupMemberId, MemberId) +createAcceptedMember + db + gVar + User {userId, userContactId} + GroupInfo {groupId} + UserContactRequest {localDisplayName, profileId} + memberRole = do + liftIO $ + DB.execute db "DELETE FROM contact_requests WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName) + createWithRandomId gVar $ \memId -> do + createdAt <- liftIO getCurrentTime + insertMember_ (MemberId memId) createdAt + groupMemberId <- liftIO $ insertedRowId db + pure (groupMemberId, MemberId memId) + where + insertMember_ memberId createdAt = + DB.execute db [sql| - SELECT - -- Contact - ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, - cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, - -- Connection - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, - c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, - c.peer_chat_min_version, c.peer_chat_max_version - FROM contacts ct - JOIN contact_profiles cp ON cp.contact_profile_id = ct.contact_profile_id - JOIN connections c ON c.connection_id = ( - SELECT max(cc.connection_id) - FROM connections cc - where cc.contact_id = ct.contact_id - ) - JOIN group_members m ON m.contact_id = ct.contact_id - WHERE ct.user_id = ? AND m.group_member_id = ? AND ct.deleted = 0 + INSERT INTO group_members + ( group_id, member_id, member_role, member_category, member_status, invited_by, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) |] - (userId, groupMemberId) + ( (groupId, memberId, memberRole, GCInviteeMember, GSMemAccepted, fromInvitedBy userContactId IBUser) + :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, createdAt, createdAt) + ) + +createAcceptedMemberConnection :: DB.Connection -> User -> (CommandId, ConnId) -> UserContactRequest -> GroupMemberId -> SubscriptionMode -> IO () +createAcceptedMemberConnection + db + user@User {userId} + (cmdId, agentConnId) + UserContactRequest {cReqChatVRange, userContactLinkId} + groupMemberId + subMode = do + createdAt <- liftIO getCurrentTime + Connection {connId} <- createConnection_ db userId ConnMember (Just groupMemberId) agentConnId (fromJVersionRange cReqChatVRange) Nothing (Just userContactLinkId) Nothing 0 createdAt subMode + setCommandConnId db user cmdId connId + +getContactViaMember :: DB.Connection -> User -> GroupMember -> ExceptT StoreError IO Contact +getContactViaMember db user@User {userId} GroupMember {groupMemberId} = do + contactId <- + ExceptT $ + firstRow fromOnly (SEContactNotFoundByMemberId groupMemberId) $ + DB.query + db + [sql| + SELECT ct.contact_id + FROM group_members m + JOIN contacts ct ON ct.contact_id = m.contact_id + WHERE m.user_id = ? AND m.group_member_id = ? AND ct.deleted = 0 + LIMIT 1 + |] + (userId, groupMemberId) + getContact db user contactId setNewContactMemberConnRequest :: DB.Connection -> User -> GroupMember -> ConnReqInvitation -> IO () setNewContactMemberConnRequest db User {userId} GroupMember {groupMemberId} connRequest = do @@ -756,9 +863,9 @@ updateGroupMemberStatusById db userId groupMemberId memStatus = do -- | add new member with profile createNewGroupMember :: DB.Connection -> User -> GroupInfo -> MemberInfo -> GroupMemberCategory -> GroupMemberStatus -> ExceptT StoreError IO GroupMember -createNewGroupMember db user gInfo memInfo memCategory memStatus = do +createNewGroupMember db user gInfo memInfo@MemberInfo {profile} memCategory memStatus = do currentTs <- liftIO getCurrentTime - (localDisplayName, memProfileId) <- createNewMemberProfile_ db user memInfo currentTs + (localDisplayName, memProfileId) <- createNewMemberProfile_ db user profile currentTs let newMember = NewGroupMember { memInfo, @@ -771,8 +878,8 @@ createNewGroupMember db user gInfo memInfo memCategory memStatus = do } liftIO $ createNewMember_ db user gInfo newMember currentTs -createNewMemberProfile_ :: DB.Connection -> User -> MemberInfo -> UTCTime -> ExceptT StoreError IO (Text, ProfileId) -createNewMemberProfile_ db User {userId} (MemberInfo _ _ _ Profile {displayName, fullName, image, contactLink, preferences}) createdAt = +createNewMemberProfile_ :: DB.Connection -> User -> Profile -> UTCTime -> ExceptT StoreError IO (Text, ProfileId) +createNewMemberProfile_ db User {userId} Profile {displayName, fullName, image, contactLink, preferences} createdAt = ExceptT . withLocalDisplayName db userId displayName $ \ldn -> do DB.execute db @@ -808,7 +915,8 @@ createNewMember_ |] (groupId, memberId, memberRole, memberCategory, memberStatus, invitedById, userId, localDisplayName, memberContactId, memberContactProfileId, createdAt, createdAt) groupMemberId <- insertedRowId db - pure GroupMember {groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus, invitedBy, localDisplayName, memberProfile = toLocalProfile memberContactProfileId memberProfile "", memberContactId, memberContactProfileId, activeConn} + let memberSettings = defaultMemberSettings + pure GroupMember {groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus, memberSettings, invitedBy, localDisplayName, memberProfile = toLocalProfile memberContactProfileId memberProfile "", memberContactId, memberContactProfileId, activeConn} checkGroupMemberHasItems :: DB.Connection -> User -> GroupMember -> IO (Maybe ChatItemId) checkGroupMemberHasItems db User {userId} GroupMember {groupMemberId, groupId} = @@ -947,7 +1055,7 @@ createIntroReMember db user@User {userId} gInfo@GroupInfo {groupId} _host@GroupM (localDisplayName, contactId, memProfileId) <- createContact_ db userId directConnId memberProfile "" (Just groupId) currentTs Nothing pure $ NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memInvitedBy = IBUnknown, localDisplayName, memContactId = Just contactId, memProfileId} Nothing -> do - (localDisplayName, memProfileId) <- createNewMemberProfile_ db user memInfo currentTs + (localDisplayName, memProfileId) <- createNewMemberProfile_ db user memberProfile currentTs pure $ NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memInvitedBy = IBUnknown, localDisplayName, memContactId = Nothing, memProfileId} liftIO $ do member <- createNewMember_ db user gInfo newMember currentTs @@ -1006,14 +1114,14 @@ getViaGroupMember db User {userId, userContactId} Contact {contactId} = g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category, - mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + mu.member_status, mu.show_messages, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, -- via GroupMember - m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, + m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.show_messages, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM group_members m JOIN contacts ct ON ct.contact_id = m.contact_id @@ -1038,37 +1146,21 @@ getViaGroupMember db User {userId, userContactId} Contact {contactId} = in (groupInfo, (member :: GroupMember) {activeConn = toMaybeConnection connRow}) getViaGroupContact :: DB.Connection -> User -> GroupMember -> IO (Maybe Contact) -getViaGroupContact db user@User {userId} GroupMember {groupMemberId} = - maybeFirstRow toContact' $ - DB.query - db - [sql| - SELECT - ct.contact_id, ct.contact_profile_id, ct.local_display_name, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, ct.via_group, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, - p.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, - c.peer_chat_min_version, c.peer_chat_max_version - FROM contacts ct - JOIN contact_profiles p ON ct.contact_profile_id = p.contact_profile_id - JOIN connections c ON c.connection_id = ( - SELECT max(cc.connection_id) - FROM connections cc - where cc.contact_id = ct.contact_id - ) - JOIN groups g ON g.group_id = ct.via_group - JOIN group_members m ON m.group_id = g.group_id AND m.contact_id = ct.contact_id - WHERE ct.user_id = ? AND m.group_member_id = ? AND ct.deleted = 0 - |] - (userId, groupMemberId) - where - toContact' :: ((ContactId, ProfileId, ContactName, Text, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Int64, Bool, ContactStatus) :. (Maybe Bool, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime, Maybe GroupMemberId, Bool)) :. ConnectionRow -> Contact - toContact' (((contactId, profileId, localDisplayName, displayName, fullName, image, contactLink, localAlias, viaGroup, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)) :. connRow) = - let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} - chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_, sendRcpts, favorite} - activeConn = toConnection connRow - mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn - in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent} +getViaGroupContact db user@User {userId} GroupMember {groupMemberId} = do + contactId_ <- + maybeFirstRow fromOnly $ + DB.query + db + [sql| + SELECT ct.contact_id + FROM group_members m + JOIN groups g ON g.group_id = m.group_id + JOIN contacts ct ON ct.contact_id = m.contact_id AND ct.via_group = g.group_id + WHERE m.user_id = ? AND m.group_member_id = ? AND ct.deleted = 0 + LIMIT 1 + |] + (userId, groupMemberId) + maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getContact db user) contactId_ updateGroupProfile :: DB.Connection -> User -> GroupInfo -> GroupProfile -> ExceptT StoreError IO GroupInfo updateGroupProfile db User {userId} g@GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName}} p'@GroupProfile {displayName = newName, fullName, description, image, groupPreferences} @@ -1115,7 +1207,7 @@ getGroupInfo db User {userId, userContactId} groupId = g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category, - mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + mu.member_status, mu.show_messages, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences FROM groups g JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id @@ -1125,6 +1217,35 @@ getGroupInfo db User {userId, userContactId} groupId = |] (groupId, userId, userContactId) +getGroupInfoByUserContactLinkConnReq :: DB.Connection -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe GroupInfo) +getGroupInfoByUserContactLinkConnReq db user@User {userId} (cReqSchema1, cReqSchema2) = do + groupId_ <- maybeFirstRow fromOnly $ + DB.query + db + [sql| + SELECT group_id + FROM user_contact_links + WHERE user_id = ? AND conn_req_contact IN (?,?) + |] + (userId, cReqSchema1, cReqSchema2) + maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getGroupInfo db user) groupId_ + +getGroupInfoByGroupLinkHash :: DB.Connection -> User -> (ConnReqUriHash, ConnReqUriHash) -> IO (Maybe GroupInfo) +getGroupInfoByGroupLinkHash db user@User {userId, userContactId} (groupLinkHash1, groupLinkHash2) = do + groupId_ <- maybeFirstRow fromOnly $ + DB.query + db + [sql| + SELECT g.group_id + FROM groups g + JOIN group_members mu ON mu.group_id = g.group_id + WHERE g.user_id = ? AND g.via_group_link_uri_hash IN (?,?) + AND mu.contact_id = ? AND mu.member_status NOT IN (?,?,?) + LIMIT 1 + |] + (userId, groupLinkHash1, groupLinkHash2, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted) + maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getGroupInfo db user) groupId_ + getGroupIdByName :: DB.Connection -> User -> GroupName -> ExceptT StoreError IO GroupId getGroupIdByName db User {userId} gName = ExceptT . firstRow fromOnly (SEGroupNotFoundByName gName) $ @@ -1176,13 +1297,32 @@ getMatchingContacts db user@User {userId} Contact {contactId, profile = LocalPro AND p.display_name = ? AND p.full_name = ? |] +getMatchingMembers :: DB.Connection -> User -> Contact -> IO [GroupMember] +getMatchingMembers db user@User {userId} Contact {profile = LocalProfile {displayName, fullName, image}} = do + memberIds <- + map fromOnly <$> case image of + Just img -> DB.query db (q <> " AND p.image = ?") (userId, GCUserMember, displayName, fullName, img) + Nothing -> DB.query db (q <> " AND p.image is NULL") (userId, GCUserMember, displayName, fullName) + filter memberCurrent . rights <$> mapM (runExceptT . getGroupMemberById db user) memberIds + where + -- only match with members without associated contact + q = + [sql| + SELECT m.group_member_id + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + WHERE m.user_id = ? AND m.contact_id IS NULL + AND m.member_category != ? + AND p.display_name = ? AND p.full_name = ? + |] + getMatchingMemberContacts :: DB.Connection -> User -> GroupMember -> IO [Contact] getMatchingMemberContacts _ _ GroupMember {memberContactId = Just _} = pure [] getMatchingMemberContacts db user@User {userId} GroupMember {memberProfile = LocalProfile {displayName, fullName, image}} = do contactIds <- map fromOnly <$> case image of - Just img -> DB.query db (q <> " AND p.image = ?") (userId, displayName, fullName, img) - Nothing -> DB.query db (q <> " AND p.image is NULL") (userId, displayName, fullName) + Just img -> DB.query db (q <> " AND p.image = ?") (userId, CSActive, displayName, fullName, img) + Nothing -> DB.query db (q <> " AND p.image is NULL") (userId, CSActive, displayName, fullName) rights <$> mapM (runExceptT . getContact db user) contactIds where q = @@ -1191,55 +1331,63 @@ getMatchingMemberContacts db user@User {userId} GroupMember {memberProfile = Loc FROM contacts ct JOIN contact_profiles p ON ct.contact_profile_id = p.contact_profile_id WHERE ct.user_id = ? - AND ct.deleted = 0 + AND ct.contact_status = ? AND ct.deleted = 0 AND p.display_name = ? AND p.full_name = ? |] -createSentProbe :: DB.Connection -> TVar ChaChaDRG -> UserId -> ContactOrGroupMember -> ExceptT StoreError IO (Probe, Int64) +createSentProbe :: DB.Connection -> TVar ChaChaDRG -> UserId -> ContactOrMember -> ExceptT StoreError IO (Probe, Int64) createSentProbe db gVar userId to = createWithRandomBytes 32 gVar $ \probe -> do currentTs <- getCurrentTime - let (ctId, gmId) = contactOrGroupMemberIds to + let (ctId, gmId) = contactOrMemberIds to DB.execute db "INSERT INTO sent_probes (contact_id, group_member_id, probe, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" (ctId, gmId, probe, userId, currentTs, currentTs) - (Probe probe,) <$> insertedRowId db + (Probe probe,) <$> insertedRowId db -createSentProbeHash :: DB.Connection -> UserId -> Int64 -> ContactOrGroupMember -> IO () +createSentProbeHash :: DB.Connection -> UserId -> Int64 -> ContactOrMember -> IO () createSentProbeHash db userId probeId to = do currentTs <- getCurrentTime - let (ctId, gmId) = contactOrGroupMemberIds to + let (ctId, gmId) = contactOrMemberIds to DB.execute db "INSERT INTO sent_probe_hashes (sent_probe_id, contact_id, group_member_id, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" (probeId, ctId, gmId, userId, currentTs, currentTs) -matchReceivedProbe :: DB.Connection -> User -> ContactOrGroupMember -> Probe -> IO (Maybe ContactOrGroupMember) +matchReceivedProbe :: DB.Connection -> User -> ContactOrMember -> Probe -> IO [ContactOrMember] matchReceivedProbe db user@User {userId} from (Probe probe) = do let probeHash = C.sha256Hash probe cgmIds <- - maybeFirstRow id $ - DB.query - db - [sql| - SELECT r.contact_id, g.group_id, r.group_member_id - FROM received_probes r - LEFT JOIN contacts c ON r.contact_id = c.contact_id AND c.deleted = 0 - LEFT JOIN group_members m ON r.group_member_id = m.group_member_id - LEFT JOIN groups g ON g.group_id = m.group_id - WHERE r.user_id = ? AND r.probe_hash = ? AND r.probe IS NULL - |] - (userId, probeHash) + DB.query + db + [sql| + SELECT r.contact_id, g.group_id, r.group_member_id + FROM received_probes r + LEFT JOIN contacts c ON r.contact_id = c.contact_id AND c.deleted = 0 + LEFT JOIN group_members m ON r.group_member_id = m.group_member_id + LEFT JOIN groups g ON g.group_id = m.group_id + WHERE r.user_id = ? AND r.probe_hash = ? AND r.probe IS NULL + |] + (userId, probeHash) currentTs <- getCurrentTime - let (ctId, gmId) = contactOrGroupMemberIds from + let (ctId, gmId) = contactOrMemberIds from DB.execute db "INSERT INTO received_probes (contact_id, group_member_id, probe, probe_hash, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?)" (ctId, gmId, probe, probeHash, userId, currentTs, currentTs) - pure cgmIds $>>= getContactOrGroupMember_ db user + let cgmIds' = filterFirstContactId cgmIds + catMaybes <$> mapM (getContactOrMember_ db user) cgmIds' + where + filterFirstContactId :: [(Maybe ContactId, Maybe GroupId, Maybe GroupMemberId)] -> [(Maybe ContactId, Maybe GroupId, Maybe GroupMemberId)] + filterFirstContactId cgmIds = do + let (ctIds, memIds) = partition (\(ctId, _, _) -> isJust ctId) cgmIds + ctIds' = case ctIds of + [] -> [] + (x : _) -> [x] + ctIds' <> memIds -matchReceivedProbeHash :: DB.Connection -> User -> ContactOrGroupMember -> ProbeHash -> IO (Maybe (ContactOrGroupMember, Probe)) +matchReceivedProbeHash :: DB.Connection -> User -> ContactOrMember -> ProbeHash -> IO (Maybe (ContactOrMember, Probe)) matchReceivedProbeHash db user@User {userId} from (ProbeHash probeHash) = do probeIds <- maybeFirstRow id $ @@ -1255,18 +1403,18 @@ matchReceivedProbeHash db user@User {userId} from (ProbeHash probeHash) = do |] (userId, probeHash) currentTs <- getCurrentTime - let (ctId, gmId) = contactOrGroupMemberIds from + let (ctId, gmId) = contactOrMemberIds from DB.execute db "INSERT INTO received_probes (contact_id, group_member_id, probe_hash, user_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" (ctId, gmId, probeHash, userId, currentTs, currentTs) - pure probeIds $>>= \(Only probe :. cgmIds) -> (,Probe probe) <$$> getContactOrGroupMember_ db user cgmIds + pure probeIds $>>= \(Only probe :. cgmIds) -> (,Probe probe) <$$> getContactOrMember_ db user cgmIds -matchSentProbe :: DB.Connection -> User -> ContactOrGroupMember -> Probe -> IO (Maybe ContactOrGroupMember) -matchSentProbe db user@User {userId} _from (Probe probe) = - cgmIds $>>= getContactOrGroupMember_ db user +matchSentProbe :: DB.Connection -> User -> ContactOrMember -> Probe -> IO (Maybe ContactOrMember) +matchSentProbe db user@User {userId} _from (Probe probe) = do + cgmIds $>>= getContactOrMember_ db user where - (ctId, gmId) = contactOrGroupMemberIds _from + (ctId, gmId) = contactOrMemberIds _from cgmIds = maybeFirstRow id $ DB.query @@ -1283,60 +1431,72 @@ matchSentProbe db user@User {userId} _from (Probe probe) = |] (userId, probe, ctId, gmId) -getContactOrGroupMember_ :: DB.Connection -> User -> (Maybe ContactId, Maybe GroupId, Maybe GroupMemberId) -> IO (Maybe ContactOrGroupMember) -getContactOrGroupMember_ db user ids = +getContactOrMember_ :: DB.Connection -> User -> (Maybe ContactId, Maybe GroupId, Maybe GroupMemberId) -> IO (Maybe ContactOrMember) +getContactOrMember_ db user ids = fmap eitherToMaybe . runExceptT $ case ids of - (Just ctId, _, _) -> CGMContact <$> getContact db user ctId - (_, Just gId, Just gmId) -> CGMGroupMember <$> getGroupInfo db user gId <*> getGroupMember db user gId gmId + (Just ctId, _, _) -> COMContact <$> getContact db user ctId + (_, Just gId, Just gmId) -> COMGroupMember <$> getGroupMember db user gId gmId _ -> throwError $ SEInternalError "" -mergeContactRecords :: DB.Connection -> UserId -> Contact -> Contact -> IO () -mergeContactRecords db userId ct1 ct2 = do - let (toCt, fromCt) = toFromContacts ct1 ct2 - Contact {contactId = toContactId} = toCt - Contact {contactId = fromContactId, localDisplayName} = fromCt - currentTs <- getCurrentTime - -- TODO next query fixes incorrect unused contacts deletion; consider more thorough fix - when (contactDirect toCt && not (contactUsed toCt)) $ +-- if requested merge direction is overruled (toFromContacts), keepLDN is kept +mergeContactRecords :: DB.Connection -> User -> Contact -> Contact -> ExceptT StoreError IO Contact +mergeContactRecords db user@User {userId} to@Contact {localDisplayName = keepLDN} from = do + let (toCt, fromCt) = toFromContacts to from + Contact {contactId = toContactId, localDisplayName = toLDN} = toCt + Contact {contactId = fromContactId, localDisplayName = fromLDN} = fromCt + liftIO $ do + currentTs <- getCurrentTime + -- next query fixes incorrect unused contacts deletion + when (contactDirect toCt && not (contactUsed toCt)) $ + DB.execute + db + "UPDATE contacts SET contact_used = 1, updated_at = ? WHERE user_id = ? AND contact_id = ?" + (currentTs, userId, toContactId) DB.execute db - "UPDATE contacts SET contact_used = 1, updated_at = ? WHERE user_id = ? AND contact_id = ?" - (currentTs, userId, toContactId) - DB.execute - db - "UPDATE connections SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ?" - (toContactId, currentTs, fromContactId, userId) - DB.execute - db - "UPDATE connections SET via_contact = ?, updated_at = ? WHERE via_contact = ? AND user_id = ?" - (toContactId, currentTs, fromContactId, userId) - DB.execute - db - "UPDATE group_members SET invited_by = ?, updated_at = ? WHERE invited_by = ? AND user_id = ?" - (toContactId, currentTs, fromContactId, userId) - DB.execute - db - "UPDATE chat_items SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ?" - (toContactId, currentTs, fromContactId, userId) - DB.executeNamed - db - [sql| - UPDATE group_members - SET contact_id = :to_contact_id, - local_display_name = (SELECT local_display_name FROM contacts WHERE contact_id = :to_contact_id), - contact_profile_id = (SELECT contact_profile_id FROM contacts WHERE contact_id = :to_contact_id), - updated_at = :updated_at - WHERE contact_id = :from_contact_id - AND user_id = :user_id - |] - [ ":to_contact_id" := toContactId, - ":from_contact_id" := fromContactId, - ":user_id" := userId, - ":updated_at" := currentTs - ] - deleteContactProfile_ db userId fromContactId - DB.execute db "DELETE FROM contacts WHERE contact_id = ? AND user_id = ?" (fromContactId, userId) - deleteUnusedDisplayName_ db userId localDisplayName + "UPDATE connections SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ?" + (toContactId, currentTs, fromContactId, userId) + DB.execute + db + "UPDATE connections SET via_contact = ?, updated_at = ? WHERE via_contact = ? AND user_id = ?" + (toContactId, currentTs, fromContactId, userId) + DB.execute + db + "UPDATE group_members SET invited_by = ?, updated_at = ? WHERE invited_by = ? AND user_id = ?" + (toContactId, currentTs, fromContactId, userId) + DB.execute + db + "UPDATE chat_items SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ?" + (toContactId, currentTs, fromContactId, userId) + DB.executeNamed + db + [sql| + UPDATE group_members + SET contact_id = :to_contact_id, + local_display_name = (SELECT local_display_name FROM contacts WHERE contact_id = :to_contact_id), + contact_profile_id = (SELECT contact_profile_id FROM contacts WHERE contact_id = :to_contact_id), + updated_at = :updated_at + WHERE contact_id = :from_contact_id + AND user_id = :user_id + |] + [ ":to_contact_id" := toContactId, + ":from_contact_id" := fromContactId, + ":user_id" := userId, + ":updated_at" := currentTs + ] + deleteContactProfile_ db userId fromContactId + DB.execute db "DELETE FROM contacts WHERE contact_id = ? AND user_id = ?" (fromContactId, userId) + deleteUnusedDisplayName_ db userId fromLDN + when (keepLDN /= toLDN && keepLDN == fromLDN) $ + DB.execute + db + [sql| + UPDATE display_names + SET local_display_name = ?, updated_at = ? + WHERE user_id = ? AND local_display_name = ? + |] + (keepLDN, currentTs, userId, toLDN) + getContact db user toContactId where toFromContacts :: Contact -> Contact -> (Contact, Contact) toFromContacts c1 c2 @@ -1349,14 +1509,12 @@ mergeContactRecords db userId ct1 ct2 = do d2 = directOrUsed c2 ctCreatedAt Contact {createdAt} = createdAt -updateMemberContact :: DB.Connection -> User -> Contact -> GroupMember -> IO () -updateMemberContact +associateMemberWithContactRecord :: DB.Connection -> User -> Contact -> GroupMember -> IO () +associateMemberWithContactRecord db User {userId} Contact {contactId, localDisplayName, profile = LocalProfile {profileId}} GroupMember {groupId, groupMemberId, localDisplayName = memLDN, memberProfile = LocalProfile {profileId = memProfileId}} = do - -- TODO possibly, we should update profiles and local_display_names of all members linked to the same remote user, - -- once we decide on how we identify it, either based on shared contact_profile_id or on local_display_name currentTs <- getCurrentTime DB.execute db @@ -1369,6 +1527,34 @@ updateMemberContact when (memProfileId /= profileId) $ deleteUnusedProfile_ db userId memProfileId when (memLDN /= localDisplayName) $ deleteUnusedDisplayName_ db userId memLDN +associateContactWithMemberRecord :: DB.Connection -> User -> GroupMember -> Contact -> ExceptT StoreError IO Contact +associateContactWithMemberRecord + db + user@User {userId} + GroupMember {groupId, groupMemberId, localDisplayName = memLDN, memberProfile = LocalProfile {profileId = memProfileId}} + Contact {contactId, localDisplayName, profile = LocalProfile {profileId}} = do + liftIO $ do + currentTs <- getCurrentTime + DB.execute + db + [sql| + UPDATE group_members + SET contact_id = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND group_member_id = ? + |] + (contactId, currentTs, userId, groupId, groupMemberId) + DB.execute + db + [sql| + UPDATE contacts + SET local_display_name = ?, contact_profile_id = ?, updated_at = ? + WHERE user_id = ? AND contact_id = ? + |] + (memLDN, memProfileId, currentTs, userId, contactId) + when (profileId /= memProfileId) $ deleteUnusedProfile_ db userId profileId + when (localDisplayName /= memLDN) $ deleteUnusedDisplayName_ db userId localDisplayName + getContact db user contactId + deleteUnusedDisplayName_ :: DB.Connection -> UserId -> ContactName -> IO () deleteUnusedDisplayName_ db userId localDisplayName = DB.executeNamed @@ -1407,10 +1593,28 @@ deleteUnusedDisplayName_ db userId localDisplayName = |] [":user_id" := userId, ":local_display_name" := localDisplayName] +deleteOldProbes :: DB.Connection -> UTCTime -> IO () +deleteOldProbes db createdAtCutoff = do + DB.execute db "DELETE FROM sent_probes WHERE created_at <= ?" (Only createdAtCutoff) + DB.execute db "DELETE FROM sent_probe_hashes WHERE created_at <= ?" (Only createdAtCutoff) + DB.execute db "DELETE FROM received_probes WHERE created_at <= ?" (Only createdAtCutoff) + updateGroupSettings :: DB.Connection -> User -> Int64 -> ChatSettings -> IO () updateGroupSettings db User {userId} groupId ChatSettings {enableNtfs, sendRcpts, favorite} = DB.execute db "UPDATE groups SET enable_ntfs = ?, send_rcpts = ?, favorite = ? WHERE user_id = ? AND group_id = ?" (enableNtfs, sendRcpts, favorite, userId, groupId) +updateGroupMemberSettings :: DB.Connection -> User -> GroupId -> GroupMemberId -> GroupMemberSettings -> IO () +updateGroupMemberSettings db User {userId} gId gMemberId GroupMemberSettings {showMessages} = do + currentTs <- getCurrentTime + DB.execute + db + [sql| + UPDATE group_members + SET show_messages = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND group_member_id = ? + |] + (showMessages, currentTs, userId, gId, gMemberId) + getXGrpMemIntroContDirect :: DB.Connection -> User -> Contact -> IO (Maybe (Int64, XGrpMemIntroCont)) getXGrpMemIntroContDirect db User {userId} Contact {contactId} = do fmap join . maybeFirstRow toCont $ @@ -1511,15 +1715,15 @@ createMemberContact db [sql| INSERT INTO connections ( - user_id, agent_conn_id, conn_req_inv, conn_level, conn_status, conn_type, contact_id, custom_user_profile_id, + user_id, agent_conn_id, conn_req_inv, conn_level, conn_status, conn_type, contact_conn_initiated, contact_id, custom_user_profile_id, peer_chat_min_version, peer_chat_max_version, created_at, updated_at, to_subscribe - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (userId, acId, cReq, connLevel, ConnNew, ConnContact, contactId, customUserProfileId) + ( (userId, acId, cReq, connLevel, ConnNew, ConnContact, True, contactId, customUserProfileId) :. (minV, maxV, currentTs, currentTs, subMode == SMOnlyCreate) ) connId <- insertedRowId db - let ctConn = Connection {connId, agentConnId = AgentConnId acId, peerChatVRange, connType = ConnContact, entityId = Just contactId, viaContact = Nothing, viaUserContactLink = Nothing, viaGroupLink = False, groupLinkId = Nothing, customUserProfileId, connLevel, connStatus = ConnNew, localAlias = "", createdAt = currentTs, connectionCode = Nothing, authErrCounter = 0} + let ctConn = Connection {connId, agentConnId = AgentConnId acId, peerChatVRange, connType = ConnContact, contactConnInitiated = True, entityId = Just contactId, viaContact = Nothing, viaUserContactLink = Nothing, viaGroupLink = False, groupLinkId = Nothing, customUserProfileId, connLevel, connStatus = ConnNew, localAlias = "", createdAt = currentTs, connectionCode = Nothing, authErrCounter = 0} mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito ctConn pure Contact {contactId, localDisplayName, profile = memberProfile, activeConn = ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Just groupMemberId, contactGrpInvSent = False} @@ -1622,9 +1826,42 @@ createMemberContactConn_ peer_chat_min_version, peer_chat_max_version, created_at, updated_at, to_subscribe ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (userId, acId, connLevel, ConnNew, ConnContact, contactId, customUserProfileId) + ( (userId, acId, connLevel, ConnJoined, ConnContact, contactId, customUserProfileId) :. (minV, maxV, currentTs, currentTs, subMode == SMOnlyCreate) ) connId <- insertedRowId db setCommandConnId db user cmdId connId - pure Connection {connId, agentConnId = AgentConnId acId, peerChatVRange, connType = ConnContact, entityId = Just contactId, viaContact = Nothing, viaUserContactLink = Nothing, viaGroupLink = False, groupLinkId = Nothing, customUserProfileId, connLevel, connStatus = ConnNew, localAlias = "", createdAt = currentTs, connectionCode = Nothing, authErrCounter = 0} + pure Connection {connId, agentConnId = AgentConnId acId, peerChatVRange, connType = ConnContact, contactConnInitiated = False, entityId = Just contactId, viaContact = Nothing, viaUserContactLink = Nothing, viaGroupLink = False, groupLinkId = Nothing, customUserProfileId, connLevel, connStatus = ConnJoined, localAlias = "", createdAt = currentTs, connectionCode = Nothing, authErrCounter = 0} + +updateMemberProfile :: DB.Connection -> User -> GroupMember -> Profile -> ExceptT StoreError IO GroupMember +updateMemberProfile db User {userId} m p' + | displayName == newName = do + liftIO $ updateContactProfile_ db userId profileId p' + pure m {memberProfile = profile} + | otherwise = + ExceptT . withLocalDisplayName db userId newName $ \ldn -> do + currentTs <- getCurrentTime + updateContactProfile_' db userId profileId p' currentTs + DB.execute + db + "UPDATE group_members SET local_display_name = ?, updated_at = ? WHERE user_id = ? AND group_member_id = ?" + (ldn, currentTs, userId, groupMemberId) + DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (localDisplayName, userId) + pure $ Right m {localDisplayName = ldn, memberProfile = profile} + where + GroupMember {groupMemberId, localDisplayName, memberProfile = LocalProfile {profileId, displayName, localAlias}} = m + Profile {displayName = newName} = p' + profile = toLocalProfile profileId p' localAlias + +getXGrpLinkMemReceived :: DB.Connection -> GroupMemberId -> ExceptT StoreError IO Bool +getXGrpLinkMemReceived db mId = + ExceptT . firstRow fromOnly (SEGroupMemberNotFound mId) $ + DB.query db "SELECT xgrplinkmem_received FROM group_members WHERE group_member_id = ?" (Only mId) + +setXGrpLinkMemReceived :: DB.Connection -> GroupMemberId -> Bool -> IO () +setXGrpLinkMemReceived db mId xGrpLinkMemReceived = do + currentTs <- getCurrentTime + DB.execute + db + "UPDATE group_members SET xgrplinkmem_received = ?, updated_at = ? WHERE group_member_id = ?" + (xGrpLinkMemReceived, currentTs, mId) diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 458944b6e5..35a8bad698 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -4,6 +4,7 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TupleSections #-} @@ -14,7 +15,6 @@ module Simplex.Chat.Store.Messages ( getContactConnIds_, - getDirectChatReactions_, -- * Message and chat item functions deleteContactCIs, @@ -50,6 +50,7 @@ module Simplex.Chat.Store.Messages deleteGroupChatItem, updateGroupChatItemModerated, markGroupChatItemDeleted, + markGroupChatItemBlocked, updateDirectChatItemsRead, getDirectUnreadTimedItems, setDirectChatItemDeleteAt, @@ -66,9 +67,11 @@ module Simplex.Chat.Store.Messages setGroupReaction, getChatItemIdByAgentMsgId, getDirectChatItem, + getDirectCIWithReactions, getDirectChatItemBySharedMsgId, getDirectChatItemByAgentMsgId, getGroupChatItem, + getGroupCIWithReactions, getGroupChatItemBySharedMsgId, getGroupMemberCIBySharedMsgId, getGroupChatItemByAgentMsgId, @@ -438,7 +441,7 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe SELECT i.chat_item_id, -- GroupMember m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, - m.member_status, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, + m.member_status, m.show_messages, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) @@ -481,7 +484,7 @@ getDirectChatPreviews_ db user@User {userId} = do ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, -- Connection - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version, -- ChatStats @@ -517,10 +520,11 @@ getDirectChatPreviews_ db user@User {userId} = do SELECT cc_connection_id FROM ( SELECT cc.connection_id AS cc_connection_id, + cc.created_at AS cc_created_at, (CASE WHEN cc.conn_status = ? OR cc.conn_status = ? THEN 1 ELSE 0 END) AS cc_conn_status_ord FROM connections cc WHERE cc.user_id = ct.user_id AND cc.contact_id = ct.contact_id - ORDER BY cc_conn_status_ord DESC, cc_connection_id DESC + ORDER BY cc_conn_status_ord DESC, cc_created_at DESC LIMIT 1 ) ) @@ -547,7 +551,7 @@ getGroupChatPreviews_ db User {userId, userContactId} = do g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image, g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, g.created_at, g.updated_at, g.chat_ts, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category, - mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + mu.member_status, mu.show_messages, mu.invited_by, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, -- ChatStats COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat, @@ -557,17 +561,17 @@ getGroupChatPreviews_ db User {userId, userContactId} = do f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, -- Maybe GroupMember - sender m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, - m.member_status, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, + m.member_status, m.show_messages, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, -- quoted ChatItem ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent, -- quoted GroupMember rm.group_member_id, rm.group_id, rm.member_id, rm.member_role, rm.member_category, - rm.member_status, rm.invited_by, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, + rm.member_status, rm.show_messages, rm.invited_by, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, rp.display_name, rp.full_name, rp.image, rp.contact_link, rp.local_alias, rp.preferences, -- deleted by GroupMember dbm.group_member_id, dbm.group_id, dbm.member_id, dbm.member_role, dbm.member_category, - dbm.member_status, dbm.invited_by, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, + dbm.member_status, dbm.show_messages, dbm.invited_by, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, dbp.display_name, dbp.full_name, dbp.image, dbp.contact_link, dbp.local_alias, dbp.preferences FROM groups g JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id @@ -752,7 +756,7 @@ getGroupChat :: DB.Connection -> User -> Int64 -> ChatPagination -> Maybe String getGroupChat db user groupId pagination search_ = do let search = fromMaybe "" search_ g <- getGroupInfo db user groupId - liftIO . getGroupChatReactions_ db g =<< case pagination of + case pagination of CPLast count -> getGroupChatLast_ db user g count search CPAfter afterId count -> getGroupChatAfter_ db user g afterId count search CPBefore beforeId count -> getGroupChatBefore_ db user g beforeId count search @@ -761,7 +765,7 @@ getGroupChatLast_ :: DB.Connection -> User -> GroupInfo -> Int -> String -> Exce getGroupChatLast_ db user@User {userId} g@GroupInfo {groupId} count search = do let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} chatItemIds <- liftIO getGroupChatItemIdsLast_ - chatItems <- mapM (getGroupChatItem db user groupId) chatItemIds + chatItems <- mapM (getGroupCIWithReactions db user g) chatItemIds pure $ Chat (GroupChat g) (reverse chatItems) stats where getGroupChatItemIdsLast_ :: IO [ChatItemId] @@ -799,7 +803,7 @@ getGroupChatAfter_ db user@User {userId} g@GroupInfo {groupId} afterChatItemId c let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} afterChatItem <- getGroupChatItem db user groupId afterChatItemId chatItemIds <- liftIO $ getGroupChatItemIdsAfter_ (chatItemTs afterChatItem) - chatItems <- mapM (getGroupChatItem db user groupId) chatItemIds + chatItems <- mapM (getGroupCIWithReactions db user g) chatItemIds pure $ Chat (GroupChat g) chatItems stats where getGroupChatItemIdsAfter_ :: UTCTime -> IO [ChatItemId] @@ -822,7 +826,7 @@ getGroupChatBefore_ db user@User {userId} g@GroupInfo {groupId} beforeChatItemId let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} beforeChatItem <- getGroupChatItem db user groupId beforeChatItemId chatItemIds <- liftIO $ getGroupChatItemIdsBefore_ (chatItemTs beforeChatItem) - chatItems <- mapM (getGroupChatItem db user groupId) chatItemIds + chatItems <- mapM (getGroupCIWithReactions db user g) chatItemIds pure $ Chat (GroupChat g) (reverse chatItems) stats where getGroupChatItemIdsBefore_ :: UTCTime -> IO [ChatItemId] @@ -961,9 +965,9 @@ type MaybeCIFIleRow = (Maybe Int64, Maybe String, Maybe Integer, Maybe FilePath, type ChatItemModeRow = (Maybe Int, Maybe UTCTime, Maybe Bool) -type ChatItemRow = (Int64, ChatItemTs, AMsgDirection, Text, Text, ACIStatus, Maybe SharedMsgId) :. (Bool, Maybe UTCTime, Maybe Bool, UTCTime, UTCTime) :. ChatItemModeRow :. MaybeCIFIleRow +type ChatItemRow = (Int64, ChatItemTs, AMsgDirection, Text, Text, ACIStatus, Maybe SharedMsgId) :. (Int, Maybe UTCTime, Maybe Bool, UTCTime, UTCTime) :. ChatItemModeRow :. MaybeCIFIleRow -type MaybeChatItemRow = (Maybe Int64, Maybe ChatItemTs, Maybe AMsgDirection, Maybe Text, Maybe Text, Maybe ACIStatus, Maybe SharedMsgId) :. (Maybe Bool, Maybe UTCTime, Maybe Bool, Maybe UTCTime, Maybe UTCTime) :. ChatItemModeRow :. MaybeCIFIleRow +type MaybeChatItemRow = (Maybe Int64, Maybe ChatItemTs, Maybe AMsgDirection, Maybe Text, Maybe Text, Maybe ACIStatus, Maybe SharedMsgId) :. (Maybe Int, Maybe UTCTime, Maybe Bool, Maybe UTCTime, Maybe UTCTime) :. ChatItemModeRow :. MaybeCIFIleRow type QuoteRow = (Maybe ChatItemId, Maybe SharedMsgId, Maybe UTCTime, Maybe MsgContent, Maybe Bool) @@ -1006,7 +1010,9 @@ toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentT badItem = Left $ SEBadChatItem itemId ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTDirect d ciMeta content status = - let itemDeleted' = if itemDeleted then Just (CIDeleted @'CTDirect deletedTs) else Nothing + let itemDeleted' = case itemDeleted of + DBCINotDeleted -> Nothing + _ -> Just (CIDeleted @'CTDirect deletedTs) itemEdited' = fromMaybe False itemEdited in mkCIMeta itemId content itemText status sharedMsgId itemDeleted' itemEdited' ciTimed itemLive currentTs itemTs createdAt updatedAt ciTimed :: Maybe CITimed @@ -1062,10 +1068,10 @@ toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir, badItem = Left $ SEBadChatItem itemId ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTGroup d ciMeta content status = - let itemDeleted' = - if itemDeleted - then Just (maybe (CIDeleted @'CTGroup deletedTs) (CIModerated deletedTs) deletedByGroupMember_) - else Nothing + let itemDeleted' = case itemDeleted of + DBCINotDeleted -> Nothing + DBCIBlocked -> Just (CIBlocked @'CTGroup deletedTs) + _ -> Just (maybe (CIDeleted @'CTGroup deletedTs) (CIModerated deletedTs) deletedByGroupMember_) itemEdited' = fromMaybe False itemEdited in mkCIMeta itemId content itemText status sharedMsgId itemDeleted' itemEdited' ciTimed itemLive currentTs itemTs createdAt updatedAt ciTimed :: Maybe CITimed @@ -1144,23 +1150,24 @@ getChatItemIdByAgentMsgId db connId msgId = |] (connId, msgId) -updateDirectChatItemStatus :: forall d. MsgDirectionI d => DB.Connection -> User -> Int64 -> ChatItemId -> CIStatus d -> ExceptT StoreError IO (ChatItem 'CTDirect d) -updateDirectChatItemStatus db user@User {userId} contactId itemId itemStatus = do - ci <- liftEither . correctDir =<< getDirectChatItem db user contactId itemId +updateDirectChatItemStatus :: forall d. MsgDirectionI d => DB.Connection -> User -> Contact -> ChatItemId -> CIStatus d -> ExceptT StoreError IO (ChatItem 'CTDirect d) +updateDirectChatItemStatus db user@User {userId} ct@Contact {contactId} itemId itemStatus = do + ci <- liftEither . correctDir =<< getDirectCIWithReactions db user ct itemId currentTs <- liftIO getCurrentTime liftIO $ DB.execute db "UPDATE chat_items SET item_status = ?, updated_at = ? WHERE user_id = ? AND contact_id = ? AND chat_item_id = ?" (itemStatus, currentTs, userId, contactId, itemId) pure ci {meta = (meta ci) {itemStatus}} - where - correctDir :: CChatItem c -> Either StoreError (ChatItem c d) - correctDir (CChatItem _ ci) = first SEInternalError $ checkDirection ci -updateDirectChatItem :: forall d. MsgDirectionI d => DB.Connection -> User -> Int64 -> ChatItemId -> CIContent d -> Bool -> Maybe MessageId -> ExceptT StoreError IO (ChatItem 'CTDirect d) -updateDirectChatItem db user contactId itemId newContent live msgId_ = do - ci <- liftEither . correctDir =<< getDirectChatItem db user contactId itemId +updateDirectChatItem :: MsgDirectionI d => DB.Connection -> User -> Contact -> ChatItemId -> CIContent d -> Bool -> Maybe MessageId -> ExceptT StoreError IO (ChatItem 'CTDirect d) +updateDirectChatItem db user ct@Contact {contactId} itemId newContent live msgId_ = do + ci <- liftEither . correctDir =<< getDirectCIWithReactions db user ct itemId liftIO $ updateDirectChatItem' db user contactId ci newContent live msgId_ - where - correctDir :: CChatItem c -> Either StoreError (ChatItem c d) - correctDir (CChatItem _ ci) = first SEInternalError $ checkDirection ci + +getDirectCIWithReactions :: DB.Connection -> User -> Contact -> ChatItemId -> ExceptT StoreError IO (CChatItem 'CTDirect) +getDirectCIWithReactions db user ct@Contact {contactId} itemId = + liftIO . directCIWithReactions db ct =<< getDirectChatItem db user contactId itemId + +correctDir :: MsgDirectionI d => CChatItem c -> Either StoreError (ChatItem c d) +correctDir (CChatItem _ ci) = first SEInternalError $ checkDirection ci updateDirectChatItem' :: forall d. MsgDirectionI d => DB.Connection -> User -> Int64 -> ChatItem 'CTDirect d -> CIContent d -> Bool -> Maybe MessageId -> IO (ChatItem 'CTDirect d) updateDirectChatItem' db User {userId} contactId ci newContent live msgId_ = do @@ -1224,8 +1231,8 @@ createChatItemVersion db itemId itemVersionTs msgContent = |] (itemId, toMCText msgContent, itemVersionTs) -deleteDirectChatItem :: DB.Connection -> User -> Contact -> CChatItem 'CTDirect -> IO () -deleteDirectChatItem db User {userId} Contact {contactId} (CChatItem _ ci) = do +deleteDirectChatItem :: DB.Connection -> User -> Contact -> ChatItem 'CTDirect d -> IO () +deleteDirectChatItem db User {userId} Contact {contactId} ci = do let itemId = chatItemId' ci deleteChatItemMessages_ db itemId deleteChatItemVersions_ db itemId @@ -1256,8 +1263,8 @@ deleteChatItemVersions_ :: DB.Connection -> ChatItemId -> IO () deleteChatItemVersions_ db itemId = DB.execute db "DELETE FROM chat_item_versions WHERE chat_item_id = ?" (Only itemId) -markDirectChatItemDeleted :: DB.Connection -> User -> Contact -> CChatItem 'CTDirect -> MessageId -> UTCTime -> IO () -markDirectChatItemDeleted db User {userId} Contact {contactId} (CChatItem _ ci) msgId deletedTs = do +markDirectChatItemDeleted :: DB.Connection -> User -> Contact -> ChatItem 'CTDirect d -> MessageId -> UTCTime -> IO (ChatItem 'CTDirect d) +markDirectChatItemDeleted db User {userId} Contact {contactId} ci@ChatItem {meta} msgId deletedTs = do currentTs <- liftIO getCurrentTime let itemId = chatItemId' ci insertChatItemMessage_ db itemId msgId currentTs @@ -1265,10 +1272,11 @@ markDirectChatItemDeleted db User {userId} Contact {contactId} (CChatItem _ ci) db [sql| UPDATE chat_items - SET item_deleted = 1, item_deleted_ts = ?, updated_at = ? + SET item_deleted = ?, item_deleted_ts = ?, updated_at = ? WHERE user_id = ? AND contact_id = ? AND chat_item_id = ? |] - (deletedTs, currentTs, userId, contactId, itemId) + (DBCIDeleted, deletedTs, currentTs, userId, contactId, itemId) + pure ci {meta = meta {itemDeleted = Just $ CIDeleted $ Just deletedTs}} getDirectChatItemBySharedMsgId :: DB.Connection -> User -> ContactId -> SharedMsgId -> ExceptT StoreError IO (CChatItem 'CTDirect) getDirectChatItemBySharedMsgId db user@User {userId} contactId sharedMsgId = do @@ -1297,7 +1305,7 @@ getDirectChatItemIdBySharedMsgId_ db userId contactId sharedMsgId = getDirectChatItem :: DB.Connection -> User -> Int64 -> ChatItemId -> ExceptT StoreError IO (CChatItem 'CTDirect) getDirectChatItem db User {userId} contactId itemId = ExceptT $ do currentTs <- getCurrentTime - join <$> firstRow (toDirectChatItem currentTs) (SEChatItemNotFound itemId) getItem + firstRow' (toDirectChatItem currentTs) (SEChatItemNotFound itemId) getItem where getItem = DB.query @@ -1345,17 +1353,26 @@ getDirectChatItemIdByText' db User {userId} contactId msg = |] (userId, contactId, msg <> "%") -updateGroupChatItemStatus :: forall d. MsgDirectionI d => DB.Connection -> User -> GroupId -> ChatItemId -> CIStatus d -> ExceptT StoreError IO (ChatItem 'CTGroup d) -updateGroupChatItemStatus db user@User {userId} groupId itemId itemStatus = do - ci <- liftEither . correctDir =<< getGroupChatItem db user groupId itemId +updateGroupChatItemStatus :: MsgDirectionI d => DB.Connection -> User -> GroupInfo -> ChatItemId -> CIStatus d -> ExceptT StoreError IO (ChatItem 'CTGroup d) +updateGroupChatItemStatus db user@User {userId} g@GroupInfo {groupId} itemId itemStatus = do + ci <- liftEither . correctDir =<< getGroupCIWithReactions db user g itemId currentTs <- liftIO getCurrentTime liftIO $ DB.execute db "UPDATE chat_items SET item_status = ?, updated_at = ? WHERE user_id = ? AND group_id = ? AND chat_item_id = ?" (itemStatus, currentTs, userId, groupId, itemId) pure ci {meta = (meta ci) {itemStatus}} - where - correctDir :: CChatItem c -> Either StoreError (ChatItem c d) - correctDir (CChatItem _ ci) = first SEInternalError $ checkDirection ci -updateGroupChatItem :: forall d. MsgDirectionI d => DB.Connection -> User -> Int64 -> ChatItem 'CTGroup d -> CIContent d -> Bool -> Maybe MessageId -> IO (ChatItem 'CTGroup d) +getGroupCIWithReactions :: DB.Connection -> User -> GroupInfo -> ChatItemId -> ExceptT StoreError IO (CChatItem 'CTGroup) +getGroupCIWithReactions db user g@GroupInfo {groupId} itemId = do + liftIO . groupCIWithReactions db g =<< getGroupChatItem db user groupId itemId + +groupCIWithReactions :: DB.Connection -> GroupInfo -> CChatItem 'CTGroup -> IO (CChatItem 'CTGroup) +groupCIWithReactions db g cci@(CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId}}) = case itemSharedMsgId of + Just sharedMsgId -> do + let GroupMember {memberId} = chatItemMember g ci + reactions <- getGroupCIReactions db g memberId sharedMsgId + pure $ CChatItem md ci {reactions} + Nothing -> pure cci + +updateGroupChatItem :: MsgDirectionI d => DB.Connection -> User -> Int64 -> ChatItem 'CTGroup d -> CIContent d -> Bool -> Maybe MessageId -> IO (ChatItem 'CTGroup d) updateGroupChatItem db user groupId ci newContent live msgId_ = do currentTs <- liftIO getCurrentTime let ci' = updatedChatItem ci newContent live currentTs @@ -1364,7 +1381,7 @@ updateGroupChatItem db user groupId ci newContent live msgId_ = do -- this function assumes that the group item with correct chat direction already exists, -- it should be checked before calling it -updateGroupChatItem_ :: forall d. MsgDirectionI d => DB.Connection -> User -> Int64 -> ChatItem 'CTGroup d -> Maybe MessageId -> IO () +updateGroupChatItem_ :: MsgDirectionI d => DB.Connection -> User -> Int64 -> ChatItem 'CTGroup d -> Maybe MessageId -> IO () updateGroupChatItem_ db User {userId} groupId ChatItem {content, meta} msgId_ = do let CIMeta {itemId, itemText, itemStatus, itemDeleted, itemEdited, itemTimed, itemLive, updatedAt} = meta itemDeleted' = isJust itemDeleted @@ -1379,8 +1396,8 @@ updateGroupChatItem_ db User {userId} groupId ChatItem {content, meta} msgId_ = ((content, itemText, itemStatus, itemDeleted', itemDeletedTs', itemEdited, itemLive, updatedAt) :. ciTimedRow itemTimed :. (userId, groupId, itemId)) forM_ msgId_ $ \msgId -> insertChatItemMessage_ db itemId msgId updatedAt -deleteGroupChatItem :: DB.Connection -> User -> GroupInfo -> CChatItem 'CTGroup -> IO () -deleteGroupChatItem db User {userId} g@GroupInfo {groupId} (CChatItem _ ci) = do +deleteGroupChatItem :: DB.Connection -> User -> GroupInfo -> ChatItem 'CTGroup d -> IO () +deleteGroupChatItem db User {userId} g@GroupInfo {groupId} ci = do let itemId = chatItemId' ci deleteChatItemMessages_ db itemId deleteChatItemVersions_ db itemId @@ -1393,10 +1410,10 @@ deleteGroupChatItem db User {userId} g@GroupInfo {groupId} (CChatItem _ ci) = do |] (userId, groupId, itemId) -updateGroupChatItemModerated :: DB.Connection -> User -> GroupInfo -> CChatItem 'CTGroup -> GroupMember -> UTCTime -> IO AChatItem -updateGroupChatItemModerated db User {userId} gInfo@GroupInfo {groupId} (CChatItem msgDir ci) m@GroupMember {groupMemberId} deletedTs = do +updateGroupChatItemModerated :: forall d. MsgDirectionI d => DB.Connection -> User -> GroupInfo -> ChatItem 'CTGroup d -> GroupMember -> UTCTime -> IO (ChatItem 'CTGroup d) +updateGroupChatItemModerated db User {userId} GroupInfo {groupId} ci m@GroupMember {groupMemberId} deletedTs = do currentTs <- getCurrentTime - let toContent = msgDirToModeratedContent_ msgDir + let toContent = msgDirToModeratedContent_ $ msgDirection @d toText = ciModeratedText itemId = chatItemId' ci deleteChatItemMessages_ db itemId @@ -1410,24 +1427,47 @@ updateGroupChatItemModerated db User {userId} gInfo@GroupInfo {groupId} (CChatIt WHERE user_id = ? AND group_id = ? AND chat_item_id = ? |] (deletedTs, groupMemberId, toContent, toText, currentTs, userId, groupId, itemId) - pure $ AChatItem SCTGroup msgDir (GroupChat gInfo) (ci {content = toContent, meta = (meta ci) {itemText = toText, itemDeleted = Just (CIModerated (Just currentTs) m), editable = False}, formattedText = Nothing}) + pure $ ci {content = toContent, meta = (meta ci) {itemText = toText, itemDeleted = Just (CIModerated (Just currentTs) m), editable = False}, formattedText = Nothing} -markGroupChatItemDeleted :: DB.Connection -> User -> GroupInfo -> CChatItem 'CTGroup -> MessageId -> Maybe GroupMember -> UTCTime -> IO () -markGroupChatItemDeleted db User {userId} GroupInfo {groupId} (CChatItem _ ci) msgId byGroupMember_ deletedTs = do +pattern DBCINotDeleted :: Int +pattern DBCINotDeleted = 0 + +pattern DBCIDeleted :: Int +pattern DBCIDeleted = 1 + +pattern DBCIBlocked :: Int +pattern DBCIBlocked = 2 + +markGroupChatItemDeleted :: DB.Connection -> User -> GroupInfo -> ChatItem 'CTGroup d -> MessageId -> Maybe GroupMember -> UTCTime -> IO (ChatItem 'CTGroup d) +markGroupChatItemDeleted db User {userId} GroupInfo {groupId} ci@ChatItem {meta} msgId byGroupMember_ deletedTs = do currentTs <- liftIO getCurrentTime let itemId = chatItemId' ci - deletedByGroupMemberId = case byGroupMember_ of - Just GroupMember {groupMemberId} -> Just groupMemberId - _ -> Nothing + (deletedByGroupMemberId, itemDeleted) = case byGroupMember_ of + Just m@GroupMember {groupMemberId} -> (Just groupMemberId, Just $ CIModerated (Just deletedTs) m) + _ -> (Nothing, Just $ CIDeleted @'CTGroup (Just deletedTs)) insertChatItemMessage_ db itemId msgId currentTs DB.execute db [sql| UPDATE chat_items - SET item_deleted = 1, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, updated_at = ? + SET item_deleted = ?, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, updated_at = ? WHERE user_id = ? AND group_id = ? AND chat_item_id = ? |] - (deletedTs, deletedByGroupMemberId, currentTs, userId, groupId, itemId) + (DBCIDeleted, deletedTs, deletedByGroupMemberId, currentTs, userId, groupId, itemId) + pure ci {meta = meta {itemDeleted}} + +markGroupChatItemBlocked :: DB.Connection -> User -> GroupInfo -> ChatItem 'CTGroup 'MDRcv -> IO (ChatItem 'CTGroup 'MDRcv) +markGroupChatItemBlocked db User {userId} GroupInfo {groupId} ci@ChatItem {meta} = do + deletedTs <- getCurrentTime + DB.execute + db + [sql| + UPDATE chat_items + SET item_deleted = ?, item_deleted_ts = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND chat_item_id = ? + |] + (DBCIBlocked, deletedTs, deletedTs, userId, groupId, chatItemId' ci) + pure ci {meta = meta {itemDeleted = Just $ CIBlocked $ Just deletedTs}} getGroupChatItemBySharedMsgId :: DB.Connection -> User -> GroupId -> GroupMemberId -> SharedMsgId -> ExceptT StoreError IO (CChatItem 'CTGroup) getGroupChatItemBySharedMsgId db user@User {userId} groupId groupMemberId sharedMsgId = do @@ -1472,7 +1512,7 @@ getGroupChatItemByAgentMsgId db user groupId connId msgId = do getGroupChatItem :: DB.Connection -> User -> Int64 -> ChatItemId -> ExceptT StoreError IO (CChatItem 'CTGroup) getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do currentTs <- getCurrentTime - join <$> firstRow (toGroupChatItem currentTs userContactId) (SEChatItemNotFound itemId) getItem + firstRow' (toGroupChatItem currentTs userContactId) (SEChatItemNotFound itemId) getItem where getItem = DB.query @@ -1485,17 +1525,17 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, -- GroupMember m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, - m.member_status, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, + m.member_status, m.show_messages, m.invited_by, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, -- quoted ChatItem ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent, -- quoted GroupMember rm.group_member_id, rm.group_id, rm.member_id, rm.member_role, rm.member_category, - rm.member_status, rm.invited_by, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, + rm.member_status, rm.show_messages, rm.invited_by, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, rp.display_name, rp.full_name, rp.image, rp.contact_link, rp.local_alias, rp.preferences, -- deleted by GroupMember dbm.group_member_id, dbm.group_id, dbm.member_id, dbm.member_role, dbm.member_category, - dbm.member_status, dbm.invited_by, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, + dbm.member_status, dbm.show_messages, dbm.invited_by, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, dbp.display_name, dbp.full_name, dbp.image, dbp.contact_link, dbp.local_alias, dbp.preferences FROM chat_items i LEFT JOIN files f ON f.chat_item_id = i.chat_item_id @@ -1642,18 +1682,15 @@ getChatItemVersions db itemId = do getDirectChatReactions_ :: DB.Connection -> Contact -> Chat 'CTDirect -> IO (Chat 'CTDirect) getDirectChatReactions_ db ct c@Chat {chatItems} = do - chatItems' <- forM chatItems $ \(CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId}}) -> do - reactions <- maybe (pure []) (getDirectCIReactions db ct) itemSharedMsgId - pure $ CChatItem md ci {reactions} + chatItems' <- mapM (directCIWithReactions db ct) chatItems pure c {chatItems = chatItems'} -getGroupChatReactions_ :: DB.Connection -> GroupInfo -> Chat 'CTGroup -> IO (Chat 'CTGroup) -getGroupChatReactions_ db g c@Chat {chatItems} = do - chatItems' <- forM chatItems $ \(CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId}}) -> do - let GroupMember {memberId} = chatItemMember g ci - reactions <- maybe (pure []) (getGroupCIReactions db g memberId) itemSharedMsgId +directCIWithReactions :: DB.Connection -> Contact -> CChatItem 'CTDirect -> IO (CChatItem 'CTDirect) +directCIWithReactions db ct cci@(CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId}}) = case itemSharedMsgId of + Just sharedMsgId -> do + reactions <- getDirectCIReactions db ct sharedMsgId pure $ CChatItem md ci {reactions} - pure c {chatItems = chatItems'} + Nothing -> pure cci getDirectCIReactions :: DB.Connection -> Contact -> SharedMsgId -> IO [CIReactionCount] getDirectCIReactions db Contact {contactId} itemSharedMsgId = diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index 2f5bfec9ea..9335ae90e6 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -82,6 +82,11 @@ import Simplex.Chat.Migrations.M20230903_connections_to_subscribe import Simplex.Chat.Migrations.M20230913_member_contacts import Simplex.Chat.Migrations.M20230914_member_probes import Simplex.Chat.Migrations.M20230926_contact_status +import Simplex.Chat.Migrations.M20231002_conn_initiated +import Simplex.Chat.Migrations.M20231009_via_group_link_uri_hash +import Simplex.Chat.Migrations.M20231010_member_settings +import Simplex.Chat.Migrations.M20231019_indexes +import Simplex.Chat.Migrations.M20231030_xgrplinkmem_received import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -163,7 +168,12 @@ schemaMigrations = ("20230903_connections_to_subscribe", m20230903_connections_to_subscribe, Just down_m20230903_connections_to_subscribe), ("20230913_member_contacts", m20230913_member_contacts, Just down_m20230913_member_contacts), ("20230914_member_probes", m20230914_member_probes, Just down_m20230914_member_probes), - ("20230926_contact_status", m20230926_contact_status, Just down_m20230926_contact_status) + ("20230926_contact_status", m20230926_contact_status, Just down_m20230926_contact_status), + ("20231002_conn_initiated", m20231002_conn_initiated, Just down_m20231002_conn_initiated), + ("20231009_via_group_link_uri_hash", m20231009_via_group_link_uri_hash, Just down_m20231009_via_group_link_uri_hash), + ("20231010_member_settings", m20231010_member_settings, Just down_m20231010_member_settings), + ("20231019_indexes", m20231019_indexes, Just down_m20231019_indexes), + ("20231030_xgrplinkmem_received", m20231030_xgrplinkmem_received, Just down_m20231030_xgrplinkmem_received) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index e521cb43cf..80499dee82 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -42,6 +42,7 @@ module Simplex.Chat.Store.Profiles deleteUserAddress, getUserAddress, getUserContactLinkById, + getUserContactLinkByConnReq, updateUserAddressAutoAccept, getProtocolServers, overwriteProtocolServers, @@ -320,7 +321,7 @@ getUserAddressConnections db User {userId} = do db [sql| SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version FROM connections c JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id @@ -335,7 +336,7 @@ getUserContactLinks db User {userId} = db [sql| SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, c.peer_chat_min_version, c.peer_chat_max_version, uc.user_contact_link_id, uc.conn_req_contact, uc.group_id FROM connections c @@ -440,6 +441,18 @@ getUserContactLinkById db userId userContactLinkId = |] (userId, userContactLinkId) +getUserContactLinkByConnReq :: DB.Connection -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe UserContactLink) +getUserContactLinkByConnReq db User {userId} (cReqSchema1, cReqSchema2) = + maybeFirstRow toUserContactLink $ + DB.query + db + [sql| + SELECT conn_req_contact, auto_accept, auto_accept_incognito, auto_reply_msg_content + FROM user_contact_links + WHERE user_id = ? AND conn_req_contact IN (?,?) + |] + (userId, cReqSchema1, cReqSchema2) + updateUserAddressAutoAccept :: DB.Connection -> User -> Maybe AutoAccept -> ExceptT StoreError IO UserContactLink updateUserAddressAutoAccept db user@User {userId} autoAccept = do link <- getUserAddress db user diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 4dc4f6e82d..2ad447aa8e 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -138,16 +138,16 @@ toFileInfo (fileId, fileStatus, filePath) = CIFileInfo {fileId, fileStatus, file type EntityIdsRow = (Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64) -type ConnectionRow = (Int64, ConnId, Int, Maybe Int64, Maybe Int64, Bool, Maybe GroupLinkId, Maybe Int64, ConnStatus, ConnType, LocalAlias) :. EntityIdsRow :. (UTCTime, Maybe Text, Maybe UTCTime, Int, Version, Version) +type ConnectionRow = (Int64, ConnId, Int, Maybe Int64, Maybe Int64, Bool, Maybe GroupLinkId, Maybe Int64, ConnStatus, ConnType, Bool, LocalAlias) :. EntityIdsRow :. (UTCTime, Maybe Text, Maybe UTCTime, Int, Version, Version) -type MaybeConnectionRow = (Maybe Int64, Maybe ConnId, Maybe Int, Maybe Int64, Maybe Int64, Maybe Bool, Maybe GroupLinkId, Maybe Int64, Maybe ConnStatus, Maybe ConnType, Maybe LocalAlias) :. EntityIdsRow :. (Maybe UTCTime, Maybe Text, Maybe UTCTime, Maybe Int, Maybe Version, Maybe Version) +type MaybeConnectionRow = (Maybe Int64, Maybe ConnId, Maybe Int, Maybe Int64, Maybe Int64, Maybe Bool, Maybe GroupLinkId, Maybe Int64, Maybe ConnStatus, Maybe ConnType, Maybe Bool, Maybe LocalAlias) :. EntityIdsRow :. (Maybe UTCTime, Maybe Text, Maybe UTCTime, Maybe Int, Maybe Version, Maybe Version) toConnection :: ConnectionRow -> Connection -toConnection ((connId, acId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, authErrCounter, minVer, maxVer)) = +toConnection ((connId, acId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, contactConnInitiated, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, authErrCounter, minVer, maxVer)) = let entityId = entityId_ connType connectionCode = SecurityCode <$> code_ <*> verifiedAt_ peerChatVRange = JVersionRange $ fromMaybe (versionToRange maxVer) $ safeVersionRange minVer maxVer - in Connection {connId, agentConnId = AgentConnId acId, peerChatVRange, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, localAlias, entityId, connectionCode, authErrCounter, createdAt} + in Connection {connId, agentConnId = AgentConnId acId, peerChatVRange, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, contactConnInitiated, localAlias, entityId, connectionCode, authErrCounter, createdAt} where entityId_ :: ConnType -> Maybe Int64 entityId_ ConnContact = contactId @@ -157,8 +157,8 @@ toConnection ((connId, acId, connLevel, viaContact, viaUserContactLink, viaGroup entityId_ ConnUserContact = userContactLinkId toMaybeConnection :: MaybeConnectionRow -> Maybe Connection -toMaybeConnection ((Just connId, Just agentConnId, Just connLevel, viaContact, viaUserContactLink, Just viaGroupLink, groupLinkId, customUserProfileId, Just connStatus, Just connType, Just localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (Just createdAt, code_, verifiedAt_, Just authErrCounter, Just minVer, Just maxVer)) = - Just $ toConnection ((connId, agentConnId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, authErrCounter, minVer, maxVer)) +toMaybeConnection ((Just connId, Just agentConnId, Just connLevel, viaContact, viaUserContactLink, Just viaGroupLink, groupLinkId, customUserProfileId, Just connStatus, Just connType, Just contactConnInitiated, Just localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (Just createdAt, code_, verifiedAt_, Just authErrCounter, Just minVer, Just maxVer)) = + Just $ toConnection ((connId, agentConnId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, customUserProfileId, connStatus, connType, contactConnInitiated, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, authErrCounter, minVer, maxVer)) toMaybeConnection _ = Nothing createConnection_ :: DB.Connection -> UserId -> ConnType -> Maybe Int64 -> ConnId -> VersionRange -> Maybe ContactId -> Maybe Int64 -> Maybe ProfileId -> Int -> UTCTime -> SubscriptionMode -> IO Connection @@ -180,10 +180,21 @@ createConnection_ db userId connType entityId acId peerChatVRange@(VersionRange :. (minV, maxV, subMode == SMOnlyCreate) ) connId <- insertedRowId db - pure Connection {connId, agentConnId = AgentConnId acId, peerChatVRange = JVersionRange peerChatVRange, connType, entityId, viaContact, viaUserContactLink, viaGroupLink, groupLinkId = Nothing, customUserProfileId, connLevel, connStatus = ConnNew, localAlias = "", createdAt = currentTs, connectionCode = Nothing, authErrCounter = 0} + pure Connection {connId, agentConnId = AgentConnId acId, peerChatVRange = JVersionRange peerChatVRange, connType, contactConnInitiated = False, entityId, viaContact, viaUserContactLink, viaGroupLink, groupLinkId = Nothing, customUserProfileId, connLevel, connStatus = ConnNew, localAlias = "", createdAt = currentTs, connectionCode = Nothing, authErrCounter = 0} where ent ct = if connType == ct then entityId else Nothing +createIncognitoProfile_ :: DB.Connection -> UserId -> UTCTime -> Profile -> IO Int64 +createIncognitoProfile_ db userId createdAt Profile {displayName, fullName, image} = do + DB.execute + db + [sql| + INSERT INTO contact_profiles (display_name, full_name, image, user_id, incognito, created_at, updated_at) + VALUES (?,?,?,?,?,?,?) + |] + (displayName, fullName, image, userId, Just True, createdAt, createdAt) + insertedRowId db + setPeerChatVRange :: DB.Connection -> Int64 -> VersionRange -> IO () setPeerChatVRange db connId (VersionRange minVer maxVer) = DB.execute @@ -241,20 +252,20 @@ deleteUnusedIncognitoProfileById_ db User {userId} profileId = |] [":user_id" := userId, ":profile_id" := profileId] -type ContactRow = (ContactId, ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Bool, ContactStatus) :. (Maybe Bool, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime, Maybe GroupMemberId, Bool) +type ContactRow = (ContactId, ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Bool, ContactStatus) :. (Maybe MsgFilter, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime, Maybe GroupMemberId, Bool) toContact :: User -> ContactRow :. ConnectionRow -> Contact toContact user (((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)) :. connRow) = let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} activeConn = toConnection connRow - chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_, sendRcpts, favorite} + chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts, favorite} mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent} toContactOrError :: User -> ContactRow :. MaybeConnectionRow -> Either StoreError Contact toContactOrError user (((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)) :. connRow) = let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} - chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_, sendRcpts, favorite} + chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts, favorite} in case toMaybeConnection connRow of Just activeConn -> let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn diff --git a/src/Simplex/Chat/Terminal.hs b/src/Simplex/Chat/Terminal.hs index 0ef3d3bace..68aaa51318 100644 --- a/src/Simplex/Chat/Terminal.hs +++ b/src/Simplex/Chat/Terminal.hs @@ -15,7 +15,6 @@ import Simplex.Chat.Core import Simplex.Chat.Help (chatWelcome) import Simplex.Chat.Options import Simplex.Chat.Terminal.Input -import Simplex.Chat.Terminal.Notification import Simplex.Chat.Terminal.Output import Simplex.FileTransfer.Client.Presets (defaultXFTPServers) import Simplex.Messaging.Client (defaultNetworkConfig) @@ -40,10 +39,9 @@ terminalChatConfig = } simplexChatTerminal :: WithTerminal t => ChatConfig -> ChatOpts -> t -> IO () -simplexChatTerminal cfg opts t = do - sendToast <- if muteNotifications opts then pure Nothing else Just <$> initializeNotifications - handle checkDBKeyError . simplexChatCore cfg opts sendToast $ \u cc -> do - ct <- newChatTerminal t +simplexChatTerminal cfg opts t = + handle checkDBKeyError . simplexChatCore cfg opts $ \u cc -> do + ct <- newChatTerminal t opts when (firstTime cc) . printToTerminal ct $ chatWelcome u runChatTerminal ct cc diff --git a/src/Simplex/Chat/Terminal/Input.hs b/src/Simplex/Chat/Terminal/Input.hs index 8841f15ffd..0fd95cf680 100644 --- a/src/Simplex/Chat/Terminal/Input.hs +++ b/src/Simplex/Chat/Terminal/Input.hs @@ -57,14 +57,26 @@ runInputLoop ct@ChatTerminal {termState, liveMessageState} cc = forever $ do cmd = parseChatCommand bs unless (isMessage cmd) $ echo s r <- runReaderT (execChatCommand bs) cc - case r of - CRChatCmdError _ _ -> when (isMessage cmd) $ echo s - CRChatError _ _ -> when (isMessage cmd) $ echo s - _ -> pure () + processResp s cmd r printRespToTerminal ct cc False r startLiveMessage cmd r where echo s = printToTerminal ct [plain s] + processResp s cmd = \case + CRActiveUser _ -> setActive ct "" + CRChatItems u chatName_ _ -> whenCurrUser cc u $ mapM_ (setActive ct . chatActiveTo) chatName_ + CRNewChatItem u (AChatItem _ SMDSnd cInfo _) -> whenCurrUser cc u $ setActiveChat ct cInfo + CRChatItemUpdated u (AChatItem _ SMDSnd cInfo _) -> whenCurrUser cc u $ setActiveChat ct cInfo + CRChatItemDeleted u (AChatItem _ _ cInfo _) _ _ _ -> whenCurrUser cc u $ setActiveChat ct cInfo + CRContactDeleted u c -> whenCurrUser cc u $ unsetActiveContact ct c + CRGroupDeletedUser u g -> whenCurrUser cc u $ unsetActiveGroup ct g + CRSentGroupInvitation u g _ _ -> whenCurrUser cc u $ setActiveGroup ct g + CRChatCmdError _ _ -> when (isMessage cmd) $ echo s + CRChatError _ _ -> when (isMessage cmd) $ echo s + CRCmdOk _ -> case cmd of + Right APIDeleteUser {} -> setActive ct "" + _ -> pure () + _ -> pure () isMessage = \case Right SendMessage {} -> True Right SendLiveMessage {} -> True @@ -134,7 +146,7 @@ runTerminalInput ct cc = withChatTerm ct $ do receiveFromTTY cc ct receiveFromTTY :: forall m. MonadTerminal m => ChatController -> ChatTerminal -> m () -receiveFromTTY cc@ChatController {inputQ, activeTo, currentUser, chatStore} ct@ChatTerminal {termSize, termState, liveMessageState} = +receiveFromTTY cc@ChatController {inputQ, currentUser, chatStore} ct@ChatTerminal {termSize, termState, liveMessageState, activeTo} = forever $ getKey >>= liftIO . processKey >> withTermLock ct (updateInput ct) where processKey :: (Key, Modifiers) -> IO () @@ -153,11 +165,11 @@ receiveFromTTY cc@ChatController {inputQ, activeTo, currentUser, chatStore} ct@C when (inputString ts /= "" || isLive) $ atomically (submitInput live ts) >>= mapM_ (uncurry endLiveMessage) update key = do - ac <- readTVarIO activeTo + chatPrefix <- readTVarIO activeTo live <- isJust <$> readTVarIO liveMessageState ts <- readTVarIO termState user_ <- readTVarIO currentUser - ts' <- updateTermState user_ chatStore ac live (width termSize) key ts + ts' <- updateTermState user_ chatStore chatPrefix live (width termSize) key ts atomically $ writeTVar termState $! ts' endLiveMessage :: String -> LiveMessage -> IO () @@ -203,8 +215,8 @@ data AutoComplete | ACCommand Text | ACNone -updateTermState :: Maybe User -> SQLiteStore -> ActiveTo -> Bool -> Int -> (Key, Modifiers) -> TerminalState -> IO TerminalState -updateTermState user_ st ac live tw (key, ms) ts@TerminalState {inputString = s, inputPosition = p, autoComplete = acp} = case key of +updateTermState :: Maybe User -> SQLiteStore -> String -> Bool -> Int -> (Key, Modifiers) -> TerminalState -> IO TerminalState +updateTermState user_ st chatPrefix live tw (key, ms) ts@TerminalState {inputString = s, inputPosition = p, autoComplete = acp} = case key of CharKey c | ms == mempty || ms == shiftKey -> pure $ insertChars $ charsWithContact [c] | ms == altKey && c == 'b' -> pure $ setPosition prevWordPos @@ -326,17 +338,13 @@ updateTermState user_ st ac live tw (key, ms) ts@TerminalState {inputString = s, charsWithContact cs | live = cs | null s && cs /= "@" && cs /= "#" && cs /= "/" && cs /= ">" && cs /= "\\" && cs /= "!" && cs /= "+" && cs /= "-" = - contactPrefix <> cs + chatPrefix <> cs | (s == ">" || s == "\\" || s == "!") && cs == " " = - cs <> contactPrefix + cs <> chatPrefix | otherwise = cs insertChars = ts' . if p >= length s then append else insert append cs = let s' = s <> cs in (s', length s') insert cs = let (b, a) = splitAt p s in (b <> cs <> a, p + length cs) - contactPrefix = case ac of - ActiveNone -> "" - ActiveC c -> "@" <> T.unpack c <> " " - ActiveG g -> "#" <> T.unpack g <> " " backDeleteChar | p == 0 || null s = ts | p >= length s = ts' (init s, length s - 1) diff --git a/src/Simplex/Chat/Terminal/Notification.hs b/src/Simplex/Chat/Terminal/Notification.hs index 98031fe525..87bed5be1a 100644 --- a/src/Simplex/Chat/Terminal/Notification.hs +++ b/src/Simplex/Chat/Terminal/Notification.hs @@ -13,13 +13,14 @@ import qualified Data.Map as M import Data.Maybe (fromMaybe, isJust) import Data.Text (Text) import qualified Data.Text as T -import Simplex.Chat.Types import Simplex.Messaging.Util (catchAll_) import System.Directory (createDirectoryIfMissing, doesFileExist, findExecutable, getAppUserDataDirectory) import System.FilePath (combine) import System.Info (os) import System.Process (readCreateProcess, shell) +data Notification = Notification {title :: Text, text :: Text} + initializeNotifications :: IO (Notification -> IO ()) initializeNotifications = hideException <$> case os of diff --git a/src/Simplex/Chat/Terminal/Output.hs b/src/Simplex/Chat/Terminal/Output.hs index db6f16f3ca..a623536cd9 100644 --- a/src/Simplex/Chat/Terminal/Output.hs +++ b/src/Simplex/Chat/Terminal/Output.hs @@ -3,6 +3,7 @@ {-# LANGUAGE GADTs #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RankNTypes #-} {-# LANGUAGE ScopedTypeVariables #-} @@ -14,13 +15,24 @@ import Control.Monad.Catch (MonadMask) import Control.Monad.Except import Control.Monad.Reader import Data.List (intercalate) +import Data.Text (Text) +import qualified Data.Text as T import Data.Time.Clock (getCurrentTime) import Data.Time.LocalTime (getCurrentTimeZone) import Simplex.Chat (processChatCommand) import Simplex.Chat.Controller -import Simplex.Chat.Messages hiding (NewChatItem (..)) +import Simplex.Chat.Markdown +import Simplex.Chat.Messages +import Simplex.Chat.Messages.CIContent (CIContent(..), SMsgDirection (..)) +import Simplex.Chat.Options +import Simplex.Chat.Protocol (MsgContent (..), msgContentText) import Simplex.Chat.Styled +import Simplex.Chat.Terminal.Notification (Notification (..), initializeNotifications) +import Simplex.Chat.Types import Simplex.Chat.View +import Simplex.Messaging.Agent.Protocol +import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Util (safeDecodeUtf8) import System.Console.ANSI.Types import System.IO (IOMode (..), hPutStrLn, withFile) import System.Mem.Weak (Weak) @@ -34,7 +46,9 @@ data ChatTerminal = ChatTerminal termSize :: Size, liveMessageState :: TVar (Maybe LiveMessage), nextMessageRow :: TVar Int, - termLock :: TMVar () + termLock :: TMVar (), + sendNotification :: Maybe (Notification -> IO ()), + activeTo :: TVar String } data TerminalState = TerminalState @@ -79,16 +93,28 @@ instance WithTerminal VirtualTerminal where withChatTerm :: (MonadIO m, MonadMask m) => ChatTerminal -> (forall t. WithTerminal t => TerminalT t m a) -> m a withChatTerm ChatTerminal {termDevice = TerminalDevice t} action = withTerm t $ runTerminalT action -newChatTerminal :: WithTerminal t => t -> IO ChatTerminal -newChatTerminal t = do +newChatTerminal :: WithTerminal t => t -> ChatOpts -> IO ChatTerminal +newChatTerminal t opts = do termSize <- withTerm t . runTerminalT $ getWindowSize let lastRow = height termSize - 1 termState <- newTVarIO mkTermState liveMessageState <- newTVarIO Nothing termLock <- newTMVarIO () nextMessageRow <- newTVarIO lastRow + sendNotification <- if muteNotifications opts then pure Nothing else Just <$> initializeNotifications + activeTo <- newTVarIO "" -- threadDelay 500000 -- this delay is the same as timeout in getTerminalSize - return ChatTerminal {termDevice = TerminalDevice t, termState, termSize, liveMessageState, nextMessageRow, termLock} + pure + ChatTerminal + { termDevice = TerminalDevice t, + termState, + termSize, + liveMessageState, + nextMessageRow, + termLock, + sendNotification, + activeTo + } mkTermState :: TerminalState mkTermState = @@ -114,24 +140,119 @@ runTerminalOutput ct cc@ChatController {outputQ, showLiveItems, logFilePath} = d forever $ do (_, r) <- atomically $ readTBQueue outputQ case r of - CRNewChatItem _ ci -> markChatItemRead ci - CRChatItemUpdated _ ci -> markChatItemRead ci + CRNewChatItem u ci -> markChatItemRead u ci + CRChatItemUpdated u ci -> markChatItemRead u ci _ -> pure () let printResp = case logFilePath of Just path -> if logResponseToFile r then logResponse path else printToTerminal ct _ -> printToTerminal ct liveItems <- readTVarIO showLiveItems responseString cc liveItems r >>= printResp + responseNotification ct cc r where - markChatItemRead (AChatItem _ _ chat item@ChatItem {chatDir, meta = CIMeta {itemStatus}}) = - case (muted chat chatDir, itemStatus) of - (False, CISRcvNew) -> do - let itemId = chatItemId' item + markChatItemRead u (AChatItem _ _ chat ci@ChatItem {chatDir, meta = CIMeta {itemStatus}}) = + case (chatDirNtf u chat chatDir (isMention ci), itemStatus) of + (True, CISRcvNew) -> do + let itemId = chatItemId' ci chatRef = chatInfoToRef chat void $ runReaderT (runExceptT $ processChatCommand (APIChatRead chatRef (Just (itemId, itemId)))) cc _ -> pure () logResponse path s = withFile path AppendMode $ \h -> mapM_ (hPutStrLn h . unStyle) s +responseNotification :: ChatTerminal -> ChatController -> ChatResponse -> IO () +responseNotification t@ChatTerminal {sendNotification} cc = \case + CRNewChatItem u (AChatItem _ SMDRcv cInfo ci@ChatItem {chatDir, content = CIRcvMsgContent mc, formattedText}) -> + when (chatDirNtf u cInfo chatDir $ isMention ci) $ do + whenCurrUser cc u $ setActiveChat t cInfo + case (cInfo, chatDir) of + (DirectChat ct, _) -> sendNtf (viewContactName ct <> "> ", text) + (GroupChat g, CIGroupRcv m) -> sendNtf (fromGroup_ g m, text) + _ -> pure () + where + text = msgText mc formattedText + CRChatItemUpdated u (AChatItem _ SMDRcv cInfo ci@ChatItem {chatDir, content = CIRcvMsgContent _}) -> + whenCurrUser cc u $ when (chatDirNtf u cInfo chatDir $ isMention ci) $ setActiveChat t cInfo + CRContactConnected u ct _ -> when (contactNtf u ct False) $ do + whenCurrUser cc u $ setActiveContact t ct + sendNtf (viewContactName ct <> "> ", "connected") + CRContactAnotherClient u ct -> do + whenCurrUser cc u $ unsetActiveContact t ct + when (contactNtf u ct False) $ sendNtf (viewContactName ct <> "> ", "connected to another client") + CRContactsDisconnected srv _ -> serverNtf srv "disconnected" + CRContactsSubscribed srv _ -> serverNtf srv "connected" + CRReceivedGroupInvitation u g ct _ _ -> + when (contactNtf u ct False) $ + sendNtf ("#" <> viewGroupName g <> " " <> viewContactName ct <> "> ", "invited you to join the group") + CRUserJoinedGroup u g _ -> when (groupNtf u g False) $ do + whenCurrUser cc u $ setActiveGroup t g + sendNtf ("#" <> viewGroupName g, "you are connected to group") + CRJoinedGroupMember u g m -> + when (groupNtf u g False) $ sendNtf ("#" <> viewGroupName g, "member " <> viewMemberName m <> " is connected") + CRConnectedToGroupMember u g m _ -> + when (groupNtf u g False) $ sendNtf ("#" <> viewGroupName g, "member " <> viewMemberName m <> " is connected") + CRReceivedContactRequest u UserContactRequest {localDisplayName = n} -> + when (userNtf u) $ sendNtf (viewName n <> ">", "wants to connect to you") + _ -> pure () + where + sendNtf = maybe (\_ -> pure ()) (. uncurry Notification) sendNotification + serverNtf (SMPServer host _ _) str = sendNtf ("server " <> str, safeDecodeUtf8 $ strEncode host) + +msgText :: MsgContent -> Maybe MarkdownList -> Text +msgText (MCFile _) _ = "wants to send a file" +msgText mc md_ = maybe (msgContentText mc) (mconcat . map hideSecret) md_ + where + hideSecret :: FormattedText -> Text + hideSecret FormattedText {format = Just Secret} = "..." + hideSecret FormattedText {text} = text + +chatActiveTo :: ChatName -> String +chatActiveTo (ChatName cType name) = case cType of + CTDirect -> T.unpack $ "@" <> viewName name <> " " + CTGroup -> T.unpack $ "#" <> viewName name <> " " + _ -> "" + +chatInfoActiveTo :: ChatInfo c -> String +chatInfoActiveTo = \case + DirectChat c -> contactActiveTo c + GroupChat g -> groupActiveTo g + _ -> "" + +contactActiveTo :: Contact -> String +contactActiveTo c = T.unpack $ "@" <> viewContactName c <> " " + +groupActiveTo :: GroupInfo -> String +groupActiveTo g = T.unpack $ "#" <> viewGroupName g <> " " + +setActiveChat :: ChatTerminal -> ChatInfo c -> IO () +setActiveChat t = setActive t . chatInfoActiveTo + +setActiveContact :: ChatTerminal -> Contact -> IO () +setActiveContact t = setActive t . contactActiveTo + +setActiveGroup :: ChatTerminal -> GroupInfo -> IO () +setActiveGroup t = setActive t . groupActiveTo + +setActive :: ChatTerminal -> String -> IO () +setActive ChatTerminal {activeTo} to = atomically $ writeTVar activeTo to + +unsetActiveContact :: ChatTerminal -> Contact -> IO () +unsetActiveContact t = unsetActive t . contactActiveTo + +unsetActiveGroup :: ChatTerminal -> GroupInfo -> IO () +unsetActiveGroup t = unsetActive t . groupActiveTo + +unsetActive :: ChatTerminal -> String -> IO () +unsetActive ChatTerminal {activeTo} to' = atomically $ modifyTVar activeTo unset + where + unset to = if to == to' then "" else to + +whenCurrUser :: ChatController -> User -> IO () -> IO () +whenCurrUser cc u a = do + u_ <- readTVarIO $ currentUser cc + when (sameUser u u_) a + where + sameUser User {userId = uId} = maybe False $ \User {userId} -> userId == uId + printRespToTerminal :: ChatTerminal -> ChatController -> Bool -> ChatResponse -> IO () printRespToTerminal ct cc liveItems r = responseString cc liveItems r >>= printToTerminal ct diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 43265671b6..23ed608639 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -37,7 +37,11 @@ import Data.Maybe (isJust) import Data.Text (Text) import qualified Data.Text as T import Data.Time.Clock (UTCTime) -import Database.SQLite.Simple.FromField (FromField (..)) +import Data.Typeable (Typeable) +import Database.SQLite.Simple (ResultError (..), SQLData (..)) +import Database.SQLite.Simple.FromField (returnError, FromField(..)) +import Database.SQLite.Simple.Internal (Field (..)) +import Database.SQLite.Simple.Ok import Database.SQLite.Simple.ToField (ToField (..)) import GHC.Generics (Generic) import Simplex.Chat.Types.Preferences @@ -46,7 +50,7 @@ import Simplex.FileTransfer.Description (FileDigest) import Simplex.Messaging.Agent.Protocol (ACommandTag (..), ACorrId, AParty (..), APartyCmdTag (..), ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId, SAEntity (..), UserId) import Simplex.Messaging.Crypto.File (CryptoFileArgs (..)) import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (dropPrefix, fromTextField_, sumTypeJSON, taggedObjectJSON) +import Simplex.Messaging.Parsers (dropPrefix, fromTextField_, sumTypeJSON, taggedObjectJSON, enumJSON) import Simplex.Messaging.Protocol (ProtoServerWithAuth, ProtocolTypeI) import Simplex.Messaging.Util ((<$?>)) import Simplex.Messaging.Version @@ -188,6 +192,9 @@ instance ToJSON Contact where contactConn :: Contact -> Connection contactConn Contact {activeConn} = activeConn +contactAgentConnId :: Contact -> AgentConnId +contactAgentConnId Contact {activeConn = Connection {agentConnId}} = agentConnId + contactConnId :: Contact -> ConnId contactConnId = aConnId . contactConn @@ -206,9 +213,15 @@ directOrUsed ct@Contact {contactUsed} = anyDirectOrUsed :: Contact -> Bool anyDirectOrUsed Contact {contactUsed, activeConn = Connection {connLevel}} = connLevel == 0 || contactUsed +contactReady :: Contact -> Bool +contactReady Contact {activeConn} = connReady activeConn + contactActive :: Contact -> Bool contactActive Contact {contactStatus} = contactStatus == CSActive +contactDeleted :: Contact -> Bool +contactDeleted Contact {contactStatus} = contactStatus == CSDeleted + contactSecurityCode :: Contact -> Maybe SecurityCode contactSecurityCode Contact {activeConn} = connectionCode activeConn @@ -244,18 +257,18 @@ data ContactRef = ContactRef instance ToJSON ContactRef where toEncoding = J.genericToEncoding J.defaultOptions -data ContactOrGroupMember = CGMContact Contact | CGMGroupMember GroupInfo GroupMember +data ContactOrMember = COMContact Contact | COMGroupMember GroupMember deriving (Show) -contactOrGroupMemberIds :: ContactOrGroupMember -> (Maybe ContactId, Maybe GroupMemberId) -contactOrGroupMemberIds = \case - CGMContact Contact {contactId} -> (Just contactId, Nothing) - CGMGroupMember _ GroupMember {groupMemberId} -> (Nothing, Just groupMemberId) +contactOrMemberIds :: ContactOrMember -> (Maybe ContactId, Maybe GroupMemberId) +contactOrMemberIds = \case + COMContact Contact {contactId} -> (Just contactId, Nothing) + COMGroupMember GroupMember {groupMemberId} -> (Nothing, Just groupMemberId) -contactOrGroupMemberIncognito :: ContactOrGroupMember -> IncognitoEnabled -contactOrGroupMemberIncognito = \case - CGMContact ct -> contactConnIncognito ct - CGMGroupMember _ m -> memberIncognito m +contactOrMemberIncognito :: ContactOrMember -> IncognitoEnabled +contactOrMemberIncognito = \case + COMContact ct -> contactConnIncognito ct + COMGroupMember m -> memberIncognito m data UserContact = UserContact { userContactLinkId :: Int64, @@ -382,7 +395,7 @@ contactAndGroupIds = \case -- TODO when more settings are added we should create another type to allow partial setting updates (with all Maybe properties) data ChatSettings = ChatSettings - { enableNtfs :: Bool, + { enableNtfs :: MsgFilter, sendRcpts :: Maybe Bool, favorite :: Bool } @@ -393,13 +406,48 @@ instance ToJSON ChatSettings where toEncoding = J.genericToEncoding J.defaultOpt defaultChatSettings :: ChatSettings defaultChatSettings = ChatSettings - { enableNtfs = True, + { enableNtfs = MFAll, sendRcpts = Nothing, favorite = False } -pattern DisableNtfs :: ChatSettings -pattern DisableNtfs <- ChatSettings {enableNtfs = False} +chatHasNtfs :: ChatSettings -> Bool +chatHasNtfs ChatSettings {enableNtfs} = enableNtfs /= MFNone + +data MsgFilter = MFNone | MFAll | MFMentions + deriving (Eq, Show, Generic) + +instance FromJSON MsgFilter where + parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "MF" + +instance ToJSON MsgFilter where + toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "MF" + toJSON = J.genericToJSON . enumJSON $ dropPrefix "MF" + +instance FromField MsgFilter where fromField = fromIntField_ msgFilterIntP + +instance ToField MsgFilter where toField = toField . msgFilterInt + +msgFilterInt :: MsgFilter -> Int +msgFilterInt = \case + MFNone -> 0 + MFAll -> 1 + MFMentions -> 2 + +msgFilterIntP :: Int64 -> Maybe MsgFilter +msgFilterIntP = \case + 0 -> Just MFNone + 1 -> Just MFAll + 2 -> Just MFMentions + _ -> Just MFAll + +fromIntField_ :: Typeable a => (Int64 -> Maybe a) -> Field -> Ok a +fromIntField_ fromInt = \case + f@(Field (SQLInteger i) _) -> + case fromInt i of + Just x -> Ok x + _ -> returnError ConversionFailed f ("invalid integer: " <> show i) + f -> returnError ConversionFailed f "expecting SQLInteger column type" featureAllowed :: SChatFeature f -> (PrefEnabled -> Bool) -> Contact -> Bool featureAllowed feature forWhom Contact {mergedPreferences} = @@ -467,6 +515,10 @@ instance ToJSON Profile where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} +profileFromName :: ContactName -> Profile +profileFromName displayName = + Profile {displayName, fullName = "", image = Nothing, contactLink = Nothing, preferences = Nothing} + -- check if profiles match ignoring preferences profilesMatch :: LocalProfile -> LocalProfile -> Bool profilesMatch @@ -573,6 +625,18 @@ instance ToJSON GroupInvitation where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} +data GroupLinkInvitation = GroupLinkInvitation + { fromMember :: MemberIdRole, + fromMemberName :: ContactName, + invitedMember :: MemberIdRole, + groupProfile :: GroupProfile + } + deriving (Eq, Show, Generic, FromJSON) + +instance ToJSON GroupLinkInvitation where + toJSON = J.genericToJSON J.defaultOptions + toEncoding = J.genericToEncoding J.defaultOptions + data MemberIdRole = MemberIdRole { memberId :: MemberId, memberRole :: GroupMemberRole @@ -627,6 +691,7 @@ data GroupMember = GroupMember memberRole :: GroupMemberRole, memberCategory :: GroupMemberCategory, memberStatus :: GroupMemberStatus, + memberSettings :: GroupMemberSettings, invitedBy :: InvitedBy, localDisplayName :: ContactName, -- for membership, memberProfile can be either user's profile or incognito profile, based on memberIncognito test. @@ -761,6 +826,16 @@ instance ToJSON GroupMemberRole where toJSON = strToJSON toEncoding = strToJEncoding +data GroupMemberSettings = GroupMemberSettings + { showMessages :: Bool + } + deriving (Eq, Show, Generic, FromJSON) + +instance ToJSON GroupMemberSettings where toEncoding = J.genericToEncoding J.defaultOptions + +defaultMemberSettings :: GroupMemberSettings +defaultMemberSettings = GroupMemberSettings {showMessages = True} + newtype Probe = Probe {unProbe :: ByteString} deriving (Eq, Show) @@ -1087,13 +1162,16 @@ liveRcvFileTransferPath ft = fp <$> liveRcvFileTransferInfo ft fp RcvFileInfo {filePath} = filePath newtype AgentConnId = AgentConnId ConnId - deriving (Eq, Show) + deriving (Eq, Ord, Show) instance StrEncoding AgentConnId where strEncode (AgentConnId connId) = strEncode connId strDecode s = AgentConnId <$> strDecode s strP = AgentConnId <$> strP +instance FromJSON AgentConnId where + parseJSON = strParseJSON "AgentConnId" + instance ToJSON AgentConnId where toJSON = strToJSON toEncoding = strToJEncoding @@ -1235,6 +1313,7 @@ data Connection = Connection customUserProfileId :: Maybe Int64, connType :: ConnType, connStatus :: ConnStatus, + contactConnInitiated :: Bool, localAlias :: Text, entityId :: Maybe Int64, -- contact, group member, file ID or user contact ID connectionCode :: Maybe SecurityCode, @@ -1243,6 +1322,9 @@ data Connection = Connection } deriving (Eq, Show, Generic) +connReady :: Connection -> Bool +connReady Connection {connStatus} = connStatus == ConnReady || connStatus == ConnSndReady + authErrDisableCount :: Int authErrDisableCount = 10 @@ -1417,11 +1499,38 @@ serializeIntroStatus = \case GMIntroToConnected -> "to-con" GMIntroConnected -> "con" -data Notification = Notification {title :: Text, text :: Text} - textParseJSON :: TextEncoding a => String -> J.Value -> JT.Parser a textParseJSON name = J.withText name $ maybe (fail $ "bad " <> name) pure . textDecode +data NetworkStatus + = NSUnknown + | NSConnected + | NSDisconnected + | NSError {connectionError :: String} + deriving (Eq, Ord, Show, Generic) + +netStatusStr :: NetworkStatus -> String +netStatusStr = \case + NSUnknown -> "unknown" + NSConnected -> "connected" + NSDisconnected -> "disconnected" + NSError e -> "error: " <> e + +instance FromJSON NetworkStatus where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "NS" + +instance ToJSON NetworkStatus where + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "NS" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "NS" + +data ConnNetworkStatus = ConnNetworkStatus + { agentConnId :: AgentConnId, + networkStatus :: NetworkStatus + } + deriving (Show, Generic, FromJSON) + +instance ToJSON ConnNetworkStatus where toEncoding = J.genericToEncoding J.defaultOptions + type CommandId = Int64 aCorrId :: CommandId -> ACorrId diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 732dfb786c..50759ff39d 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -19,7 +19,7 @@ import Data.Char (isSpace, toUpper) import Data.Function (on) import Data.Int (Int64) import Data.List (groupBy, intercalate, intersperse, partition, sortOn) -import Data.List.NonEmpty (NonEmpty) +import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) import qualified Data.Map.Strict as M @@ -102,15 +102,15 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRConnectionVerified u verified code -> ttyUser u [plain $ if verified then "connection verified" else "connection not verified, current code is " <> code] CRContactCode u ct code -> ttyUser u $ viewContactCode ct code testView CRGroupMemberCode u g m code -> ttyUser u $ viewGroupMemberCode g m code testView - CRNewChatItem u (AChatItem _ _ chat item) -> ttyUser u $ unmuted chat item $ viewChatItem chat item False ts tz <> viewItemReactions item - CRChatItems u chatItems -> ttyUser u $ concatMap (\(AChatItem _ _ chat item) -> viewChatItem chat item True ts tz <> viewItemReactions item) chatItems + CRNewChatItem u (AChatItem _ _ chat item) -> ttyUser u $ unmuted u chat item $ viewChatItem chat item False ts tz <> viewItemReactions item + CRChatItems u _ chatItems -> ttyUser u $ concatMap (\(AChatItem _ _ chat item) -> viewChatItem chat item True ts tz <> viewItemReactions item) chatItems CRChatItemInfo u ci ciInfo -> ttyUser u $ viewChatItemInfo ci ciInfo tz CRChatItemId u itemId -> ttyUser u [plain $ maybe "no item" show itemId] CRChatItemStatusUpdated u ci -> ttyUser u $ viewChatItemStatusUpdated ci ts tz testView showReceipts - CRChatItemUpdated u (AChatItem _ _ chat item) -> ttyUser u $ unmuted chat item $ viewItemUpdate chat item liveItems ts tz + CRChatItemUpdated u (AChatItem _ _ chat item) -> ttyUser u $ unmuted u chat item $ viewItemUpdate chat item liveItems ts tz CRChatItemNotChanged u ci -> ttyUser u $ viewItemNotChanged ci - CRChatItemDeleted u (AChatItem _ _ chat deletedItem) toItem byUser timed -> ttyUser u $ unmuted chat deletedItem $ viewItemDelete chat deletedItem toItem byUser timed ts tz testView - CRChatItemReaction u added (ACIReaction _ _ chat reaction) -> ttyUser u $ unmutedReaction chat reaction $ viewItemReaction showReactions chat reaction added ts tz + CRChatItemDeleted u (AChatItem _ _ chat deletedItem) toItem byUser timed -> ttyUser u $ unmuted u chat deletedItem $ viewItemDelete chat deletedItem toItem byUser timed ts tz testView + CRChatItemReaction u added (ACIReaction _ _ chat reaction) -> ttyUser u $ unmutedReaction u chat reaction $ viewItemReaction showReactions chat reaction added ts tz CRChatItemDeletedNotFound u Contact {localDisplayName = c} _ -> ttyUser u [ttyFrom $ c <> "> [deleted - original message not found]"] CRBroadcastSent u mc s f t -> ttyUser u $ viewSentBroadcast mc s f ts tz t CRMsgIntegrityError u mErr -> ttyUser u $ viewMsgIntegrityError mErr @@ -132,7 +132,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRUserContactLink u UserContactLink {connReqContact, autoAccept} -> ttyUser u $ connReqContact_ "Your chat address:" connReqContact <> autoAcceptStatus_ autoAccept CRUserContactLinkUpdated u UserContactLink {autoAccept} -> ttyUser u $ autoAcceptStatus_ autoAccept CRContactRequestRejected u UserContactRequest {localDisplayName = c} -> ttyUser u [ttyContact c <> ": contact request rejected"] - CRGroupCreated u g -> ttyUser u $ viewGroupCreated g + CRGroupCreated u g -> ttyUser u $ viewGroupCreated g testView CRGroupMembers u g -> ttyUser u $ viewGroupMembers g CRGroupsList u gs -> ttyUser u $ viewGroupsList gs CRSentGroupInvitation u g c _ -> @@ -148,6 +148,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRVersionInfo info _ _ -> viewVersionInfo logLevel info CRInvitation u cReq _ -> ttyUser u $ viewConnReqInvitation cReq CRConnectionIncognitoUpdated u c -> ttyUser u $ viewConnectionIncognitoUpdated c + CRConnectionPlan u connectionPlan -> ttyUser u $ viewConnectionPlan connectionPlan CRSentConfirmation u -> ttyUser u ["confirmation sent!"] CRSentInvitation u customUserProfile -> ttyUser u $ viewSentInvitation customUserProfile testView CRContactDeleted u c -> ttyUser u [ttyContact' c <> ": contact is deleted"] @@ -159,13 +160,14 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRUserContactLinkCreated u cReq -> ttyUser u $ connReqContact_ "Your new chat address is created!" cReq CRUserContactLinkDeleted u -> ttyUser u viewUserContactLinkDeleted CRUserAcceptedGroupSent u _g _ -> ttyUser u [] -- [ttyGroup' g <> ": joining the group..."] + CRGroupLinkConnecting u g _ -> ttyUser u [ttyGroup' g <> ": joining the group..."] CRUserDeletedMember u g m -> ttyUser u [ttyGroup' g <> ": you removed " <> ttyMember m <> " from the group"] CRLeftMemberUser u g -> ttyUser u $ [ttyGroup' g <> ": you left the group"] <> groupPreserved g CRGroupDeletedUser u g -> ttyUser u [ttyGroup' g <> ": you deleted the group"] CRRcvFileDescrReady _ _ -> [] CRRcvFileDescrNotReady _ _ -> [] CRRcvFileProgressXFTP {} -> [] - CRRcvFileAccepted u ci -> ttyUser u $ savingFile' testView ci + CRRcvFileAccepted u ci -> ttyUser u $ savingFile' ci CRRcvFileAcceptedSndCancelled u ft -> ttyUser u $ viewRcvFileSndCancelled ft CRSndFileCancelled u _ ftm fts -> ttyUser u $ viewSndFileCancelled ftm fts CRRcvFileCancelled u _ ft -> ttyUser u $ receivingFile_ "cancelled" ft @@ -175,12 +177,13 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRContactAliasUpdated u c -> ttyUser u $ viewContactAliasUpdated c CRConnectionAliasUpdated u c -> ttyUser u $ viewConnectionAliasUpdated c CRContactUpdated {user = u, fromContact = c, toContact = c'} -> ttyUser u $ viewContactUpdated c c' <> viewContactPrefsUpdated u c c' - CRContactsMerged u intoCt mergedCt -> ttyUser u $ viewContactsMerged intoCt mergedCt + CRGroupMemberUpdated {} -> [] + CRContactsMerged u intoCt mergedCt ct' -> ttyUser u $ viewContactsMerged intoCt mergedCt ct' CRReceivedContactRequest u UserContactRequest {localDisplayName = c, profile} -> ttyUser u $ viewReceivedContactRequest c profile - CRRcvFileStart u ci -> ttyUser u $ receivingFile_' "started" ci - CRRcvFileComplete u ci -> ttyUser u $ receivingFile_' "completed" ci + CRRcvFileStart u ci -> ttyUser u $ receivingFile_' testView "started" ci + CRRcvFileComplete u ci -> ttyUser u $ receivingFile_' testView "completed" ci CRRcvFileSndCancelled u _ ft -> ttyUser u $ viewRcvFileSndCancelled ft - CRRcvFileError u ci e -> ttyUser u $ receivingFile_' "error" ci <> [sShow e] + CRRcvFileError u ci e -> ttyUser u $ receivingFile_' testView "error" ci <> [sShow e] CRSndFileStart u _ ft -> ttyUser u $ sendingFile_ "started" ft CRSndFileComplete u _ ft -> ttyUser u $ sendingFile_ "completed" ft CRSndFileStartXFTP {} -> [] @@ -209,6 +212,8 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView (addresses, groupLinks) = partition (\UserContactSubStatus {userContact} -> isNothing . userContactGroupId $ userContact) summary 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 + CRNetworkStatus status conns -> if testView then [plain $ show (length conns) <> " connections " <> netStatusStr status] else [] + CRNetworkStatuses u statuses -> if testView then ttyUser' u $ viewNetworkStatuses statuses else [] CRGroupInvitation u g -> ttyUser u [groupInvitation' g] CRReceivedGroupInvitation {user = u, groupInfo = g, contact = c, memberRole = r} -> ttyUser u $ viewReceivedGroupInvitation g c r CRUserJoinedGroup u g _ -> ttyUser u $ viewUserJoinedGroup g @@ -232,11 +237,12 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView CRGroupLink u g cReq mRole -> ttyUser u $ groupLink_ "Group link:" g cReq mRole CRGroupLinkDeleted u g -> ttyUser u $ viewGroupLinkDeleted g CRAcceptingGroupJoinRequest _ g c -> [ttyFullContact c <> ": accepting request to join group " <> ttyGroup' g <> "..."] + CRAcceptingGroupJoinRequestMember _ g m -> [ttyFullMember m <> ": accepting request to join group " <> ttyGroup' g <> "..."] CRNoMemberContactCreating u g m -> ttyUser u ["member " <> ttyGroup' g <> " " <> ttyMember m <> " does not have direct connection, creating"] CRNewMemberContact u _ g m -> ttyUser u ["contact for member " <> ttyGroup' g <> " " <> ttyMember m <> " is created"] CRNewMemberContactSentInv u _ct g m -> ttyUser u ["sent invitation to connect directly to member " <> ttyGroup' g <> " " <> ttyMember m] CRNewMemberContactReceivedInv u ct g m -> ttyUser u [ttyGroup' g <> " " <> ttyMember m <> " is creating direct contact " <> ttyContact' ct <> " with you"] - CRMemberContactConnected u ct g m -> ttyUser u ["member " <> ttyGroup' g <> " " <> ttyMember m <> " is merged into " <> ttyContact' ct] + CRContactAndMemberAssociated u ct g m ct' -> ttyUser u $ viewContactAndMemberAssociated ct g m ct' CRMemberSubError u g m e -> ttyUser u [ttyGroup' g <> " member " <> ttyMember m <> " error: " <> sShow e] CRMemberSubSummary u summary -> ttyUser u $ viewErrorsSummary (filter (isJust . memberError) summary) " group member errors" CRGroupSubscribed u g -> ttyUser u $ viewGroupSubscribed g @@ -351,24 +357,56 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView viewErrorsSummary summary s = [ttyError (T.pack . show $ length summary) <> s <> " (run with -c option to show each error)" | not (null summary)] contactList :: [ContactRef] -> String contactList cs = T.unpack . T.intercalate ", " $ map (\ContactRef {localDisplayName = n} -> "@" <> n) cs - unmuted :: ChatInfo c -> ChatItem c d -> [StyledString] -> [StyledString] - unmuted chat ChatItem {chatDir} = unmuted' chat chatDir - unmutedReaction :: ChatInfo c -> CIReaction c d -> [StyledString] -> [StyledString] - unmutedReaction chat CIReaction {chatDir} = unmuted' chat chatDir - unmuted' :: ChatInfo c -> CIDirection c d -> [StyledString] -> [StyledString] - unmuted' chat chatDir s - | muted chat chatDir = [] - | otherwise = s + unmuted :: User -> ChatInfo c -> ChatItem c d -> [StyledString] -> [StyledString] + unmuted u chat ci@ChatItem {chatDir} = unmuted' u chat chatDir $ isMention ci + unmutedReaction :: User -> ChatInfo c -> CIReaction c d -> [StyledString] -> [StyledString] + unmutedReaction u chat CIReaction {chatDir} = unmuted' u chat chatDir False + unmuted' :: User -> ChatInfo c -> CIDirection c d -> Bool -> [StyledString] -> [StyledString] + unmuted' u chat chatDir mention s + | chatDirNtf u chat chatDir mention = s + | otherwise = [] + +userNtf :: User -> Bool +userNtf User {showNtfs, activeUser} = showNtfs || activeUser + +chatNtf :: User -> ChatInfo c -> Bool -> Bool +chatNtf user cInfo mention = case cInfo of + DirectChat ct -> contactNtf user ct mention + GroupChat g -> groupNtf user g mention + _ -> False + +chatDirNtf :: User -> ChatInfo c -> CIDirection c d -> Bool -> Bool +chatDirNtf user cInfo chatDir mention = case (cInfo, chatDir) of + (DirectChat ct, CIDirectRcv) -> contactNtf user ct mention + (GroupChat g, CIGroupRcv m) -> groupNtf user g mention && showMessages (memberSettings m) + _ -> True + +contactNtf :: User -> Contact -> Bool -> Bool +contactNtf user Contact {chatSettings} mention = + userNtf user && showMessageNtf chatSettings mention + +groupNtf :: User -> GroupInfo -> Bool -> Bool +groupNtf user GroupInfo {chatSettings} mention = + userNtf user && showMessageNtf chatSettings mention + +showMessageNtf :: ChatSettings -> Bool -> Bool +showMessageNtf ChatSettings {enableNtfs} mention = + enableNtfs == MFAll || (mention && enableNtfs == MFMentions) chatItemDeletedText :: ChatItem c d -> Maybe GroupMember -> Maybe Text -chatItemDeletedText ci membership_ = deletedStateToText <$> chatItemDeletedState ci +chatItemDeletedText ChatItem {meta = CIMeta {itemDeleted}, content} membership_ = + deletedText <$> itemDeleted where - deletedStateToText = \CIDeletedState {markedDeleted, deletedByMember} -> - if markedDeleted - then "marked deleted" <> byMember deletedByMember - else "deleted" <> byMember deletedByMember - byMember m_ = case (m_, membership_) of - (Just GroupMember {groupMemberId = mId, localDisplayName = n}, Just GroupMember {groupMemberId = membershipId}) -> + deletedText = \case + CIModerated _ m -> markedDeleted content <> byMember m + CIDeleted _ -> markedDeleted content + CIBlocked _ -> "blocked" + markedDeleted = \case + CISndModerated -> "deleted" + CIRcvModerated -> "deleted" + _ -> "marked deleted" + byMember GroupMember {groupMemberId = mId, localDisplayName = n} = case membership_ of + Just GroupMember {groupMemberId = membershipId} -> " by " <> if mId == membershipId then "you" else n _ -> "" @@ -387,12 +425,6 @@ viewUsersList = mapMaybe userInfo . sortOn ldn <> ["muted" | not showNtfs] <> [plain ("unread: " <> show count) | count /= 0] -muted :: ChatInfo c -> CIDirection c d -> Bool -muted chat chatDir = case (chat, chatDir) of - (DirectChat Contact {chatSettings = DisableNtfs}, CIDirectRcv) -> True - (GroupChat GroupInfo {chatSettings = DisableNtfs}, CIGroupRcv _) -> True - _ -> False - viewGroupSubscribed :: GroupInfo -> [StyledString] viewGroupSubscribed g = [membershipIncognito g <> ttyFullGroup g <> ": connected to server(s)"] @@ -667,11 +699,14 @@ viewConnReqInvitation :: ConnReqInvitation -> [StyledString] viewConnReqInvitation cReq = [ "pass this invitation link to your contact (via another channel): ", "", - (plain . strEncode) cReq, + (plain . strEncode) (simplexChatInvitation cReq), "", "and ask them to connect: " <> highlight' "/c " ] +simplexChatInvitation :: ConnReqInvitation -> ConnReqInvitation +simplexChatInvitation (CRInvitationUri crData e2e) = CRInvitationUri crData {crScheme = simplexChat} e2e + viewContactNotFound :: ContactName -> Maybe (GroupInfo, GroupMember) -> [StyledString] viewContactNotFound cName suspectedMember = ["no contact " <> ttyContact cName <> useMessageMember] @@ -694,7 +729,7 @@ viewContactsList = in map (\ct -> ctIncognito ct <> ttyFullContact ct <> muted' ct <> alias ct) . sortOn ldn where muted' Contact {chatSettings, localDisplayName = ldn} - | enableNtfs chatSettings = "" + | chatHasNtfs chatSettings = "" | otherwise = " (muted, you can " <> highlight ("/unmute @" <> ldn) <> ")" alias Contact {profile = LocalProfile {localAlias}} | localAlias == "" = "" @@ -710,7 +745,7 @@ connReqContact_ :: StyledString -> ConnReqContact -> [StyledString] connReqContact_ intro cReq = [ intro, "", - (plain . strEncode) cReq, + (plain . strEncode) (simplexChatContact cReq), "", "Anybody can send you contact requests with: " <> highlight' "/c ", "to show it again: " <> highlight' "/sa", @@ -718,6 +753,9 @@ connReqContact_ intro cReq = "to delete it: " <> highlight' "/da" <> " (accepted contacts will remain connected)" ] +simplexChatContact :: ConnReqContact -> ConnReqContact +simplexChatContact (CRContactUri crData) = CRContactUri crData {crScheme = simplexChat} + autoAcceptStatus_ :: Maybe AutoAccept -> [StyledString] autoAcceptStatus_ = \case Just AutoAccept {acceptIncognito, autoReply} -> @@ -729,7 +767,7 @@ groupLink_ :: StyledString -> GroupInfo -> ConnReqContact -> GroupMemberRole -> groupLink_ intro g cReq mRole = [ intro, "", - (plain . strEncode) cReq, + (plain . strEncode) (simplexChatContact cReq), "", "Anybody can connect to you and join group as " <> showRole mRole <> " with: " <> highlight' "/c ", "to show it again: " <> highlight ("/show link #" <> viewGroupName g), @@ -760,11 +798,22 @@ viewReceivedContactRequest c Profile {fullName} = "to reject: " <> highlight ("/rc " <> viewName c) <> " (the sender will NOT be notified)" ] -viewGroupCreated :: GroupInfo -> [StyledString] -viewGroupCreated g = - [ "group " <> ttyFullGroup g <> " is created", - "to add members use " <> highlight ("/a " <> viewGroupName g <> " ") <> " or " <> highlight ("/create link #" <> viewGroupName g) - ] +viewGroupCreated :: GroupInfo -> Bool -> [StyledString] +viewGroupCreated g testView = + case incognitoMembershipProfile g of + Just localProfile + | testView -> incognitoProfile' profile : message + | otherwise -> message + where + profile = fromLocalProfile localProfile + message = + [ "group " <> ttyFullGroup g <> " is created, your incognito profile for this group is " <> incognitoProfile' profile, + "to add members use " <> highlight ("/create link #" <> viewGroupName g) + ] + Nothing -> + [ "group " <> ttyFullGroup g <> " is created", + "to add members use " <> highlight ("/a " <> viewGroupName g <> " ") <> " or " <> highlight ("/create link #" <> viewGroupName g) + ] viewCannotResendInvitation :: GroupInfo -> ContactName -> [StyledString] viewCannotResendInvitation g c = @@ -776,6 +825,12 @@ viewDirectMessagesProhibited :: MsgDirection -> Contact -> [StyledString] viewDirectMessagesProhibited MDSnd c = ["direct messages to indirect contact " <> ttyContact' c <> " are prohibited"] viewDirectMessagesProhibited MDRcv c = ["received prohibited direct message from indirect contact " <> ttyContact' c <> " (discarded)"] +viewNetworkStatuses :: [ConnNetworkStatus] -> [StyledString] +viewNetworkStatuses = map viewStatuses . L.groupBy ((==) `on` netStatus) . sortOn netStatus + where + netStatus ConnNetworkStatus {networkStatus} = networkStatus + viewStatuses ss@(s :| _) = plain $ show (L.length ss) <> " connections " <> netStatusStr (netStatus s) + viewUserJoinedGroup :: GroupInfo -> [StyledString] viewUserJoinedGroup g = case incognitoMembershipProfile g of @@ -827,22 +882,25 @@ viewGroupMembers :: Group -> [StyledString] viewGroupMembers (Group GroupInfo {membership} members) = map groupMember . filter (not . removedOrLeft) $ membership : members where removedOrLeft m = let s = memberStatus m in s == GSMemRemoved || s == GSMemLeft - groupMember m = memIncognito m <> ttyFullMember m <> ": " <> role m <> ", " <> category m <> status m - role :: GroupMember -> StyledString - role m = plain . strEncode $ m.memberRole + groupMember m = memIncognito m <> ttyFullMember m <> ": " <> plain (intercalate ", " $ [role m] <> category m <> status m <> muted m) + role :: GroupMember -> String + role m = B.unpack . strEncode $ m.memberRole category m = case memberCategory m of - GCUserMember -> "you, " - GCInviteeMember -> "invited, " - GCHostMember -> "host, " - _ -> "" + GCUserMember -> ["you"] + GCInviteeMember -> ["invited"] + GCHostMember -> ["host"] + _ -> [] status m = case memberStatus m of - GSMemRemoved -> "removed" - GSMemLeft -> "left" - GSMemInvited -> "not yet joined" - GSMemConnected -> "connected" - GSMemComplete -> "connected" - GSMemCreator -> "created group" - _ -> "" + GSMemRemoved -> ["removed"] + GSMemLeft -> ["left"] + GSMemInvited -> ["not yet joined"] + GSMemConnected -> ["connected"] + GSMemComplete -> ["connected"] + GSMemCreator -> ["created group"] + _ -> [] + muted m + | showMessages (memberSettings m) = [] + | otherwise = ["blocked"] viewContactConnected :: Contact -> Maybe Profile -> Bool -> [StyledString] viewContactConnected ct userIncognitoProfile testView = @@ -865,7 +923,7 @@ viewGroupsList gs = map groupSS $ sortOn (ldn_ . fst) gs where ldn_ :: GroupInfo -> Text ldn_ g = T.toLower g.localDisplayName - groupSS (g@GroupInfo {membership, chatSettings}, GroupSummary {currentMembers}) = + groupSS (g@GroupInfo {membership, chatSettings = ChatSettings {enableNtfs}}, GroupSummary {currentMembers}) = case memberStatus membership of GSMemInvited -> groupInvitation' g s -> membershipIncognito g <> ttyFullGroup g <> viewMemberStatus s @@ -874,9 +932,13 @@ viewGroupsList gs = map groupSS $ sortOn (ldn_ . fst) gs GSMemRemoved -> delete "you are removed" GSMemLeft -> delete "you left" GSMemGroupDeleted -> delete "group deleted" - _ - | enableNtfs chatSettings -> " (" <> memberCount <> ")" - | otherwise -> " (" <> memberCount <> ", muted, you can " <> highlight ("/unmute #" <> viewGroupName g) <> ")" + _ -> " (" <> memberCount <> + case enableNtfs of + MFAll -> ")" + MFNone -> ", muted, " <> unmute + MFMentions -> ", mentions only, " <> unmute + where + unmute = "you can " <> highlight ("/unmute #" <> viewGroupName g) <> ")" delete reason = " (" <> reason <> ", delete local copy: " <> highlight ("/d #" <> viewGroupName g) <> ")" memberCount = sShow currentMembers <> " member" <> if currentMembers == 1 then "" else "s" @@ -894,12 +956,18 @@ groupInvitation' g@GroupInfo {localDisplayName = ldn, groupProfile = GroupProfil Just mp -> " to join as " <> incognitoProfile' (fromLocalProfile mp) <> ", " Nothing -> " to join, " -viewContactsMerged :: Contact -> Contact -> [StyledString] -viewContactsMerged c1 c2 = +viewContactsMerged :: Contact -> Contact -> Contact -> [StyledString] +viewContactsMerged c1 c2 ct' = [ "contact " <> ttyContact' c2 <> " is merged into " <> ttyContact' c1, - "use " <> ttyToContact' c1 <> highlight' "" <> " to send messages" + "use " <> ttyToContact' ct' <> highlight' "" <> " to send messages" ] +viewContactAndMemberAssociated :: Contact -> GroupInfo -> GroupMember -> Contact -> [StyledString] +viewContactAndMemberAssociated ct g m ct' = + [ "contact and member are merged: " <> ttyContact' ct <> ", " <> ttyGroup' g <> " " <> ttyMember m, + "use " <> ttyToContact' ct' <> highlight' "" <> " to send messages" + ] + viewUserProfile :: Profile -> [StyledString] viewUserProfile Profile {displayName, fullName} = [ "user profile: " <> ttyFullName displayName fullName, @@ -977,7 +1045,7 @@ viewContactInfo :: Contact -> ConnectionStats -> Maybe Profile -> [StyledString] viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, contactLink}, activeConn} stats incognitoProfile = ["contact ID: " <> sShow contactId] <> viewConnectionStats stats - <> maybe [] (\l -> ["contact address: " <> (plain . strEncode) l]) contactLink + <> maybe [] (\l -> ["contact address: " <> (plain . strEncode) (simplexChatContact l)]) contactLink <> maybe ["you've shared main profile with this contact"] (\p -> ["you've shared incognito profile with this contact: " <> incognitoProfile' p]) @@ -1220,6 +1288,43 @@ viewConnectionIncognitoUpdated PendingContactConnection {pccConnId, customUserPr | isJust customUserProfileId = ["connection " <> sShow pccConnId <> " changed to incognito"] | otherwise = ["connection " <> sShow pccConnId <> " changed to non incognito"] +viewConnectionPlan :: ConnectionPlan -> [StyledString] +viewConnectionPlan = \case + CPInvitationLink ilp -> case ilp of + ILPOk -> [invLink "ok to connect"] + ILPOwnLink -> [invLink "own link"] + ILPConnecting Nothing -> [invLink "connecting"] + ILPConnecting (Just ct) -> [invLink ("connecting to contact " <> ttyContact' ct)] + ILPKnown ct -> + [ invLink ("known contact " <> ttyContact' ct), + "use " <> ttyToContact' ct <> highlight' "" <> " to send messages" + ] + where + invLink = ("invitation link: " <>) + CPContactAddress cap -> case cap of + CAPOk -> [ctAddr "ok to connect"] + CAPOwnLink -> [ctAddr "own address"] + CAPConnectingConfirmReconnect -> [ctAddr "connecting, allowed to reconnect"] + CAPConnectingProhibit ct -> [ctAddr ("connecting to contact " <> ttyContact' ct)] + CAPKnown ct -> + [ ctAddr ("known contact " <> ttyContact' ct), + "use " <> ttyToContact' ct <> highlight' "" <> " to send messages" + ] + where + ctAddr = ("contact address: " <>) + CPGroupLink glp -> case glp of + GLPOk -> [grpLink "ok to connect"] + GLPOwnLink g -> [grpLink "own link for group " <> ttyGroup' g] + GLPConnectingConfirmReconnect -> [grpLink "connecting, allowed to reconnect"] + GLPConnectingProhibit Nothing -> [grpLink "connecting"] + GLPConnectingProhibit (Just g) -> [grpLink ("connecting to group " <> ttyGroup' g)] + GLPKnown g -> + [ grpLink ("known group " <> ttyGroup' g), + "use " <> ttyToGroup g <> highlight' "" <> " to send messages" + ] + where + grpLink = ("group link: " <>) + viewContactUpdated :: Contact -> Contact -> [StyledString] viewContactUpdated Contact {localDisplayName = n, profile = LocalProfile {fullName, contactLink}} @@ -1369,27 +1474,28 @@ humanReadableSize size mB = kB * 1024 gB = mB * 1024 -savingFile' :: Bool -> AChatItem -> [StyledString] -savingFile' testView (AChatItem _ _ chat ChatItem {file = Just CIFile {fileId, fileSource = Just (CryptoFile filePath cfArgs_)}, chatDir}) = - let from = case (chat, chatDir) of - (DirectChat Contact {localDisplayName = c}, CIDirectRcv) -> " from " <> ttyContact c - (_, CIGroupRcv GroupMember {localDisplayName = m}) -> " from " <> ttyContact m - _ -> "" - in ["saving file " <> sShow fileId <> from <> " to " <> plain filePath] <> cfArgsStr - where - cfArgsStr = case cfArgs_ of - Just cfArgs@(CFArgs key nonce) - | testView -> [plain $ LB.unpack $ J.encode cfArgs] - | otherwise -> [plain $ "encryption key: " <> strEncode key <> ", nonce: " <> strEncode nonce] - _ -> [] -savingFile' _ _ = ["saving file"] -- shouldn't happen +savingFile' :: AChatItem -> [StyledString] +savingFile' (AChatItem _ _ chat ChatItem {file = Just CIFile {fileId, fileSource = Just (CryptoFile filePath _)}, chatDir}) = + ["saving file " <> sShow fileId <> fileFrom chat chatDir <> " to " <> plain filePath] +savingFile' _ = ["saving file"] -- shouldn't happen -receivingFile_' :: StyledString -> AChatItem -> [StyledString] -receivingFile_' status (AChatItem _ _ (DirectChat c) ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIDirectRcv}) = - [status <> " receiving " <> fileTransferStr fileId fileName <> " from " <> ttyContact' c] -receivingFile_' status (AChatItem _ _ _ ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIGroupRcv m}) = - [status <> " receiving " <> fileTransferStr fileId fileName <> " from " <> ttyMember m] -receivingFile_' status _ = [status <> " receiving file"] -- shouldn't happen +receivingFile_' :: Bool -> String -> AChatItem -> [StyledString] +receivingFile_' testView status (AChatItem _ _ chat ChatItem {file = Just CIFile {fileId, fileName, fileSource = Just (CryptoFile _ cfArgs_)}, chatDir}) = + [plain status <> " receiving " <> fileTransferStr fileId fileName <> fileFrom chat chatDir] <> cfArgsStr cfArgs_ + where + cfArgsStr (Just cfArgs@(CFArgs key nonce)) = [plain s | status == "completed"] + where + s = + if testView + then LB.toStrict $ J.encode cfArgs + else "encryption key: " <> strEncode key <> ", nonce: " <> strEncode nonce + cfArgsStr _ = [] +receivingFile_' _ status _ = [plain status <> " receiving file"] -- shouldn't happen + +fileFrom :: ChatInfo c -> CIDirection c d -> StyledString +fileFrom (DirectChat ct) CIDirectRcv = " from " <> ttyContact' ct +fileFrom _ (CIGroupRcv m) = " from " <> ttyMember m +fileFrom _ _ = "" receivingFile_ :: StyledString -> RcvFileTransfer -> [StyledString] receivingFile_ status ft@RcvFileTransfer {senderDisplayName = c} = @@ -1562,6 +1668,7 @@ viewChatError logLevel = \case CEChatNotStarted -> ["error: chat not started"] CEChatNotStopped -> ["error: chat not stopped"] CEChatStoreChanged -> ["error: chat store changed, please restart chat"] + CEConnectionPlan connectionPlan -> viewConnectionPlan connectionPlan CEInvalidConnReq -> viewInvalidConnReq CEInvalidChatMessage Connection {connId} msgMeta_ msg e -> [ plain $ @@ -1582,7 +1689,7 @@ viewChatError logLevel = \case _ -> ": you have insufficient permissions for this action, the required role is " <> plain (strEncode role) CEGroupMemberInitialRole g role -> [ttyGroup' g <> ": initial role for group member cannot be " <> plain (strEncode role) <> ", use member or observer"] CEContactIncognitoCantInvite -> ["you're using your main profile for this group - prohibited to invite contacts to whom you are connected incognito"] - CEGroupIncognitoCantInvite -> ["you've connected to this group using an incognito profile - prohibited to invite contacts"] + CEGroupIncognitoCantInvite -> ["you are using an incognito profile for this group - prohibited to invite contacts"] CEGroupContactRole c -> ["contact " <> ttyContact c <> " has insufficient permissions for this group action"] CEGroupNotJoined g -> ["you did not join this group, use " <> highlight ("/join #" <> viewGroupName g)] CEGroupMemberNotActive -> ["your group connection is not active yet, try later"] diff --git a/stack.yaml b/stack.yaml index 321d6d8c0f..b723deec3c 100644 --- a/stack.yaml +++ b/stack.yaml @@ -49,9 +49,9 @@ extra-deps: # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - ../simplexmq - github: simplex-chat/simplexmq - commit: 9cb8616d4648c9df7ca58fee7249c9cc611f1b4b + commit: 7ebb63025cc70d0649830b31846deba2348c3c38 - github: kazu-yamamoto/http2 - commit: b5a1b7200cf5bc7044af34ba325284271f6dff25 + commit: 804fa283f067bd3fd89b8c5f8d25b3047813a517 # - ../direct-sqlcipher - github: simplex-chat/direct-sqlcipher commit: f814ee68b16a9447fbb467ccc8f29bdd3546bfd9 diff --git a/tests/Bots/BroadcastTests.hs b/tests/Bots/BroadcastTests.hs index ae2d67c7f0..ed0b9e069a 100644 --- a/tests/Bots/BroadcastTests.hs +++ b/tests/Bots/BroadcastTests.hs @@ -26,7 +26,7 @@ withBroadcastBot :: BroadcastBotOpts -> IO () -> IO () withBroadcastBot opts test = bracket (forkIO bot) killThread (\_ -> threadDelay 500000 >> test) where - bot = simplexChatCore testCfg (mkChatOpts opts) Nothing $ broadcastBot opts + bot = simplexChatCore testCfg (mkChatOpts opts) $ broadcastBot opts broadcastBotProfile :: Profile broadcastBotProfile = Profile {displayName = "broadcast_bot", fullName = "Broadcast Bot", image = Nothing, contactLink = Nothing, preferences = Nothing} diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 0e315190c5..36b990ba35 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -195,10 +195,10 @@ testSuspendResume tmp = testJoinGroup :: HasCallStack => FilePath -> IO () testJoinGroup tmp = - withDirectoryService tmp $ \superUser dsLink -> - withNewTestChat tmp "bob" bobProfile $ \bob -> do - withNewTestChat tmp "cath" cathProfile $ \cath -> - withNewTestChat tmp "dan" danProfile $ \dan -> do + withDirectoryServiceCfg tmp testCfgGroupLinkViaContact $ \superUser dsLink -> + withNewTestChatCfg tmp testCfgGroupLinkViaContact "bob" bobProfile $ \bob -> do + withNewTestChatCfg tmp testCfgGroupLinkViaContact "cath" cathProfile $ \cath -> + withNewTestChatCfg tmp testCfgGroupLinkViaContact "dan" danProfile $ \dan -> do bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" cath `connectVia` dsLink @@ -827,7 +827,7 @@ runDirectory cfg opts@DirectoryOpts {directoryLog} action = do threadDelay 500000 action `finally` (mapM_ hClose (directoryLogFile st) >> killThread t) where - bot st = simplexChatCore cfg (mkChatOpts opts) Nothing $ directoryService st opts + bot st = simplexChatCore cfg (mkChatOpts opts) $ directoryService st opts registerGroup :: TestCC -> TestCC -> String -> String -> IO () registerGroup su u n fn = registerGroupId su u n fn 1 1 diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index c0b839fb6b..1d1de251e0 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -147,6 +147,16 @@ mkCfgCreateGroupDirect cfg = cfg {chatVRange = groupCreateDirectVRange} groupCreateDirectVRange :: VersionRange groupCreateDirectVRange = mkVersionRange 1 1 +testCfgGroupLinkViaContact :: ChatConfig +testCfgGroupLinkViaContact = + mkCfgGroupLinkViaContact testCfg + +mkCfgGroupLinkViaContact :: ChatConfig -> ChatConfig +mkCfgGroupLinkViaContact cfg = cfg {chatVRange = groupLinkViaContactVRange} + +groupLinkViaContactVRange :: VersionRange +groupLinkViaContactVRange = mkVersionRange 1 2 + createTestChat :: FilePath -> ChatConfig -> ChatOpts -> String -> Profile -> IO TestCC createTestChat tmp cfg opts@ChatOpts {coreOptions = CoreChatOpts {dbKey}} dbPrefix profile = do Right db@ChatDatabase {chatStore} <- createChatDatabase (tmp dbPrefix) dbKey MCError @@ -162,8 +172,8 @@ startTestChat tmp cfg opts@ChatOpts {coreOptions = CoreChatOpts {dbKey}} dbPrefi startTestChat_ :: ChatDatabase -> ChatConfig -> ChatOpts -> User -> IO TestCC startTestChat_ db cfg opts user = do t <- withVirtualTerminal termSettings pure - ct <- newChatTerminal t - cc <- newChatController db (Just user) cfg opts Nothing -- no notifications + ct <- newChatTerminal t opts + cc <- newChatController db (Just user) cfg opts chatAsync <- async . runSimplexChat opts user cc . const $ runChatTerminal ct atomically . unless (maintenance opts) $ readTVar (agentAsync cc) >>= \a -> when (isNothing a) retry termQ <- newTQueueIO @@ -212,6 +222,8 @@ withTestChatOpts tmp = withTestChatCfgOpts tmp testCfg withTestChatCfgOpts :: HasCallStack => FilePath -> ChatConfig -> ChatOpts -> String -> (HasCallStack => TestCC -> IO a) -> IO a withTestChatCfgOpts tmp cfg opts dbPrefix = bracket (startTestChat tmp cfg opts dbPrefix) (\cc -> cc > stopTestChat cc) +-- enable output for specific chat controller, use like this: +-- withNewTestChat tmp "alice" aliceProfile $ \a -> withTestOutput a $ \alice -> do ... withTestOutput :: HasCallStack => TestCC -> (HasCallStack => TestCC -> IO a) -> IO a withTestOutput cc runTest = runTest cc {printOutput = True} diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 5c4d96cc94..1a133fd8e3 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -29,7 +29,8 @@ import Test.Hspec chatDirectTests :: SpecWith FilePath chatDirectTests = do describe "direct messages" $ do - describe "add contact and send/receive message" testAddContact + describe "add contact and send/receive messages" testAddContact + it "clear chat with contact" testContactClear it "deleting contact deletes profile" testDeleteContactDeletesProfile it "unused contact is deleted silently" testDeleteUnusedContactSilent it "direct message quoted replies" testDirectMessageQuotedReply @@ -40,6 +41,13 @@ chatDirectTests = do it "direct timed message" testDirectTimedMessage it "repeat AUTH errors disable contact" testRepeatAuthErrorsDisableContact it "should send multiline message" testMultilineMessage + describe "duplicate contacts" $ do + it "duplicate contacts are separate (contacts don't merge)" testDuplicateContactsSeparate + it "new contact is separate with multiple duplicate contacts (contacts don't merge)" testDuplicateContactsMultipleSeparate + describe "invitation link connection plan" $ do + it "invitation link ok to connect" testPlanInvitationLinkOk + it "own invitation link" testPlanInvitationLinkOwn + it "connecting via invitation link" testPlanInvitationLinkConnecting describe "SMP servers" $ do it "get and set SMP servers" testGetSetSMPServers it "test SMP server connection" testTestSMPServerConnection @@ -62,7 +70,7 @@ chatDirectTests = do it "should not subscribe in NSE and subscribe in the app" testSubscribeAppNSE describe "mute/unmute messages" $ do it "mute/unmute contact" testMuteContact - it "mute/unmute group" testMuteGroup + it "mute/unmute group and member" testMuteGroup describe "multiple users" $ do it "create second user" testCreateSecondUser it "multiple users subscribe and receive messages after restart" testUsersSubscribeAfterRestart @@ -111,6 +119,8 @@ chatDirectTests = do testReqVRange vr11 supportedChatVRange testReqVRange vr11 vr11 it "update peer version range on received messages" testUpdatePeerChatVRange + describe "network statuses" $ do + it "should get network statuses" testGetNetworkStatuses where testInvVRange vr1 vr2 = it (vRangeStr vr1 <> " - " <> vRangeStr vr2) $ testConnInvChatVRange vr1 vr2 testReqVRange vr1 vr2 = it (vRangeStr vr1 <> " - " <> vRangeStr vr2) $ testConnReqChatVRange vr1 vr2 @@ -140,35 +150,6 @@ testAddContact = versionTestMatrix2 runTestAddContact bob #> "@alice how are you?" alice <# "bob> how are you?" chatsManyMessages alice bob - -- test adding the same contact one more time - local name will be different - alice ##> "/c" - inv' <- getInvitation alice - bob ##> ("/c " <> inv') - bob <## "confirmation sent!" - concurrently_ - (bob <## "alice_1 (Alice): contact is connected") - (alice <## "bob_1 (Bob): contact is connected") - alice #> "@bob_1 hello" - bob <# "alice_1> hello" - bob #> "@alice_1 hi" - alice <# "bob_1> hi" - alice @@@ [("@bob_1", "hi"), ("@bob", "how are you?")] - bob @@@ [("@alice_1", "hi"), ("@alice", "how are you?")] - -- test deleting contact - alice ##> "/d bob_1" - alice <## "bob_1: contact is deleted" - bob <## "alice_1 (Alice) deleted contact with you" - alice ##> "@bob_1 hey" - alice <## "no contact bob_1" - alice @@@ [("@bob", "how are you?")] - alice `hasContactProfiles` ["alice", "bob"] - bob @@@ [("@alice_1", "contact deleted"), ("@alice", "how are you?")] - bob `hasContactProfiles` ["alice", "alice", "bob"] - -- test clearing chat - alice #$> ("/clear bob", id, "bob: all messages are removed locally ONLY") - alice #$> ("/_get chat @2 count=100", chat, []) - bob #$> ("/clear alice", id, "alice: all messages are removed locally ONLY") - bob #$> ("/_get chat @2 count=100", chat, []) chatsEmpty alice bob = do alice @@@ [("@bob", lastChatFeature)] alice #$> ("/_get chat @2 count=100", chat, chatFeatures) @@ -195,6 +176,155 @@ testAddContact = versionTestMatrix2 runTestAddContact alice #$> ("/_read chat @2", id, "ok") bob #$> ("/_read chat @2", id, "ok") +testDuplicateContactsSeparate :: HasCallStack => FilePath -> IO () +testDuplicateContactsSeparate = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + alice <##> bob + + alice ##> "/c" + inv' <- getInvitation alice + bob ##> ("/c " <> inv') + bob <## "confirmation sent!" + concurrently_ + (alice <## "bob_1 (Bob): contact is connected") + (bob <## "alice_1 (Alice): contact is connected") + + alice <##> bob + alice #> "@bob_1 1" + bob <# "alice_1> 1" + bob #> "@alice_1 2" + alice <# "bob_1> 2" + + alice @@@ [("@bob", "hey"), ("@bob_1", "2")] + alice `hasContactProfiles` ["alice", "bob", "bob"] + bob @@@ [("@alice", "hey"), ("@alice_1", "2")] + bob `hasContactProfiles` ["bob", "alice", "alice"] + +testDuplicateContactsMultipleSeparate :: HasCallStack => FilePath -> IO () +testDuplicateContactsMultipleSeparate = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + alice <##> bob + + alice ##> "/c" + inv' <- getInvitation alice + bob ##> ("/c " <> inv') + bob <## "confirmation sent!" + concurrently_ + (alice <## "bob_1 (Bob): contact is connected") + (bob <## "alice_1 (Alice): contact is connected") + + alice ##> "/c" + inv'' <- getInvitation alice + bob ##> ("/c " <> inv'') + bob <## "confirmation sent!" + concurrently_ + (alice <## "bob_2 (Bob): contact is connected") + (bob <## "alice_2 (Alice): contact is connected") + + alice <##> bob + alice #> "@bob_1 1" + bob <# "alice_1> 1" + bob #> "@alice_1 2" + alice <# "bob_1> 2" + alice #> "@bob_2 3" + bob <# "alice_2> 3" + bob #> "@alice_2 4" + alice <# "bob_2> 4" + + alice ##> "/contacts" + alice <### ["bob (Bob)", "bob_1 (Bob)", "bob_2 (Bob)"] + bob ##> "/contacts" + bob <### ["alice (Alice)", "alice_1 (Alice)", "alice_2 (Alice)"] + alice `hasContactProfiles` ["alice", "bob", "bob", "bob"] + bob `hasContactProfiles` ["bob", "alice", "alice", "alice"] + +testPlanInvitationLinkOk :: HasCallStack => FilePath -> IO () +testPlanInvitationLinkOk = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + alice ##> "/c" + inv <- getInvitation alice + bob ##> ("/_connect plan 1 " <> inv) + bob <## "invitation link: ok to connect" + + bob ##> ("/c " <> inv) + bob <## "confirmation sent!" + concurrently_ + (alice <## "bob (Bob): contact is connected") + (bob <## "alice (Alice): contact is connected") + + bob ##> ("/_connect plan 1 " <> inv) + bob <## "invitation link: ok to connect" -- conn_req_inv is forgotten after connection + + alice <##> bob + +testPlanInvitationLinkOwn :: HasCallStack => FilePath -> IO () +testPlanInvitationLinkOwn tmp = + withNewTestChat tmp "alice" aliceProfile $ \alice -> do + alice ##> "/c" + inv <- getInvitation alice + alice ##> ("/_connect plan 1 " <> inv) + alice <## "invitation link: own link" + + let invSchema2 = linkAnotherSchema inv + alice ##> ("/_connect plan 1 " <> invSchema2) + alice <## "invitation link: own link" + + alice ##> ("/c " <> inv) + alice <## "confirmation sent!" + alice + <### [ "alice_1 (Alice): contact is connected", + "alice_2 (Alice): contact is connected" + ] + + alice ##> ("/_connect plan 1 " <> inv) + alice <## "invitation link: ok to connect" -- conn_req_inv is forgotten after connection + + alice @@@ [("@alice_1", lastChatFeature), ("@alice_2", lastChatFeature)] + alice `send` "@alice_2 hi" + alice + <### [ WithTime "@alice_2 hi", + WithTime "alice_1> hi" + ] + alice `send` "@alice_1 hey" + alice + <### [ WithTime "@alice_1 hey", + WithTime "alice_2> hey" + ] + alice @@@ [("@alice_1", "hey"), ("@alice_2", "hey")] + +testPlanInvitationLinkConnecting :: HasCallStack => FilePath -> IO () +testPlanInvitationLinkConnecting tmp = do + inv <- withNewTestChat tmp "alice" aliceProfile $ \alice -> do + alice ##> "/c" + getInvitation alice + withNewTestChat tmp "bob" bobProfile $ \bob -> do + bob ##> ("/c " <> inv) + bob <## "confirmation sent!" + + bob ##> ("/_connect plan 1 " <> inv) + bob <## "invitation link: connecting" + + let invSchema2 = linkAnotherSchema inv + bob ##> ("/_connect plan 1 " <> invSchema2) + bob <## "invitation link: connecting" + +testContactClear :: HasCallStack => FilePath -> IO () +testContactClear = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + alice <##> bob + threadDelay 500000 + alice #$> ("/clear bob", id, "bob: all messages are removed locally ONLY") + alice #$> ("/_get chat @2 count=100", chat, []) + bob #$> ("/clear alice", id, "alice: all messages are removed locally ONLY") + bob #$> ("/_get chat @2 count=100", chat, []) + testDeleteContactDeletesProfile :: HasCallStack => FilePath -> IO () testDeleteContactDeletesProfile = testChat2 aliceProfile bobProfile $ @@ -1076,14 +1206,79 @@ testMuteGroup = concurrently_ (bob hi") + bob #> "#team hello" + concurrently_ + (alice <# "#team bob> hello") + (cath <# "#team bob> hello") + cath `send` "> #team (hello) hello too!" + cath <# "#team > bob hello" + cath <## " hello too!" + concurrently_ + (bob > bob hello" + alice <## " hello too!" + ) + bob ##> "/unmute mentions #team" + bob <## "ok" + alice `send` "> #team @bob (hello) hey bob!" + alice <# "#team > bob hello" + alice <## " hey bob!" + concurrently_ + ( do bob <# "#team alice> > bob hello" + bob <## " hey bob!" + ) + ( do cath <# "#team alice> > bob hello" + cath <## " hey bob!" + ) + alice `send` "> #team @cath (hello) hey cath!" + alice <# "#team > cath hello too!" + alice <## " hey cath!" + concurrently_ + (bob > cath hello too!" + cath <## " hey cath!" + ) bob ##> "/gs" - bob <## "#team (3 members, muted, you can /unmute #team)" + bob <## "#team (3 members, mentions only, you can /unmute #team)" bob ##> "/unmute #team" bob <## "ok" alice #> "#team hi again" concurrently_ (bob <# "#team alice> hi again") (cath <# "#team alice> hi again") + bob ##> "/block #team alice" + bob <## "ok" + bob ##> "/ms team" + bob <## "bob (Bob): admin, you, connected" + bob <## "alice (Alice): owner, host, connected, blocked" + bob <## "cath (Catherine): admin, connected" + alice #> "#team test 1" + concurrently_ + (bob test 1") + cath #> "#team test 2" + concurrently_ + (bob <# "#team cath> test 2") + (alice <# "#team cath> test 2") + bob ##> "/tail #team 3" + bob <# "#team alice> hi again" + bob <# "#team alice> test 1 [blocked]" + bob <# "#team cath> test 2" + threadDelay 1000000 + bob ##> "/unblock #team alice" + bob <## "ok" + bob ##> "/ms team" + bob <## "bob (Bob): admin, you, connected" + bob <## "alice (Alice): owner, host, connected" + bob <## "cath (Catherine): admin, connected" + alice #> "#team test 3" + concurrently_ + (bob <# "#team alice> test 3") + (cath <# "#team alice> test 3") + cath #> "#team test 4" + concurrently_ + (bob <# "#team cath> test 4") + (alice <# "#team cath> test 4") bob ##> "/gs" bob <## "#team (3 members)" @@ -1817,7 +2012,7 @@ testUserPrivacy = -- shows hidden user when active alice ##> "/users" alice <## "alice (Alice)" - alice <## "alisa (active, hidden, muted)" + alice <## "alisa (active, hidden, muted, unread: 1)" -- hidden message is saved alice ##> "/tail" alice <##? chatHistory @@ -2104,7 +2299,7 @@ testMsgDecryptError tmp = withTestChat tmp "bob" $ \bob -> do bob <## "1 contacts connected (use /cs for the list)" alice #> "@bob hello again" - bob <# "alice> skipped message ID 9..11" + bob <# "alice> skipped message ID 10..12" bob <# "alice> hello again" bob #> "@alice received!" alice <# "bob> received!" @@ -2438,6 +2633,20 @@ testUpdatePeerChatVRange tmp = where cfg11 = testCfg {chatVRange = vr11} :: ChatConfig +testGetNetworkStatuses :: HasCallStack => FilePath -> IO () +testGetNetworkStatuses tmp = do + withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do + withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do + connectUsers alice bob + alice ##> "/_network_statuses" + alice <## "1 connections connected" + withTestChatCfg tmp cfg "alice" $ \alice -> + withTestChatCfg tmp cfg "bob" $ \bob -> do + alice <## "1 connections connected" + bob <## "1 connections connected" + where + cfg = testCfg {coreApi = True} + vr11 :: VersionRange vr11 = mkVersionRange 1 1 diff --git a/tests/ChatTests/Files.hs b/tests/ChatTests/Files.hs index dc679dbae3..03a5d6acfb 100644 --- a/tests/ChatTests/Files.hs +++ b/tests/ChatTests/Files.hs @@ -33,6 +33,7 @@ chatFileTests = do describe "send and receive file" $ fileTestMatrix2 runTestFileTransfer describe "send file, receive and locally encrypt file" $ fileTestMatrix2 runTestFileTransferEncrypted it "send and receive file inline (without accepting)" testInlineFileTransfer + it "send inline file, receive (without accepting) and locally encrypt" testInlineFileTransferEncrypted xit'' "accept inline file transfer, sender cancels during transfer" testAcceptInlineFileSndCancelDuringTransfer it "send and receive small file inline (default config)" testSmallInlineFileTransfer it "small file sent without acceptance is ignored in terminal by default" testSmallInlineFileIgnored @@ -107,7 +108,6 @@ runTestFileTransferEncrypted alice bob = do bob <## "use /fr 1 [/ | ] to receive it" bob ##> "/fr 1 encrypt=on ./tests/tmp" bob <## "saving file 1 from alice to ./tests/tmp/test.pdf" - Just (CFArgs key nonce) <- J.decode . LB.pack <$> getTermLine bob concurrently_ (bob <## "started receiving file 1 (test.pdf) from alice") (alice <## "started sending file 1 (test.pdf) to bob") @@ -123,6 +123,7 @@ runTestFileTransferEncrypted alice bob = do "completed sending file 1 (test.pdf) to bob" ] ] + Just (CFArgs key nonce) <- J.decode . LB.pack <$> getTermLine bob src <- B.readFile "./tests/fixtures/test.pdf" -- dest <- B.readFile "./tests/tmp/test.pdf" -- dest `shouldBe` src @@ -154,6 +155,34 @@ testInlineFileTransfer = where cfg = testCfg {inlineFiles = defaultInlineFilesConfig {offerChunks = 100, sendChunks = 100, receiveChunks = 100}} +testInlineFileTransferEncrypted :: HasCallStack => FilePath -> IO () +testInlineFileTransferEncrypted = + testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do + connectUsers alice bob + bob ##> "/_files_folder ./tests/tmp/" + bob <## "ok" + bob ##> "/_files_encrypt on" + bob <## "ok" + alice ##> "/_send @2 json {\"msgContent\":{\"type\":\"voice\", \"duration\":10, \"text\":\"\"}, \"filePath\":\"./tests/fixtures/test.jpg\"}" + alice <# "@bob voice message (00:10)" + alice <# "/f @bob ./tests/fixtures/test.jpg" + -- below is not shown in "sent" mode + -- alice <## "use /fc 1 to cancel sending" + bob <# "alice> voice message (00:10)" + bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + -- below is not shown in "sent" mode + -- bob <## "use /fr 1 [/ | ] to receive it" + bob <## "started receiving file 1 (test.jpg) from alice" + concurrently_ + (alice <## "completed sending file 1 (test.jpg) to bob") + (bob <## "completed receiving file 1 (test.jpg) from alice") + Just (CFArgs key nonce) <- J.decode . LB.pack <$> getTermLine bob + src <- B.readFile "./tests/fixtures/test.jpg" + Right dest <- chatReadFile "./tests/tmp/test.jpg" (strEncode key) (strEncode nonce) + LB.toStrict dest `shouldBe` src + where + cfg = testCfg {inlineFiles = defaultInlineFilesConfig {offerChunks = 100, sendChunks = 100, receiveChunks = 100}} + testAcceptInlineFileSndCancelDuringTransfer :: HasCallStack => FilePath -> IO () testAcceptInlineFileSndCancelDuringTransfer = testChatCfg2 cfg aliceProfile bobProfile $ \alice bob -> do @@ -1077,10 +1106,10 @@ testXFTPFileTransferEncrypted = bob <## "use /fr 1 [/ | ] to receive it" bob ##> "/fr 1 encrypt=on ./tests/tmp/bob/" bob <## "saving file 1 from alice to ./tests/tmp/bob/test.pdf" - Just (CFArgs key nonce) <- J.decode . LB.pack <$> getTermLine bob alice <## "completed uploading file 1 (test.pdf) for bob" bob <## "started receiving file 1 (test.pdf) from alice" bob <## "completed receiving file 1 (test.pdf) from alice" + Just (CFArgs key nonce) <- J.decode . LB.pack <$> getTermLine bob Right dest <- chatReadFile "./tests/tmp/bob/test.pdf" (strEncode key) (strEncode nonce) LB.length dest `shouldBe` fromIntegral srcLen LB.toStrict dest `shouldBe` src @@ -1363,11 +1392,17 @@ testXFTPMarkToReceive = do -- alice <## "started sending file 1 (test.pdf) to bob" -- TODO "started uploading" ? alice <## "completed uploading file 1 (test.pdf) for bob" bob #$> ("/_set_file_to_receive 1", id, "ok") - threadDelay 500000 + + threadDelay 100000 + bob ##> "/_stop" bob <## "chat stopped" + bob #$> ("/_files_folder ./tests/tmp/bob_files", id, "ok") bob #$> ("/_temp_folder ./tests/tmp/bob_xftp", id, "ok") + + threadDelay 100000 + bob ##> "/_start" bob <## "chat started" diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 4280810ca6..36b5cf4ea5 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -23,6 +23,7 @@ chatGroupTests = do describe "chat groups" $ do it "add contacts, create group and send/receive messages" testGroup it "add contacts, create group and send/receive messages, check messages" testGroupCheckMessages + it "create group with incognito membership" testNewGroupIncognito it "create and join group with 4 members" testGroup2 it "create and delete group" testGroupDelete it "create group with the same displayName" testGroupSameName @@ -57,6 +58,22 @@ chatGroupTests = do it "leaving groups with unused host contacts deletes incognito profiles" testGroupLinkIncognitoUnusedHostContactsDeleted it "group link member role" testGroupLinkMemberRole it "leaving and deleting the group joined via link should NOT delete previously existing direct contacts" testGroupLinkLeaveDelete + describe "group link connection plan" $ do + it "group link ok to connect; known group" testPlanGroupLinkOkKnown + it "group is known if host contact was deleted" testPlanHostContactDeletedGroupLinkKnown + it "own group link" testPlanGroupLinkOwn + it "connecting via group link" testPlanGroupLinkConnecting + it "re-join existing group after leaving" testPlanGroupLinkLeaveRejoin + describe "group links without contact" $ do + it "join via group link without creating contact" testGroupLinkNoContact + it "group link member role" testGroupLinkNoContactMemberRole + it "host incognito" testGroupLinkNoContactHostIncognito + it "invitee incognito" testGroupLinkNoContactInviteeIncognito + it "host profile received" testGroupLinkNoContactHostProfileReceived + it "existing contact merged" testGroupLinkNoContactExistingContactMerged + describe "group links without contact connection plan" $ do + it "group link without contact - known group" testPlanGroupLinkNoContactKnown + it "group link without contact - connecting" testPlanGroupLinkNoContactConnecting describe "group message errors" $ do it "show message decryption error" testGroupMsgDecryptError it "should report ratchet de-synchronization, synchronize ratchets" testGroupSyncRatchet @@ -73,7 +90,11 @@ chatGroupTests = do testNoDirect _1 _0 False testNoDirect _1 _1 False it "members have different local display names in different groups" testNoDirectDifferentLDNs - it "member should connect to contact when profile match" testConnectMemberToContact + describe "merge members and contacts" $ do + it "new member should merge with existing contact" testMergeMemberExistingContact + it "new contact should merge with existing member" testMergeContactExistingMember + it "new contact should merge with multiple existing members" testMergeContactMultipleMembers + it "new group link host contact should merge with single existing contact out of multiple" testMergeGroupLinkHostMultipleContacts describe "create member contact" $ do it "create contact with group member with invitation message" testMemberContactMessage it "create contact with group member without invitation message" testMemberContactNoMessage @@ -267,6 +288,56 @@ testGroupShared alice bob cath checkMessages = do alice #$> ("/_unread chat #1 on", id, "ok") alice #$> ("/_unread chat #1 off", id, "ok") +testNewGroupIncognito :: HasCallStack => FilePath -> IO () +testNewGroupIncognito = + testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + + -- alice creates group with incognito membership + alice ##> "/g i team" + aliceIncognito <- getTermLine alice + alice <## ("group #team is created, your incognito profile for this group is " <> aliceIncognito) + alice <## "to add members use /create link #team" + + -- alice invites bob + alice ##> "/a team bob" + alice <## "you are using an incognito profile for this group - prohibited to invite contacts" + + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob_1 (Bob): accepting request to join group #team..." + _ <- getTermLine alice + concurrentlyN_ + [ do + alice <## ("bob_1 (Bob): contact is connected, your incognito profile for this contact is " <> aliceIncognito) + alice <## "use /i bob_1 to print out this incognito profile again" + alice <## "bob_1 invited to group #team via your group link" + alice <## "#team: bob_1 joined the group", + do + bob <## (aliceIncognito <> ": contact is connected") + bob <## "#team: you joined the group" + ] + + alice <##> bob + + alice ?#> "@bob_1 hi, I'm incognito" + bob <# (aliceIncognito <> "> hi, I'm incognito") + bob #> ("@" <> aliceIncognito <> " hey, I'm bob") + alice ?<# "bob_1> hey, I'm bob" + + alice ?#> "#team hello" + bob <# ("#team " <> aliceIncognito <> "> hello") + bob #> "#team hi there" + alice ?<# "#team bob_1> hi there" + + alice ##> "/gs" + alice <## "i #team (2 members)" + bob ##> "/gs" + bob <## "#team (2 members)" + testGroup2 :: HasCallStack => FilePath -> IO () testGroup2 = testChatCfg4 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile danProfile $ @@ -1528,7 +1599,6 @@ testGroupDelayedModerationFullDelete tmp = do testGroupAsync :: HasCallStack => FilePath -> IO () testGroupAsync tmp = do - print (0 :: Integer) withNewTestChat tmp "alice" aliceProfile $ \alice -> do withNewTestChat tmp "bob" bobProfile $ \bob -> do connectUsers alice bob @@ -1675,7 +1745,7 @@ testGroupAsync tmp = do testGroupLink :: HasCallStack => FilePath -> IO () testGroupLink = - testChat3 aliceProfile bobProfile cathProfile $ + testChatCfg3 testCfgGroupLinkViaContact aliceProfile bobProfile cathProfile $ \alice bob cath -> do alice ##> "/g team" alice <## "group #team is created" @@ -1776,7 +1846,7 @@ testGroupLink = testGroupLinkDeleteGroupRejoin :: HasCallStack => FilePath -> IO () testGroupLinkDeleteGroupRejoin = - testChat2 aliceProfile bobProfile $ + testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ \alice bob -> do alice ##> "/g team" alice <## "group #team is created" @@ -1832,7 +1902,7 @@ testGroupLinkDeleteGroupRejoin = testGroupLinkContactUsed :: HasCallStack => FilePath -> IO () testGroupLinkContactUsed = - testChat2 aliceProfile bobProfile $ + testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ \alice bob -> do alice ##> "/g team" alice <## "group #team is created" @@ -1865,7 +1935,7 @@ testGroupLinkContactUsed = testGroupLinkIncognitoMembership :: HasCallStack => FilePath -> IO () testGroupLinkIncognitoMembership = - testChat4 aliceProfile bobProfile cathProfile danProfile $ + testChatCfg4 testCfgGroupLinkViaContact aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do -- bob connected incognito to alice alice ##> "/c" @@ -2038,7 +2108,7 @@ testGroupLinkUnusedHostContactDeleted = (bob TestCC -> TestCC -> String -> IO () bobLeaveDeleteGroup alice bob group = do bob ##> ("/l " <> group) @@ -2076,7 +2146,7 @@ testGroupLinkIncognitoUnusedHostContactsDeleted = (bob TestCC -> TestCC -> String -> String -> IO String createGroupBobIncognito alice bob group bobsAliceContact = do alice ##> ("/g " <> group) @@ -2114,7 +2184,7 @@ testGroupLinkIncognitoUnusedHostContactsDeleted = testGroupLinkMemberRole :: HasCallStack => FilePath -> IO () testGroupLinkMemberRole = - testChat3 aliceProfile bobProfile cathProfile $ + testChatCfg3 testCfgGroupLinkViaContact aliceProfile bobProfile cathProfile $ \alice bob cath -> do alice ##> "/g team" alice <## "group #team is created" @@ -2247,6 +2317,569 @@ testGroupLinkLeaveDelete = bob <## "alice (Alice)" bob <## "cath (Catherine)" +testPlanGroupLinkOkKnown :: HasCallStack => FilePath -> IO () +testPlanGroupLinkOkKnown = + testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ + \alice bob -> do + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + + bob ##> ("/_connect plan 1 " <> gLink) + bob <## "group link: ok to connect" + + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob (Bob): accepting request to join group #team..." + concurrentlyN_ + [ do + alice <## "bob (Bob): contact is connected" + alice <## "bob invited to group #team via your group link" + alice <## "#team: bob joined the group", + do + bob <## "alice (Alice): contact is connected" + bob <## "#team: you joined the group" + ] + alice #> "#team hi" + bob <# "#team alice> hi" + bob #> "#team hey" + alice <# "#team bob> hey" + + bob ##> ("/_connect plan 1 " <> gLink) + bob <## "group link: known group #team" + bob <## "use #team to send messages" + + let gLinkSchema2 = linkAnotherSchema gLink + bob ##> ("/_connect plan 1 " <> gLinkSchema2) + bob <## "group link: known group #team" + bob <## "use #team to send messages" + + bob ##> ("/c " <> gLink) + bob <## "group link: known group #team" + bob <## "use #team to send messages" + +testPlanHostContactDeletedGroupLinkKnown :: HasCallStack => FilePath -> IO () +testPlanHostContactDeletedGroupLinkKnown = + testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ + \alice bob -> do + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob (Bob): accepting request to join group #team..." + concurrentlyN_ + [ do + alice <## "bob (Bob): contact is connected" + alice <## "bob invited to group #team via your group link" + alice <## "#team: bob joined the group", + do + bob <## "alice (Alice): contact is connected" + bob <## "#team: you joined the group" + ] + alice #> "#team hi" + bob <# "#team alice> hi" + bob #> "#team hey" + alice <# "#team bob> hey" + + alice <##> bob + threadDelay 500000 + bob ##> "/d alice" + bob <## "alice: contact is deleted" + alice <## "bob (Bob) deleted contact with you" + + bob ##> ("/_connect plan 1 " <> gLink) + bob <## "group link: known group #team" + bob <## "use #team to send messages" + + let gLinkSchema2 = linkAnotherSchema gLink + bob ##> ("/_connect plan 1 " <> gLinkSchema2) + bob <## "group link: known group #team" + bob <## "use #team to send messages" + + bob ##> ("/c " <> gLink) + bob <## "group link: known group #team" + bob <## "use #team to send messages" + +testPlanGroupLinkOwn :: HasCallStack => FilePath -> IO () +testPlanGroupLinkOwn tmp = + withNewTestChatCfg tmp testCfgGroupLinkViaContact "alice" aliceProfile $ \alice -> do + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + + alice ##> ("/_connect plan 1 " <> gLink) + alice <## "group link: own link for group #team" + + let gLinkSchema2 = linkAnotherSchema gLink + alice ##> ("/_connect plan 1 " <> gLinkSchema2) + alice <## "group link: own link for group #team" + + alice ##> ("/c " <> gLink) + alice <## "connection request sent!" + alice <## "alice_1 (Alice): accepting request to join group #team..." + alice + <### [ "alice_1 (Alice): contact is connected", + "alice_1 invited to group #team via your group link", + "#team: alice_1 joined the group", + "alice_2 (Alice): contact is connected", + "#team_1: you joined the group", + "contact alice_2 is merged into alice_1", + "use @alice_1 to send messages" + ] + alice `send` "#team 1" + alice + <### [ WithTime "#team 1", + WithTime "#team_1 alice_1> 1" + ] + alice `send` "#team_1 2" + alice + <### [ WithTime "#team_1 2", + WithTime "#team alice_1> 2" + ] + + alice ##> ("/_connect plan 1 " <> gLink) + alice <## "group link: own link for group #team" + + alice ##> ("/_connect plan 1 " <> gLinkSchema2) + alice <## "group link: own link for group #team" + + -- group works if merged contact is deleted + alice ##> "/d alice_1" + alice <## "alice_1: contact is deleted" + + alice `send` "#team 3" + alice + <### [ WithTime "#team 3", + WithTime "#team_1 alice_1> 3" + ] + alice `send` "#team_1 4" + alice + <### [ WithTime "#team_1 4", + WithTime "#team alice_1> 4" + ] + +testPlanGroupLinkConnecting :: HasCallStack => FilePath -> IO () +testPlanGroupLinkConnecting tmp = do + gLink <- withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + getGroupLink alice "team" GRMember True + withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do + threadDelay 100000 + + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + + bob ##> ("/_connect plan 1 " <> gLink) + bob <## "group link: connecting, allowed to reconnect" + + let gLinkSchema2 = linkAnotherSchema gLink + bob ##> ("/_connect plan 1 " <> gLinkSchema2) + bob <## "group link: connecting, allowed to reconnect" + + threadDelay 100000 + withTestChatCfg tmp cfg "alice" $ \alice -> do + alice + <### [ "1 group links active", + "#team: group is empty", + "bob (Bob): accepting request to join group #team..." + ] + withTestChatCfg tmp cfg "bob" $ \bob -> do + threadDelay 500000 + bob ##> ("/_connect plan 1 " <> gLink) + bob <## "group link: connecting" + + let gLinkSchema2 = linkAnotherSchema gLink + bob ##> ("/_connect plan 1 " <> gLinkSchema2) + bob <## "group link: connecting" + + bob ##> ("/c " <> gLink) + bob <## "group link: connecting" + where + cfg = testCfgGroupLinkViaContact + +testPlanGroupLinkLeaveRejoin :: HasCallStack => FilePath -> IO () +testPlanGroupLinkLeaveRejoin = + testChatCfg2 testCfgGroupLinkViaContact aliceProfile bobProfile $ + \alice bob -> do + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob (Bob): accepting request to join group #team..." + concurrentlyN_ + [ do + alice <## "bob (Bob): contact is connected" + alice <## "bob invited to group #team via your group link" + alice <## "#team: bob joined the group", + do + bob <## "alice (Alice): contact is connected" + bob <## "#team: you joined the group" + ] + + bob ##> ("/_connect plan 1 " <> gLink) + bob <## "group link: known group #team" + bob <## "use #team to send messages" + + bob ##> ("/c " <> gLink) + bob <## "group link: known group #team" + bob <## "use #team to send messages" + + bob ##> "/leave #team" + concurrentlyN_ + [ do + bob <## "#team: you left the group" + bob <## "use /d #team to delete the group", + alice <## "#team: bob left the group" + ] + + bob ##> ("/_connect plan 1 " <> gLink) + bob <## "group link: ok to connect" + + let gLinkSchema2 = linkAnotherSchema gLink + bob ##> ("/_connect plan 1 " <> gLinkSchema2) + bob <## "group link: ok to connect" + + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob_1 (Bob): accepting request to join group #team..." + concurrentlyN_ + [ alice + <### [ "bob_1 (Bob): contact is connected", + EndsWith "invited to group #team via your group link", + EndsWith "joined the group", + "contact bob_1 is merged into bob", + "use @bob to send messages" + ], + bob + <### [ "alice_1 (Alice): contact is connected", + "#team_1: you joined the group", + "contact alice_1 is merged into alice", + "use @alice to send messages" + ] + ] + + alice #> "#team hi" + bob <# "#team_1 alice> hi" + bob #> "#team_1 hey" + alice <# "#team bob> hey" + + bob ##> ("/_connect plan 1 " <> gLink) + bob <## "group link: known group #team_1" + bob <## "use #team_1 to send messages" + + bob ##> ("/_connect plan 1 " <> gLinkSchema2) + bob <## "group link: known group #team_1" + bob <## "use #team_1 to send messages" + + bob ##> ("/c " <> gLink) + bob <## "group link: known group #team_1" + bob <## "use #team_1 to send messages" + +testGroupLinkNoContact :: HasCallStack => FilePath -> IO () +testGroupLinkNoContact = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob (Bob): accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: bob joined the group", + do + bob <## "#team: joining the group..." + bob <## "#team: you joined the group" + ] + + threadDelay 100000 + alice #$> ("/_get chat #1 count=100", chat, [(0, "invited via your group link"), (0, "connected")]) + + alice @@@ [("#team", "connected")] + bob @@@ [("#team", "connected")] + alice ##> "/contacts" + bob ##> "/contacts" + + alice #> "#team hello" + bob <# "#team alice> hello" + bob #> "#team hi there" + alice <# "#team bob> hi there" + +testGroupLinkNoContactMemberRole :: HasCallStack => FilePath -> IO () +testGroupLinkNoContactMemberRole = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team observer" + gLink <- getGroupLink alice "team" GRObserver True + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob (Bob): accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: bob joined the group", + do + bob <## "#team: joining the group..." + bob <## "#team: you joined the group" + ] + + threadDelay 100000 + + alice ##> "/ms team" + alice + <### [ "alice (Alice): owner, you, created group", + "bob (Bob): observer, invited, connected" + ] + + bob ##> "/ms team" + bob + <### [ "alice (Alice): owner, host, connected", + "bob (Bob): observer, you, connected" + ] + + bob ##> "#team hi there" + bob <## "#team: you don't have permission to send messages" + + alice ##> "/mr #team bob member" + alice <## "#team: you changed the role of bob from observer to member" + bob <## "#team: alice changed your role from observer to member" + + bob #> "#team hey now" + alice <# "#team bob> hey now" + +testGroupLinkNoContactHostIncognito :: HasCallStack => FilePath -> IO () +testGroupLinkNoContactHostIncognito = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + alice ##> "/g i team" + aliceIncognito <- getTermLine alice + alice <## ("group #team is created, your incognito profile for this group is " <> aliceIncognito) + alice <## "to add members use /create link #team" + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob (Bob): accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: bob joined the group", + do + bob <## "#team: joining the group..." + bob <## "#team: you joined the group" + ] + + threadDelay 100000 + alice #$> ("/_get chat #1 count=100", chat, [(0, "invited via your group link"), (0, "connected")]) + + alice @@@ [("#team", "connected")] + bob @@@ [("#team", "connected")] + alice ##> "/contacts" + bob ##> "/contacts" + + alice ?#> "#team hello" + bob <# ("#team " <> aliceIncognito <> "> hello") + bob #> "#team hi there" + alice ?<# "#team bob> hi there" + +testGroupLinkNoContactInviteeIncognito :: HasCallStack => FilePath -> IO () +testGroupLinkNoContactInviteeIncognito = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + bob ##> ("/c i " <> gLink) + bobIncognito <- getTermLine bob + bob <## "connection request sent incognito!" + alice <## (bobIncognito <> ": accepting request to join group #team...") + concurrentlyN_ + [ alice <## ("#team: " <> bobIncognito <> " joined the group"), + do + bob <## "#team: joining the group..." + bob <## ("#team: you joined the group incognito as " <> bobIncognito) + ] + + threadDelay 100000 + alice #$> ("/_get chat #1 count=100", chat, [(0, "invited via your group link"), (0, "connected")]) + + alice @@@ [("#team", "connected")] + bob @@@ [("#team", "connected")] + alice ##> "/contacts" + bob ##> "/contacts" + + alice #> "#team hello" + bob ?<# "#team alice> hello" + bob ?#> "#team hi there" + alice <# ("#team " <> bobIncognito <> "> hi there") + +testGroupLinkNoContactHostProfileReceived :: HasCallStack => FilePath -> IO () +testGroupLinkNoContactHostProfileReceived = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + let profileImage = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=" + alice ##> ("/set profile image " <> profileImage) + alice <## "profile image updated" + + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob (Bob): accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: bob joined the group", + do + bob <## "#team: joining the group..." + bob <## "#team: you joined the group" + ] + + threadDelay 100000 + + aliceImage <- getProfilePictureByName bob "alice" + aliceImage `shouldBe` Just profileImage + +testGroupLinkNoContactExistingContactMerged :: HasCallStack => FilePath -> IO () +testGroupLinkNoContactExistingContactMerged = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob_1 (Bob): accepting request to join group #team..." + concurrentlyN_ + [ do + alice <## "#team: bob_1 joined the group" + alice <## "contact and member are merged: bob, #team bob_1" + alice <## "use @bob to send messages", + do + bob <## "#team: joining the group..." + bob <## "#team: you joined the group" + bob <## "contact and member are merged: alice, #team alice_1" + bob <## "use @alice to send messages" + ] + + threadDelay 100000 + alice #$> ("/_get chat #1 count=100", chat, [(0, "invited via your group link"), (0, "connected")]) + + alice <##> bob + + alice @@@ [("#team", "connected"), ("@bob", "hey")] + bob @@@ [("#team", "connected"), ("@alice", "hey")] + alice ##> "/contacts" + alice <## "bob (Bob)" + bob ##> "/contacts" + bob <## "alice (Alice)" + + alice #> "#team hello" + bob <# "#team alice> hello" + bob #> "#team hi there" + alice <# "#team bob> hi there" + +testPlanGroupLinkNoContactKnown :: HasCallStack => FilePath -> IO () +testPlanGroupLinkNoContactKnown = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + + bob ##> ("/_connect plan 1 " <> gLink) + bob <## "group link: ok to connect" + + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob (Bob): accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: bob joined the group", + do + bob <## "#team: joining the group..." + bob <## "#team: you joined the group" + ] + + bob ##> ("/_connect plan 1 " <> gLink) + bob <## "group link: known group #team" + bob <## "use #team to send messages" + + let gLinkSchema2 = linkAnotherSchema gLink + bob ##> ("/_connect plan 1 " <> gLinkSchema2) + bob <## "group link: known group #team" + bob <## "use #team to send messages" + + bob ##> ("/c " <> gLink) + bob <## "group link: known group #team" + bob <## "use #team to send messages" + +testPlanGroupLinkNoContactConnecting :: HasCallStack => FilePath -> IO () +testPlanGroupLinkNoContactConnecting tmp = do + gLink <- withNewTestChat tmp "alice" aliceProfile $ \alice -> do + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/create link #team" + getGroupLink alice "team" GRMember True + withNewTestChat tmp "bob" bobProfile $ \bob -> do + threadDelay 100000 + + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + + bob ##> ("/_connect plan 1 " <> gLink) + bob <## "group link: connecting, allowed to reconnect" + + let gLinkSchema2 = linkAnotherSchema gLink + bob ##> ("/_connect plan 1 " <> gLinkSchema2) + bob <## "group link: connecting, allowed to reconnect" + + threadDelay 100000 + withTestChat tmp "alice" $ \alice -> do + alice + <### [ "1 group links active", + "#team: group is empty", + "bob (Bob): accepting request to join group #team..." + ] + withTestChat tmp "bob" $ \bob -> do + threadDelay 500000 + bob <## "#team: joining the group..." + + bob ##> ("/_connect plan 1 " <> gLink) + bob <## "group link: connecting to group #team" + + let gLinkSchema2 = linkAnotherSchema gLink + bob ##> ("/_connect plan 1 " <> gLinkSchema2) + bob <## "group link: connecting to group #team" + + bob ##> ("/c " <> gLink) + bob <## "group link: connecting to group #team" + testGroupMsgDecryptError :: HasCallStack => FilePath -> IO () testGroupMsgDecryptError tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do @@ -2734,8 +3367,8 @@ testNoDirectDifferentLDNs = bob <# ("#" <> gName <> " " <> cathLDN <> "> hey") ] -testConnectMemberToContact :: HasCallStack => FilePath -> IO () -testConnectMemberToContact = +testMergeMemberExistingContact :: HasCallStack => FilePath -> IO () +testMergeMemberExistingContact = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do connectUsers alice bob @@ -2750,13 +3383,15 @@ testConnectMemberToContact = [ do alice <## "#team: you joined the group" alice <## "#team: member cath_1 (Catherine) is connected" - alice <## "member #team cath_1 is merged into cath", + alice <## "contact and member are merged: cath, #team cath_1" + alice <## "use @cath to send messages", do bob <## "#team: alice joined the group", do cath <## "#team: bob added alice_1 (Alice) to the group (connecting...)" cath <## "#team: new member alice_1 is connected" - cath <## "member #team alice_1 is merged into alice" + cath <## "contact and member are merged: alice, #team alice_1" + cath <## "use @alice to send messages" ] alice <##> cath alice #> "#team hello" @@ -2779,6 +3414,124 @@ testConnectMemberToContact = alice `hasContactProfiles` ["alice", "bob", "cath"] cath `hasContactProfiles` ["cath", "alice", "bob"] +testMergeContactExistingMember :: HasCallStack => FilePath -> IO () +testMergeContactExistingMember = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup3 "team" alice bob cath + + bob ##> "/c" + inv' <- getInvitation bob + cath ##> ("/c " <> inv') + cath <## "confirmation sent!" + concurrentlyN_ + [ bob + <### [ "cath_1 (Catherine): contact is connected", + "contact and member are merged: cath_1, #team cath", + "use @cath to send messages" + ], + cath + <### [ "bob_1 (Bob): contact is connected", + "contact and member are merged: bob_1, #team bob", + "use @bob to send messages" + ] + ] + bob <##> cath + + bob ##> "/contacts" + bob <### ["alice (Alice)", "cath (Catherine)"] + cath ##> "/contacts" + cath <### ["alice (Alice)", "bob (Bob)"] + bob `hasContactProfiles` ["alice", "bob", "cath"] + cath `hasContactProfiles` ["cath", "alice", "bob"] + +testMergeContactMultipleMembers :: HasCallStack => FilePath -> IO () +testMergeContactMultipleMembers = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + create2Groups3 "team" "club" alice bob cath + + bob `hasContactProfiles` ["alice", "bob", "cath", "cath"] + cath `hasContactProfiles` ["cath", "alice", "bob", "bob"] + + bob ##> "/c" + inv' <- getInvitation bob + cath ##> ("/c " <> inv') + cath <## "confirmation sent!" + concurrentlyN_ + [ bob + <### [ "cath_2 (Catherine): contact is connected", + StartsWith "contact and member are merged: cath", + StartsWith "use @cath", + StartsWith "contact and member are merged: cath", + StartsWith "use @cath" + ], + cath + <### [ "bob_2 (Bob): contact is connected", + StartsWith "contact and member are merged: bob", + StartsWith "use @bob", + StartsWith "contact and member are merged: bob", + StartsWith "use @bob" + ] + ] + bob <##> cath + + bob ##> "/contacts" + bob <### ["alice (Alice)", "cath (Catherine)"] + cath ##> "/contacts" + cath <### ["alice (Alice)", "bob (Bob)"] + bob `hasContactProfiles` ["alice", "bob", "cath"] + cath `hasContactProfiles` ["cath", "alice", "bob"] + +testMergeGroupLinkHostMultipleContacts :: HasCallStack => FilePath -> IO () +testMergeGroupLinkHostMultipleContacts = + testChatCfg2 testCfgGroupLinkViaContact bobProfile cathProfile $ + \bob cath -> do + connectUsers bob cath + + bob ##> "/c" + inv' <- getInvitation bob + cath ##> ("/c " <> inv') + cath <## "confirmation sent!" + concurrently_ + (bob <## "cath_1 (Catherine): contact is connected") + (cath <## "bob_1 (Bob): contact is connected") + + bob `hasContactProfiles` ["bob", "cath", "cath"] + cath `hasContactProfiles` ["cath", "bob", "bob"] + + bob ##> "/g party" + bob <## "group #party is created" + bob <## "to add members use /a party or /create link #party" + bob ##> "/create link #party" + gLink <- getGroupLink bob "party" GRMember True + cath ##> ("/c " <> gLink) + cath <## "connection request sent!" + bob <## "cath_2 (Catherine): accepting request to join group #party..." + concurrentlyN_ + [ bob + <### [ "cath_2 (Catherine): contact is connected", + EndsWith "invited to group #party via your group link", + EndsWith "joined the group", + StartsWith "contact cath_2 is merged into cath", + StartsWith "use @cath" + ], + cath + <### [ "bob_2 (Bob): contact is connected", + "#party: you joined the group", + StartsWith "contact bob_2 is merged into bob", + StartsWith "use @bob" + ] + ] + bob <##> cath + + bob ##> "/contacts" + bob <### ["cath (Catherine)", "cath_1 (Catherine)"] + cath ##> "/contacts" + cath <### ["bob (Bob)", "bob_1 (Bob)"] + bob `hasContactProfiles` ["bob", "cath", "cath"] + cath `hasContactProfiles` ["cath", "bob", "bob"] + testMemberContactMessage :: HasCallStack => FilePath -> IO () testMemberContactMessage = testChat3 aliceProfile bobProfile cathProfile $ @@ -2891,9 +3644,9 @@ testMemberContactProhibitedRepeatInv = testMemberContactInvitedConnectionReplaced :: HasCallStack => FilePath -> IO () testMemberContactInvitedConnectionReplaced tmp = do - withNewTestChat tmp "alice" aliceProfile $ \alice -> do - withNewTestChat tmp "bob" bobProfile $ \bob -> do - withNewTestChat tmp "cath" cathProfile $ \cath -> do + withNewTestChat tmp "alice" aliceProfile $ \a -> withTestOutput a $ \alice -> do + withNewTestChat tmp "bob" bobProfile $ \b -> withTestOutput b $ \bob -> do + withNewTestChat tmp "cath" cathProfile $ \c -> withTestOutput c $ \cath -> do createGroup3 "team" alice bob cath alice ##> "/d bob" @@ -2916,7 +3669,9 @@ testMemberContactInvitedConnectionReplaced tmp = do (alice <## "bob (Bob): contact is connected") (bob <## "alice (Alice): contact is connected") - bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "received invitation to join group team as admin"), (0, "contact deleted"), (0, "hi"), (0, "security code changed")] <> chatFeatures) + bob ##> "/_get chat @2 count=100" + items <- chat <$> getTermLine bob + items `shouldContain` [(0, "security code changed")] withTestChat tmp "bob" $ \bob -> do subscriptions bob 1 @@ -2959,7 +3714,7 @@ testMemberContactInvitedConnectionReplaced tmp = do testMemberContactIncognito :: HasCallStack => FilePath -> IO () testMemberContactIncognito = - testChat3 aliceProfile bobProfile cathProfile $ + testChatCfg3 testCfgGroupLinkViaContact aliceProfile bobProfile cathProfile $ \alice bob cath -> do -- create group, bob joins incognito alice ##> "/g team" diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index da6cbd156f..d806290d6e 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -28,6 +28,11 @@ chatProfileTests = do it "delete connection requests when contact link deleted" testDeleteConnectionRequests it "auto-reply message" testAutoReplyMessage it "auto-reply message in incognito" testAutoReplyMessageInIncognito + describe "contact address connection plan" $ do + it "contact address ok to connect; known contact" testPlanAddressOkKnown + it "own contact address" testPlanAddressOwn + it "connecting via contact address" testPlanAddressConnecting + it "re-connect with deleted contact" testPlanAddressContactDeletedReconnected describe "incognito" $ do it "connect incognito via invitation link" testConnectIncognitoInvitationLink it "connect incognito via contact address" testConnectIncognitoContactAddress @@ -369,7 +374,8 @@ testDeduplicateContactRequests = testChat3 aliceProfile bobProfile cathProfile $ (alice <## "bob (Bob): contact is connected") bob ##> ("/c " <> cLink) - bob <## "alice (Alice): contact already exists" + bob <## "contact address: known contact alice" + bob <## "use @alice to send messages" alice @@@ [("@bob", lastChatFeature)] bob @@@ [("@alice", lastChatFeature), (":2", ""), (":1", "")] bob ##> "/_delete :1" @@ -382,7 +388,8 @@ testDeduplicateContactRequests = testChat3 aliceProfile bobProfile cathProfile $ bob @@@ [("@alice", "hey")] bob ##> ("/c " <> cLink) - bob <## "alice (Alice): contact already exists" + bob <## "contact address: known contact alice" + bob <## "use @alice to send messages" alice <##> bob alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "hi"), (0, "hey"), (1, "hi"), (0, "hey")]) @@ -440,7 +447,8 @@ testDeduplicateContactRequestsProfileChange = testChat3 aliceProfile bobProfile (alice <## "robert (Robert): contact is connected") bob ##> ("/c " <> cLink) - bob <## "alice (Alice): contact already exists" + bob <## "contact address: known contact alice" + bob <## "use @alice to send messages" alice @@@ [("@robert", lastChatFeature)] bob @@@ [("@alice", lastChatFeature), (":3", ""), (":2", ""), (":1", "")] bob ##> "/_delete :1" @@ -455,7 +463,8 @@ testDeduplicateContactRequestsProfileChange = testChat3 aliceProfile bobProfile bob @@@ [("@alice", "hey")] bob ##> ("/c " <> cLink) - bob <## "alice (Alice): contact already exists" + bob <## "contact address: known contact alice" + bob <## "use @alice to send messages" alice <##> bob alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "hi"), (0, "hey"), (1, "hi"), (0, "hey")]) @@ -566,6 +575,186 @@ testAutoReplyMessageInIncognito = testChat2 aliceProfile bobProfile $ ] ] +testPlanAddressOkKnown :: HasCallStack => FilePath -> IO () +testPlanAddressOkKnown = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + alice ##> "/ad" + cLink <- getContactLink alice True + + bob ##> ("/_connect plan 1 " <> cLink) + bob <## "contact address: ok to connect" + + bob ##> ("/c " <> cLink) + alice <#? bob + alice @@@ [("<@bob", "")] + alice ##> "/ac bob" + alice <## "bob (Bob): accepting contact request..." + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "bob (Bob): contact is connected") + alice <##> bob + + bob ##> ("/_connect plan 1 " <> cLink) + bob <## "contact address: known contact alice" + bob <## "use @alice to send messages" + + let cLinkSchema2 = linkAnotherSchema cLink + bob ##> ("/_connect plan 1 " <> cLinkSchema2) + bob <## "contact address: known contact alice" + bob <## "use @alice to send messages" + + bob ##> ("/c " <> cLink) + bob <## "contact address: known contact alice" + bob <## "use @alice to send messages" + +testPlanAddressOwn :: HasCallStack => FilePath -> IO () +testPlanAddressOwn tmp = + withNewTestChat tmp "alice" aliceProfile $ \alice -> do + alice ##> "/ad" + cLink <- getContactLink alice True + + alice ##> ("/_connect plan 1 " <> cLink) + alice <## "contact address: own address" + + let cLinkSchema2 = linkAnotherSchema cLink + alice ##> ("/_connect plan 1 " <> cLinkSchema2) + alice <## "contact address: own address" + + alice ##> ("/c " <> cLink) + alice <## "connection request sent!" + alice <## "alice_1 (Alice) wants to connect to you!" + alice <## "to accept: /ac alice_1" + alice <## "to reject: /rc alice_1 (the sender will NOT be notified)" + alice @@@ [("<@alice_1", ""), (":2","")] + alice ##> "/ac alice_1" + alice <## "alice_1 (Alice): accepting contact request..." + alice + <### [ "alice_1 (Alice): contact is connected", + "alice_2 (Alice): contact is connected" + ] + + alice @@@ [("@alice_1", lastChatFeature), ("@alice_2", lastChatFeature)] + alice `send` "@alice_2 hi" + alice + <### [ WithTime "@alice_2 hi", + WithTime "alice_1> hi" + ] + alice `send` "@alice_1 hey" + alice + <### [ WithTime "@alice_1 hey", + WithTime "alice_2> hey" + ] + alice @@@ [("@alice_1", "hey"), ("@alice_2", "hey")] + + alice ##> ("/_connect plan 1 " <> cLink) + alice <## "contact address: own address" + + alice ##> ("/c " <> cLink) + alice <## "alice_2 (Alice): contact already exists" + +testPlanAddressConnecting :: HasCallStack => FilePath -> IO () +testPlanAddressConnecting tmp = do + cLink <- withNewTestChat tmp "alice" aliceProfile $ \alice -> do + alice ##> "/ad" + getContactLink alice True + withNewTestChat tmp "bob" bobProfile $ \bob -> do + threadDelay 100000 + + bob ##> ("/c " <> cLink) + bob <## "connection request sent!" + + bob ##> ("/_connect plan 1 " <> cLink) + bob <## "contact address: connecting, allowed to reconnect" + + let cLinkSchema2 = linkAnotherSchema cLink + bob ##> ("/_connect plan 1 " <> cLinkSchema2) + bob <## "contact address: connecting, allowed to reconnect" + + threadDelay 100000 + withTestChat tmp "alice" $ \alice -> do + alice <## "Your address is active! To show: /sa" + alice <## "bob (Bob) wants to connect to you!" + alice <## "to accept: /ac bob" + alice <## "to reject: /rc bob (the sender will NOT be notified)" + alice ##> "/ac bob" + alice <## "bob (Bob): accepting contact request..." + withTestChat tmp "bob" $ \bob -> do + threadDelay 500000 + bob @@@ [("@alice", "")] + bob ##> ("/_connect plan 1 " <> cLink) + bob <## "contact address: connecting to contact alice" + + let cLinkSchema2 = linkAnotherSchema cLink + bob ##> ("/_connect plan 1 " <> cLinkSchema2) + bob <## "contact address: connecting to contact alice" + + bob ##> ("/c " <> cLink) + bob <## "contact address: connecting to contact alice" + +testPlanAddressContactDeletedReconnected :: HasCallStack => FilePath -> IO () +testPlanAddressContactDeletedReconnected = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + alice ##> "/ad" + cLink <- getContactLink alice True + + bob ##> ("/c " <> cLink) + alice <#? bob + alice ##> "/ac bob" + alice <## "bob (Bob): accepting contact request..." + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "bob (Bob): contact is connected") + alice <##> bob + + bob ##> ("/_connect plan 1 " <> cLink) + bob <## "contact address: known contact alice" + bob <## "use @alice to send messages" + + bob ##> ("/c " <> cLink) + bob <## "contact address: known contact alice" + bob <## "use @alice to send messages" + + alice ##> "/d bob" + alice <## "bob: contact is deleted" + bob <## "alice (Alice) deleted contact with you" + + bob ##> ("/_connect plan 1 " <> cLink) + bob <## "contact address: ok to connect" + + let cLinkSchema2 = linkAnotherSchema cLink + bob ##> ("/_connect plan 1 " <> cLinkSchema2) + bob <## "contact address: ok to connect" + + bob ##> ("/c " <> cLink) + bob <## "connection request sent!" + alice <## "bob (Bob) wants to connect to you!" + alice <## "to accept: /ac bob" + alice <## "to reject: /rc bob (the sender will NOT be notified)" + alice ##> "/ac bob" + alice <## "bob (Bob): accepting contact request..." + concurrently_ + (bob <## "alice_1 (Alice): contact is connected") + (alice <## "bob (Bob): contact is connected") + + alice #> "@bob hi" + bob <# "alice_1> hi" + bob #> "@alice_1 hey" + alice <# "bob> hey" + + bob ##> ("/_connect plan 1 " <> cLink) + bob <## "contact address: known contact alice_1" + bob <## "use @alice_1 to send messages" + + bob ##> ("/_connect plan 1 " <> cLinkSchema2) + bob <## "contact address: known contact alice_1" + bob <## "use @alice_1 to send messages" + + bob ##> ("/c " <> cLink) + bob <## "contact address: known contact alice_1" + bob <## "use @alice_1 to send messages" + testConnectIncognitoInvitationLink :: HasCallStack => FilePath -> IO () testConnectIncognitoInvitationLink = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -896,7 +1085,7 @@ testJoinGroupIncognito = ] -- cath cannot invite to the group because her membership is incognito cath ##> "/a secret_club dan" - cath <## "you've connected to this group using an incognito profile - prohibited to invite contacts" + cath <## "you are using an incognito profile for this group - prohibited to invite contacts" -- alice invites dan alice ##> "/a secret_club dan admin" concurrentlyN_ diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index 6831cf3190..f0f47978b5 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -17,12 +17,14 @@ import Data.List (isPrefixOf, isSuffixOf) import Data.Maybe (fromMaybe) import Data.String import qualified Data.Text as T +import Database.SQLite.Simple (Only (..)) import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), InlineFilesConfig (..), defaultInlineFilesConfig) import Simplex.Chat.Protocol import Simplex.Chat.Store.Profiles (getUserContactProfiles) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences -import Simplex.Messaging.Agent.Store.SQLite (withTransaction) +import Simplex.Messaging.Agent.Store.SQLite (maybeFirstRow, withTransaction) +import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import Simplex.Messaging.Encoding.String import Simplex.Messaging.Version import System.Directory (doesFileExist) @@ -277,7 +279,7 @@ cc <##.. ls = do unless prefix $ print ("expected to start from one of: " <> show ls, ", got: " <> l) prefix `shouldBe` True -data ConsoleResponse = ConsoleString String | WithTime String | EndsWith String +data ConsoleResponse = ConsoleString String | WithTime String | EndsWith String | StartsWith String deriving (Show) instance IsString ConsoleResponse where fromString = ConsoleString @@ -287,7 +289,7 @@ getInAnyOrder :: HasCallStack => (String -> String) -> TestCC -> [ConsoleRespons getInAnyOrder _ _ [] = pure () getInAnyOrder f cc ls = do line <- f <$> getTermLine cc - let rest = filter (not . expected line) ls + let rest = filterFirst (expected line) ls if length rest < length ls then getInAnyOrder f cc rest else error $ "unexpected output: " <> line @@ -297,6 +299,12 @@ getInAnyOrder f cc ls = do ConsoleString s -> l == s WithTime s -> dropTime_ l == Just s EndsWith s -> s `isSuffixOf` l + StartsWith s -> s `isPrefixOf` l + filterFirst :: (a -> Bool) -> [a] -> [a] + filterFirst _ [] = [] + filterFirst p (x:xs) + | p x = xs + | otherwise = x : filterFirst p xs (<###) :: HasCallStack => TestCC -> [ConsoleResponse] -> Expectation (<###) = getInAnyOrder id @@ -427,6 +435,12 @@ getContactProfiles cc = do profiles <- withTransaction (chatStore $ chatController cc) $ \db -> getUserContactProfiles db user pure $ map (\Profile {displayName} -> displayName) profiles +getProfilePictureByName :: TestCC -> String -> IO (Maybe String) +getProfilePictureByName cc displayName = + withTransaction (chatStore $ chatController cc) $ \db -> + maybeFirstRow fromOnly $ + DB.query db "SELECT image FROM contact_profiles WHERE display_name = ? LIMIT 1" (Only displayName) + lastItemId :: HasCallStack => TestCC -> IO String lastItemId cc = do cc ##> "/last_item_id" @@ -456,8 +470,11 @@ showName (TestCC ChatController {currentUser} _ _ _ _ _) = do pure . T.unpack $ localDisplayName <> optionalFullName localDisplayName fullName createGroup2 :: HasCallStack => String -> TestCC -> TestCC -> IO () -createGroup2 gName cc1 cc2 = do - connectUsers cc1 cc2 +createGroup2 gName cc1 cc2 = createGroup2' gName cc1 cc2 True + +createGroup2' :: HasCallStack => String -> TestCC -> TestCC -> Bool -> IO () +createGroup2' gName cc1 cc2 doConnectUsers = do + when doConnectUsers $ connectUsers cc1 cc2 name2 <- userName cc2 cc1 ##> ("/g " <> gName) cc1 <## ("group #" <> gName <> " is created") @@ -488,6 +505,24 @@ createGroup3 gName cc1 cc2 cc3 = do cc2 <## ("#" <> gName <> ": new member " <> name3 <> " is connected") ] +create2Groups3 :: HasCallStack => String -> String -> TestCC -> TestCC -> TestCC -> IO () +create2Groups3 gName1 gName2 cc1 cc2 cc3 = do + createGroup3 gName1 cc1 cc2 cc3 + createGroup2' gName2 cc1 cc2 False + name1 <- userName cc1 + name3 <- userName cc3 + addMember gName2 cc1 cc3 GRAdmin + cc3 ##> ("/j " <> gName2) + concurrentlyN_ + [ cc1 <## ("#" <> gName2 <> ": " <> name3 <> " joined the group"), + do + cc3 <## ("#" <> gName2 <> ": you joined the group") + cc3 <##. ("#" <> gName2 <> ": member "), -- "#gName2: member sName2 is connected" + do + cc2 <##. ("#" <> gName2 <> ": " <> name1 <> " added ") -- "#gName2: name1 added sName3 to the group (connecting...)" + cc2 <##. ("#" <> gName2 <> ": new member ") -- "#gName2: new member name3 is connected" + ] + addMember :: HasCallStack => String -> TestCC -> TestCC -> GroupMemberRole -> IO () addMember gName = fullAddMember gName "" @@ -532,3 +567,11 @@ currentChatVRangeInfo = vRangeStr :: VersionRange -> String vRangeStr (VersionRange minVer maxVer) = "(" <> show minVer <> ", " <> show maxVer <> ")" + +linkAnotherSchema :: String -> String +linkAnotherSchema link + | "https://simplex.chat/" `isPrefixOf` link = + T.unpack $ T.replace "https://simplex.chat/" "simplex:/" $ T.pack link + | "simplex:/" `isPrefixOf` link = + T.unpack $ T.replace "simplex:/" "https://simplex.chat/" $ T.pack link + | otherwise = error "link starts with neither https://simplex.chat/ nor simplex:/" diff --git a/tests/MarkdownTests.hs b/tests/MarkdownTests.hs index 837849d7e4..1cd2aa2c47 100644 --- a/tests/MarkdownTests.hs +++ b/tests/MarkdownTests.hs @@ -137,13 +137,15 @@ textColor = describe "text color (red)" do uri :: Text -> Markdown uri = Markdown $ Just Uri -simplexLink :: SimplexLinkType -> Text -> Bool -> NonEmpty Text -> Text -> Markdown -simplexLink linkType simplexUri trustedUri smpHosts = Markdown $ Just SimplexLink {linkType, simplexUri, trustedUri, smpHosts} +simplexLink :: SimplexLinkType -> Text -> NonEmpty Text -> Text -> Markdown +simplexLink linkType simplexUri smpHosts = Markdown $ Just SimplexLink {linkType, simplexUri, smpHosts} textWithUri :: Spec textWithUri = describe "text with Uri" do it "correct markdown" do parseMarkdown "https://simplex.chat" `shouldBe` uri "https://simplex.chat" + parseMarkdown "https://simplex.chat." `shouldBe` uri "https://simplex.chat" <> "." + parseMarkdown "https://simplex.chat, hello" `shouldBe` uri "https://simplex.chat" <> ", hello" parseMarkdown "http://simplex.chat" `shouldBe` uri "http://simplex.chat" parseMarkdown "this is https://simplex.chat" `shouldBe` "this is " <> uri "https://simplex.chat" parseMarkdown "https://simplex.chat site" `shouldBe` uri "https://simplex.chat" <> " site" @@ -152,13 +154,13 @@ textWithUri = describe "text with Uri" do parseMarkdown "this is _https://simplex.chat" `shouldBe` "this is _https://simplex.chat" it "SimpleX links" do let inv = "/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D" - parseMarkdown ("https://simplex.chat" <> inv) `shouldBe` simplexLink XLInvitation ("simplex:" <> inv) True ["smp.simplex.im"] ("https://simplex.chat" <> inv) - parseMarkdown ("simplex:" <> inv) `shouldBe` simplexLink XLInvitation ("simplex:" <> inv) True ["smp.simplex.im"] ("simplex:" <> inv) - parseMarkdown ("https://example.com" <> inv) `shouldBe` simplexLink XLInvitation ("simplex:" <> inv) False ["smp.simplex.im"] ("https://example.com" <> inv) + parseMarkdown ("https://simplex.chat" <> inv) `shouldBe` simplexLink XLInvitation ("simplex:" <> inv) ["smp.simplex.im"] ("https://simplex.chat" <> inv) + parseMarkdown ("simplex:" <> inv) `shouldBe` simplexLink XLInvitation ("simplex:" <> inv) ["smp.simplex.im"] ("simplex:" <> inv) + parseMarkdown ("https://example.com" <> inv) `shouldBe` simplexLink XLInvitation ("simplex:" <> inv) ["smp.simplex.im"] ("https://example.com" <> inv) let ct = "/contact#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D" - parseMarkdown ("https://simplex.chat" <> ct) `shouldBe` simplexLink XLContact ("simplex:" <> ct) True ["smp.simplex.im"] ("https://simplex.chat" <> ct) + parseMarkdown ("https://simplex.chat" <> ct) `shouldBe` simplexLink XLContact ("simplex:" <> ct) ["smp.simplex.im"] ("https://simplex.chat" <> ct) let gr = "/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FWHV0YU1sYlU7NqiEHkHDB6gxO1ofTync%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAWbebOqVYuBXaiqHcXYjEHCpYi6VzDlu6CVaijDTmsQU%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22mL-7Divb94GGmGmRBef5Dg%3D%3D%22%7D" - parseMarkdown ("https://simplex.chat" <> gr) `shouldBe` simplexLink XLGroup ("simplex:" <> gr) True ["smp4.simplex.im", "o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion"] ("https://simplex.chat" <> gr) + parseMarkdown ("https://simplex.chat" <> gr) `shouldBe` simplexLink XLGroup ("simplex:" <> gr) ["smp4.simplex.im", "o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion"] ("https://simplex.chat" <> gr) email :: Text -> Markdown email = Markdown $ Just Email diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index 585395566c..c895ab5309 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -65,71 +65,134 @@ mobileTests = do it "should convert invalid name to a valid name" testValidNameCApi noActiveUser :: LB.ByteString +noActiveUser = #if defined(darwin_HOST_OS) && defined(swiftJSON) -noActiveUser = "{\"resp\":{\"chatCmdError\":{\"chatError\":{\"error\":{\"errorType\":{\"noActiveUser\":{}}}}}}}" + noActiveUserSwift #else -noActiveUser = "{\"resp\":{\"type\":\"chatCmdError\",\"chatError\":{\"type\":\"error\",\"errorType\":{\"type\":\"noActiveUser\"}}}}" + noActiveUserTagged #endif +noActiveUserSwift :: LB.ByteString +noActiveUserSwift = "{\"resp\":{\"_owsf\":true,\"chatCmdError\":{\"chatError\":{\"_owsf\":true,\"error\":{\"errorType\":{\"_owsf\":true,\"noActiveUser\":{}}}}}}}" + +noActiveUserTagged :: LB.ByteString +noActiveUserTagged = "{\"resp\":{\"type\":\"chatCmdError\",\"chatError\":{\"type\":\"error\",\"errorType\":{\"type\":\"noActiveUser\"}}}}" + activeUserExists :: LB.ByteString +activeUserExists = #if defined(darwin_HOST_OS) && defined(swiftJSON) -activeUserExists = "{\"resp\":{\"chatCmdError\":{\"user_\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true},\"chatError\":{\"error\":{\"errorType\":{\"userExists\":{\"contactName\":\"alice\"}}}}}}}" + activeUserExistsSwift #else -activeUserExists = "{\"resp\":{\"type\":\"chatCmdError\",\"user_\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true},\"chatError\":{\"type\":\"error\",\"errorType\":{\"type\":\"userExists\",\"contactName\":\"alice\"}}}}" + activeUserExistsTagged #endif +activeUserExistsSwift :: LB.ByteString +activeUserExistsSwift = "{\"resp\":{\"_owsf\":true,\"chatCmdError\":{\"user_\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true},\"chatError\":{\"_owsf\":true,\"error\":{\"errorType\":{\"_owsf\":true,\"userExists\":{\"contactName\":\"alice\"}}}}}}}" + +activeUserExistsTagged :: LB.ByteString +activeUserExistsTagged = "{\"resp\":{\"type\":\"chatCmdError\",\"user_\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true},\"chatError\":{\"type\":\"error\",\"errorType\":{\"type\":\"userExists\",\"contactName\":\"alice\"}}}}" + activeUser :: LB.ByteString +activeUser = #if defined(darwin_HOST_OS) && defined(swiftJSON) -activeUser = "{\"resp\":{\"activeUser\":{\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}}}}" + activeUserSwift #else -activeUser = "{\"resp\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}}}" + activeUserTagged #endif +activeUserSwift :: LB.ByteString +activeUserSwift = "{\"resp\":{\"_owsf\":true,\"activeUser\":{\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}}}}" + +activeUserTagged :: LB.ByteString +activeUserTagged = "{\"resp\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}}}" + chatStarted :: LB.ByteString +chatStarted = #if defined(darwin_HOST_OS) && defined(swiftJSON) -chatStarted = "{\"resp\":{\"chatStarted\":{}}}" + chatStartedSwift #else -chatStarted = "{\"resp\":{\"type\":\"chatStarted\"}}" + chatStartedTagged #endif -contactSubSummary :: LB.ByteString +chatStartedSwift :: LB.ByteString +chatStartedSwift = "{\"resp\":{\"_owsf\":true,\"chatStarted\":{}}}" + +chatStartedTagged :: LB.ByteString +chatStartedTagged = "{\"resp\":{\"type\":\"chatStarted\"}}" + +networkStatuses :: LB.ByteString +networkStatuses = #if defined(darwin_HOST_OS) && defined(swiftJSON) -contactSubSummary = "{\"resp\":{\"contactSubSummary\":{" <> userJSON <> ",\"contactSubscriptions\":[]}}}" + networkStatusesSwift #else -contactSubSummary = "{\"resp\":{\"type\":\"contactSubSummary\"," <> userJSON <> ",\"contactSubscriptions\":[]}}" + networkStatusesTagged #endif +networkStatusesSwift :: LB.ByteString +networkStatusesSwift = "{\"resp\":{\"_owsf\":true,\"networkStatuses\":{\"user_\":" <> userJSON <> ",\"networkStatuses\":[]}}}" + +networkStatusesTagged :: LB.ByteString +networkStatusesTagged = "{\"resp\":{\"type\":\"networkStatuses\",\"user_\":" <> userJSON <> ",\"networkStatuses\":[]}}" + memberSubSummary :: LB.ByteString +memberSubSummary = #if defined(darwin_HOST_OS) && defined(swiftJSON) -memberSubSummary = "{\"resp\":{\"memberSubSummary\":{" <> userJSON <> ",\"memberSubscriptions\":[]}}}" + memberSubSummarySwift #else -memberSubSummary = "{\"resp\":{\"type\":\"memberSubSummary\"," <> userJSON <> ",\"memberSubscriptions\":[]}}" + memberSubSummaryTagged #endif +memberSubSummarySwift :: LB.ByteString +memberSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"memberSubSummary\":{\"user\":" <> userJSON <> ",\"memberSubscriptions\":[]}}}" + +memberSubSummaryTagged :: LB.ByteString +memberSubSummaryTagged = "{\"resp\":{\"type\":\"memberSubSummary\",\"user\":" <> userJSON <> ",\"memberSubscriptions\":[]}}" + userContactSubSummary :: LB.ByteString +userContactSubSummary = #if defined(darwin_HOST_OS) && defined(swiftJSON) -userContactSubSummary = "{\"resp\":{\"userContactSubSummary\":{" <> userJSON <> ",\"userContactSubscriptions\":[]}}}" + userContactSubSummarySwift #else -userContactSubSummary = "{\"resp\":{\"type\":\"userContactSubSummary\"," <> userJSON <> ",\"userContactSubscriptions\":[]}}" + userContactSubSummaryTagged #endif +userContactSubSummarySwift :: LB.ByteString +userContactSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"userContactSubSummary\":{\"user\":" <> userJSON <> ",\"userContactSubscriptions\":[]}}}" + +userContactSubSummaryTagged :: LB.ByteString +userContactSubSummaryTagged = "{\"resp\":{\"type\":\"userContactSubSummary\",\"user\":" <> userJSON <> ",\"userContactSubscriptions\":[]}}" + pendingSubSummary :: LB.ByteString +pendingSubSummary = #if defined(darwin_HOST_OS) && defined(swiftJSON) -pendingSubSummary = "{\"resp\":{\"pendingSubSummary\":{" <> userJSON <> ",\"pendingSubscriptions\":[]}}}" + pendingSubSummarySwift #else -pendingSubSummary = "{\"resp\":{\"type\":\"pendingSubSummary\"," <> userJSON <> ",\"pendingSubscriptions\":[]}}" + pendingSubSummaryTagged #endif +pendingSubSummarySwift :: LB.ByteString +pendingSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"pendingSubSummary\":{\"user\":" <> userJSON <> ",\"pendingSubscriptions\":[]}}}" + +pendingSubSummaryTagged :: LB.ByteString +pendingSubSummaryTagged = "{\"resp\":{\"type\":\"pendingSubSummary\",\"user\":" <> userJSON <> ",\"pendingSubscriptions\":[]}}" + userJSON :: LB.ByteString -userJSON = "\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}" +userJSON = "{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}" parsedMarkdown :: LB.ByteString +parsedMarkdown = #if defined(darwin_HOST_OS) && defined(swiftJSON) -parsedMarkdown = "{\"formattedText\":[{\"format\":{\"bold\":{}},\"text\":\"hello\"}]}" + parsedMarkdownSwift #else -parsedMarkdown = "{\"formattedText\":[{\"format\":{\"type\":\"bold\"},\"text\":\"hello\"}]}" + parsedMarkdownTagged #endif +parsedMarkdownSwift :: LB.ByteString +parsedMarkdownSwift = "{\"formattedText\":[{\"format\":{\"_owsf\":true,\"bold\":{}},\"text\":\"hello\"}]}" + +parsedMarkdownTagged :: LB.ByteString +parsedMarkdownTagged = "{\"formattedText\":[{\"format\":{\"type\":\"bold\"},\"text\":\"hello\"}]}" + testChatApiNoUser :: FilePath -> IO () testChatApiNoUser tmp = do let dbPrefix = tmp "1" @@ -157,7 +220,7 @@ testChatApi tmp = do chatSendCmd cc "/u" `shouldReturn` activeUser chatSendCmd cc "/create user alice Alice" `shouldReturn` activeUserExists chatSendCmd cc "/_start" `shouldReturn` chatStarted - chatRecvMsg cc `shouldReturn` contactSubSummary + chatRecvMsg cc `shouldReturn` networkStatuses chatRecvMsg cc `shouldReturn` userContactSubSummary chatRecvMsg cc `shouldReturn` memberSubSummary chatRecvMsgWait cc 10000 `shouldReturn` pendingSubSummary diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index d62d7a470a..f5c1bf8560 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -39,7 +39,7 @@ queue = connReqData :: ConnReqUriData connReqData = ConnReqUriData - { crScheme = simplexChat, + { crScheme = CRSSimplex, crAgentVRange = mkVersionRange 1 1, crSmpQueues = [queue], crClientData = Nothing @@ -122,7 +122,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) it "x.msg.new chat message with chat version range" $ - "{\"v\":\"1-2\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + "{\"v\":\"1-3\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage supportedChatVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) it "x.msg.new quote" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}" @@ -184,7 +184,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.msg.deleted\",\"params\":{}}" #==# XMsgDeleted it "x.file" $ - "{\"v\":\"1\",\"event\":\"x.file\",\"params\":{\"file\":{\"fileConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" + "{\"v\":\"1\",\"event\":\"x.file\",\"params\":{\"file\":{\"fileConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" #==# XFile FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Just testConnReq, fileInline = Nothing, fileDescr = Nothing} it "x.file without file invitation" $ "{\"v\":\"1\",\"event\":\"x.file\",\"params\":{\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" @@ -193,7 +193,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.file.acpt\",\"params\":{\"fileName\":\"photo.jpg\"}}" #==# XFileAcpt "photo.jpg" it "x.file.acpt.inv" $ - "{\"v\":\"1\",\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\",\"fileConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}" + "{\"v\":\"1\",\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\",\"fileConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}" #==# XFileAcptInv (SharedMsgId "\1\2\3\4") (Just testConnReq) "photo.jpg" it "x.file.acpt.inv" $ "{\"v\":\"1\",\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\"}}" @@ -220,10 +220,10 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.contact\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" ==# XContact testProfile Nothing it "x.grp.inv" $ - "{\"v\":\"1\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}" #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, groupLinkId = Nothing} it "x.grp.inv with group link id" $ - "{\"v\":\"1\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}, \"groupLinkId\":\"AQIDBA==\"}}}" + "{\"v\":\"1\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}, \"groupLinkId\":\"AQIDBA==\"}}}" #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, groupLinkId = Just $ GroupLinkId "\1\2\3\4"} it "x.grp.acpt without incognito profile" $ "{\"v\":\"1\",\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\"}}" @@ -232,25 +232,25 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} it "x.grp.mem.new with member chat version range" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-2\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-3\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} it "x.grp.mem.intro" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} it "x.grp.mem.intro with member chat version range" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-2\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-3\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} it "x.grp.mem.inv" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"directConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" #==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} it "x.grp.mem.inv w/t directConnReq" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"groupConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" #==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} it "x.grp.mem.fwd" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} it "x.grp.mem.fwd with member chat version range and w/t directConnReq" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-2\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-3\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} it "x.grp.mem.info" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" @@ -271,10 +271,10 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.del\",\"params\":{}}" ==# XGrpDel it "x.grp.direct.inv" $ - "{\"v\":\"1\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\", \"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + "{\"v\":\"1\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\", \"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" #==# XGrpDirectInv testConnReq (Just $ MCText "hello") it "x.grp.direct.inv without content" $ - "{\"v\":\"1\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}" + "{\"v\":\"1\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}" #==# XGrpDirectInv testConnReq Nothing it "x.info.probe" $ "{\"v\":\"1\",\"event\":\"x.info.probe\",\"params\":{\"probe\":\"AQIDBA==\"}}" diff --git a/tests/SchemaDump.hs b/tests/SchemaDump.hs index f4538e4b3b..f517d13df1 100644 --- a/tests/SchemaDump.hs +++ b/tests/SchemaDump.hs @@ -71,7 +71,9 @@ skipComparisonForDownMigrations = -- on down migration idx_chat_items_timed_delete_at index moves down to the end of the file "20230529_indexes", -- table and index definitions move down the file, so fields are re-created as not unique - "20230914_member_probes" + "20230914_member_probes", + -- on down migration idx_connections_via_contact_uri_hash index moves down to the end of the file + "20231019_indexes" ] getSchema :: FilePath -> FilePath -> IO String diff --git a/tests/ValidNames.hs b/tests/ValidNames.hs index 40cda01431..0700d80846 100644 --- a/tests/ValidNames.hs +++ b/tests/ValidNames.hs @@ -14,14 +14,26 @@ testMkValidName = do mkValidName "John Doe" `shouldBe` "John Doe" mkValidName "J.Doe" `shouldBe` "J.Doe" mkValidName "J. Doe" `shouldBe` "J. Doe" - mkValidName "J..Doe" `shouldBe` "J.Doe" - mkValidName "J ..Doe" `shouldBe` "J Doe" - mkValidName "J . . Doe" `shouldBe` "J Doe" + mkValidName "J..Doe" `shouldBe` "J..Doe" + mkValidName "J ..Doe" `shouldBe` "J ..Doe" + mkValidName "J ... Doe" `shouldBe` "J ... Doe" + mkValidName "J .... Doe" `shouldBe` "J ... Doe" + mkValidName "J . . Doe" `shouldBe` "J . Doe" mkValidName "@alice" `shouldBe` "alice" mkValidName "#alice" `shouldBe` "alice" mkValidName " alice" `shouldBe` "alice" mkValidName "alice " `shouldBe` "alice" mkValidName "John Doe" `shouldBe` "John Doe" mkValidName "'John Doe'" `shouldBe` "John Doe" - mkValidName "\"John Doe\"" `shouldBe` "John Doe" - mkValidName "`John Doe`" `shouldBe` "John Doe" + mkValidName "\"John Doe\"" `shouldBe` "John Doe\"" + mkValidName "`John Doe`" `shouldBe` "`John Doe`" + mkValidName "John \"Doe\"" `shouldBe` "John \"Doe\"" + mkValidName "John `Doe`" `shouldBe` "John `Doe`" + mkValidName "alice/bob" `shouldBe` "alice/bob" + mkValidName "alice / bob" `shouldBe` "alice / bob" + mkValidName "alice /// bob" `shouldBe` "alice /// bob" + mkValidName "alice //// bob" `shouldBe` "alice /// bob" + mkValidName "alice >>= bob" `shouldBe` "alice >>= bob" + mkValidName "alice@example.com" `shouldBe` "alice@example.com" + mkValidName "alice <> bob" `shouldBe` "alice <> bob" + mkValidName "alice -> bob" `shouldBe` "alice -> bob" diff --git a/website/langs/en.json b/website/langs/en.json index 434aed8343..1bb64c7efa 100644 --- a/website/langs/en.json +++ b/website/langs/en.json @@ -37,12 +37,12 @@ "hero-overlay-2-title": "Why user IDs are bad for privacy?", "hero-overlay-3-title": "Security assessment", "feature-1-title": "E2E-encrypted messages with markdown and editing", - "feature-2-title": "E2E-encrypted
images and files", - "feature-3-title": "Decentralized secret groups —
only users know they exist", + "feature-2-title": "E2E-encrypted
images, videos and files", + "feature-3-title": "E2E-encrypted decentralized groups — only users know they exist", "feature-4-title": "E2E-encrypted voice messages", "feature-5-title": "Disappearing messages", "feature-6-title": "E2E-encrypted
audio and video calls", - "feature-7-title": "Portable encrypted database — move your profile to another device", + "feature-7-title": "Portable encrypted app storage — move profile to another device", "feature-8-title": "Incognito mode —
unique to SimpleX Chat", "simplex-network-overlay-1-title": "Comparison with P2P messaging protocols", "simplex-private-1-title": "2-layers of
end-to-end encryption", diff --git a/website/src/_includes/contact_page.html b/website/src/_includes/contact_page.html index a03a3aea4f..6beb148f8d 100644 --- a/website/src/_includes/contact_page.html +++ b/website/src/_includes/contact_page.html @@ -152,7 +152,7 @@ v1.0.0+, {{ "copy-the-command-below-text" | i18n({}, lang ) | safe }}

- /c https://simplex.chat/contact#/?v=1&smp=smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im/KBCmxJ3-lEjpWLPPkI6OWPk-YJneU5uY%23MCowBQYDK2VuAyEAtixHJWDXvYWcoe-77vIfjvI6XWEuzUsapMS9nVHP_Go= +