diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7f72d114b9..a86d4790a8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -221,6 +221,16 @@ jobs: done strip /out/simplex-chat + - name: Build CLI deb + if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true + shell: docker exec -t builder sh -eu {0} + run: | + version=${{ github.ref }} + version=${version#refs/tags/v} + version=${version%-*} + + ./scripts/desktop/build-cli-deb.sh "$version" + - name: Copy tests from container if: matrix.should_run == true shell: bash @@ -232,21 +242,41 @@ jobs: if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true shell: bash run: | - docker cp builder:/out/simplex-chat ./simplex-chat-ubuntu-${{ matrix.os_underscore }}-${{ matrix.arch }} - path="${{ github.workspace }}/simplex-chat-ubuntu-${{ matrix.os_underscore }}-${{ matrix.arch }}" - echo "bin_path=$path" >> $GITHUB_OUTPUT - echo "bin_hash=$(echo SHA2-256\(simplex-chat-ubuntu-${{ matrix.os_underscore }}-${{ matrix.arch }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT + cli_name="simplex-chat-ubuntu-${{ matrix.os_underscore }}-${{ matrix.arch }}" + cli_deb_name="${cli_name}.deb" + cli_path="${{ github.workspace }}" + + docker cp builder:/out/simplex-chat "./${cli_name}" + docker cp builder:/out/deb-build/simplex-chat.deb "./${cli_deb_name}" + + echo "bin_name=${cli_name}" >> $GITHUB_OUTPUT + echo "bin_path=${cli_path}/${cli_name}" >> $GITHUB_OUTPUT + echo "bin_hash=$(echo SHA2-256\(${cli_name}\)= $(openssl sha256 "${cli_path}/${cli_name}" | cut -d' ' -f 2))" >> $GITHUB_OUTPUT + + echo "deb_name=${cli_deb_name}" >> $GITHUB_OUTPUT + echo "deb_path=${cli_path}/${cli_deb_name}" >> $GITHUB_OUTPUT + echo "deb_hash=$(echo SHA2-256\(${cli_deb_name}\)= $(openssl sha256 "${cli_path}/${cli_deb_name}" | cut -d' ' -f 2))" >> $GITHUB_OUTPUT - name: Upload CLI if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true uses: ./.github/actions/prepare-release with: + bin_name: ${{ steps.linux_cli_prepare.outputs.bin_name }} bin_path: ${{ steps.linux_cli_prepare.outputs.bin_path }} - bin_name: simplex-chat-ubuntu-${{ matrix.os_underscore }}-${{ matrix.arch }} bin_hash: ${{ steps.linux_cli_prepare.outputs.bin_hash }} github_ref: ${{ github.ref }} github_token: ${{ secrets.GITHUB_TOKEN }} + - name: Upload CLI deb + if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true + uses: ./.github/actions/prepare-release + with: + bin_name: ${{ steps.linux_cli_prepare.outputs.deb_name }} + bin_path: ${{ steps.linux_cli_prepare.outputs.deb_path }} + bin_hash: ${{ steps.linux_cli_prepare.outputs.deb_hash }} + github_ref: ${{ github.ref }} + github_token: ${{ secrets.GITHUB_TOKEN }} + - name: Build Desktop if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true shell: docker exec -t builder sh -eu {0} diff --git a/.github/workflows/reproduce-schedule.yml b/.github/workflows/reproduce-schedule.yml index 7d28d6f70c..0febed4c87 100644 --- a/.github/workflows/reproduce-schedule.yml +++ b/.github/workflows/reproduce-schedule.yml @@ -9,9 +9,6 @@ jobs: reproduce: runs-on: ubuntu-latest steps: - - name: Checkout code - uses: actions/checkout@v3 - - name: Get latest release shell: bash run: | @@ -23,6 +20,16 @@ jobs: grep -i "tag_name" | \ awk -F \" '{print "TAG="$4}' >> $GITHUB_ENV + - name: Checkout code + uses: actions/checkout@v3 + with: + ref: ${{ env.TAG }} + repository: simplex-chat/simplex-chat + + # Otherwise we run out of disk space with Docker build + - name: Free disk space + shell: bash + run: ./scripts/ci/linux_util_free_space.sh - name: Execute reproduce script run: | ${GITHUB_WORKSPACE}/scripts/simplex-chat-reproduce-builds.sh "$TAG" || : diff --git a/Dockerfile.build b/Dockerfile.build index 3c841cfb25..fddc96b6c2 100644 --- a/Dockerfile.build +++ b/Dockerfile.build @@ -6,7 +6,9 @@ FROM ubuntu:${TAG} AS build ARG GHC=9.6.3 ARG CABAL=3.10.2.0 -ARG JAVA=17 +ARG JAVA_VER=17.0.17.10.1 +ARG JAVA_HASH_AMD64=e3e11daa5c22a45153bbeff1a0c21bf08631791e4e8d8ed14deba31c7cf9af1a +ARG JAVA_HASH_ARM64=2b460859b681757b33a7591b6238ecaf51569d05d2684984e5f0a89c6514acbc ENV TZ=Etc/UTC \ DEBIAN_FRONTEND=noninteractive @@ -44,10 +46,26 @@ RUN apt-get update && \ # depends on libjpeg.so.8 and liblcms2.so.2 which are NOT copied into final # /usr/lib/runtime/lib directory and I do not have time to figure out gradle.kotlin # to fix this :( -RUN curl --proto '=https' --tlsv1.2 -sSf 'https://apt.corretto.aws/corretto.key' | gpg --dearmor -o /usr/share/keyrings/corretto-keyring.gpg &&\ - echo "deb [signed-by=/usr/share/keyrings/corretto-keyring.gpg] https://apt.corretto.aws stable main" > /etc/apt/sources.list.d/corretto.list &&\ - apt update &&\ - apt install -y java-${JAVA}-amazon-corretto-jdk +RUN export JAVA_FILENAME='java-corretto.deb' \ + JAVA_VER_MAJOR=$(printf "${JAVA_VER}" | awk -F. '{print $1}') \ + JAVA_VER_DEB=$(printf "${JAVA_VER}" | sed 's/\.1$/-1/') && \ + case "$(uname -m)" in \ + x86_64) export JAVA_ARCH='amd64' JAVA_HASH="$JAVA_HASH_AMD64" ;; \ + aarch64) export JAVA_ARCH='arm64' JAVA_HASH="$JAVA_HASH_ARM64" ;; \ + *) echo "unknown arch $(uname -m)" && exit 1 ;; \ + esac && \ + curl --proto '=https' --tlsv1.2 -sSf \ + "https://corretto.aws/downloads/resources/${JAVA_VER}/java-${JAVA_VER_MAJOR}-amazon-corretto-jdk_${JAVA_VER_DEB}_${JAVA_ARCH}.deb" \ + -o "${JAVA_FILENAME}" && \ + if echo "${JAVA_HASH} ${JAVA_FILENAME}" | sha256sum -c -; then \ + if apt install -y ./"${JAVA_FILENAME}"; then \ + rm ./"${JAVA_FILENAME}"; \ + else \ + echo "Failed to install Java Corretto" && exit 1; \ + fi \ + else \ + echo "Checksum mismatch" && exit 1; \ + fi # Specify bootstrap Haskell versions ENV BOOTSTRAP_HASKELL_GHC_VERSION=${GHC} diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 5ebab167fd..f1f4e686bd 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -766,11 +766,6 @@ final class ChatModel: ObservableObject { } func removeMemberItems(_ removedMember: GroupMember, byMember: GroupMember, _ groupInfo: GroupInfo) { - // this should not happen, only another member can "remove" user, user can only "leave" (another event). - if byMember.groupMemberId == groupInfo.membership.groupMemberId { - logger.debug("exiting removeMemberItems") - return - } if chatId == groupInfo.id { for i in 0.. (GroupInfo, [GroupMember]) { +func apiRemoveMembers(_ groupId: Int64, _ memberIds: [Int64], _ withMessages: Bool) async throws -> (GroupInfo, [GroupMember]) { let r: ChatResponse2 = try await chatSendCmd(.apiRemoveMembers(groupId: groupId, memberIds: memberIds, withMessages: withMessages), bgTask: false) if case let .userDeletedMembers(_, updatedGroupInfo, members, _withMessages) = r { return (updatedGroupInfo, members) } throw r.unexpected diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift index 143bf42ea4..e9913053ea 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift @@ -48,7 +48,7 @@ func showRejectMemberAlert(_ groupInfo: GroupInfo, _ member: GroupMember, dismis showAlert( title: NSLocalizedString("Reject member?", comment: "alert title"), buttonTitle: "Reject", - buttonAction: { removeMember(groupInfo, member, dismiss: dismiss) }, + buttonAction: { removeMember(groupInfo, member, withMessages: false, dismiss: dismiss) }, cancelButton: true ) } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index d8929caa3e..96b5e2898a 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -46,7 +46,6 @@ struct GroupChatInfoView: View { case unblockMemberAlert(mem: GroupMember) case blockForAllAlert(mem: GroupMember) case unblockForAllAlert(mem: GroupMember) - case removeMemberAlert(mem: GroupMember) case error(title: LocalizedStringKey, error: LocalizedStringKey?) var id: String { @@ -60,7 +59,6 @@ struct GroupChatInfoView: View { case let .unblockMemberAlert(mem): return "unblockMemberAlert \(mem.groupMemberId)" case let .blockForAllAlert(mem): return "blockForAllAlert \(mem.groupMemberId)" case let .unblockForAllAlert(mem): return "unblockForAllAlert \(mem.groupMemberId)" - case let .removeMemberAlert(mem): return "removeMemberAlert \(mem.groupMemberId)" case let .error(title, _): return "error \(title)" } } @@ -212,7 +210,6 @@ struct GroupChatInfoView: View { case let .unblockMemberAlert(mem): return unblockMemberAlert(groupInfo, mem) case let .blockForAllAlert(mem): return blockForAllAlert(groupInfo, mem) case let .unblockForAllAlert(mem): return unblockForAllAlert(groupInfo, mem) - case let .removeMemberAlert(mem): return removeMemberAlert(mem) case let .error(title, error): return mkAlert(title: title, message: error) } } @@ -517,7 +514,7 @@ struct GroupChatInfoView: View { private func removeSwipe(_ member: GroupMember, _ v: V) -> some View { v.swipeActions(edge: .trailing) { Button(role: .destructive) { - alert = .removeMemberAlert(mem: member) + showRemoveMemberAlert(groupInfo, member) } label: { Label("Remove member", systemImage: "trash") .foregroundColor(Color.red) @@ -791,32 +788,38 @@ struct GroupChatInfoView: View { alert = .largeGroupReceiptsDisabled } } - - private func removeMemberAlert(_ mem: GroupMember) -> Alert { - let messageLabel: LocalizedStringKey = ( - groupInfo.businessChat == nil - ? "Member will be removed from group - this cannot be undone!" - : "Member will be removed from chat - this cannot be undone!" - ) - return Alert( - title: Text("Remove member?"), - message: Text(messageLabel), - primaryButton: .destructive(Text("Remove")) { - removeMember(groupInfo, mem) - }, - secondaryButton: .cancel() - ) - } } -func removeMember(_ groupInfo: GroupInfo, _ mem: GroupMember, dismiss: DismissAction? = nil) { +func showRemoveMemberAlert(_ groupInfo: GroupInfo, _ mem: GroupMember, dismiss: DismissAction? = nil) { + showAlert( + NSLocalizedString("Remove member?", comment: "alert title"), + message: + groupInfo.businessChat == nil + ? NSLocalizedString("Member will be removed from group - this cannot be undone!", comment: "alert message") + : NSLocalizedString("Member will be removed from chat - this cannot be undone!", comment: "alert message"), + actions: {[ + UIAlertAction(title: NSLocalizedString("Remove", comment: "alert action"), style: .destructive) { _ in + removeMember(groupInfo, mem, withMessages: false, dismiss: dismiss) + }, + UIAlertAction(title: NSLocalizedString("Remove and delete messages", comment: "alert action"), style: .destructive) { _ in + removeMember(groupInfo, mem, withMessages: true, dismiss: dismiss) + }, + cancelAlertAction + ]} + ) +} + +func removeMember(_ groupInfo: GroupInfo, _ mem: GroupMember, withMessages: Bool, dismiss: DismissAction?) { Task { do { - let (updatedGroupInfo, updatedMembers) = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId]) + let (updatedGroupInfo, updatedMembers) = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId], withMessages) await MainActor.run { ChatModel.shared.updateGroup(updatedGroupInfo) updatedMembers.forEach { updatedMember in _ = ChatModel.shared.upsertGroupMember(updatedGroupInfo, updatedMember) + if withMessages { + ChatModel.shared.removeMemberItems(updatedMember, byMember: groupInfo.membership, groupInfo) + } } dismiss?() } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 01a3805910..207c2170a3 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -36,7 +36,6 @@ struct GroupMemberInfoView: View { case unblockMemberAlert(mem: GroupMember) case blockForAllAlert(mem: GroupMember) case unblockForAllAlert(mem: GroupMember) - case removeMemberAlert(mem: GroupMember) case changeMemberRoleAlert(mem: GroupMember, role: GroupMemberRole) case switchAddressAlert case abortSwitchAddressAlert @@ -51,7 +50,6 @@ struct GroupMemberInfoView: View { case let .unblockMemberAlert(mem): return "unblockMemberAlert \(mem.groupMemberId)" case let .blockForAllAlert(mem): return "blockForAllAlert \(mem.groupMemberId)" case let .unblockForAllAlert(mem): return "unblockForAllAlert \(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" @@ -273,7 +271,6 @@ struct GroupMemberInfoView: View { case let .unblockMemberAlert(mem): return unblockMemberAlert(groupInfo, mem) case let .blockForAllAlert(mem): return blockForAllAlert(groupInfo, mem) case let .unblockForAllAlert(mem): return unblockForAllAlert(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) @@ -579,7 +576,11 @@ struct GroupMemberInfoView: View { } } if canRemove { - removeMemberButton(mem) + if mem.memberStatus == .memRemoved || mem.memberStatus == .memLeft { + deleteMemberMessagesButton(mem) + } else { + removeMemberButton(mem) + } } } } @@ -634,41 +635,32 @@ struct GroupMemberInfoView: View { private func removeMemberButton(_ mem: GroupMember) -> some View { Button(role: .destructive) { - alert = .removeMemberAlert(mem: mem) + showRemoveMemberAlert(groupInfo, mem, dismiss: dismiss) } label: { Label("Remove member", systemImage: "trash") .foregroundColor(.red) } } - private func removeMemberAlert(_ mem: GroupMember) -> Alert { - let label: LocalizedStringKey = ( - groupInfo.businessChat == nil - ? "Member will be removed from group - this cannot be undone!" - : "Member will be removed from chat - this cannot be undone!" - ) - return Alert( - title: Text("Remove member?"), - message: Text(label), - primaryButton: .destructive(Text("Remove")) { - Task { - do { - let (updatedGroupInfo, updatedMembers) = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId]) - await MainActor.run { - chatModel.updateGroup(updatedGroupInfo) - updatedMembers.forEach { updatedMember in - _ = chatModel.upsertGroupMember(updatedGroupInfo, updatedMember) - } - dismiss() - } - } catch let error { - logger.error("apiRemoveMembers error: \(responseError(error))") - let a = getErrorAlert(error, "Error removing member") - alert = .error(title: a.title, error: a.message) - } - } - }, - secondaryButton: .cancel() + private func deleteMemberMessagesButton(_ mem: GroupMember) -> some View { + Button(role: .destructive) { + showDeleteMemberMessagesAlert(mem) + } label: { + Label("Delete member messages", systemImage: "trash") + .foregroundColor(.red) + } + } + + func showDeleteMemberMessagesAlert(_ mem: GroupMember) { + showAlert( + NSLocalizedString("Delete member messages?", comment: "alert title"), + message: NSLocalizedString("Member messages will be deleted - this cannot be undone!", comment: "alert message"), + actions: {[ + UIAlertAction(title: NSLocalizedString("Delete messages", comment: "alert action"), style: .destructive) { _ in + removeMember(groupInfo, mem, withMessages: true, dismiss: dismiss) + }, + cancelAlertAction + ]} ) } diff --git a/apps/ios/SimpleX SE/ShareAPI.swift b/apps/ios/SimpleX SE/ShareAPI.swift index 4dad9d5d15..6495d09b03 100644 --- a/apps/ios/SimpleX SE/ShareAPI.swift +++ b/apps/ios/SimpleX SE/ShareAPI.swift @@ -48,7 +48,7 @@ func apiSetEncryptLocalFiles(_ enable: Bool) throws { throw r.unexpected } -func apiGetChats(userId: User.ID) throws -> Array { +func apiGetChats(userId: User.ID) throws -> Array { let r: APIResult = sendSimpleXCmd(SEChatCommand.apiGetChats(userId: userId)) if case let .result(.apiChats(user: _, chats: chats)) = r { return chats } throw r.unexpected @@ -170,7 +170,7 @@ enum SEChatResponse: Decodable, ChatAPIResult { case activeUser(user: User) case chatStarted case chatRunning - case apiChats(user: UserRef, chats: [ChatData]) + case apiChats(user: UserRef, chats: [SEChatData]) case newChatItems(user: UserRef, chatItems: [AChatItem]) case cmdOk(user_: UserRef?) @@ -199,7 +199,7 @@ enum SEChatResponse: Decodable, ChatAPIResult { } static func fallbackResult(_ type: String, _ json: NSDictionary) -> SEChatResponse? { - if type == "apiChats", let r = parseApiChats(json) { + if type == "apiChats", let r = seParseApiChats(json) { .apiChats(user: r.user, chats: r.chats) } else { nil @@ -239,3 +239,35 @@ enum SEChatEvent: Decodable, ChatAPIResult { } } } + +public struct SEChatData: Decodable, Identifiable, Hashable, ChatLike { + public var chatInfo: ChatInfo + + public var id: ChatId { get { chatInfo.id } } + + public init(chatInfo: ChatInfo) { + self.chatInfo = chatInfo + } + + public static func invalidJSON(_ json: Data?) -> SEChatData { + SEChatData(chatInfo: .invalidJSON(json: json)) + } +} + +public func seParseApiChats(_ jResp: NSDictionary) -> (user: UserRef, chats: [SEChatData])? { + if let jApiChats = jResp["apiChats"] as? NSDictionary, + let user: UserRef = try? decodeObject(jApiChats["user"] as Any), + let jChats = jApiChats["chats"] as? NSArray { + let chats: [SEChatData] = jChats.map { jChat in + if let jChatDict = jChat as? NSDictionary, + let jChatInfo = jChatDict["chatInfo"], + let chatInfo: ChatInfo = try? decodeObject(jChatInfo) { + return SEChatData(chatInfo: chatInfo) + } + return SEChatData.invalidJSON(serializeJSON(jChat, options: .prettyPrinted)) + } + return (user, chats) + } else { + return nil + } +} diff --git a/apps/ios/SimpleX SE/ShareModel.swift b/apps/ios/SimpleX SE/ShareModel.swift index 5080cf2040..fd5c4c990f 100644 --- a/apps/ios/SimpleX SE/ShareModel.swift +++ b/apps/ios/SimpleX SE/ShareModel.swift @@ -19,11 +19,11 @@ private let MAX_DOWNSAMPLE_SIZE: Int64 = 2000 class ShareModel: ObservableObject { @Published var sharedContent: SharedContent? - @Published var chats: [ChatData] = [] + @Published var chats: [SEChatData] = [] @Published var profileImages: [ChatInfo.ID: UIImage] = [:] @Published var search = "" @Published var comment = "" - @Published var selected: ChatData? + @Published var selected: SEChatData? @Published var isLoaded = false @Published var bottomBar: BottomBar = .loadingSpinner @Published var errorAlert: ErrorAlert? @@ -60,13 +60,13 @@ class ShareModel: ObservableObject { } } - func isProhibited(_ chat: ChatData?) -> Bool { + func isProhibited(_ chat: SEChatData?) -> Bool { if let chat, let sharedContent { sharedContent.prohibited(in: chat, hasSimplexLink: hasSimplexLink) } else { false } } - var filteredChats: [ChatData] { + var filteredChats: [SEChatData] { search.isEmpty ? filterChatsToForwardTo(chats: chats) : filterChatsToForwardTo(chats: chats) @@ -253,7 +253,7 @@ class ShareModel: ObservableObject { } } - private func fetchChats() -> Result, ErrorAlert> { + private func fetchChats() -> Result, ErrorAlert> { do { guard let user = try apiGetActiveUser() else { return .failure( @@ -396,7 +396,7 @@ enum SharedContent { } } - func prohibited(in chatData: ChatData, hasSimplexLink: Bool) -> Bool { + func prohibited(in chatData: SEChatData, hasSimplexLink: Bool) -> Bool { chatData.prohibitedByPref( hasSimplexLink: hasSimplexLink, isMediaOrFileAttachment: cryptoFile != nil, diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 1e9fbf15e6..5d1d5b4302 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2635,19 +2635,18 @@ public struct GroupMember: Identifiable, Decodable, Hashable { public func canBeRemoved(groupInfo: GroupInfo) -> Bool { let userRole = groupInfo.membership.memberRole - return memberStatus != .memRemoved && memberStatus != .memLeft - && userRole >= .admin && userRole >= memberRole && groupInfo.membership.memberActive + return userRole >= .admin && userRole >= memberRole && groupInfo.membership.memberActive } public func canChangeRoleTo(groupInfo: GroupInfo) -> [GroupMemberRole]? { - if !canBeRemoved(groupInfo: groupInfo) || memberPending { return nil } + if !canBeRemoved(groupInfo: groupInfo) || memberStatus == .memRemoved || memberStatus == .memLeft || memberPending { return nil } let userRole = groupInfo.membership.memberRole return GroupMemberRole.supportedRoles.filter { $0 <= userRole } } public func canBlockForAll(groupInfo: GroupInfo) -> Bool { let userRole = groupInfo.membership.memberRole - return memberStatus != .memRemoved && memberStatus != .memLeft && memberRole < .moderator + return memberRole < .moderator && userRole >= .moderator && userRole >= memberRole && groupInfo.membership.memberActive && !memberPending } @@ -3071,33 +3070,33 @@ public struct ChatItem: Identifiable, Decodable, Hashable { } 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 - case .newMemberPendingReview: nil - default: .rcvGroupEvent - } - case let .sndGroupEvent(event): - switch event { - case .userRole: nil - case .userLeft: nil - case .memberAccepted: nil - case .userPendingReview: nil - default: .sndGroupEvent - } - default: - if meta.itemDeleted == nil { + if meta.itemDeleted != nil { + chatDir.sent ? .sndItemDeleted : .rcvItemDeleted + } else { + 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 + case .newMemberPendingReview: nil + default: .rcvGroupEvent + } + case let .sndGroupEvent(event): + switch event { + case .userRole: nil + case .userLeft: nil + case .memberAccepted: nil + case .userPendingReview: nil + default: .sndGroupEvent + } + default: nil - } else { - chatDir.sent ? .sndItemDeleted : .rcvItemDeleted } } } diff --git a/apps/ios/SimpleXChat/ChatUtils.swift b/apps/ios/SimpleXChat/ChatUtils.swift index 98ee9cd5d4..451ac8b4ef 100644 --- a/apps/ios/SimpleXChat/ChatUtils.swift +++ b/apps/ios/SimpleXChat/ChatUtils.swift @@ -9,9 +9,7 @@ import Foundation public protocol ChatLike { - var chatInfo: ChatInfo { get} - var chatItems: [ChatItem] { get } - var chatStats: ChatStats { get } + var chatInfo: ChatInfo { get } } extension ChatLike { 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 debd0ec328..8db2cc1a76 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 @@ -683,11 +683,6 @@ object ChatModel { return updatedItem } - // this should not happen, only another member can "remove" user, user can only "leave" (another event). - if (byMember.groupMemberId == groupInfo.membership.groupMemberId) { - Log.d(TAG, "exiting removeMemberItems") - return - } val cInfo = ChatInfo.Group(groupInfo, groupChatScope = null) // TODO [knocking] review if (chatId.value == groupInfo.id) { for (i in 0 until chatItems.value.size) { @@ -2295,19 +2290,18 @@ data class GroupMember ( fun canBeRemoved(groupInfo: GroupInfo): Boolean { val userRole = groupInfo.membership.memberRole - return memberStatus != GroupMemberStatus.MemRemoved && memberStatus != GroupMemberStatus.MemLeft - && userRole >= GroupMemberRole.Admin && userRole >= memberRole && groupInfo.membership.memberActive + return userRole >= GroupMemberRole.Admin && userRole >= memberRole && groupInfo.membership.memberActive } fun canChangeRoleTo(groupInfo: GroupInfo): List? = - if (!canBeRemoved(groupInfo) || memberPending) null + if (!canBeRemoved(groupInfo) || memberStatus == GroupMemberStatus.MemRemoved || memberStatus == GroupMemberStatus.MemLeft || memberPending) null else groupInfo.membership.memberRole.let { userRole -> GroupMemberRole.selectableRoles.filter { it <= userRole } } fun canBlockForAll(groupInfo: GroupInfo): Boolean { val userRole = groupInfo.membership.memberRole - return memberStatus != GroupMemberStatus.MemRemoved && memberStatus != GroupMemberStatus.MemLeft && memberRole < GroupMemberRole.Moderator + return memberRole < GroupMemberRole.Moderator && userRole >= GroupMemberRole.Moderator && userRole >= memberRole && groupInfo.membership.memberActive && !memberPending } @@ -2796,30 +2790,29 @@ data class ChatItem ( } 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, - is RcvGroupEvent.NewMemberPendingReview -> - null - else -> CIMergeCategory.RcvGroupEvent - } - is CIContent.SndGroupEventContent -> when (content.sndGroupEvent) { - is SndGroupEvent.UserRole, is SndGroupEvent.UserLeft, is SndGroupEvent.MemberAccepted, is SndGroupEvent.UserPendingReview -> null - else -> CIMergeCategory.SndGroupEvent - } - else -> { - if (meta.itemDeleted == null) { - null - } else { - if (chatDir.sent) CIMergeCategory.SndItemDeleted else CIMergeCategory.RcvItemDeleted + get() = if (meta.itemDeleted != null) { + if (chatDir.sent) CIMergeCategory.SndItemDeleted else CIMergeCategory.RcvItemDeleted + } else { + 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, + is RcvGroupEvent.NewMemberPendingReview -> + null + else -> CIMergeCategory.RcvGroupEvent } + is CIContent.SndGroupEventContent -> when (content.sndGroupEvent) { + is SndGroupEvent.UserRole, is SndGroupEvent.UserLeft, is SndGroupEvent.MemberAccepted, is SndGroupEvent.UserPendingReview -> null + else -> CIMergeCategory.SndGroupEvent + } + else -> + null } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt index 28b46f592d..6ef56a9124 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt @@ -21,7 +21,7 @@ sealed class WriteFileResult { * */ fun writeCryptoFile(path: String, data: ByteArray): CryptoFileArgs { - val ctrl = ChatController.ctrl ?: throw Exception("Controller is not initialized") + val ctrl = ChatController.getChatCtrl() ?: throw Exception("Controller is not initialized") val buffer = ByteBuffer.allocateDirect(data.size) buffer.put(data) buffer.rewind() @@ -44,7 +44,7 @@ fun readCryptoFile(path: String, cryptoArgs: CryptoFileArgs): ByteArray { } fun encryptCryptoFile(fromPath: String, toPath: String): CryptoFileArgs { - val ctrl = ChatController.ctrl ?: throw Exception("Controller is not initialized") + val ctrl = ChatController.getChatCtrl() ?: throw Exception("Controller is not initialized") val str = chatEncryptFile(ctrl, fromPath, toPath) val d = json.decodeFromString(WriteFileResult.serializer(), str) return when (d) { 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 5ad334e4bc..fb61cb5100 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 @@ -490,17 +490,26 @@ class AppPreferences { private const val MESSAGE_TIMEOUT: Int = 300_000_000 object ChatController { - var ctrl: ChatCtrl? = -1 + private var chatCtrl: ChatCtrl? = -1 val appPrefs: AppPreferences by lazy { AppPreferences() } val messagesChannel: Channel = Channel() val chatModel = ChatModel - private var receiverStarted = false + private var receiverJob: Job? = null var lastMsgReceivedTimestamp: Long = System.currentTimeMillis() private set - fun hasChatCtrl() = ctrl != -1L && ctrl != null + fun hasChatCtrl() = chatCtrl != -1L && chatCtrl != null + + fun getChatCtrl(): ChatCtrl? = chatCtrl + + fun setChatCtrl(ctrl: ChatCtrl?) { + val wasRunning = receiverJob != null + stopReceiver() + chatCtrl = ctrl + if (wasRunning && ctrl != null) startReceiver() + } suspend fun getAgentSubsTotal(rh: Long?): Pair? { val userId = currentUserId("getAgentSubsTotal") @@ -647,17 +656,16 @@ object ChatController { private fun startReceiver() { Log.d(TAG, "ChatController startReceiver") - if (receiverStarted) return - receiverStarted = true - CoroutineScope(Dispatchers.IO).launch { + if (receiverJob != null || chatCtrl == null) return + receiverJob = CoroutineScope(Dispatchers.IO).launch { var releaseLock: (() -> Unit) = {} - while (true) { + while (isActive) { /** Global [ctrl] can be null. It's needed for having the same [ChatModel] that already made in [ChatController] without the need * to change it everywhere in code after changing a database. * Since it can be changed in background thread, making this check to prevent NullPointerException */ - val ctrl = ctrl + val ctrl = chatCtrl if (ctrl == null) { - receiverStarted = false + stopReceiver() break } try { @@ -697,6 +705,15 @@ object ChatController { } } + private fun stopReceiver() { + Log.d(TAG, "ChatController stopReceiver") + val job = receiverJob + if (job != null) { + receiverJob = null + job.cancel() + } + } + private suspend fun sendCmdWithRetry(rhId: Long?, cmd: CC, inProgress: MutableState? = null, retryNum: Int = 0): API? { val r = sendCmd(rhId, cmd, retryNum = retryNum) val alert = if (r is API.Error) retryableNetworkErrorAlert(r.err) else null @@ -781,7 +798,7 @@ object ChatController { } suspend fun sendCmd(rhId: Long?, cmd: CC, otherCtrl: ChatCtrl? = null, retryNum: Int = 0, log: Boolean = true): API { - val ctrl = otherCtrl ?: ctrl ?: throw Exception("Controller is not initialized") + val ctrl = otherCtrl ?: chatCtrl ?: throw Exception("Controller is not initialized") return withContext(Dispatchers.IO) { val c = cmd.cmdString @@ -2118,7 +2135,7 @@ object ChatController { return null } - suspend fun apiRemoveMembers(rh: Long?, groupId: Long, memberIds: List, withMessages: Boolean = false): Pair>? { + suspend fun apiRemoveMembers(rh: Long?, groupId: Long, memberIds: List, withMessages: Boolean): Pair>? { val r = sendCmd(rh, CC.ApiRemoveMembers(groupId, memberIds, withMessages)) if (r is API.Result && r.res is CR.UserDeletedMembers) return r.res.groupInfo to r.res.members if (!(networkErrorAlert(r))) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt index 959e4749dc..d0ce703033 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt @@ -56,6 +56,7 @@ fun initChatControllerOnStart() { } suspend fun initChatController(useKey: String? = null, confirmMigrations: MigrationConfirmation? = null, startChat: () -> CompletableDeferred = { CompletableDeferred(true) }) { + Log.d(TAG, "initChatController") try { if (chatModel.ctrlInitInProgress.value) return chatModel.ctrlInitInProgress.value = true @@ -92,7 +93,7 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat val ctrl = if (res is DBMigrationResult.OK) { migrated[1] as Long } else null - chatController.ctrl = ctrl + chatController.setChatCtrl(ctrl) chatModel.chatDbEncrypted.value = dbKey != "" chatModel.chatDbStatus.value = res if (res != DBMigrationResult.OK) { @@ -206,7 +207,7 @@ fun chatInitControllerRemovingDatabases() { }.getOrElse { DBMigrationResult.Unknown(migrated[0] as String) } val ctrl = migrated[1] as Long - chatController.ctrl = ctrl + chatController.setChatCtrl(ctrl) // We need only controller, not databases File(dbPath + "_chat.db").delete() File(dbPath + "_agent.db").delete() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt index 01e19ea478..df9af7fbf6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt @@ -804,7 +804,8 @@ fun SimpleXTheme(darkTheme: Boolean? = null, content: @Composable () -> Unit) { LocalAppColors provides rememberedAppColors, LocalAppWallpaper provides rememberedWallpaper, LocalDensity provides density, - content = content) + content = content + ) } ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt index 3c3f99ad94..be82a5d2d3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt @@ -45,7 +45,7 @@ fun ComposeContextPendingMemberActionsView( .fillMaxHeight() .weight(1F) .clickable { - rejectMemberDialog(rhId, member, chatModel, close = { ModalManager.end.closeModal() }) + rejectMemberDialog(rhId, groupInfo, member, chatModel, close = { ModalManager.end.closeModal() }) }, verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally @@ -70,12 +70,12 @@ fun ComposeContextPendingMemberActionsView( } } -fun rejectMemberDialog(rhId: Long?, member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) { +fun rejectMemberDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.reject_pending_member_alert_title), confirmText = generalGetString(MR.strings.reject_pending_member_button), onConfirm = { - removeMember(rhId, member, chatModel, close) + removeMember(rhId, groupInfo, member, withMessages = false, chatModel, close) }, destructive = true, ) 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 46669ead70..3f80361249 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 @@ -233,15 +233,30 @@ private fun removeMemberAlert(rhId: Long?, groupInfo: GroupInfo, mem: GroupMembe MR.strings.member_will_be_removed_from_group_cannot_be_undone else MR.strings.member_will_be_removed_from_chat_cannot_be_undone - AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.button_remove_member_question), - text = generalGetString(messageId), - confirmText = generalGetString(MR.strings.remove_member_confirmation), - onConfirm = { - removeMembers(rhId, groupInfo, listOf(mem.groupMemberId)) - }, - destructive = true, - ) + AlertManager.shared.showAlertDialogButtonsColumn( + generalGetString(MR.strings.button_remove_member_question), + generalGetString(messageId), + buttons = { + Column { + SectionItemView({ + AlertManager.shared.hideAlert() + removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = false) + }) { + Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) + } + SectionItemView({ + AlertManager.shared.hideAlert() + removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = true) + }) { + Text(generalGetString(MR.strings.remove_member_delete_messages_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) + } + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + }) } private fun removeMembersAlert(rhId: Long?, groupInfo: GroupInfo, memberIds: List, onSuccess: () -> Unit = {}) { @@ -249,15 +264,30 @@ private fun removeMembersAlert(rhId: Long?, groupInfo: GroupInfo, memberIds: Lis MR.strings.members_will_be_removed_from_group_cannot_be_undone else MR.strings.members_will_be_removed_from_chat_cannot_be_undone - AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.button_remove_members_question), - text = generalGetString(messageId), - confirmText = generalGetString(MR.strings.remove_member_confirmation), - onConfirm = { - removeMembers(rhId, groupInfo, memberIds, onSuccess) - }, - destructive = true, - ) + AlertManager.shared.showAlertDialogButtonsColumn( + generalGetString(MR.strings.button_remove_members_question), + generalGetString(messageId), + buttons = { + Column { + SectionItemView({ + AlertManager.shared.hideAlert() + removeMembers(rhId, groupInfo, memberIds, withMessages = false, onSuccess = onSuccess) + }) { + Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) + } + SectionItemView({ + AlertManager.shared.hideAlert() + removeMembers(rhId, groupInfo, memberIds, withMessages = true, onSuccess = onSuccess) + }) { + Text(generalGetString(MR.strings.remove_member_delete_messages_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) + } + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + }) } @Composable @@ -1052,20 +1082,26 @@ private fun setGroupAlias(chat: Chat, localAlias: String, chatModel: ChatModel) } } -fun removeMembers(rhId: Long?, groupInfo: GroupInfo, memberIds: List, onSuccess: () -> Unit = {}) { +fun removeMembers(rhId: Long?, groupInfo: GroupInfo, memberIds: List, withMessages: Boolean, onSuccess: () -> Unit = {}) { withBGApi { - val r = chatModel.controller.apiRemoveMembers(rhId, groupInfo.groupId, memberIds) + val r = chatModel.controller.apiRemoveMembers(rhId, groupInfo.groupId, memberIds, withMessages = withMessages) if (r != null) { val (updatedGroupInfo, updatedMembers) = r withContext(Dispatchers.Main) { chatModel.chatsContext.updateGroup(rhId, updatedGroupInfo) updatedMembers.forEach { updatedMember -> chatModel.chatsContext.upsertGroupMember(rhId, updatedGroupInfo, updatedMember) + if (withMessages) { + chatModel.chatsContext.removeMemberItems(rhId, updatedMember, byMember = groupInfo.membership, groupInfo) + } } } withContext(Dispatchers.Main) { updatedMembers.forEach { updatedMember -> chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, updatedGroupInfo, updatedMember) + if (withMessages) { + chatModel.chatsContext.removeMemberItems(rhId, updatedMember, byMember = groupInfo.membership, groupInfo) + } } } onSuccess() 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 d7e052e502..f09d2f44bb 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 @@ -136,6 +136,7 @@ fun GroupMemberInfoView( blockForAll = { blockForAllAlert(rhId, groupInfo, member) }, unblockForAll = { unblockForAllAlert(rhId, groupInfo, member) }, removeMember = { removeMemberDialog(rhId, groupInfo, member, chatModel, close) }, + deleteMemberMessages = { deleteMemberMessagesDialog(rhId, groupInfo, member, chatModel, close) }, onRoleSelected = { if (it == newRole.value) return@GroupMemberInfoLayout val prevValue = newRole.value @@ -243,26 +244,56 @@ fun removeMemberDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, c MR.strings.member_will_be_removed_from_group_cannot_be_undone else MR.strings.member_will_be_removed_from_chat_cannot_be_undone + AlertManager.shared.showAlertDialogButtonsColumn( + generalGetString(MR.strings.button_remove_member_question), + generalGetString(messageId), + buttons = { + Column { + SectionItemView({ + AlertManager.shared.hideAlert() + removeMember(rhId, groupInfo, member, withMessages = false, chatModel, close) + }) { + Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) + } + SectionItemView({ + AlertManager.shared.hideAlert() + removeMember(rhId, groupInfo, member, withMessages = true, chatModel, close) + }) { + Text(generalGetString(MR.strings.remove_member_delete_messages_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) + } + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + }) +} + +fun deleteMemberMessagesDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) { AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.button_remove_member), - text = generalGetString(messageId), - confirmText = generalGetString(MR.strings.remove_member_confirmation), + title = generalGetString(MR.strings.button_delete_member_messages_question), + text = generalGetString(MR.strings.member_messages_will_be_deleted_cannot_be_undone), + confirmText = generalGetString(MR.strings.delete_member_messages_confirmation), onConfirm = { - removeMember(rhId, member, chatModel, close) + removeMember(rhId, groupInfo, member, withMessages = true, chatModel, close) }, destructive = true, ) } -fun removeMember(rhId: Long?, member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) { +fun removeMember(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, withMessages: Boolean, chatModel: ChatModel, close: (() -> Unit)? = null) { withBGApi { - val r = chatModel.controller.apiRemoveMembers(rhId, member.groupId, listOf(member.groupMemberId)) + val r = chatModel.controller.apiRemoveMembers(rhId, member.groupId, listOf(member.groupMemberId), withMessages = withMessages) if (r != null) { val (updatedGroupInfo, removedMembers) = r withContext(Dispatchers.Main) { chatModel.chatsContext.updateGroup(rhId, updatedGroupInfo) removedMembers.forEach { removedMember -> chatModel.chatsContext.upsertGroupMember(rhId, updatedGroupInfo, removedMember) + if (withMessages) { + chat.simplex.common.platform.chatModel.chatsContext.removeMemberItems(rhId, removedMember, byMember = groupInfo.membership, groupInfo) + } } } } @@ -289,6 +320,7 @@ fun GroupMemberInfoLayout( blockForAll: () -> Unit, unblockForAll: () -> Unit, removeMember: () -> Unit, + deleteMemberMessages: () -> Unit, onRoleSelected: (GroupMemberRole) -> Unit, switchMemberAddress: () -> Unit, abortSwitchMemberAddress: () -> Unit, @@ -345,7 +377,11 @@ fun GroupMemberInfoLayout( } } if (canRemove) { - RemoveMemberButton(removeMember) + if (member.memberStatus == GroupMemberStatus.MemRemoved || member.memberStatus == GroupMemberStatus.MemLeft) { + DeleteMemberMessagesButton(deleteMemberMessages) + } else { + RemoveMemberButton(removeMember) + } } } } @@ -669,6 +705,17 @@ fun RemoveMemberButton(onClick: () -> Unit) { ) } +@Composable +fun DeleteMemberMessagesButton(onClick: () -> Unit) { + SettingsActionItem( + painterResource(MR.images.ic_delete), + stringResource(MR.strings.button_delete_member_messages), + click = onClick, + textColor = Color.Red, + iconColor = Color.Red, + ) +} + @Composable fun OpenChatButton( modifier: Modifier, @@ -937,6 +984,7 @@ fun PreviewGroupMemberInfoLayout() { blockForAll = {}, unblockForAll = {}, removeMember = {}, + deleteMemberMessages = {}, onRoleSelected = {}, switchMemberAddress = {}, abortSwitchMemberAddress = {}, 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 6f873035f1..758980059d 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 @@ -563,23 +563,18 @@ fun ChatItemView( @Composable fun ContentItem() { val mc = cItem.content.msgContent - if (cItem.meta.itemDeleted != null && (!revealed.value || cItem.isDeletedContent)) { - MarkedDeletedItemView(chatsCtx, cItem, cInfo, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp) - MarkedDeletedItemDropdownMenu() - } else { - if (cItem.quotedItem == null && cItem.meta.itemForwarded == null && cItem.meta.itemDeleted == null && !cItem.meta.isLive) { - if (mc is MsgContent.MCText && isShortEmoji(cItem.content.text)) { - EmojiItemView(cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) - } else if (mc is MsgContent.MCVoice && cItem.content.text.isEmpty()) { - CIVoiceView(mc.duration, cItem.file, cItem.meta.itemEdited, cItem.chatDir.sent, hasText = false, cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp, longClick = { onLinkLongClick("") }, receiveFile = receiveFile) - } else { - framedItemView() - } + if (cItem.quotedItem == null && cItem.meta.itemForwarded == null && cItem.meta.itemDeleted == null && !cItem.meta.isLive) { + if (mc is MsgContent.MCText && isShortEmoji(cItem.content.text)) { + EmojiItemView(cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + } else if (mc is MsgContent.MCVoice && cItem.content.text.isEmpty()) { + CIVoiceView(mc.duration, cItem.file, cItem.meta.itemEdited, cItem.chatDir.sent, hasText = false, cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp, longClick = { onLinkLongClick("") }, receiveFile = receiveFile) } else { framedItemView() } - MsgContentItemDropdownMenu() + } else { + framedItemView() } + MsgContentItemDropdownMenu() } @Composable fun LegacyDeletedItem() { @@ -696,102 +691,107 @@ fun ChatItemView( } } - when (val c = cItem.content) { - is CIContent.SndMsgContent -> ContentItem() - is CIContent.RcvMsgContent -> ContentItem() - is CIContent.SndDeleted -> LegacyDeletedItem() - is CIContent.RcvDeleted -> LegacyDeletedItem() - is CIContent.SndCall -> CallItem(c.status, c.duration) - is CIContent.RcvCall -> CallItem(c.status, c.duration) - is CIContent.RcvIntegrityError -> if (developerTools) { - IntegrityErrorItemView(c.msgError, cItem, showTimestamp, cInfo.timedMessagesTTL) - DeleteItemMenu() - } else { - Box(Modifier.size(0.dp)) {} - } - is CIContent.RcvDecryptionError -> { - CIRcvDecryptionError(c.msgDecryptError, c.msgCount, cInfo, cItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember) - DeleteItemMenu() - } - is CIContent.RcvGroupInvitation -> { - CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito, showTimestamp = showTimestamp, timedMessagesTTL = cInfo.timedMessagesTTL) - DeleteItemMenu() - } - is CIContent.SndGroupInvitation -> { - CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito, showTimestamp = showTimestamp, timedMessagesTTL = cInfo.timedMessagesTTL) - DeleteItemMenu() - } - is CIContent.RcvDirectEventContent -> { - EventItemView() - MsgContentItemDropdownMenu() - } - is CIContent.RcvGroupEventContent -> { - when (c.rcvGroupEvent) { - is RcvGroupEvent.MemberCreatedContact -> CIMemberCreatedContactView(cItem, openDirectChat) - is RcvGroupEvent.NewMemberPendingReview -> PendingReviewEventItemView() - else -> EventItemView() + if (cItem.meta.itemDeleted != null && (!revealed.value || cItem.isDeletedContent)) { + MarkedDeletedItemView(chatsCtx, cItem, cInfo, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + MarkedDeletedItemDropdownMenu() + } else { + when (val c = cItem.content) { + is CIContent.SndMsgContent -> ContentItem() + is CIContent.RcvMsgContent -> ContentItem() + is CIContent.SndDeleted -> LegacyDeletedItem() + is CIContent.RcvDeleted -> LegacyDeletedItem() + is CIContent.SndCall -> CallItem(c.status, c.duration) + is CIContent.RcvCall -> CallItem(c.status, c.duration) + is CIContent.RcvIntegrityError -> if (developerTools) { + IntegrityErrorItemView(c.msgError, cItem, showTimestamp, cInfo.timedMessagesTTL) + DeleteItemMenu() + } else { + Box(Modifier.size(0.dp)) {} } - MsgContentItemDropdownMenu() - } - is CIContent.SndGroupEventContent -> { - when (c.sndGroupEvent) { - is SndGroupEvent.UserPendingReview -> PendingReviewEventItemView() - else -> EventItemView() + is CIContent.RcvDecryptionError -> { + CIRcvDecryptionError(c.msgDecryptError, c.msgCount, cInfo, cItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember) + DeleteItemMenu() + } + is CIContent.RcvGroupInvitation -> { + CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito, showTimestamp = showTimestamp, timedMessagesTTL = cInfo.timedMessagesTTL) + DeleteItemMenu() + } + is CIContent.SndGroupInvitation -> { + CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito, showTimestamp = showTimestamp, timedMessagesTTL = cInfo.timedMessagesTTL) + DeleteItemMenu() + } + is CIContent.RcvDirectEventContent -> { + EventItemView() + MsgContentItemDropdownMenu() + } + is CIContent.RcvGroupEventContent -> { + when (c.rcvGroupEvent) { + is RcvGroupEvent.MemberCreatedContact -> CIMemberCreatedContactView(cItem, openDirectChat) + is RcvGroupEvent.NewMemberPendingReview -> PendingReviewEventItemView() + else -> EventItemView() + } + MsgContentItemDropdownMenu() + } + is CIContent.SndGroupEventContent -> { + when (c.sndGroupEvent) { + is SndGroupEvent.UserPendingReview -> PendingReviewEventItemView() + else -> EventItemView() + } + MsgContentItemDropdownMenu() + } + is CIContent.RcvConnEventContent -> { + EventItemView() + MsgContentItemDropdownMenu() + } + is CIContent.SndConnEventContent -> { + EventItemView() + MsgContentItemDropdownMenu() + } + is CIContent.RcvChatFeature -> { + CIChatFeatureView(chatsCtx, cInfo, cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.SndChatFeature -> { + CIChatFeatureView(chatsCtx, cInfo, cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.RcvChatPreference -> { + val ct = if (cInfo is ChatInfo.Direct) cInfo.contact else null + CIFeaturePreferenceView(cItem, ct, c.feature, c.allowed, acceptFeature) + DeleteItemMenu() + } + is CIContent.SndChatPreference -> { + CIChatFeatureView(chatsCtx, cInfo, cItem, c.feature, MaterialTheme.colors.secondary, icon = c.feature.icon, revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.RcvGroupFeature -> { + CIChatFeatureView(chatsCtx, cInfo, cItem, c.groupFeature, c.preference.enabled(c.memberRole_, (cInfo as? ChatInfo.Group)?.groupInfo?.membership).iconColor, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.SndGroupFeature -> { + CIChatFeatureView(chatsCtx, cInfo, cItem, c.groupFeature, c.preference.enabled(c.memberRole_, (cInfo as? ChatInfo.Group)?.groupInfo?.membership).iconColor, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.RcvChatFeatureRejected -> { + CIChatFeatureView(chatsCtx, cInfo, cItem, c.feature, Color.Red, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.RcvGroupFeatureRejected -> { + CIChatFeatureView(chatsCtx, cInfo, cItem, c.groupFeature, Color.Red, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.SndModerated -> DeletedItem() + is CIContent.RcvModerated -> DeletedItem() + is CIContent.RcvBlocked -> DeletedItem() + is CIContent.SndDirectE2EEInfo -> DirectE2EEInfoText(c.e2eeInfo) + is CIContent.RcvDirectE2EEInfo -> DirectE2EEInfoText(c.e2eeInfo) + is CIContent.SndGroupE2EEInfo -> E2EEInfoNoPQText() + is CIContent.RcvGroupE2EEInfo -> E2EEInfoNoPQText() + is CIContent.ChatBanner -> Spacer(modifier = Modifier.size(0.dp)) + is CIContent.InvalidJSON -> { + CIInvalidJSONView(c.json) + DeleteItemMenu() } - MsgContentItemDropdownMenu() - } - is CIContent.RcvConnEventContent -> { - EventItemView() - MsgContentItemDropdownMenu() - } - is CIContent.SndConnEventContent -> { - EventItemView() - MsgContentItemDropdownMenu() - } - is CIContent.RcvChatFeature -> { - CIChatFeatureView(chatsCtx, cInfo, cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu) - MsgContentItemDropdownMenu() - } - is CIContent.SndChatFeature -> { - CIChatFeatureView(chatsCtx, cInfo, cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu) - MsgContentItemDropdownMenu() - } - is CIContent.RcvChatPreference -> { - val ct = if (cInfo is ChatInfo.Direct) cInfo.contact else null - CIFeaturePreferenceView(cItem, ct, c.feature, c.allowed, acceptFeature) - DeleteItemMenu() - } - is CIContent.SndChatPreference -> { - CIChatFeatureView(chatsCtx, cInfo, cItem, c.feature, MaterialTheme.colors.secondary, icon = c.feature.icon, revealed, showMenu = showMenu) - MsgContentItemDropdownMenu() - } - is CIContent.RcvGroupFeature -> { - CIChatFeatureView(chatsCtx, cInfo, cItem, c.groupFeature, c.preference.enabled(c.memberRole_, (cInfo as? ChatInfo.Group)?.groupInfo?.membership).iconColor, revealed = revealed, showMenu = showMenu) - MsgContentItemDropdownMenu() - } - is CIContent.SndGroupFeature -> { - CIChatFeatureView(chatsCtx, cInfo, cItem, c.groupFeature, c.preference.enabled(c.memberRole_, (cInfo as? ChatInfo.Group)?.groupInfo?.membership).iconColor, revealed = revealed, showMenu = showMenu) - MsgContentItemDropdownMenu() - } - is CIContent.RcvChatFeatureRejected -> { - CIChatFeatureView(chatsCtx, cInfo, cItem, c.feature, Color.Red, revealed = revealed, showMenu = showMenu) - MsgContentItemDropdownMenu() - } - is CIContent.RcvGroupFeatureRejected -> { - CIChatFeatureView(chatsCtx, cInfo, cItem, c.groupFeature, Color.Red, revealed = revealed, showMenu = showMenu) - MsgContentItemDropdownMenu() - } - is CIContent.SndModerated -> DeletedItem() - is CIContent.RcvModerated -> DeletedItem() - is CIContent.RcvBlocked -> DeletedItem() - is CIContent.SndDirectE2EEInfo -> DirectE2EEInfoText(c.e2eeInfo) - is CIContent.RcvDirectE2EEInfo -> DirectE2EEInfoText(c.e2eeInfo) - is CIContent.SndGroupE2EEInfo -> E2EEInfoNoPQText() - is CIContent.RcvGroupE2EEInfo -> E2EEInfoNoPQText() - is CIContent.ChatBanner -> Spacer(modifier = Modifier.size(0.dp)) - is CIContent.InvalidJSON -> { - CIInvalidJSONView(c.json) - DeleteItemMenu() } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt index 13351a2111..ed74e083e7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt @@ -131,7 +131,7 @@ fun UserPicker( } LaunchedEffect(Unit) { // Controller.ctrl can be null when self-destructing activates - if (controller.ctrl != null && controller.ctrl != -1L) { + if (controller.hasChatCtrl()) { withBGApi { controller.reloadRemoteHosts() } 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 4a911fa6f0..d55d89f26b 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 @@ -366,6 +366,7 @@ fun startChat( chatDbChanged: MutableState, progressIndicator: MutableState? = null ) { + Log.d(TAG, "startChat") withLongRunningApi { try { progressIndicator?.value = true @@ -532,7 +533,7 @@ fun deleteChatDatabaseFilesAndState() { appPrefs.newDatabaseInitialized.set(false) chatModel.desktopOnboardingRandomPassword.value = false controller.appPrefs.storeDBPassphrase.set(true) - controller.ctrl = null + controller.setChatCtrl(null) // Clear sensitive data on screen just in case ModalManager will fail to prevent hiding its modals while database encrypts itself chatModel.chatId.value = null diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt index 8021a605db..f86edb0388 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt @@ -33,7 +33,7 @@ fun LocalAuthView(m: ChatModel, authRequest: LocalAuthRequest) { } } else { val r: LAResult = if (passcode.value == authRequest.password) { - if (authRequest.selfDestruct && sdPassword != null && controller.ctrl == -1L) { + if (authRequest.selfDestruct && sdPassword != null && controller.getChatCtrl() == -1L) { initChatControllerOnStart() } LAResult.Success @@ -58,7 +58,7 @@ private fun deleteStorageAndRestart(m: ChatModel, password: String, completed: ( if (m.chatRunning.value == true) { stopChatAsync(m) } - val ctrl = m.controller.ctrl + val ctrl = m.controller.getChatCtrl() if (ctrl != null && ctrl != -1L) { /** * The following sequence can bring a user here: diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt index d74846f8a3..6199621c39 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt @@ -633,7 +633,7 @@ private fun MutableState.startDownloading( private fun MutableState.importArchive(archivePath: String, netCfg: NetCfg, networkProxy: NetworkProxy?) { withLongRunningApi { try { - if (ChatController.ctrl == null || ChatController.ctrl == -1L) { + if (!ChatController.hasChatCtrl()) { chatInitControllerRemovingDatabases() } controller.apiDeleteStorage() 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 8c8493358e..371f0e076f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1861,14 +1861,19 @@ Remove member? Remove members? + Delete member messages? Remove member + Delete member messages Chat with member Send direct message Member will be removed from group - this cannot be undone! Members will be removed from group - this cannot be undone! Member will be removed from chat - this cannot be undone! Members will be removed from chat - this cannot be undone! + Member messages will be deleted - this cannot be undone! Remove + Remove and delete messages + Delete messages Remove member Block member? Block member diff --git a/apps/simplex-directory-service/src/Directory/Store/Migrate.hs b/apps/simplex-directory-service/src/Directory/Store/Migrate.hs index ad37eba6d7..e22f4ed470 100644 --- a/apps/simplex-directory-service/src/Directory/Store/Migrate.hs +++ b/apps/simplex-directory-service/src/Directory/Store/Migrate.hs @@ -46,7 +46,7 @@ runDirectoryMigrations :: DirectoryOpts -> ChatConfig -> DBStore -> IO () runDirectoryMigrations opts ChatConfig {confirmMigrations} chatStore = migrateDBSchema chatStore - (toDBOpts dbOptions chatSuffix False) + (toDBOpts dbOptions chatSuffix False []) (Just "sx_directory_migrations") directorySchemaMigrations MigrationConfig {confirm, backupPath = Nothing} diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 0c173df77c..080391aa1a 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -2201,6 +2201,7 @@ Known: **Record type**: - groupMemberId: int64 - groupId: int64 +- indexInGroup: int64 - memberId: string - memberRole: [GroupMemberRole](#groupmemberrole) - memberCategory: [GroupMemberCategory](#groupmembercategory) @@ -3353,6 +3354,14 @@ GroupMemberNotFound: - type: "groupMemberNotFound" - groupMemberId: int64 +GroupMemberNotFoundByIndex: +- type: "groupMemberNotFoundByIndex" +- groupMemberIndex: int64 + +MemberRelationsVectorNotFound: +- type: "memberRelationsVectorNotFound" +- groupMemberId: int64 + GroupHostMemberNotFound: - type: "groupHostMemberNotFound" - groupId: int64 @@ -3365,6 +3374,9 @@ MemberContactGroupMemberNotFound: - type: "memberContactGroupMemberNotFound" - contactId: int64 +InvalidMemberRelationUpdate: +- type: "invalidMemberRelationUpdate" + GroupWithoutUser: - type: "groupWithoutUser" @@ -3451,9 +3463,6 @@ PendingConnectionNotFound: - type: "pendingConnectionNotFound" - connId: int64 -IntroNotFound: -- type: "introNotFound" - UniqueID: - type: "uniqueID" diff --git a/cabal.project b/cabal.project index df5ac1ba24..1f331bdf74 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 3016b929b48d3116f8ee60169c06383ca57f78e0 + tag: 2ca440dd2dfd494ff2bb40cc0409d08069d02e04 source-repository-package type: git diff --git a/docs/DONATIONS.md b/docs/DONATIONS.md new file mode 100644 index 0000000000..276a57ed8a --- /dev/null +++ b/docs/DONATIONS.md @@ -0,0 +1,30 @@ +--- +layout: layouts/privacy.html +permalink: /donate/index.html +--- + +# Please support us with donations + +Huge thank you to everybody who donated to SimpleX Chat! + +We are prioritizing users privacy and security - it would be impossible without your support. + +Our pledge to our users is that SimpleX protocols are and will remain open, and in public domain, - so anybody can build the future implementations of the clients and the servers. We are building SimpleX platform based on the same principles as email and web, but much more private and secure. + +Your donations help us raise more funds - any amount, even the price of the cup of coffee, makes a big difference for us. + +Please donate via: + +- [GitHub](https://github.com/sponsors/simplex-chat) (commission-free) or [OpenCollective](https://opencollective.com/simplex-chat) (~10% commission) +- BTC: [bc1q2gy6f02nn6vvcxs0pnu29tpnpyz0qf66505d4u](bitcoin:bc1q2gy6f02nn6vvcxs0pnu29tpnpyz0qf66505d4u) +- XMR: [8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt](monero:8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt) +- ETH/USDT (Ethereum, Arbitrum One): [0xD7047Fe3Eecb2f2FF78d839dD927Be27Bc12c86a](ethereum:0xD7047Fe3Eecb2f2FF78d839dD927Be27Bc12c86a) ([donate.simplexchat.eth](ethereum:0xD7047Fe3Eecb2f2FF78d839dD927Be27Bc12c86a)) +- [Other cryptocurrencies](https://github.com/simplex-chat/simplex-chat#please-support-us-with-your-donations) + +Thank you, + +Evgeny, SimpleX Chat founder + +## SimpleX Community Vouchers + +Please comment on our plan to make SimpleX network sustainable and get a free access pass (an NFT) for early testing: https://simplex.chat/vouchers diff --git a/docs/FAQ.md b/docs/FAQ.md index a24f9f9c56..401f025d9c 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -79,7 +79,7 @@ When "Incognito Mode” is turned on, your currently chosen profile name and ima ### How do invitations work? -It is quite a complex process, but fortunately all of this happens in the background, so it's simply to use. +It is quite a complex process, but fortunately all of this happens in the background, so it's simple to use. Whenever somebody connects to you via your address, they basically ask your client whether they want to establish connection. After that, you can either agree or disagree. If interested, please read more: [Addresses and invitations](./guide/making-connections.md). @@ -126,7 +126,7 @@ You can also revoke the files you send. If the recipients did not yet receive th This is different from most other messengers that allow deleting messages from the recipients' devices without any agreement with the recipients. We believe that allowing deleting information from your device to your contacts is a very wrong design decision for several reasons: -1) it violates your data sovereignty as the device owner - once your are in possession of any information, you have the rights to retain it, and any deletion should be agreed with you. And security and privacy is not possible if users don't have sovereignty over their devices. +1) it violates your data sovereignty as the device owner - once you are in possession of any information, you have the rights to retain it, and any deletion should be agreed with you. And security and privacy is not possible if users don't have sovereignty over their devices. 2) it may be a business communication, and either your organization policy or a compliance requirement is that every message you receive must be preserved for some time. 3) the message can contain a legally binding promise, effectively a contract between you and your contact, in which case you both need to keep it. 4) the messages may contain threat or abuse and you may want to keep them as a proof. diff --git a/docs/JOIN_TEAM.md b/docs/JOIN_TEAM.md index 2e428409df..edf3ba199b 100644 --- a/docs/JOIN_TEAM.md +++ b/docs/JOIN_TEAM.md @@ -6,31 +6,49 @@ layout: layouts/jobs.html # Join SimpleX Chat team -SimpleX Chat is a seed stage startup with a lot of user growth in 2022-2025, and a lot of exciting technical and product problems to solve to grow faster. - -We currently have 4 people in the team. - -We are looking for passionate and creative people to help us! +Join SimpleX Chat team to build the future of secure, private, and decentralized communications. ## Who we are looking for -### Mobile application developer +### iOS Engineer -You: -- created mobile applications for Android platforms as **your own full-time or side projects**, -- expertise with Android APIs, Kotlin and JetPack Compose framework, -- [a good taste](https://paulgraham.com/taste.html) for mobile apps design would be a bonus. +We are looking for an entrepreneurial iOS engineer. -It is not a full time job yet, we have some specific problems to solve in the Android app. If we are happy working together it is likely to evolve into a full-time job offer in 2026. +Please send your exceptional achievements related to iOS (created in your own free time, or via grants, or startups you co-founded, but not as part of employment or contract): +- popular iOS open-source libraries or frameworks. +- iOS apps you developed on your own or as part of 2-people team. +- technical publications about iOS engineering. -Please ONLY apply if you created and released your own apps (not as a job or contract for somebody else). +This is a full-time remote contract. You must be in UTC +/- 8 hours timezone. + +### Application Engineer + +We are looking for an entrepreneurial Android/Desktop applications engineer with an advanced expertise in: +- Kotlin Multiplatform. +- Jetpack Compose and JVM development. +- desktop applications for Linux, Mac and Windows. +- advanced knowledge of C/C++, Java, JavaScript and some other programming languages. + +Please send your exceptional engineering achievements (created in your own free time, or via grants, or startups you co-founded, but not as part of employment or contract): +- popular open-source libraries, frameworks or applications. +- apps you developed on your own or as part of 2-3 people team. +- technical publications about engineering. + +This is a full-time remote contract. You must be in UTC +/- 8 hours timezone. + +

Community Builder

+ +We are looking for an entrepreneurial Community Builder and Marketer, with successful crowdfunding experience. + +Please send your exceptional achievements related to community building, social media marketing, or crowdfunding (created in your free time or in startups you co-founded, but not as part of contract or employment): +- large, active online communities, social media accounts, podcasts, or YouTube channels. +- successful crowdfunding campaigns that raised significant funds. +- high-profile publications, interviews, or viral content pieces. + +This is a part-time remote contract. You must be in UTC +/- 8 hours timezone. ## How to join the team -1. [Install the app](https://github.com/simplex-chat/simplex-chat#install-the-app), try using it with the friends and [join some user groups](https://github.com/simplex-chat/simplex-chat#join-user-groups) – you will discover a lot of things that need improvements. +To apply, please [install SimpleX Chat app](./DOWNLOADS.md) and send your achievements to this [SimpleX address](https://smp16.simplex.im/a#OGL3qf7utOrUERFoFOROgdQaAkj_znzoeACNKDAsFNA). -2. Also look through [GitHub issues](https://github.com/simplex-chat/simplex-chat/issues) submitted by the users to see what would you want to contribute as a test. - -3. [Connect to us](https://smp4.simplex.im/a#IWCurmcnKDvfOzGrQdqlXjKinqkvO10a2q__nWBVG6c) via SimpleX Chat to chat about what you want to contribute and about joining the team. - -4. You can also email [jobs@simplex.chat](mailto:jobs@simplex.chat?subject=Join%20SimpleX%20Chat%20team) +**We do NOT review CVs at the initial stage, please only send the links to your achievements**. diff --git a/docs/rfcs/2025-11-17-async-commands-acks.md b/docs/rfcs/2025-11-17-async-commands-acks.md new file mode 100644 index 0000000000..bb95c09717 --- /dev/null +++ b/docs/rfcs/2025-11-17-async-commands-acks.md @@ -0,0 +1,74 @@ +# Acknowledgments for async command responses + +## Problem + +Continuations for asynchronous commands can be forever lost if their execution fails, e.g. due to a crash. This can result in failures in establishing connections, sending post-connection auto-reply, etc. depending on other applications of asynchronous commands. + +## Solution + +An idea is to persist events in agent until chat acknowledges their processing, and replay them on next start of command processing. + +### Agent persistence + +Save response on command before notifying chat (event received by chat via subQ). + +```sql +ALTER TABLE commands ADD COLUMN event BLOB; +``` + +Type is `AEvent`, requires encoding for To and FromField instances. + +Application of chat continuations is very limited, so not all events need to be saved. In fact currently we only need 2 types of events to be recorded - see below. This breaks separation between chat and agent (agent knows which events to record), however that abstraction has long been violated. This can be a contract between agent and chat - which events to keep and acknowledge. + +TBC separate type for storing only necessary events: + +```haskell +data AEventDB where + ... -- only necessary constructors + +-- AEventDB encoding, instances + +toDBEvent :: AEvent -> AEventDB + +fromDBEvent :: AEventBD -> AEvent +``` + +Alternatively, we can save all events and require chat to acknowledge all events. This seems like an overkill and unnecessary work and generalization. + +### Agent event processing + +Currently agent deletes command records after processing. Instead it will: +- keep records until receiving acknowledgement on event; +- delete command record when receiving acknowledgment on event from chat; +- when retrieving next command to process filter out already processed commands (that have event saved); +- replay to chat unacknowledged events on starting async command processing (`resumeAllCommands`?). + +Same correlation id that is used for command can be used for acknowledging event. + +Agent API: + +```haskell +ackCommandEvent :: AgentClient -> UserId -> ACorrId -> AE () +``` + +### Command continuations + +Chat uses command continuations on following events: +- INV in group connection - XGrpMemIntro continuation (send XGrpMemInv with created connection link); +- JOINED in both direct and group (business chat) connections - send auto-reply. + +So it is enough for agent to record only INV and JOINED events, and for chat to acknowledge processing only for these events. However, as agent doesn't discriminate which INVs to save, chat should acknowledge all INVs. Another alternative is for chat to inform agent whether event should be kept when making command, e.g.: + +```haskell +createConnectionAsync :: AgentClient -> ... -> Bool -> ... + +-- Bool is flag whether to keep INV event for this command until acknowledged +``` + +Group relay protocol may add new continuations, for example for owner on adding relay link to group link (new async version of setConnShortLink api). + +Chat continuations should be idempotent. +- More important for INV event, to not repeatedly send XGrpMemIntro. +- For JOINED in worst case auto-reply would be re-sent which is not ideal but not very damaging. +- Chat can track additional state to help identify which part of event processing to replay. +- E.g. for group INV continuation chat can track that XGrpMemIntro was sent on group record. TBC per continuation. diff --git a/docs/rfcs/2025-11-24-member-relations-vector.md b/docs/rfcs/2025-11-24-member-relations-vector.md new file mode 100644 index 0000000000..087541fda3 --- /dev/null +++ b/docs/rfcs/2025-11-24-member-relations-vector.md @@ -0,0 +1,134 @@ +# Member relations vectors + +## Problem + +Maintaining member introduction records takes N^2 space. + +## Solution + +Migrate to member relations byte vector, with per member relation encoded by member index. + +Requires: + +1. Per group member index (Done). +2. Primitives to work with byte vector (Done). +3. Rework forwarding logic to use relations vector. +4. Rework introductions logic to use relations vector (avoid duplicate introductions). +5. Migration from introductions to vector. + +Migration is 2-stage: + +1. Live migration to accommodate large volume of introductions data, with admin client choosing mode of operation based on presence of relation vector for member. +2. Offline migration of remaining introduction records. Drop mode of operation based on introductions. + +### Forwarding + +When new invitee connects (CON) -> host makes introductions: + +1. For this invitee: set member relations to 'MRIntroduced' for respective members. _**(Take member lock)**_ +2. For pre-members: + - Member has vector: Set relation to 'MRIntroducedTo' for invitee member - N updates. _**(Take member locks/take group lock?)**_ + - No vector: Create introduction record (Transitional mode of operation based on introductions). + +When member reports XGrpMemCon ("connected with another member"), for both reporting and referenced members: + +1. Member has vector: Set relation to 'MRConnected'. _**(Take member lock)**_ +2. No vector: Update introduction record status (Transitional). + +When member sends message -> host forwards: + +1. Member has vector: Get recipients based on sender relations vector ('MRIntroduced' + 'MRIntroducedTo' members). +2. No vector: Get recipients based on introduction records (Transitional), set sender's vector. _**(Take member lock)**_ + - Compiled list of recipients to be marked as introduced; differentiate 'MRIntroduced'/'MRIntroducedTo'? (Complication of splitting introduced into 2 relations). + - Additionally get Connected members, currently they are filtered out as not requiring forward. (It is necessary to make a complete computation of vector in one go, as this member will then be skipped in background updates) + +#### Avoid duplicate forwards + +N updates approach allows us to avoid duplicate forwards: + +- Admin only forwards based on introductions embedded into relations vector: 'MRIntroduced', 'MRIntroducedTo'. + +- Admin doesn't forward to 'MRNew' members. + +Following diagram illustrates that in multi-admin scenario only host of "later" invitee (Bob) will forward messages between his and other admin's invitees. + +```mermaid +sequenceDiagram + participant A as Alice + participant B as Bob + participant C as Cath + +note over A, C: Alice invites and introduces Cath +A <<->> C: invite, CON +A ->> B: announce Cath +A ->> C: introduce Bob +note over A, C: Bob invites and introduces Dan +create participant D as Dan +B <<->> D: invite, CON +B ->> A: announce Dan +B ->> C: announce Dan +B ->> D: introduce Alice, Cath +note over A, B: Vectors (only Dan/Cath relation interests us
- we want to avoid duplicate forwards) +note left of A: Alice vectors
For Cath: Dan - MRNew
For Dan: Cath - MRNew +note right of B: Bob vectors
For Cath: Dan - MRIntroduced
For Dan: Cath - MRIntroducedTo +note over A, B: Only Bob forwards between Cath and Dan +C <<->> D: connect +C ->> B: x.grp.mem.con (connected to Dan) +D ->> B: or: x.grp.mem.con (connected to Cath)
(x.grp.mem.con from either is enough) +note right of B: Bob vectors
For Cath: Dan - MRConnected
For Dan: Cath - MRConnected +note over A, B: Bob stops forwarding between Cath and Dan +``` + +### Avoid duplicate introductions + +Scenario 1. Pending member is accepted to group -> avoid repeat introductions to moderators and above. + +Scenario 2. Two invitees connect to host concurrently -> avoid introductions race. + +Both can be solved by excluding already introduced members: +- Member (new invitee) has vector: filter out 'MRIntroduced', 'MRIntroducedTo' members from list of members to introduce. +- No vector: filter out based on introduction records (Transitional; `introduceToRemaining` + restore `checkInverseIntro` logic). + +### Live migration (Stage 1) + +Background process to set members' vectors based on introductions. + +Goes over members with NULL relation vector. Logic to determine relations is same as when setting sender's vector on forwarding. The latter is optimization -> faster migration of hot paths. _**(Take member locks)**_ + +TBC report when done - for directory service. Or we can track remaining member records with NULL vector. + +### Offline migration (Stage 2) + +TBC SQL to set relations vectors based on remaining introductions records. + +### Other considerations + +#### 1. Introductions race - missed introductions + +We may have identified race where some pairs of members may never become introduced to each other. It can occur if 2 hosts concurrently invite (announce) and introduce their respective invitees based to their respective local member lists. + +Consider such timeline: + + 1. Admin 1 invites Invitee 1. + + Invitee 1 connects to Admin 1 (CON). + + Admin 1 announces (x.grp.mem.new) Invitee 1 and introduces him to known members (Admin 1 hasn't seen Invitee 2). + + 2. Admin 2 invites Invitee 2. + + Invitee 2 connects to Admin 2 (CON). + + _Consider following scenario: Admin 2 hasn't received x.grp.mem.new for Invitee 1 from Admin 1._ Admin 2 announces (x.grp.mem.new) Invitee 2 and introduces him to known members (Admin 2 hasn't seen Invitee 1). + + 3. Both admins receive (with delay) opposite x.grp.mem.new -> both admins already made introductions before and consider opposite admin would introduce "new" member to their "older" invitee. + +This is status quo, this work will not improve it. + +We will revert change of admins making decision of introductions lists based purely on member index, which may have made such race more likely. Instead they will determine introductions lists as following: all current members minus already introduced members (see "Avoid duplicate introductions" section). + +#### 2. Double x.grp.mem.con notifications + +As alternative to N updates for introduced members, we considered redundant forwarding in multi-admin scenario and modifying user clients (2-stage release) to send x.grp.mem.con notifications to both own host and host of connected member. + +Not symmetrical: a "later" invitee doesn't know which member is the host of an "earlier" invitee. diff --git a/flake.nix b/flake.nix index a68b42e2f1..c130e1a1fd 100644 --- a/flake.nix +++ b/flake.nix @@ -41,14 +41,24 @@ }; sha256map = import ./scripts/nix/sha256map.nix; modules = [ - ({ pkgs, lib, ...}: lib.mkIf (!pkgs.stdenv.hostPlatform.isWindows) { - # This patch adds `dl` as an extra-library to direct-sqlciper, which is needed - # on pretty much all unix platforms, but then blows up on windows m( - packages.direct-sqlcipher.patches = [ ./scripts/nix/direct-sqlcipher-2.3.27.patch ]; - }) - ({ pkgs,lib, ... }: lib.mkIf (pkgs.stdenv.hostPlatform.isAndroid) { - packages.simplex-chat.components.library.ghcOptions = [ "-pie" ]; - })] ++ extra-modules; + ({ pkgs, lib, config, ... }: + { + # Override ghcOptions for ALL packages + ghcOptions = lib.mkDefault [ + "-j1" + ]; + } + ) + + ({ pkgs, lib, ...}: lib.mkIf (!pkgs.stdenv.hostPlatform.isWindows) { + # This patch adds `dl` as an extra-library to direct-sqlciper, which is needed + # on pretty much all unix platforms, but then blows up on windows m( + packages.direct-sqlcipher.patches = [ ./scripts/nix/direct-sqlcipher-2.3.27.patch ]; + }) + + ({ pkgs,lib, ... }: lib.mkIf (pkgs.stdenv.hostPlatform.isAndroid) { + packages.simplex-chat.components.library.ghcOptions = [ "-pie" ]; + })] ++ extra-modules; }; in # by defualt we don't need to pass extra-modules. let drv = pkgs': drv' { extra-modules = []; inherit pkgs'; }; in @@ -368,6 +378,7 @@ "-threaded" # "-debug" "-optl-lffi" + "-j1" ] # This is fairly idiotic. LLD will strip out foreign exported # symbols (a GHC bug? Codegen bug?). So we need to pass `-u ` @@ -433,7 +444,16 @@ done ${pkgs.tree}/bin/tree $out/_pkg - (cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 $out/pkg-armv7a-android-libsimplex.zip *) + + # Strip from debug symbols + find "$out/_pkg" -type f -name "*.so" -exec ${android32Pkgs.stdenv.cc.targetPrefix}strip --strip-unneeded {} + + + # Normalize permissions + timestamps + find "$out/_pkg" -type f -exec chmod 644 {} + + find "$out/_pkg" -type d -exec chmod 755 {} + + find "$out/_pkg" -exec touch -h -d '@1764547200' {} + + + (cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 -X $out/pkg-armv7a-android-libsimplex.zip *) rm -fR $out/_pkg mkdir -p $out/nix-support echo "file binary-dist \"$(echo $out/*.zip)\"" \ @@ -477,6 +497,7 @@ # "-debug" "-optl-lffi" "-optl-Wl,-z,max-page-size=16384" + "-j1" ] # This is fairly idiotic. LLD will strip out foreign exported # symbols (a GHC bug? Codegen bug?). So we need to pass `-u ` @@ -542,7 +563,16 @@ done ${pkgs.tree}/bin/tree $out/_pkg - (cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 $out/pkg-aarch64-android-libsimplex.zip *) + + # Strip from debug symbols + find "$out/_pkg" -type f -name "*.so" -exec ${androidPkgs.stdenv.cc.targetPrefix}strip --strip-unneeded {} + + + # Normalize permissions + timestamps + find "$out/_pkg" -type f -exec chmod 644 {} + + find "$out/_pkg" -type d -exec chmod 755 {} + + find "$out/_pkg" -exec touch -h -d '@1764547200' {} + + + (cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 -X $out/pkg-aarch64-android-libsimplex.zip *) rm -fR $out/_pkg mkdir -p $out/nix-support echo "file binary-dist \"$(echo $out/*.zip)\"" \ diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 993ca789b3..1a6b41e3dc 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -2493,6 +2493,7 @@ export namespace GroupLinkPlan { export interface GroupMember { groupMemberId: number // int64 groupId: number // int64 + indexInGroup: number // int64 memberId: string memberRole: GroupMemberRole memberCategory: GroupMemberCategory @@ -3742,9 +3743,12 @@ export type StoreError = | StoreError.GroupNotFoundByName | StoreError.GroupMemberNameNotFound | StoreError.GroupMemberNotFound + | StoreError.GroupMemberNotFoundByIndex + | StoreError.MemberRelationsVectorNotFound | StoreError.GroupHostMemberNotFound | StoreError.GroupMemberNotFoundByMemberId | StoreError.MemberContactGroupMemberNotFound + | StoreError.InvalidMemberRelationUpdate | StoreError.GroupWithoutUser | StoreError.DuplicateGroupMember | StoreError.GroupAlreadyJoined @@ -3768,7 +3772,6 @@ export type StoreError = | StoreError.ConnectionNotFoundById | StoreError.ConnectionNotFoundByMemberId | StoreError.PendingConnectionNotFound - | StoreError.IntroNotFound | StoreError.UniqueID | StoreError.LargeMsg | StoreError.InternalError @@ -3827,9 +3830,12 @@ export namespace StoreError { | "groupNotFoundByName" | "groupMemberNameNotFound" | "groupMemberNotFound" + | "groupMemberNotFoundByIndex" + | "memberRelationsVectorNotFound" | "groupHostMemberNotFound" | "groupMemberNotFoundByMemberId" | "memberContactGroupMemberNotFound" + | "invalidMemberRelationUpdate" | "groupWithoutUser" | "duplicateGroupMember" | "groupAlreadyJoined" @@ -3853,7 +3859,6 @@ export namespace StoreError { | "connectionNotFoundById" | "connectionNotFoundByMemberId" | "pendingConnectionNotFound" - | "introNotFound" | "uniqueID" | "largeMsg" | "internalError" @@ -3995,6 +4000,16 @@ export namespace StoreError { groupMemberId: number // int64 } + export interface GroupMemberNotFoundByIndex extends Interface { + type: "groupMemberNotFoundByIndex" + groupMemberIndex: number // int64 + } + + export interface MemberRelationsVectorNotFound extends Interface { + type: "memberRelationsVectorNotFound" + groupMemberId: number // int64 + } + export interface GroupHostMemberNotFound extends Interface { type: "groupHostMemberNotFound" groupId: number // int64 @@ -4010,6 +4025,10 @@ export namespace StoreError { contactId: number // int64 } + export interface InvalidMemberRelationUpdate extends Interface { + type: "invalidMemberRelationUpdate" + } + export interface GroupWithoutUser extends Interface { type: "groupWithoutUser" } @@ -4119,10 +4138,6 @@ export namespace StoreError { connId: number // int64 } - export interface IntroNotFound extends Interface { - type: "introNotFound" - } - export interface UniqueID extends Interface { type: "uniqueID" } diff --git a/scripts/android/build-android.sh b/scripts/android/build-android.sh index 97190ec103..90d6092385 100755 --- a/scripts/android/build-android.sh +++ b/scripts/android/build-android.sh @@ -138,10 +138,15 @@ build() { mkdir -p "$android_tmp_folder" unzip -oqd "$android_tmp_folder" "$android_apk_output" + # Determenistic build + find "$android_tmp_folder" -type f -exec chmod 644 {} + + find "$android_tmp_folder" -type d -exec chmod 755 {} + + find "$android_tmp_folder" -exec touch -h -d '@1764547200' {} + + ( cd "$android_tmp_folder" && \ - zip -rq5 "$tmp/$android_apk_output_final" . && \ - zip -rq0 "$tmp/$android_apk_output_final" resources.arsc res + find . -type f -print0 | sort -z | xargs -0 zip -X -rq5 "$tmp/$android_apk_output_final" && \ + find res resources.arsc -type f -print0 | sort -z | xargs -0 zip -X -rq0 "$tmp/$android_apk_output_final" ) zipalign -p -f 4 "$tmp/$android_apk_output_final" "$PWD/$android_apk_output_final" diff --git a/scripts/desktop/build-cli-deb.sh b/scripts/desktop/build-cli-deb.sh new file mode 100755 index 0000000000..7422520d71 --- /dev/null +++ b/scripts/desktop/build-cli-deb.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env sh +set -eu + +export SOURCE_DATE_EPOCH=1764547200 + +CLI_VERSION="$1" +CLI_PATH_TO_BIN="${2:-/out/simplex-chat}" +BUILD_FOLDER="${3:-/out/deb-build}" + +size=$(stat -c '%s' "$CLI_PATH_TO_BIN" | awk '{printf "%.0f\n", ($1+1023)/1024}') +arch=$(case "$(uname -m)" in x86_64) printf "amd64" ;; aarch64) printf "arm64" ;; *) printf "unknown" ;; esac) +package='simplex-chat' + +mkdir "$BUILD_FOLDER" +cd "$BUILD_FOLDER" + +mkdir -p ./${package}/DEBIAN +mkdir -p ./${package}/usr/bin +cat > ./${package}/DEBIAN/control << EOF +Package: ${package} +Version: ${CLI_VERSION} +Section: Messenger +Priority: optional +Architecture: ${arch} +Maintainer: SimpleX Chat +Description: SimpleX - the first messaging platform that has no user identifiers of any kind - 100% private by design! (CLI) +Installed-Size: ${size} +EOF + +cp "$CLI_PATH_TO_BIN" ./${package}/usr/bin/simplex-chat +chmod +x ./${package}/usr/bin/simplex-chat + +find ./${package} -exec touch -d "@${SOURCE_DATE_EPOCH}" {} + + +dpkg-deb --build --root-owner-group --uniform-compression ./${package} + +strip-nondeterminism "./${package}.deb" diff --git a/scripts/flatpak/chat.simplex.simplex.metainfo.xml b/scripts/flatpak/chat.simplex.simplex.metainfo.xml index 950cb42ea2..4f5f5d395c 100644 --- a/scripts/flatpak/chat.simplex.simplex.metainfo.xml +++ b/scripts/flatpak/chat.simplex.simplex.metainfo.xml @@ -38,6 +38,27 @@ + + https://simplex.chat/blog/20250729-simplex-chat-v6-4-1-welcome-contacts-protect-groups-app-security.html + +

New in v6.4.8:

+
    +
  • fix stuck message reception and other events after passphrase change (e.g., during desktop app initial start)
  • +
+

New in v6.4-6.4.7:

+
    +
  • new UX to connect.
  • +
  • review new group members.
  • +
  • chat with group admins.
  • +
  • new UI languages: Catalan, Indonesian, Romanian and Vietnamese.
  • +
  • Linux app builds for aarch64 CPUs
  • +
  • UI support for bot commands.
  • +
  • support markdown hyperlinks, such as [click here](https://example.com).
  • +
  • option to remove tracking parameters from the links.
  • +
  • better information about network errors.
  • +
+
+
https://simplex.chat/blog/20250729-simplex-chat-v6-4-1-welcome-contacts-protect-groups-app-security.html diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 7acb51e97c..61ede61996 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."3016b929b48d3116f8ee60169c06383ca57f78e0" = "1gvv96w1ag8xh19an5j50isjw0yq1cbsywii5s1adaplr6fw34mb"; + "https://github.com/simplex-chat/simplexmq.git"."2ca440dd2dfd494ff2bb40cc0409d08069d02e04" = "1jc1a9vh59l0l5hxlin1spv03afrgmmiml5xnakhbi4rk67n0wwr"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/scripts/simplex-chat-reproduce-builds.sh b/scripts/simplex-chat-reproduce-builds.sh index c7d9170c4a..e1a62dc73a 100755 --- a/scripts/simplex-chat-reproduce-builds.sh +++ b/scripts/simplex-chat-reproduce-builds.sh @@ -20,115 +20,133 @@ package direct-sqlcipher export DOCKER_BUILDKIT=1 +version=${TAG#v} +version=${version%-*} + cleanup() { - docker exec -t "${container_name}" sh -c 'rm -rf ./dist-newstyle ./apps' 2>/dev/null || : - rm -rf -- "${tempdir}" - docker rm --force "${container_name}" 2>/dev/null || : - docker image rm "${image_name}" 2>/dev/null || : - cd "${init_dir}" + docker exec -t "${container_name}" sh -c 'rm -rf ./dist-newstyle ./apps' 2>/dev/null || : + rm -rf -- "${tempdir}" + docker rm --force "${container_name}" 2>/dev/null || : + docker image rm "${image_name}" 2>/dev/null || : + cd "${init_dir}" } trap 'cleanup' EXIT INT mkdir -p "${init_dir}/${TAG}-${repo_name}/from-source" "${init_dir}/${TAG}-${repo_name}/prebuilt" git -C "${tempdir}" clone "${repo}.git" &&\ - cd "${tempdir}/${repo_name}" &&\ - git checkout "${TAG}" + cd "${tempdir}/${repo_name}" &&\ + git checkout "${TAG}" for os in '22.04' '24.04'; do - os_url="$(printf '%s' "${os}" | tr '.' '_')" + os_url="$(printf '%s' "${os}" | tr '.' '_')" - cli_name="simplex-chat-ubuntu-${os_url}-x86_64" - deb_name="simplex-desktop-ubuntu-${os_url}-x86_64.deb" - appimage_name="simplex-desktop-x86_64.AppImage" + cli_name="simplex-chat-ubuntu-${os_url}-x86_64" + deb_name="simplex-desktop-ubuntu-${os_url}-x86_64.deb" + appimage_name="simplex-desktop-x86_64.AppImage" - # Build image - docker build \ - --no-cache \ - --build-arg TAG="${os}" \ - --build-arg GHC="${ghc}" \ - -f "${tempdir}/${repo_name}/Dockerfile.build" \ - -t "${image_name}" \ - . + # Build image + docker build \ + --no-cache \ + --build-arg TAG="${os}" \ + --build-arg GHC="${ghc}" \ + -f "${tempdir}/${repo_name}/Dockerfile.build" \ + -t "${image_name}" \ + . - printf '%s' "${cabal_local}" > "${tempdir}/${repo_name}/cabal.project.local" + printf '%s' "${cabal_local}" > "${tempdir}/${repo_name}/cabal.project.local" - # Run container in background - docker run -t -d \ - --name "${container_name}" \ - --device /dev/fuse \ - --cap-add SYS_ADMIN \ - --security-opt apparmor:unconfined \ - -v "${tempdir}/${repo_name}:/project" \ - "${image_name}" + # Run container in background + docker run -t -d \ + --name "${container_name}" \ + --device /dev/fuse \ + --cap-add SYS_ADMIN \ + --security-opt apparmor:unconfined \ + -v "${tempdir}/${repo_name}:/project" \ + "${image_name}" - # Consistent permissions - docker exec \ - -t "${container_name}" \ - sh -c 'find /project -type d -exec chmod 755 {} \; ; find /project -type f -perm /111 -exec chmod 755 {} \; ; find /project -type f ! -perm /111 -exec chmod 644 {} \;' + # Consistent permissions + docker exec \ + -t "${container_name}" \ + sh -c 'find /project -type d -exec chmod 755 {} \; ; find /project -type f -perm /111 -exec chmod 755 {} \; ; find /project -type f ! -perm /111 -exec chmod 644 {} \;' - # CLI - docker exec \ - -t "${container_name}" \ - sh -c 'cabal clean && cabal update && cabal build -j && mkdir -p /out && for i in simplex-chat; do bin=$(find /project/dist-newstyle -name "$i" -type f -executable) && chmod +x "$bin" && mv "$bin" /out/; done && strip /out/simplex-chat' + # CLI + docker exec \ + -t "${container_name}" \ + sh -c 'cabal clean && cabal update && cabal build -j && mkdir -p /out && for i in simplex-chat; do bin=$(find /project/dist-newstyle -name "$i" -type f -executable) && chmod +x "$bin" && mv "$bin" /out/; done && strip /out/simplex-chat' - # Copy CLI - docker cp \ - "${container_name}":/out/simplex-chat \ - "${init_dir}/${TAG}-${repo_name}/from-source/${cli_name}" + # Copy CLI + docker cp \ + "${container_name}":/out/simplex-chat \ + "${init_dir}/${TAG}-${repo_name}/from-source/${cli_name}" - # Download prebuilt CLI binary - curl -L \ - --output-dir "${init_dir}/${TAG}-${repo_name}/prebuilt/" \ - -O "${repo}/releases/download/${TAG}/${cli_name}" + # Download prebuilt CLI binary + curl -L \ + --output-dir "${init_dir}/${TAG}-${repo_name}/prebuilt/" \ + -O "${repo}/releases/download/${TAG}/${cli_name}" - # Desktop: deb - docker exec \ - -t "${container_name}" \ - sh -c './scripts/desktop/make-deb-linux.sh' + # CLI: deb + docker exec \ + -t "${container_name}" \ + sh -c "./scripts/desktop/build-cli-deb.sh ${version}" - # Copy deb - docker cp \ - "${container_name}":/project/apps/multiplatform/release/main/deb/simplex_x86_64.deb \ - "${init_dir}/${TAG}-${repo_name}/from-source/${deb_name}" + # Copy CLI: deb + docker cp \ + "${container_name}":/out/deb-build/simplex-chat.deb \ + "${init_dir}/${TAG}-${repo_name}/from-source/${cli_name}.deb" - # Download prebuilt deb package - curl -L \ - --output-dir "${init_dir}/${TAG}-${repo_name}/prebuilt/" \ - -O "${repo}/releases/download/${TAG}/${deb_name}" + # Download prebuilt CLI: deb binary + curl -L \ + --output-dir "${init_dir}/${TAG}-${repo_name}/prebuilt/" \ + -O "${repo}/releases/download/${TAG}/${cli_name}.deb" - # Desktop: appimage. Build only on 22.04 - case "$os" in - 22.04) - # Appimage - docker exec \ - -t "${container_name}" \ - sh -c './scripts/desktop/make-appimage-linux.sh && mv ./apps/multiplatform/release/main/*imple*.AppImage ./apps/multiplatform/release/main/simplex.appimage' + # Desktop: deb + docker exec \ + -t "${container_name}" \ + sh -c './scripts/desktop/make-deb-linux.sh' - # Copy appimage - docker cp \ - "${container_name}":/project/apps/multiplatform/release/main/simplex.appimage \ - "${init_dir}/${TAG}-${repo_name}/from-source/${appimage_name}" + # Copy deb + docker cp \ + "${container_name}":/project/apps/multiplatform/release/main/deb/simplex_x86_64.deb \ + "${init_dir}/${TAG}-${repo_name}/from-source/${deb_name}" - # Download prebuilt appimage binary - curl -L \ - --output-dir "${init_dir}/${TAG}-${repo_name}/prebuilt/" \ - -O "${repo}/releases/download/${TAG}/${appimage_name}" - ;; - esac - - # Important! Remove dist-newstyle for the next interation - docker exec \ - -t "${container_name}" \ - sh -c 'rm -rf ./dist-newstyle ./apps/multiplatform' + # Download prebuilt deb package + curl -L \ + --output-dir "${init_dir}/${TAG}-${repo_name}/prebuilt/" \ + -O "${repo}/releases/download/${TAG}/${deb_name}" - # Also restore git to previous state - git reset --hard && git clean -dfx + # Desktop: appimage. Build only on 22.04 + case "$os" in + 22.04) + # Appimage + docker exec \ + -t "${container_name}" \ + sh -c './scripts/desktop/make-appimage-linux.sh && mv ./apps/multiplatform/release/main/*imple*.AppImage ./apps/multiplatform/release/main/simplex.appimage' - # Stop containers, delete images - docker stop "${container_name}" - docker rm --force "${container_name}" - docker image rm "${image_name}" + # Copy appimage + docker cp \ + "${container_name}":/project/apps/multiplatform/release/main/simplex.appimage \ + "${init_dir}/${TAG}-${repo_name}/from-source/${appimage_name}" + + # Download prebuilt appimage binary + curl -L \ + --output-dir "${init_dir}/${TAG}-${repo_name}/prebuilt/" \ + -O "${repo}/releases/download/${TAG}/${appimage_name}" + ;; + esac + + # Important! Remove dist-newstyle for the next interation + docker exec \ + -t "${container_name}" \ + sh -c 'rm -rf ./dist-newstyle ./apps/multiplatform' + + # Also restore git to previous state + git reset --hard && git clean -dfx + + # Stop containers, delete images + docker stop "${container_name}" + docker rm --force "${container_name}" + docker image rm "${image_name}" done # Cleanup @@ -145,27 +163,27 @@ bad=0 # Check hashes for all binaries for file in "${path_bin}"/from-source/*; do - # Extract binary name - app="$(basename ${file})" + # Extract binary name + app="$(basename ${file})" - # Compute hash for compiled binary - compiled=$(sha256sum "${path_bin}/from-source/${app}" | awk '{print $1}') - # Compute hash for prebuilt binary - prebuilt=$(sha256sum "${path_bin}/prebuilt/${app}" | awk '{print $1}') + # Compute hash for compiled binary + compiled=$(sha256sum "${path_bin}/from-source/${app}" | awk '{print $1}') + # Compute hash for prebuilt binary + prebuilt=$(sha256sum "${path_bin}/prebuilt/${app}" | awk '{print $1}') - # Compare - if [ "${compiled}" != "${prebuilt}" ]; then - # If hashes doesn't match, set bad... - bad=1 + # Compare + if [ "${compiled}" != "${prebuilt}" ]; then + # If hashes doesn't match, set bad... + bad=1 - # ... and print affected binary - printf "%s - sha256sum hash doesn't match\n" "${app}" - fi + # ... and print affected binary + printf "%s - sha256sum hash doesn't match\n" "${app}" + fi done # If everything is still okay, compute checksums file if [ "${bad}" = 0 ]; then - sha256sum "${path_bin}"/from-source/* | sed -e "s|$PWD/||g" -e 's|from-source/||g' -e "s|-$repo_name||g" > "${path_bin}/_sha256sums" + sha256sum "${path_bin}"/from-source/* | sed -e "s|$PWD/||g" -e 's|from-source/||g' -e "s|-$repo_name||g" > "${path_bin}/_sha256sums" - printf 'Checksums computed - %s\n' "${path_bin}/_sha256sums" + printf 'Checksums computed - %s\n' "${path_bin}/_sha256sums" fi diff --git a/simplex-chat.cabal b/simplex-chat.cabal index ff5cf1e264..0b815c9fe5 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: 6.5.0.4 +version: 6.5.0.5 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat @@ -84,6 +84,7 @@ library Simplex.Chat.Store.Shared Simplex.Chat.Styled Simplex.Chat.Types + Simplex.Chat.Types.MemberRelations Simplex.Chat.Types.Preferences Simplex.Chat.Types.Shared Simplex.Chat.Types.UITheme @@ -121,7 +122,9 @@ library Simplex.Chat.Store.Postgres.Migrations.M20250922_remove_unused_connections Simplex.Chat.Store.Postgres.Migrations.M20251007_connections_sync Simplex.Chat.Store.Postgres.Migrations.M20251017_chat_tags_cascade - Simplex.Chat.Store.Postgres.Migrations.M20251018_chat_relays + Simplex.Chat.Store.Postgres.Migrations.M20251117_member_relations_vector + Simplex.Chat.Store.Postgres.Migrations.M20251128_member_relations_vector_stage_2 + Simplex.Chat.Store.Postgres.Migrations.M20251212_chat_relays else exposed-modules: Simplex.Chat.Archive @@ -267,7 +270,9 @@ library Simplex.Chat.Store.SQLite.Migrations.M20250922_remove_unused_connections Simplex.Chat.Store.SQLite.Migrations.M20251007_connections_sync Simplex.Chat.Store.SQLite.Migrations.M20251017_chat_tags_cascade - Simplex.Chat.Store.SQLite.Migrations.M20251018_chat_relays + Simplex.Chat.Store.SQLite.Migrations.M20251117_member_relations_vector + Simplex.Chat.Store.SQLite.Migrations.M20251128_member_relations_vector_stage_2 + Simplex.Chat.Store.SQLite.Migrations.M20251212_chat_relays other-modules: Paths_simplex_chat hs-source-dirs: @@ -545,6 +550,7 @@ test-suite simplex-chat-test JSONFixtures JSONTests MarkdownTests + MemberRelationsTests MessageBatching OperatorTests ProtocolTests diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 8d70bd3bdd..94b6c90495 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -122,8 +122,8 @@ logCfg = LogConfig {lc_file = Nothing, lc_stderr = True} createChatDatabase :: ChatDbOpts -> MigrationConfig -> IO (Either MigrationError ChatDatabase) createChatDatabase chatDbOpts migrationConfig = runExceptT $ do - chatStore <- ExceptT $ createChatStore (toDBOpts chatDbOpts chatSuffix False) migrationConfig - agentStore <- ExceptT $ createAgentStore (toDBOpts chatDbOpts agentSuffix False) migrationConfig + chatStore <- ExceptT $ createChatStore (toDBOpts chatDbOpts chatSuffix False chatDBFunctions) migrationConfig + agentStore <- ExceptT $ createAgentStore (toDBOpts chatDbOpts agentSuffix False []) migrationConfig pure ChatDatabase {chatStore, agentStore} newChatController :: ChatDatabase -> Maybe User -> ChatConfig -> ChatOpts -> Bool -> IO ChatController diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 4d529c53e4..721e4db837 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -167,6 +167,9 @@ startChatController mainApp enableSndFiles = do runExceptT (syncConnections' users) >>= \case Left e -> liftIO $ putStrLn $ "Error synchronizing connections: " <> show e Right _ -> pure () + runExceptT migrateMemberRelations >>= \case + Left e -> liftIO $ putStrLn $ "Error migrating member relations: " <> show e + Right _ -> pure () restoreCalls s <- asks agentAsync readTVarIO s >>= maybe (start s users) (pure . fst) @@ -178,6 +181,10 @@ startChatController mainApp enableSndFiles = do (userDiff, connDiff) <- withAgent (\a -> syncConnections a aUserIds connIds) withFastStore' setConnectionsSyncTs toView $ CEvtConnectionsDiff (AgentUserId <$> userDiff) (AgentConnId <$> connDiff) + migrateMemberRelations = + when mainApp $ + whenM (withStore' hasMembersWithoutVector) $ + void $ forkIO runRelationsVectorMigration start s users = do a1 <- async agentSubscriber a2 <- @@ -2369,7 +2376,13 @@ processChatCommand vr nm = \case (gInfo, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user groupId <*> getGroupMemberById db vr user gmId when (isNothing $ supportChat m) $ throwCmdError "member has no support chat" when (memberPending m) $ throwCmdError "member is pending" - (gInfo', m') <- withFastStore' $ \db -> deleteGroupMemberSupportChat db user gInfo m + (gInfo', m') <- withFastStore' $ \db -> do + gInfo' <- + if gmRequiresAttention m + then decreaseGroupMembersRequireAttention db user gInfo + else pure gInfo + m' <- deleteGroupMemberSupportChat db m + pure (gInfo', m') pure $ CRMemberSupportChatDeleted user gInfo' m' APIMembersRole groupId memberIds newRole -> withUser $ \user -> withGroupLock "memberRole" groupId $ do @@ -2551,19 +2564,25 @@ processChatCommand vr nm = \case let chatScope = toChatScope <$> chatScopeInfo events = L.map (\GroupMember {memberId} -> XGrpMemDel memberId withMessages) memsToDelete' (msgs_, _gsr) <- sendGroupMessages user gInfo chatScope recipients events - let itemsData = zipWith (fmap . sndItemData) memsToDelete (L.toList msgs_) + let itemsData_ = zipWith (fmap . sndItemData) memsToDelete (L.toList msgs_) + skipUnwantedItem = \case + Right Nothing -> Nothing + Right (Just a) -> Just $ Right a + Left e -> Just $ Left e + itemsData = mapMaybe skipUnwantedItem itemsData_ cis_ <- saveSndChatItems user (CDGroupSnd gInfo chatScopeInfo) itemsData Nothing False - when (length cis_ /= length memsToDelete) $ logError "deleteCurrentMems: memsToDelete and cis_ length mismatch" deleteMembersConnections' user memsToDelete True (errs, deleted) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (delMember db) memsToDelete) let acis = map (AChatItem SCTGroup SMDSnd (GroupChat gInfo chatScopeInfo)) $ rights cis_ pure (errs, deleted, acis) where - sndItemData :: GroupMember -> SndMessage -> NewSndChatItemData c - sndItemData GroupMember {groupMemberId, memberProfile} msg = - let content = CISndGroupEvent $ SGEMemberDeleted groupMemberId (fromLocalProfile memberProfile) - ts = ciContentTexts content - in NewSndChatItemData msg content ts M.empty Nothing Nothing Nothing + sndItemData :: GroupMember -> SndMessage -> Maybe (NewSndChatItemData c) + sndItemData GroupMember {groupMemberId, memberProfile, memberStatus} msg + | memberStatus == GSMemRemoved || memberStatus == GSMemLeft = Nothing + | otherwise = + let content = CISndGroupEvent $ SGEMemberDeleted groupMemberId (fromLocalProfile memberProfile) + ts = ciContentTexts content + in Just $ NewSndChatItemData msg content ts M.empty Nothing Nothing Nothing delMember db m = do -- We're in a function used in batch member deletion, and since we're passing same gInfo for each member, -- voided result (updated group info) may have incorrect state of membersRequireAttention. @@ -4201,6 +4220,21 @@ agentSubscriber = do type AgentSubResult = Map ConnId (Either AgentErrorType (Maybe ClientServiceId)) +runRelationsVectorMigration :: CM () +runRelationsVectorMigration = do + liftIO $ threadDelay' 5000000 -- 5 seconds (initial delay) + migrateMembers + where + stepDelay = 1000000 -- 1 second + migrateMembers = flip catchAllErrors eToView $ do + lift waitChatStartedAndActivated + gmIds <- withStore' getGMsWithoutVectorIds + forM_ gmIds $ \gmId -> do + lift waitChatStartedAndActivated + withStore' (`migrateMemberRelationsVector'` gmId) `catchAllErrors` eToView + liftIO $ threadDelay' stepDelay + unless (null gmIds) migrateMembers + cleanupManager :: CM () cleanupManager = do interval <- asks (cleanupManagerInterval . config) diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 808baaa6c4..190021b121 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -73,6 +73,7 @@ import Simplex.Chat.Store.Messages import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared import Simplex.Chat.Types +import Simplex.Chat.Types.MemberRelations import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Util (encryptFile, shuffle) @@ -1024,65 +1025,84 @@ introduceToModerators :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> introduceToModerators vr user gInfo@GroupInfo {groupId} m@GroupMember {memberRole, memberId} = do forM_ (memberConn m) $ \mConn -> do let msg = - if (maxVersion (memberChatVRange m) >= groupKnockingVersion) + if maxVersion (memberChatVRange m) >= groupKnockingVersion then XGrpLinkAcpt GAPendingReview memberRole memberId else XMsgNew $ MCSimple $ extMsgContent (MCText pendingReviewMessage) Nothing void $ sendDirectMemberMessage mConn msg groupId modMs <- withStore' $ \db -> getGroupModerators db vr user gInfo - let rcpModMs = filter (\mem -> memberCurrent mem && maxVersion (memberChatVRange mem) >= groupKnockingVersion) modMs + let rcpModMs = filter shouldIntroduce modMs introduceMember vr user gInfo m rcpModMs (Just $ MSMember $ memberId' m) + where + shouldIntroduce :: GroupMember -> Bool + shouldIntroduce mem = + memberCurrent mem + && groupMemberId' mem /= groupMemberId' m + && maxVersion (memberChatVRange mem) >= groupKnockingVersion introduceToAll :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> CM () introduceToAll vr user gInfo m = do members <- withStore' $ \db -> getGroupMembers db vr user gInfo - let recipients = filter memberCurrent members + vector_ <- withStore' (`getMemberRelationsVector_` m) + let recipients = filter (shouldIntroduce vector_) members introduceMember vr user gInfo m recipients Nothing + where + shouldIntroduce :: Maybe ByteString -> GroupMember -> Bool + shouldIntroduce vector_ m' = + memberCurrent m' + && groupMemberId' m' /= groupMemberId' m + && maybe True (\v -> getRelation (indexInGroup m') v == MRNew) vector_ introduceToRemaining :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> CM () introduceToRemaining vr user gInfo m = do - (members, introducedGMIds) <- - withStore' $ \db -> (,) <$> getGroupMembers db vr user gInfo <*> getIntroducedGroupMemberIds db m - let recipients = filter (introduceMemP introducedGMIds) members + members <- withStore' $ \db -> getGroupMembers db vr user gInfo + vector_ <- withStore' (`getMemberRelationsVector_` m) + recipients <- filterRecipients vector_ members introduceMember vr user gInfo m recipients Nothing where - introduceMemP introducedGMIds mem = - memberCurrent mem - && groupMemberId' mem `notElem` introducedGMIds - && groupMemberId' mem /= groupMemberId' m + filterRecipients :: Maybe ByteString -> [GroupMember] -> CM [GroupMember] + filterRecipients vector_ members = do + newRelation <- case vector_ of + Nothing -> do + introducedGMIds <- S.fromList <$> withStore' (`getIntroducedGroupMemberIds` m) + pure $ \m' -> groupMemberId' m' `S.notMember` introducedGMIds + Just vec -> pure $ \m' -> getRelation (indexInGroup m') vec == MRNew + pure $ filter (\m' -> groupMemberId' m' /= groupMemberId' m && memberCurrent m' && newRelation m') members introduceMember :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> [GroupMember] -> Maybe MsgScope -> CM () introduceMember _ _ _ GroupMember {activeConn = Nothing} _ _ = throwChatError $ CEInternalError "member connection not active" -introduceMember vr user gInfo@GroupInfo {groupId} m@GroupMember {activeConn = Just conn} introduceToMembers msgScope = do - void . sendGroupMessage' user gInfo introduceToMembers $ XGrpMemNew (memberInfo gInfo m) msgScope +introduceMember vr user gInfo@GroupInfo {groupId} toMember@GroupMember {activeConn = Just conn} introduceToMembers msgScope = do + void . sendGroupMessage' user gInfo introduceToMembers $ XGrpMemNew (memberInfo gInfo toMember) msgScope sendIntroductions introduceToMembers where - sendIntroductions members = do - intros <- withStore' $ \db -> createIntroductions db (maxVersion vr) members m - shuffledIntros <- liftIO $ shuffleIntros intros - if m `supportsVersion` batchSendVersion + sendIntroductions reMembers = do + updateToMemberVector reMembers + reMembers' <- withStore' $ \db -> createIntrosOrUpdateVectors db vr reMembers toMember + shuffledReMembers <- liftIO $ shuffleMembers reMembers' + if toMember `supportsVersion` batchSendVersion then do - let events = map (memberIntro . reMember) shuffledIntros + let events = map memberIntro shuffledReMembers forM_ (L.nonEmpty events) $ \events' -> sendGroupMemberMessages user conn events' groupId - else forM_ shuffledIntros $ \intro -> - processIntro intro `catchAllErrors` eToView + else forM_ shuffledReMembers $ \reMember -> + void $ sendDirectMemberMessage conn (memberIntro reMember) groupId + updateToMemberVector :: [GroupMember] -> CM () + updateToMemberVector reMembers = do + let relations = map (\GroupMember {indexInGroup} -> (indexInGroup, (IDReferencedIntroduced, MRIntroduced))) reMembers + withStore' $ \db -> setMemberVectorNewRelations db toMember relations memberIntro :: GroupMember -> ChatMsgEvent 'Json memberIntro reMember = let mInfo = memberInfo gInfo reMember mRestrictions = memberRestrictions reMember in XGrpMemIntro mInfo mRestrictions - shuffleIntros :: [GroupMemberIntro] -> IO [GroupMemberIntro] - shuffleIntros intros = do - let (admins, others) = partition isAdmin intros + shuffleMembers :: [GroupMember] -> IO [GroupMember] + shuffleMembers reMembers = do + let (admins, others) = partition isAdmin reMembers (admPics, admNoPics) = partition hasPicture admins (othPics, othNoPics) = partition hasPicture others mconcat <$> mapM shuffle [admPics, admNoPics, othPics, othNoPics] where - isAdmin GroupMemberIntro {reMember = GroupMember {memberRole}} = memberRole >= GRAdmin - hasPicture GroupMemberIntro {reMember = GroupMember {memberProfile = LocalProfile {image}}} = isJust image - processIntro intro@GroupMemberIntro {introId} = do - void $ sendDirectMemberMessage conn (memberIntro $ reMember intro) groupId - withStore' $ \db -> updateIntroStatus db introId GMIntroSent + isAdmin GroupMember {memberRole} = memberRole >= GRAdmin + hasPicture GroupMember {memberProfile = LocalProfile {image}} = isJust image userProfileInGroup :: User -> GroupInfo -> Maybe Profile -> Profile userProfileInGroup user = userProfileInGroup' user . groupFeatureUserAllowed SGFSimplexLinks @@ -1663,19 +1683,35 @@ deleteMemberConnection' GroupMember {activeConn} waitDelivery = do withStore' $ \db -> updateConnectionStatus db conn ConnDeleted deleteOrUpdateMemberRecord :: User -> GroupInfo -> GroupMember -> CM GroupInfo -deleteOrUpdateMemberRecord user gInfo member = - withStore' $ \db -> deleteOrUpdateMemberRecordIO db user gInfo member +deleteOrUpdateMemberRecord user gInfo m = + withStore' $ \db -> deleteOrUpdateMemberRecordIO db user gInfo m deleteOrUpdateMemberRecordIO :: DB.Connection -> User -> GroupInfo -> GroupMember -> IO GroupInfo -deleteOrUpdateMemberRecordIO db user@User {userId} gInfo member = do +deleteOrUpdateMemberRecordIO db user@User {userId} gInfo m = do + (gInfo', m') <- deleteSupportChatIfExists db user gInfo m + checkGroupMemberHasItems db user m' >>= \case + Just _ -> updateGroupMemberStatus db userId m' GSMemRemoved + Nothing -> deleteGroupMember db user m' + pure gInfo' + +updateMemberRecordDeleted :: User -> GroupInfo -> GroupMember -> GroupMemberStatus -> CM GroupInfo +updateMemberRecordDeleted user@User {userId} gInfo m newStatus = + withStore' $ \db -> do + (gInfo', m') <- deleteSupportChatIfExists db user gInfo m + updateGroupMemberStatus db userId m' newStatus + pure gInfo' + +deleteSupportChatIfExists :: DB.Connection -> User -> GroupInfo -> GroupMember -> IO (GroupInfo, GroupMember) +deleteSupportChatIfExists db user gInfo m = do gInfo' <- - if gmRequiresAttention member + if gmRequiresAttention m then decreaseGroupMembersRequireAttention db user gInfo else pure gInfo - checkGroupMemberHasItems db user member >>= \case - Just _ -> updateGroupMemberStatus db userId member GSMemRemoved - Nothing -> deleteGroupMember db user member - pure gInfo' + m' <- + if isJust (supportChat m) + then deleteGroupMemberSupportChat db m + else pure m + pure (gInfo', m') sendDirectContactMessages :: MsgEncodingI e => User -> Contact -> NonEmpty (ChatMsgEvent e) -> CM [Either ChatError SndMessage] sendDirectContactMessages user ct events = do @@ -2053,8 +2089,8 @@ readyMemberConn GroupMember {groupMemberId, activeConn = Just conn@Connection {c | otherwise = Nothing readyMemberConn GroupMember {activeConn = Nothing} = Nothing -sendGroupMemberMessage :: MsgEncodingI e => GroupInfo -> GroupMember -> ChatMsgEvent e -> Maybe Int64 -> CM () -> CM () -sendGroupMemberMessage gInfo@GroupInfo {groupId} m@GroupMember {groupMemberId} chatMsgEvent introId_ postDeliver = do +sendGroupMemberMessage :: MsgEncodingI e => GroupInfo -> GroupMember -> ChatMsgEvent e -> Maybe GroupMemberIntro -> CM () -> CM () +sendGroupMemberMessage gInfo@GroupInfo {groupId} m@GroupMember {groupMemberId} chatMsgEvent intro_ postDeliver = do msg <- createSndMessage chatMsgEvent (GroupId groupId) messageMember msg `catchAllErrors` eToView where @@ -2062,7 +2098,7 @@ sendGroupMemberMessage gInfo@GroupInfo {groupId} m@GroupMember {groupMemberId} c messageMember SndMessage {msgId, msgBody} = forM_ (memberSendAction gInfo (chatMsgEvent :| []) [m] m) $ \case MSASend conn -> deliverMessage conn (toCMEventTag chatMsgEvent) msgBody msgId >> postDeliver MSASendBatched conn -> deliverMessage conn (toCMEventTag chatMsgEvent) msgBody msgId >> postDeliver - MSAPending -> withStore' $ \db -> createPendingGroupMessage db groupMemberId msgId introId_ + MSAPending -> withStore' $ \db -> createPendingGroupMessage db groupMemberId msgId (introId <$> intro_) MSAForwarded -> pure () -- TODO ensure order - pending messages interleave with user input messages diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 75eb208962..c1fc1e917f 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -28,7 +28,7 @@ import Data.Either (lefts, partitionEithers, rights) import Data.Foldable (foldr') import Data.Functor (($>)) import Data.Int (Int64) -import Data.List (find, foldl') +import Data.List (find) import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) @@ -62,6 +62,7 @@ import Simplex.Chat.Store.Messages import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared import Simplex.Chat.Types +import Simplex.Chat.Types.MemberRelations import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.FileTransfer.Description (ValidFileDescription) @@ -2615,13 +2616,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case Left _ -> messageError "x.grp.mem.inv error: referenced member does not exist" Right reMember -> do - introId <- withStore $ \db -> do - GroupMemberIntro {introId} <- getIntroduction db reMember m - liftIO $ updateIntroStatus db introId GMIntroInvReceived - pure introId - sendGroupMemberMessage gInfo reMember (XGrpMemFwd (memberInfo gInfo m) introInv) (Just introId) $ - withStore' $ - \db -> updateIntroStatus db introId GMIntroInvForwarded + intro_ <- withStore' $ \db -> getIntroduction db reMember m + update intro_ GMIntroInvReceived + sendGroupMemberMessage gInfo reMember (XGrpMemFwd (memberInfo gInfo m) introInv) intro_ $ + update intro_ GMIntroInvForwarded + where + update (Just GroupMemberIntro {introId}) status = withStore' $ \db -> updateIntroStatus db introId status + update Nothing _ = pure () _ -> messageError "x.grp.mem.inv can be only sent by invitee member" xGrpMemFwd :: GroupInfo -> GroupMember -> MemberInfo -> IntroInvitation -> CM () @@ -2715,45 +2716,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = blocked = mrsBlocked restriction xGrpMemCon :: GroupInfo -> GroupMember -> MemberId -> CM () - xGrpMemCon gInfo sendingMember memId = do - refMember <- withStore $ \db -> getGroupMemberByMemberId db vr user gInfo memId - case (memberCategory sendingMember, memberCategory refMember) of - (GCInviteeMember, GCInviteeMember) -> - withStore' (\db -> runExceptT $ getIntroduction db refMember sendingMember) >>= \case - Right intro -> inviteeXGrpMemCon intro - Left _ -> - withStore' (\db -> runExceptT $ getIntroduction db sendingMember refMember) >>= \case - Right intro -> forwardMemberXGrpMemCon intro - Left _ -> messageWarning "x.grp.mem.con: no introduction" - (GCInviteeMember, _) -> - withStore' (\db -> runExceptT $ getIntroduction db refMember sendingMember) >>= \case - Right intro -> inviteeXGrpMemCon intro - Left _ -> messageWarning "x.grp.mem.con: no introduction" - (_, GCInviteeMember) -> - withStore' (\db -> runExceptT $ getIntroduction db sendingMember refMember) >>= \case - Right intro -> forwardMemberXGrpMemCon intro - Left _ -> messageWarning "x.grp.mem.con: no introductiosupportn" - -- Note: we can allow XGrpMemCon to all member categories if we decide to support broader group forwarding, - -- deduplication (see saveGroupRcvMsg, saveGroupFwdRcvMsg) already supports sending XGrpMemCon - -- to any forwarding member, not only host/inviting member; - -- database would track all members connections then - -- (currently it's done via group_member_intros for introduced connections only) - _ -> - messageWarning "x.grp.mem.con: neither member is invitee" - where - inviteeXGrpMemCon :: GroupMemberIntro -> CM () - inviteeXGrpMemCon GroupMemberIntro {introId, introStatus} = case introStatus of - GMIntroReConnected -> updateStatus introId GMIntroConnected - GMIntroToConnected -> pure () - GMIntroConnected -> pure () - _ -> updateStatus introId GMIntroToConnected - forwardMemberXGrpMemCon :: GroupMemberIntro -> CM () - forwardMemberXGrpMemCon GroupMemberIntro {introId, introStatus} = case introStatus of - GMIntroToConnected -> updateStatus introId GMIntroConnected - GMIntroReConnected -> pure () - GMIntroConnected -> pure () - _ -> updateStatus introId GMIntroReConnected - updateStatus introId status = withStore' $ \db -> updateIntroStatus db introId status + xGrpMemCon gInfo sendingMem memId = do + refMem <- withStore $ \db -> getGroupMemberByMemberId db vr user gInfo memId + withStore' (`migrateMemberRelationsVector` sendingMem) + withStore' (`migrateMemberRelationsVector` refMem) + -- Updating vectors in separate transactions to avoid deadlocks. + withStore $ \db -> setMemberVectorRelationConnected db sendingMem refMem MRSubjectConnected + withStore $ \db -> setMemberVectorRelationConnected db refMem sendingMem MRReferencedConnected xGrpMemDel :: GroupInfo -> GroupMember -> MemberId -> Bool -> ChatMessage 'Json -> RcvMessage -> UTCTime -> Bool -> CM (Maybe DeliveryJobScope) xGrpMemDel gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId withMessages chatMsg msg brokerTs forwarded = do @@ -2766,7 +2735,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = withStore' $ \db -> updateGroupMemberStatus db userId membership GSMemRemoved let membership' = membership {memberStatus = GSMemRemoved} when withMessages $ deleteMessages gInfo membership' SMDSnd - deleteMemberItem RGEUserDeleted + deleteMemberItem gInfo RGEUserDeleted toView $ CEvtDeletedMemberUser user gInfo {membership = membership'} m withMessages pure $ Just DJSGroup {jobSpec = DJRelayRemoved} else @@ -2774,31 +2743,36 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = Left _ -> do messageError "x.grp.mem.del with unknown member ID" pure $ Just DJSGroup {jobSpec = DJDeliveryJob {includePending = True}} - Right deletedMember@GroupMember {groupMemberId, memberProfile} -> + Right deletedMember@GroupMember {groupMemberId, memberProfile, memberStatus} -> checkRole deletedMember $ do -- ? prohibit deleting member if it's the sender - sender should use x.grp.leave - if isUserGrpFwdRelay gInfo && not forwarded + let shouldForward = isUserGrpFwdRelay gInfo && not forwarded + if shouldForward then do -- Special case: forward before deleting connection. - -- It allows us to avoid adding logic in forwardMsgs to circumvent member filtering. forwardToMember deletedMember deleteMemberConnection' deletedMember True else deleteMemberConnection deletedMember - -- undeleted "member connected" chat item will prevent deletion of member record - gInfo' <- deleteOrUpdateMemberRecord user gInfo deletedMember - let deletedMember' = deletedMember {memberStatus = GSMemRemoved} + let deliveryScope = memberEventDeliveryScope deletedMember + gInfo' <- case deliveryScope of + -- Keep member record if it's support scope - it will be required for forwarding inside that scope. + Just (DJSMemberSupport _) | shouldForward -> updateMemberRecordDeleted user gInfo deletedMember GSMemRemoved + -- Undeleted "member connected" chat item will prevent deletion of member record. + _ -> deleteOrUpdateMemberRecord user gInfo deletedMember + let wasDeleted = memberStatus == GSMemRemoved || memberStatus == GSMemLeft + deletedMember' = deletedMember {memberStatus = GSMemRemoved} when withMessages $ deleteMessages gInfo' deletedMember' SMDRcv - deleteMemberItem $ RGEMemberDeleted groupMemberId (fromLocalProfile memberProfile) + unless wasDeleted $ deleteMemberItem gInfo' $ RGEMemberDeleted groupMemberId (fromLocalProfile memberProfile) toView $ CEvtDeletedMember user gInfo' m deletedMember' withMessages - pure $ memberEventDeliveryScope deletedMember + pure deliveryScope where checkRole GroupMember {memberRole} a | senderRole < GRAdmin || senderRole < memberRole = messageError "x.grp.mem.del with insufficient member permissions" $> Nothing | otherwise = a - deleteMemberItem gEvent = do - (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m - (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo' scopeInfo m') msg brokerTs (CIRcvGroupEvent gEvent) + deleteMemberItem gi gEvent = do + (gi', m', scopeInfo) <- mkGroupChatScope gi m + (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gi' scopeInfo m') msg brokerTs (CIRcvGroupEvent gEvent) groupMsgToView cInfo ci deleteMessages :: MsgDirectionI d => GroupInfo -> GroupMember -> SMsgDirection d -> CM () deleteMessages gInfo' delMem msgDir @@ -2821,11 +2795,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = xGrpLeave gInfo m msg brokerTs = do deleteMemberConnection m -- member record is not deleted to allow creation of "member left" chat item - gInfo' <- withStore' $ \db -> do - updateGroupMemberStatus db userId m GSMemLeft - if gmRequiresAttention m - then decreaseGroupMembersRequireAttention db user gInfo - else pure gInfo + gInfo' <- updateMemberRecordDeleted user gInfo m GSMemLeft (gInfo'', m', scopeInfo) <- mkGroupChatScope gInfo' m (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo'' scopeInfo m') msg brokerTs (CIRcvGroupEvent RGEMemberLeft) groupMsgToView cInfo ci @@ -3237,10 +3207,10 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do DJSMemberSupport scopeGMId -> do -- for member support scope we just load all recipients in one go, without cursor modMs <- withStore' $ \db -> getGroupModerators db vr user gInfo - let moderatorFilter mem = - memberCurrent mem - && maxVersion (memberChatVRange mem) >= groupKnockingVersion - && Just (groupMemberId' mem) /= singleSenderGMId_ + let moderatorFilter m = + memberCurrent m + && maxVersion (memberChatVRange m) >= groupKnockingVersion + && Just (groupMemberId' m) /= singleSenderGMId_ modMs' = filter moderatorFilter modMs mems <- if Just scopeGMId == singleSenderGMId_ @@ -3254,42 +3224,30 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do Nothing -> throwChatError $ CEInternalError "delivery job worker: singleSenderGMId is required when not using relays" Just singleSenderGMId -> do sender <- withStore $ \db -> getGroupMemberById db vr user singleSenderGMId - mems <- buildMemberList sender - unless (null mems) $ deliver body mems + ms <- buildMemberList sender + unless (null ms) $ deliver body ms where - buildMemberList sender = case jobScope of - DJSGroup {jobSpec} - | jobSpecImpliedPending jobSpec -> - filter memberCurrentOrPending <$> getAllIntroducedAndInvited - | otherwise -> - filter memberCurrent <$> getAllIntroducedAndInvited - DJSMemberSupport scopeGMId -> do - -- moderators introduced to this invited member - introducedModMs <- - if memberCategory sender == GCInviteeMember - then withStore' $ \db -> getForwardIntroducedModerators db vr user sender - else pure [] - -- invited moderators to which this member was introduced - invitedModMs <- withStore' $ \db -> getForwardInvitedModerators db vr user sender - let modMs = introducedModMs <> invitedModMs - modMs' = filter (\mem -> memberCurrent mem && maxVersion (memberChatVRange mem) >= groupKnockingVersion) modMs - if scopeGMId == groupMemberId' sender - then pure modMs' - else - withStore' (\db -> getForwardScopeMember db vr user sender scopeGMId) >>= \case - Just scopeMem -> pure $ scopeMem : modMs' - _ -> pure modMs' - where - getAllIntroducedAndInvited = do - ChatConfig {highlyAvailable} <- asks config - -- members introduced to this invited member - introducedMembers <- - if memberCategory sender == GCInviteeMember - then withStore' $ \db -> getForwardIntroducedMembers db vr user sender highlyAvailable - else pure [] - -- invited members to which this member was introduced - invitedMembers <- withStore' $ \db -> getForwardInvitedMembers db vr user sender highlyAvailable - pure $ introducedMembers <> invitedMembers + buildMemberList sender = do + vec <- withStore $ \db -> migrateGetMemberRelationsVector db sender + -- this excludes the sender + let introducedMemsIdxs = getRelationsIndexes MRIntroduced vec + case jobScope of + DJSGroup {jobSpec} -> do + ms <- withStore' $ \db -> getGroupMembersByIndexes db vr user gInfo introducedMemsIdxs + pure $ filter shouldForwardTo ms + where + shouldForwardTo m + | jobSpecImpliedPending jobSpec = memberCurrentOrPending m + | otherwise = memberCurrent m + DJSMemberSupport scopeGMId -> do + ms <- withStore' $ \db -> getSupportScopeMembersByIndexes db vr user gInfo scopeGMId introducedMemsIdxs + pure $ filter shouldForwardTo ms + where + shouldForwardTo m = groupMemberId' m == scopeGMId || currentModerator m + currentModerator m@GroupMember {memberRole} = + memberRole >= GRModerator + && memberCurrent m + && maxVersion (memberChatVRange m) >= groupKnockingVersion where deliver :: ByteString -> [GroupMember] -> CM () deliver msgBody mems = diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 0d659923ca..674f4d02db 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -296,8 +296,8 @@ chatMigrateInitKey :: ChatDbOpts -> Bool -> String -> Bool -> IO (Either DBMigra chatMigrateInitKey chatDbOpts keepKey confirm backgroundMode = runExceptT $ do confirmMigrations <- liftEitherWith (const DBMInvalidConfirmation) $ strDecode $ B.pack confirm let migrationConfig = MigrationConfig confirmMigrations (Just "") - chatStore <- migrate createChatStore (toDBOpts chatDbOpts chatSuffix keepKey) migrationConfig - agentStore <- migrate createAgentStore (toDBOpts chatDbOpts agentSuffix keepKey) migrationConfig + chatStore <- migrate createChatStore (toDBOpts chatDbOpts chatSuffix keepKey chatDBFunctions) migrationConfig + agentStore <- migrate createAgentStore (toDBOpts chatDbOpts agentSuffix keepKey []) migrationConfig liftIO $ initialize chatStore ChatDatabase {chatStore, agentStore} where opts = mobileChatOpts $ removeDbKey chatDbOpts diff --git a/src/Simplex/Chat/Operators.hs b/src/Simplex/Chat/Operators.hs index 257bdb0ac5..ac3af273a8 100644 --- a/src/Simplex/Chat/Operators.hs +++ b/src/Simplex/Chat/Operators.hs @@ -65,7 +65,7 @@ previousConditionsCommit = "a5061f3147165a05979d6ace33960aced2d6ac03" usageConditionsText :: Text usageConditionsText = - $( let s = $(embedFile =<< makeRelativeToProject "PRIVACY.md") + $( let s = $(embedFile "PRIVACY.md") in [|stripFrontMatter $(lift (safeDecodeUtf8 s))|] ) diff --git a/src/Simplex/Chat/Options/Postgres.hs b/src/Simplex/Chat/Options/Postgres.hs index 13af13b20a..c74ae37750 100644 --- a/src/Simplex/Chat/Options/Postgres.hs +++ b/src/Simplex/Chat/Options/Postgres.hs @@ -58,8 +58,8 @@ migrationBackupPathP = pure Nothing dbString :: ChatDbOpts -> String dbString ChatDbOpts {dbConnstr} = dbConnstr -toDBOpts :: ChatDbOpts -> String -> Bool -> DBOpts -toDBOpts ChatDbOpts {dbConnstr, dbSchemaPrefix, dbPoolSize, dbCreateSchema} dbSuffix _keepKey = +toDBOpts :: ChatDbOpts -> String -> Bool -> [()] -> DBOpts +toDBOpts ChatDbOpts {dbConnstr, dbSchemaPrefix, dbPoolSize, dbCreateSchema} dbSuffix _keepKey _dbFunctions = DBOpts { connstr = B.pack dbConnstr, schema = B.pack $ if null dbSchemaPrefix then "simplex_v1" <> dbSuffix else dbSchemaPrefix <> dbSuffix, @@ -73,6 +73,9 @@ chatSuffix = "_chat_schema" agentSuffix :: String agentSuffix = "_agent_schema" +chatDBFunctions :: [()] +chatDBFunctions = [] + mobileDbOpts :: CString -> CString -> IO ChatDbOpts mobileDbOpts schemaPrefix connstr = do dbSchemaPrefix <- peekCString schemaPrefix diff --git a/src/Simplex/Chat/Options/SQLite.hs b/src/Simplex/Chat/Options/SQLite.hs index 8591f0801c..7e25bb2217 100644 --- a/src/Simplex/Chat/Options/SQLite.hs +++ b/src/Simplex/Chat/Options/SQLite.hs @@ -11,7 +11,9 @@ import qualified Data.ByteArray as BA import qualified Data.ByteString.Char8 as B import Foreign.C.String import Options.Applicative +import Simplex.Chat.Store.SQLite.Migrations.M20251117_member_relations_vector import Simplex.Messaging.Agent.Store.Interface (DBOpts (..)) +import Simplex.Messaging.Agent.Store.SQLite.Common (SQLiteFuncDef (..), SQLiteFuncPtrs (..)) import Simplex.Messaging.Agent.Store.SQLite.DB (TrackQueries (..)) import System.FilePath (combine) @@ -70,10 +72,11 @@ migrationBackupPathP = dbString :: ChatDbOpts -> String dbString ChatDbOpts {dbFilePrefix} = dbFilePrefix <> "_chat.db, " <> dbFilePrefix <> "_agent.db" -toDBOpts :: ChatDbOpts -> String -> Bool -> DBOpts -toDBOpts ChatDbOpts {dbFilePrefix, dbKey, trackQueries, vacuumOnMigration} dbSuffix keepKey = do +toDBOpts :: ChatDbOpts -> String -> Bool -> [SQLiteFuncDef] -> DBOpts +toDBOpts ChatDbOpts {dbFilePrefix, dbKey, trackQueries, vacuumOnMigration} dbSuffix keepKey dbFunctions = do DBOpts { dbFilePath = dbFilePrefix <> dbSuffix, + dbFunctions, dbKey, keepKey, vacuum = vacuumOnMigration, @@ -86,6 +89,12 @@ chatSuffix = "_chat.db" agentSuffix :: String agentSuffix = "_agent.db" +chatDBFunctions :: [SQLiteFuncDef] +chatDBFunctions = + [ SQLiteFuncDef "migrate_relations_vector" 3 (SQLiteAggrPtrs sqliteMemberRelationsStepPtr sqliteMemberRelationsFinalPtr), + SQLiteFuncDef "set_member_vector_new_relation" 4 (SQLiteFuncPtr True sqliteSetMemberVectorNewRelationPtr) + ] + mobileDbOpts :: CString -> CString -> IO ChatDbOpts mobileDbOpts fp key = do dbFilePrefix <- peekCString fp diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 2da8f183e7..131a66c465 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -142,14 +142,14 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, -- GroupInfo {membership} - mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, + mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.is_chat_relay, mu.invited_by, mu.invited_by_group_member_id, 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.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, -- from GroupMember - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts diff --git a/src/Simplex/Chat/Store/Delivery.hs b/src/Simplex/Chat/Store/Delivery.hs index 30fffac0e7..c1da436f04 100644 --- a/src/Simplex/Chat/Store/Delivery.hs +++ b/src/Simplex/Chat/Store/Delivery.hs @@ -185,7 +185,7 @@ getNextDeliveryTasks db gInfo task = | otherwise = -- For fully connected groups we guarantee a singleSenderGMId for a delivery job by additionally filtering -- on sender_group_member_id here, so that the job can then retrieve less members as recipients, - -- optimizing for this single sender (see processDeliveryJob -> getForwardIntroducedMembers, etc.). + -- optimizing for this single sender (see processDeliveryJob -> fully connected group branch). -- We do this optimization in the job to decrease load on admins using mobile devices for clients. map fromOnly <$> DB.query diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index ddeb320212..b1f9dc633f 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -58,10 +58,13 @@ module Simplex.Chat.Store.Groups getMentionedGroupMember, getMentionedMemberByMemberId, getGroupMemberById, + getGroupMemberByIndex, getGroupMemberByMemberId, getGroupMemberIdViaMemberId, getScopeMemberIdViaMemberId, getGroupMembers, + getGroupMembersByIndexes, + getSupportScopeMembersByIndexes, getGroupModerators, getGroupRelays, getGroupMembersForExpiration, @@ -97,14 +100,17 @@ module Simplex.Chat.Store.Groups deleteGroupMemberConnection, updateGroupMemberRole, createIntroductions, + createIntrosOrUpdateVectors, + setMemberVectorNewRelations, + setMembersVectorsNewRelation, + setMemberVectorRelationConnected, + migrateGetMemberRelationsVector, + migrateMemberRelationsVector, + migrateMemberRelationsVector', + getMemberRelationsVector_, updateIntroStatus, getIntroduction, getIntroducedGroupMemberIds, - getForwardIntroducedMembers, - getForwardIntroducedModerators, - getForwardInvitedMembers, - getForwardInvitedModerators, - getForwardScopeMember, createIntroReMember, createIntroToMemberContact, getMatchingContacts, @@ -145,6 +151,8 @@ module Simplex.Chat.Store.Groups setGroupChatTTL, getGroupChatTTL, getUserGroupsToExpire, + hasMembersWithoutVector, + getGMsWithoutVectorIds, updateGroupAlias, ) where @@ -154,8 +162,11 @@ import Control.Monad.Except import Control.Monad.IO.Class import Crypto.Random (ChaChaDRG) import Data.Bifunctor (second) +import Data.ByteString (ByteString) +import qualified Data.ByteString as B import Data.Char (toLower) import Data.Either (rights) +import Data.Foldable (foldrM) import Data.Int (Int64) import Data.List (partition, sortOn) import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing) @@ -169,6 +180,7 @@ import Simplex.Chat.Protocol hiding (Binary) import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Shared import Simplex.Chat.Types +import Simplex.Chat.Types.MemberRelations (IntroductionDirection (..), MemberRelation (..), setNewRelations, setRelationConnected, toIntroDirInt, toRelationInt) import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme @@ -179,22 +191,23 @@ import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.Ratchet (pattern PQEncOff, pattern PQSupportOff) import Simplex.Messaging.Protocol (SubscriptionMode (..)) -import Simplex.Messaging.Util (eitherToMaybe, firstRow', safeDecodeUtf8, ($>>), ($>>=), (<$$>)) +import Simplex.Messaging.Util (eitherToMaybe, firstRow', safeDecodeUtf8, ($>>=), (<$$>)) import Simplex.Messaging.Version import UnliftIO.STM #if defined(dbPostgres) -import Database.PostgreSQL.Simple (Only (..), Query, (:.) (..)) +import qualified Data.Set as S +import Database.PostgreSQL.Simple (In (..), Only (..), Query, (:.) (..)) import Database.PostgreSQL.Simple.SqlQQ (sql) #else import Database.SQLite.Simple (Only (..), Query, (:.) (..)) import Database.SQLite.Simple.QQ (sql) #endif -type MaybeGroupMemberRow = (Maybe Int64, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus, Maybe BoolInt) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId) :. (Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe LocalAlias, Maybe Preferences) :. (Maybe UTCTime, Maybe UTCTime) :. (Maybe UTCTime, Maybe Int64, Maybe Int64, Maybe Int64, Maybe UTCTime) +type MaybeGroupMemberRow = (Maybe GroupMemberId, Maybe GroupId, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus, Maybe BoolInt) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId) :. (Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe LocalAlias, Maybe Preferences) :. (Maybe UTCTime, Maybe UTCTime) :. (Maybe UTCTime, Maybe Int64, Maybe Int64, Maybe Int64, Maybe UTCTime) toMaybeGroupMember :: Int64 -> MaybeGroupMemberRow -> Maybe GroupMember -toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, Just memberId, Just minVer, Just maxVer, Just memberRole, Just memberCategory, Just memberStatus, Just showMessages, memberBlocked', Just isChatRelay) :. (invitedById, invitedByGroupMemberId, Just localDisplayName, memberContactId, Just memberContactProfileId) :. (Just profileId, Just displayName, Just fullName, shortDescr, image, contactLink, peerType, Just localAlias, contactPreferences) :. (Just createdAt, Just updatedAt) :. (supportChatTs, Just supportChatUnread, Just supportChatUnanswered, Just supportChatMentions, supportChatLastMsgFromMemberTs)) = - Just $ toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberBlocked', isChatRelay) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. (profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, contactPreferences) :. (createdAt, updatedAt) :. (supportChatTs, supportChatUnread, supportChatUnanswered, supportChatMentions, supportChatLastMsgFromMemberTs)) +toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, Just indexInGroup, Just memberId, Just minVer, Just maxVer, Just memberRole, Just memberCategory, Just memberStatus, Just showMessages, memberBlocked', Just isChatRelay) :. (invitedById, invitedByGroupMemberId, Just localDisplayName, memberContactId, Just memberContactProfileId) :. (Just profileId, Just displayName, Just fullName, shortDescr, image, contactLink, peerType, Just localAlias, contactPreferences) :. (Just createdAt, Just updatedAt) :. (supportChatTs, Just supportChatUnread, Just supportChatUnanswered, Just supportChatMentions, supportChatLastMsgFromMemberTs)) = + Just $ toGroupMember userContactId ((groupMemberId, groupId, indexInGroup, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberBlocked', isChatRelay) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. (profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, contactPreferences) :. (createdAt, updatedAt) :. (supportChatTs, supportChatUnread, supportChatUnanswered, supportChatMentions, supportChatLastMsgFromMemberTs)) toMaybeGroupMember _ _ = Nothing createGroupLink :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> ConnId -> CreatedLinkContact -> GroupLinkId -> GroupMemberRole -> SubscriptionMode -> ExceptT StoreError IO GroupLink @@ -452,18 +465,35 @@ getHostMemberId_ db User {userId} groupId = ExceptT . firstRow fromOnly (SEHostMemberIdNotFound groupId) $ DB.query db "SELECT group_member_id FROM group_members WHERE user_id = ? AND group_id = ? AND member_category = ?" (userId, groupId, GCHostMember) +getUpdateNextIndexInGroup_ :: DB.Connection -> GroupId -> ExceptT StoreError IO Int64 +getUpdateNextIndexInGroup_ db groupId = + ExceptT . firstRow fromOnly (SEGroupNotFound groupId) $ + DB.query + db + [sql| + UPDATE groups + SET member_index = member_index + 1 + WHERE group_id = ? + RETURNING member_index - 1 + |] + (Only groupId) + createContactMemberInv_ :: IsContact a => DB.Connection -> User -> GroupId -> Maybe GroupMemberId -> a -> MemberIdRole -> GroupMemberCategory -> GroupMemberStatus -> InvitedBy -> Maybe ProfileId -> UTCTime -> VersionRangeChat -> ExceptT StoreError IO GroupMember createContactMemberInv_ db User {userId, userContactId} groupId invitedByGroupMemberId userOrContact MemberIdRole {memberId, memberRole} memberCategory memberStatus invitedBy incognitoProfileId createdAt vr = do incognitoProfile <- forM incognitoProfileId $ \profileId -> getProfileById db userId profileId - (localDisplayName, memberProfile) <- case (incognitoProfile, incognitoProfileId) of - (Just profile@LocalProfile {displayName}, Just profileId) -> - (,profile) <$> insertMemberIncognitoProfile_ displayName profileId - _ -> (,profile' userOrContact) <$> liftIO insertMember_ + (indexInGroup, localDisplayName, memberProfile) <- case (incognitoProfile, incognitoProfileId) of + (Just profile@LocalProfile {displayName}, Just profileId) -> do + (indexInGroup, localDisplayName) <- insertMemberIncognitoProfile_ displayName profileId + pure (indexInGroup, localDisplayName, profile) + _ -> do + (indexInGroup, localDisplayName) <- insertMember_ + pure (indexInGroup, localDisplayName, profile' userOrContact) groupMemberId <- liftIO $ insertedRowId db pure GroupMember { groupMemberId, groupId, + indexInGroup, memberId, memberRole, memberCategory, @@ -485,40 +515,44 @@ createContactMemberInv_ db User {userId, userContactId} groupId invitedByGroupMe } where memberChatVRange@(VersionRange minV maxV) = vr - insertMember_ :: IO ContactName + insertMember_ :: ExceptT StoreError IO (Int64, ContactName) insertMember_ = do let localDisplayName = localDisplayName' userOrContact - DB.execute - db - [sql| - INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, - peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) - |] - ( (groupId, memberId, memberRole, memberCategory, memberStatus, fromInvitedBy userContactId invitedBy, invitedByGroupMemberId) - :. (userId, localDisplayName' userOrContact, contactId' userOrContact, localProfileId $ profile' userOrContact, createdAt, createdAt) - :. (minV, maxV) - ) - pure localDisplayName - insertMemberIncognitoProfile_ :: ContactName -> ProfileId -> ExceptT StoreError IO ContactName - insertMemberIncognitoProfile_ incognitoDisplayName customUserProfileId = ExceptT $ - withLocalDisplayName db userId incognitoDisplayName $ \incognitoLdn -> do + indexInGroup <- getUpdateNextIndexInGroup_ db groupId + liftIO $ DB.execute db [sql| INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, member_profile_id, created_at, updated_at, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, memberId, memberRole, memberCategory, memberStatus, fromInvitedBy userContactId invitedBy, invitedByGroupMemberId) - :. (userId, incognitoLdn, contactId' userOrContact, localProfileId $ profile' userOrContact, customUserProfileId, createdAt, createdAt) + ( (groupId, indexInGroup, memberId, memberRole, memberCategory, memberStatus, Binary B.empty, fromInvitedBy userContactId invitedBy, invitedByGroupMemberId) + :. (userId, localDisplayName' userOrContact, contactId' userOrContact, localProfileId $ profile' userOrContact, createdAt, createdAt) :. (minV, maxV) ) - pure $ Right incognitoLdn + pure (indexInGroup, localDisplayName) + insertMemberIncognitoProfile_ :: ContactName -> ProfileId -> ExceptT StoreError IO (Int64, ContactName) + insertMemberIncognitoProfile_ incognitoDisplayName customUserProfileId = + ExceptT . withLocalDisplayName db userId incognitoDisplayName $ \incognitoLdn -> runExceptT $ do + indexInGroup <- getUpdateNextIndexInGroup_ db groupId + liftIO $ + DB.execute + db + [sql| + INSERT INTO group_members + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, member_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + |] + ( (groupId, indexInGroup, memberId, memberRole, memberCategory, memberStatus, Binary B.empty, fromInvitedBy userContactId invitedBy, invitedByGroupMemberId) + :. (userId, incognitoLdn, contactId' userOrContact, localProfileId $ profile' userOrContact, customUserProfileId, createdAt, createdAt) + :. (minV, maxV) + ) + pure (indexInGroup, incognitoLdn) deleteContactCardKeepConn :: DB.Connection -> Int64 -> Contact -> IO () deleteContactCardKeepConn db connId Contact {contactId, profile = LocalProfile {profileId}} = do @@ -543,16 +577,17 @@ createPreparedGroup db vr user@User {userId, userContactId} groupProfile busines let memberId = MemberId $ encodeUtf8 groupLDN <> "_host_unknown_id" hostProfile = profileFromName $ nameFromMemberId memberId (localDisplayName, profileId) <- createNewMemberProfile_ db user hostProfile currentTs + indexInGroup <- getUpdateNextIndexInGroup_ db groupId liftIO $ do DB.execute db [sql| INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, memberId, GRAdmin, GCHostMember, GSMemAccepted, fromInvitedBy userContactId IBUnknown) + ( (groupId, indexInGroup, memberId, GRAdmin, GCHostMember, GSMemAccepted, Binary B.empty, fromInvitedBy userContactId IBUnknown) :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs) ) insertedRowId db @@ -738,16 +773,17 @@ createGroupViaLink' insertHost_ currentTs groupId = do (localDisplayName, profileId) <- createNewMemberProfile_ db user fromMemberProfile currentTs let MemberIdRole {memberId, memberRole} = fromMember + indexInGroup <- getUpdateNextIndexInGroup_ db groupId liftIO $ do DB.execute db [sql| INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, memberId, memberRole, GCHostMember, GSMemAccepted, fromInvitedBy userContactId IBUnknown) + ( (groupId, indexInGroup, memberId, memberRole, GCHostMember, GSMemAccepted, Binary B.empty, fromInvitedBy userContactId IBUnknown) :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs) ) insertedRowId db @@ -971,6 +1007,22 @@ getGroupMemberById db vr user@User {userId} groupMemberId = (groupMemberQuery <> " WHERE m.group_member_id = ? AND m.user_id = ?") (groupMemberId, userId) +getGroupMemberByIndex :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> Int64 -> ExceptT StoreError IO GroupMember +getGroupMemberByIndex db vr user GroupInfo {groupId} indexInGroup = + ExceptT . firstRow (toContactMember vr user) (SEGroupMemberNotFoundByIndex indexInGroup) $ + DB.query + db + (groupMemberQuery <> " WHERE m.group_id = ? AND m.index_in_group = ?") + (groupId, indexInGroup) + +getSupportScopeMemberByIndex :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupMemberId -> Int64 -> ExceptT StoreError IO GroupMember +getSupportScopeMemberByIndex db vr user GroupInfo {groupId} scopeGMId indexInGroup = + ExceptT . firstRow (toContactMember vr user) (SEGroupMemberNotFoundByIndex indexInGroup) $ + DB.query + db + (groupMemberQuery <> " WHERE m.group_id = ? AND m.index_in_group = ? AND (m.member_role IN (?,?,?) OR m.group_member_id = ?)") + (groupId, indexInGroup, GRModerator, GRAdmin, GROwner, scopeGMId) + getGroupMemberByMemberId :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> MemberId -> ExceptT StoreError IO GroupMember getGroupMemberByMemberId db vr user GroupInfo {groupId} memberId = ExceptT . firstRow (toContactMember vr user) (SEGroupMemberNotFoundByMemberId memberId) $ @@ -994,13 +1046,39 @@ getGroupMemberIdViaMemberId db User {userId} GroupInfo {groupId} memberId = (userId, groupId, memberId) getGroupMembers :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember] -getGroupMembers db vr user@User {userId, userContactId} GroupInfo {groupId} = do +getGroupMembers db vr user@User {userId, userContactId} GroupInfo {groupId} = map (toContactMember vr user) <$> DB.query db (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?)") (userId, groupId, userContactId) +getGroupMembersByIndexes :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> [Int64] -> IO [GroupMember] +getGroupMembersByIndexes db vr user gInfo indexesInGroup = do +#if defined(dbPostgres) + let GroupInfo {groupId} = gInfo + map (toContactMember vr user) <$> + DB.query + db + (groupMemberQuery <> " WHERE m.group_id = ? AND m.index_in_group IN ?") + (groupId, In indexesInGroup) +#else + rights <$> mapM (runExceptT . getGroupMemberByIndex db vr user gInfo) indexesInGroup +#endif + +getSupportScopeMembersByIndexes :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupMemberId -> [Int64] -> IO [GroupMember] +getSupportScopeMembersByIndexes db vr user gInfo scopeGMId indexesInGroup = do +#if defined(dbPostgres) + let GroupInfo {groupId} = gInfo + map (toContactMember vr user) <$> + DB.query + db + (groupMemberQuery <> " WHERE m.group_id = ? AND m.index_in_group IN ? AND (m.member_role IN (?,?,?) OR m.group_member_id = ?)") + (groupId, In indexesInGroup, GRModerator, GRAdmin, GROwner, scopeGMId) +#else + rights <$> mapM (runExceptT . getSupportScopeMemberByIndex db vr user gInfo scopeGMId) indexesInGroup +#endif + getGroupModerators :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember] getGroupModerators db vr user@User {userId, userContactId} GroupInfo {groupId} = do map (toContactMember vr user) @@ -1068,21 +1146,22 @@ getGroupInvitation db vr user groupId = createNewContactMember :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> Contact -> GroupMemberRole -> ConnId -> ConnReqInvitation -> SubscriptionMode -> ExceptT StoreError IO GroupMember createNewContactMember _ _ _ _ Contact {localDisplayName, activeConn = Nothing} _ _ _ _ = throwError $ SEContactNotReady localDisplayName createNewContactMember db gVar User {userId, userContactId} GroupInfo {groupId, membership} Contact {contactId, localDisplayName, profile, activeConn = Just Connection {connChatVersion, peerChatVRange}} memberRole agentConnId connRequest subMode = - createWithRandomId gVar $ \memId -> do + createWithRandomId' gVar $ \memId -> runExceptT $ do createdAt <- liftIO getCurrentTime member@GroupMember {groupMemberId} <- createMember_ (MemberId memId) createdAt - void $ createMemberConnection_ db userId groupMemberId agentConnId connChatVersion peerChatVRange Nothing 0 createdAt subMode + void $ liftIO $ createMemberConnection_ db userId groupMemberId agentConnId connChatVersion peerChatVRange Nothing 0 createdAt subMode pure member where VersionRange minV maxV = peerChatVRange invitedByGroupMemberId = groupMemberId' membership createMember_ memberId createdAt = do - insertMember_ + indexInGroup <- insertMember_ groupMemberId <- liftIO $ insertedRowId db pure GroupMember { groupMemberId, groupId, + indexInGroup, memberId, memberRole, memberCategory = GCInviteeMember, @@ -1103,45 +1182,50 @@ createNewContactMember db gVar User {userId, userContactId} GroupInfo {groupId, isChatRelay = BoolDef False } where - insertMember_ = - DB.execute - db - [sql| - INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, sent_inv_queue_info, created_at, updated_at, - peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) - |] - ( (groupId, memberId, memberRole, GCInviteeMember, GSMemInvited, fromInvitedBy userContactId IBUser, invitedByGroupMemberId) - :. (userId, localDisplayName, contactId, localProfileId profile, connRequest, createdAt, createdAt) - :. (minV, maxV) - ) + insertMember_ = do + indexInGroup <- getUpdateNextIndexInGroup_ db groupId + liftIO $ + DB.execute + db + [sql| + INSERT INTO group_members + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, sent_inv_queue_info, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + |] + ( (groupId, indexInGroup, memberId, memberRole, GCInviteeMember, GSMemInvited, Binary B.empty, fromInvitedBy userContactId IBUser, invitedByGroupMemberId) + :. (userId, localDisplayName, contactId, localProfileId profile, connRequest, createdAt, createdAt) + :. (minV, maxV) + ) + pure indexInGroup createNewContactMemberAsync :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> Contact -> GroupMemberRole -> (CommandId, ConnId) -> VersionChat -> VersionRangeChat -> SubscriptionMode -> ExceptT StoreError IO () createNewContactMemberAsync db gVar user@User {userId, userContactId} GroupInfo {groupId, membership} Contact {contactId, localDisplayName, profile} memberRole (cmdId, agentConnId) chatV peerChatVRange subMode = - createWithRandomId gVar $ \memId -> do + createWithRandomId' gVar $ \memId -> runExceptT $ do createdAt <- liftIO getCurrentTime insertMember_ (MemberId memId) createdAt groupMemberId <- liftIO $ insertedRowId db - Connection {connId} <- createMemberConnection_ db userId groupMemberId agentConnId chatV peerChatVRange Nothing 0 createdAt subMode - setCommandConnId db user cmdId connId + Connection {connId} <- liftIO $ createMemberConnection_ db userId groupMemberId agentConnId chatV peerChatVRange Nothing 0 createdAt subMode + liftIO $ setCommandConnId db user cmdId connId where VersionRange minV maxV = peerChatVRange - insertMember_ memberId createdAt = - DB.execute - db - [sql| - INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, - peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) - |] - ( (groupId, memberId, memberRole, GCInviteeMember, GSMemInvited, fromInvitedBy userContactId IBUser, groupMemberId' membership) - :. (userId, localDisplayName, contactId, localProfileId profile, createdAt, createdAt) - :. (minV, maxV) - ) + insertMember_ memberId createdAt = do + indexInGroup <- getUpdateNextIndexInGroup_ db groupId + liftIO $ + DB.execute + db + [sql| + INSERT INTO group_members + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + |] + ( (groupId, indexInGroup, memberId, memberRole, GCInviteeMember, GSMemInvited, Binary B.empty, fromInvitedBy userContactId IBUser, groupMemberId' membership) + :. (userId, localDisplayName, contactId, localProfileId profile, createdAt, createdAt) + :. (minV, maxV) + ) createJoiningMember :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> VersionRangeChat -> Profile -> Maybe XContactId -> Maybe SharedMsgId -> GroupMemberRole -> GroupMemberStatus -> ExceptT StoreError IO (GroupMemberId, MemberId) createJoiningMember @@ -1163,26 +1247,28 @@ createJoiningMember "INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" (displayName, fullName, shortDescr, image, contactLink, userId, preferences, currentTs, currentTs) profileId <- liftIO $ insertedRowId db - createWithRandomId gVar $ \memId -> do + createWithRandomId' gVar $ \memId -> runExceptT $ do insertMember_ ldn profileId (MemberId memId) currentTs groupMemberId <- liftIO $ insertedRowId db pure (groupMemberId, MemberId memId) where VersionRange minV maxV = cReqChatVRange - insertMember_ ldn profileId memberId currentTs = - DB.execute - db - [sql| - INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, member_xcontact_id, member_welcome_shared_msg_id, created_at, updated_at, - peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) - |] - ( (groupId, memberId, memberRole, GCInviteeMember, memberStatus, fromInvitedBy userContactId IBUser, groupMemberId' membership) - :. (userId, ldn, Nothing :: (Maybe Int64), profileId, cReqXContactId_, welcomeMsgId_, currentTs, currentTs) - :. (minV, maxV) - ) + insertMember_ ldn profileId memberId currentTs = do + indexInGroup <- getUpdateNextIndexInGroup_ db groupId + liftIO $ + DB.execute + db + [sql| + INSERT INTO group_members + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, member_xcontact_id, member_welcome_shared_msg_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + |] + ( (groupId, indexInGroup, memberId, memberRole, GCInviteeMember, memberStatus, Binary B.empty, fromInvitedBy userContactId IBUser, groupMemberId' membership) + :. (userId, ldn, Nothing :: (Maybe Int64), profileId, cReqXContactId_, welcomeMsgId_, currentTs, currentTs) + :. (minV, maxV) + ) getMemberJoinRequest :: DB.Connection -> User -> GroupInfo -> GroupMember -> IO (Maybe (Maybe XContactId, Maybe SharedMsgId)) getMemberJoinRequest db User {userId} GroupInfo {groupId} GroupMember {groupMemberId = mId} = @@ -1244,22 +1330,24 @@ createBusinessRequestGroup membership <- createContactMemberInv_ db user groupId Nothing user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser Nothing currentTs vr pure (groupId, membership) VersionRange minV maxV = cReqChatVRange - insertClientMember_ currentTs groupId membership = ExceptT $ do - withLocalDisplayName db userId displayName $ \localDisplayName -> runExceptT $ do - createWithRandomId gVar $ \memId -> do - DB.execute - db - [sql| - INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, - peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) - |] - ( (groupId, MemberId memId, GRMember, GCInviteeMember, GSMemAccepted, fromInvitedBy userContactId IBUser, groupMemberId' membership) - :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs) - :. (minV, maxV) - ) + insertClientMember_ currentTs groupId membership = + ExceptT . withLocalDisplayName db userId displayName $ \localDisplayName -> runExceptT $ do + createWithRandomId' gVar $ \memId -> runExceptT $ do + indexInGroup <- getUpdateNextIndexInGroup_ db groupId + liftIO $ + DB.execute + db + [sql| + INSERT INTO group_members + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + |] + ( (groupId, indexInGroup, MemberId memId, GRMember, GCInviteeMember, GSMemAccepted, Binary B.empty, fromInvitedBy userContactId IBUser, groupMemberId' membership) + :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs) + :. (minV, maxV) + ) groupMemberId <- liftIO $ insertedRowId db pure (groupMemberId, MemberId memId) @@ -1329,9 +1417,8 @@ updateGroupMemberAccepted db User {userId} m@GroupMember {groupMemberId} status (status, role, currentTs, userId, groupMemberId) pure m {memberStatus = status, memberRole = role, updatedAt = currentTs} -deleteGroupMemberSupportChat :: DB.Connection -> User -> GroupInfo -> GroupMember -> IO (GroupInfo, GroupMember) -deleteGroupMemberSupportChat db user g m@GroupMember {groupMemberId} = do - let requiredAttention = gmRequiresAttention m +deleteGroupMemberSupportChat :: DB.Connection -> GroupMember -> IO GroupMember +deleteGroupMemberSupportChat db m@GroupMember {groupMemberId} = do currentTs <- getCurrentTime DB.execute db @@ -1353,11 +1440,7 @@ deleteGroupMemberSupportChat db user g m@GroupMember {groupMemberId} = do WHERE group_member_id = ? |] (currentTs, groupMemberId) - let m' = m {supportChat = Nothing, updatedAt = currentTs} - g' <- if requiredAttention - then decreaseGroupMembersRequireAttention db user g - else pure g - pure (g', m') + pure m {supportChat = Nothing, updatedAt = currentTs} updateGroupMembersRequireAttention :: DB.Connection -> User -> GroupInfo -> GroupMember -> GroupMember -> IO GroupInfo updateGroupMembersRequireAttention db user g member member' @@ -1420,7 +1503,7 @@ createNewGroupMember db user gInfo invitingMember memInfo@MemberInfo {profile} m memProfileId, isChatRelay = False } - liftIO $ createNewMember_ db user gInfo newMember currentTs + createNewMember_ db user gInfo newMember currentTs createNewMemberProfile_ :: DB.Connection -> User -> Profile -> UTCTime -> ExceptT StoreError IO (Text, ProfileId) createNewMemberProfile_ db User {userId} Profile {displayName, fullName, shortDescr, image, contactLink, preferences} createdAt = @@ -1432,7 +1515,7 @@ createNewMemberProfile_ db User {userId} Profile {displayName, fullName, shortDe profileId <- insertedRowId db pure $ Right (ldn, profileId) -createNewMember_ :: DB.Connection -> User -> GroupInfo -> NewGroupMember -> UTCTime -> IO GroupMember +createNewMember_ :: DB.Connection -> User -> GroupInfo -> NewGroupMember -> UTCTime -> ExceptT StoreError IO GroupMember createNewMember_ db User {userId, userContactId} @@ -1453,24 +1536,29 @@ createNewMember_ let invitedById = fromInvitedBy userContactId invitedBy activeConn = Nothing memberChatVRange@(VersionRange minV maxV) = maybe chatInitialVRange fromChatVRange memChatVRange - DB.execute - db - [sql| - INSERT INTO group_members - (group_id, member_id, member_role, member_category, member_status, member_restriction, is_chat_relay, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, - peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) - |] - ( (groupId, memberId, memberRole, memberCategory, memberStatus, memRestriction, BI isChatRelay, invitedById, memInvitedByGroupMemberId) - :. (userId, localDisplayName, memberContactId, memberContactProfileId, createdAt, createdAt) - :. (minV, maxV) - ) - groupMemberId <- insertedRowId db + indexInGroup <- getUpdateNextIndexInGroup_ db groupId + liftIO $ + DB.execute + db + [sql| + INSERT INTO group_members + (group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, + member_restriction, is_chat_relay, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + |] + ( (groupId, indexInGroup, memberId, memberRole, memberCategory, memberStatus, Binary B.empty) + :. (memRestriction, BI isChatRelay, invitedById, memInvitedByGroupMemberId) + :. (userId, localDisplayName, memberContactId, memberContactProfileId, createdAt, createdAt) + :. (minV, maxV) + ) + groupMemberId <- liftIO $ insertedRowId db pure GroupMember { groupMemberId, groupId, + indexInGroup, memberId, memberRole, memberCategory, @@ -1521,16 +1609,14 @@ updateGroupMemberRole :: DB.Connection -> User -> GroupMember -> GroupMemberRole updateGroupMemberRole db User {userId} GroupMember {groupMemberId} memRole = DB.execute db "UPDATE group_members SET member_role = ? WHERE user_id = ? AND group_member_id = ?" (memRole, userId, groupMemberId) -createIntroductions :: DB.Connection -> VersionChat -> [GroupMember] -> GroupMember -> IO [GroupMemberIntro] -createIntroductions db chatV members toMember = do - let reMembers = filter (\m -> memberCurrent m && groupMemberId' m /= groupMemberId' toMember) members - if null reMembers - then pure [] - else do +createIntroductions :: DB.Connection -> VersionChat -> [GroupMember] -> GroupMember -> IO [GroupMember] +createIntroductions db chatV reMembers toMember + | null reMembers = pure [] + | otherwise = do currentTs <- getCurrentTime catMaybes <$> mapM (createIntro_ currentTs) reMembers where - createIntro_ :: UTCTime -> GroupMember -> IO (Maybe GroupMemberIntro) + createIntro_ :: UTCTime -> GroupMember -> IO (Maybe GroupMember) createIntro_ ts reMember = -- when members connect concurrently, host would try to create introductions between them in both directions; -- this check avoids creating second (redundant) introduction @@ -1545,8 +1631,7 @@ createIntroductions db chatV members toMember = do VALUES (?,?,?,?,?,?) |] (groupMemberId' reMember, groupMemberId' toMember, GMIntroPending, chatV, ts, ts) - introId <- insertedRowId db - pure $ Just GroupMemberIntro {introId, reMember, toMember, introStatus = GMIntroPending} + pure $ Just reMember where checkInverseIntro :: IO (Maybe Int64) checkInverseIntro = @@ -1556,6 +1641,165 @@ createIntroductions db chatV members toMember = do "SELECT 1 FROM group_member_intros WHERE re_group_member_id = ? AND to_group_member_id = ? LIMIT 1" (groupMemberId' toMember, groupMemberId' reMember) +-- Create introductions for members without vectors and update vectors for members with vectors. +-- Partitioning and updates happen in same transaction to avoid race conditions. +createIntrosOrUpdateVectors :: DB.Connection -> VersionRangeChat -> [GroupMember] -> GroupMember -> IO [GroupMember] +createIntrosOrUpdateVectors db vr reMembers toMember + | null reMembers = pure [] + | otherwise = do + (memsWithVec, memsWithoutVec) <- partitionByVector reMembers + let GroupMember {indexInGroup} = toMember + setMembersVectorsNewRelation db memsWithVec indexInGroup IDSubjectIntroduced MRIntroduced + memsWithoutVec' <- createIntroductions db (maxVersion vr) memsWithoutVec toMember + pure $ memsWithoutVec' <> memsWithVec + where + partitionByVector :: [GroupMember] -> IO ([GroupMember], [GroupMember]) +#if defined(dbPostgres) + partitionByVector members = do + let memberIds = map groupMemberId' members + -- Lock rows first to ensure partitioning doesn't change in case of concurrent updates + _ :: [Only Int] <- + DB.query + db + "SELECT 1 FROM group_members WHERE group_member_id IN ? FOR UPDATE" + (Only $ In memberIds) + memberIdsWithVec <- S.fromList . map fromOnly <$> + DB.query + db + "SELECT group_member_id FROM group_members WHERE group_member_id IN ? AND member_relations_vector IS NOT NULL" + (Only $ In memberIds) + pure $ partition (\m -> groupMemberId' m `S.member` memberIdsWithVec) members +#else + partitionByVector = foldrM checkMember ([], []) + where + checkMember m (withVec, withoutVec) = do + hasVec <- isJust <$> maybeFirstRow fromOnly + (DB.query db "SELECT 1 FROM group_members WHERE group_member_id = ? AND member_relations_vector IS NOT NULL" (Only $ groupMemberId' m) :: IO [Only Int64]) + pure $ if hasVec then (m : withVec, withoutVec) else (withVec, m : withoutVec) +#endif + +setMemberVectorNewRelations :: DB.Connection -> GroupMember -> [(Int64, (IntroductionDirection, MemberRelation))] -> IO () +setMemberVectorNewRelations db GroupMember {groupMemberId} relations = do + v_ <- maybeFirstRow fromOnly $ + DB.query + db +#if defined(dbPostgres) + "SELECT member_relations_vector FROM group_members WHERE group_member_id = ? FOR UPDATE" +#else + "SELECT member_relations_vector FROM group_members WHERE group_member_id = ?" +#endif + (Only groupMemberId) + let v' = setNewRelations relations $ fromMaybe B.empty v_ + currentTs <- getCurrentTime + DB.execute + db + [sql| + UPDATE group_members + SET member_relations_vector = ?, updated_at = ? + WHERE group_member_id = ? + |] + (Binary v', currentTs, groupMemberId) + +setMembersVectorsNewRelation :: DB.Connection -> [GroupMember] -> Int64 -> IntroductionDirection -> MemberRelation -> IO () +setMembersVectorsNewRelation db members idx dir status = do + currentTs <- getCurrentTime +#if defined(dbPostgres) + let memberIds = map groupMemberId' members + DB.execute + db + "UPDATE group_members SET member_relations_vector = set_member_vector_new_relation(member_relations_vector, ?, ?, ?), updated_at = ? WHERE group_member_id IN ?" + (idx, toIntroDirInt dir, toRelationInt status, currentTs, In memberIds) +#else + forM_ members $ \GroupMember {groupMemberId} -> + DB.execute + db + "UPDATE group_members SET member_relations_vector = set_member_vector_new_relation(member_relations_vector, ?, ?, ?), updated_at = ? WHERE group_member_id = ?" + (idx, toIntroDirInt dir, toRelationInt status, currentTs, groupMemberId) +#endif + +setMemberVectorRelationConnected :: DB.Connection -> GroupMember -> GroupMember -> MemberRelation -> ExceptT StoreError IO () +setMemberVectorRelationConnected db GroupMember {groupMemberId} GroupMember {indexInGroup} newStatus = do + when (newStatus /= MRSubjectConnected && newStatus /= MRReferencedConnected) $ + throwError SEInvalidMemberRelationUpdate + v <- ExceptT $ + firstRow fromOnly (SEMemberRelationsVectorNotFound groupMemberId) $ + DB.query + db +#if defined(dbPostgres) + "SELECT member_relations_vector FROM group_members WHERE group_member_id = ? AND member_relations_vector IS NOT NULL FOR UPDATE" +#else + "SELECT member_relations_vector FROM group_members WHERE group_member_id = ? AND member_relations_vector IS NOT NULL" +#endif + (Only groupMemberId) + let v' = setRelationConnected indexInGroup newStatus v + currentTs <- liftIO getCurrentTime + liftIO $ DB.execute + db + [sql| + UPDATE group_members + SET member_relations_vector = ?, updated_at = ? + WHERE group_member_id = ? + |] + (Binary v', currentTs, groupMemberId) + +migrateGetMemberRelationsVector :: DB.Connection -> GroupMember -> ExceptT StoreError IO ByteString +migrateGetMemberRelationsVector db m@GroupMember {groupMemberId} = do + liftIO $ migrateMemberRelationsVector db m + ExceptT . firstRow fromOnly (SEGroupMemberNotFound groupMemberId) $ + DB.query + db + "SELECT member_relations_vector FROM group_members WHERE group_member_id = ?" + (Only groupMemberId) + +migrateMemberRelationsVector :: DB.Connection -> GroupMember -> IO () +migrateMemberRelationsVector db GroupMember {groupMemberId} = + migrateMemberRelationsVector' db groupMemberId + +migrateMemberRelationsVector' :: DB.Connection -> GroupMemberId -> IO () +migrateMemberRelationsVector' db groupMemberId = do + currentTs <- liftIO getCurrentTime + liftIO $ do +#if defined(dbPostgres) + -- Lock the row first to ensure computation runs only after lock is acquired + _ :: [Only Int] <- + DB.query + db + "SELECT 1 FROM group_members WHERE group_member_id = ? AND member_relations_vector IS NULL FOR UPDATE" + (Only groupMemberId) +#endif + DB.execute + db + [sql| + UPDATE group_members + SET + member_relations_vector = ( + SELECT migrate_relations_vector(idx, direction, intro_status) + FROM ( + SELECT m.index_in_group AS idx, 0 AS direction, i.intro_status + FROM group_member_intros i + JOIN group_members m ON m.group_member_id = i.to_group_member_id + WHERE i.re_group_member_id = group_members.group_member_id + UNION ALL + SELECT m.index_in_group AS idx, 1 AS direction, i.intro_status + FROM group_member_intros i + JOIN group_members m ON m.group_member_id = i.re_group_member_id + WHERE i.to_group_member_id = group_members.group_member_id + ) AS relations + ), + updated_at = ? + WHERE group_member_id = ? + AND member_relations_vector IS NULL + |] + (currentTs, groupMemberId) + +getMemberRelationsVector_ :: DB.Connection -> GroupMember -> IO (Maybe ByteString) +getMemberRelationsVector_ db GroupMember {groupMemberId} = + maybeFirstRow fromOnly $ + DB.query + db + "SELECT member_relations_vector FROM group_members WHERE group_member_id = ?" + (Only groupMemberId) + updateIntroStatus :: DB.Connection -> Int64 -> GroupMemberIntroStatus -> IO () updateIntroStatus db introId introStatus = do currentTs <- getCurrentTime @@ -1568,9 +1812,9 @@ updateIntroStatus db introId introStatus = do |] (introStatus, currentTs, introId) -getIntroduction :: DB.Connection -> GroupMember -> GroupMember -> ExceptT StoreError IO GroupMemberIntro -getIntroduction db reMember toMember = ExceptT $ - firstRow toIntro SEIntroNotFound $ +getIntroduction :: DB.Connection -> GroupMember -> GroupMember -> IO (Maybe GroupMemberIntro) +getIntroduction db reMember toMember = + maybeFirstRow toIntro $ DB.query db [sql| @@ -1592,106 +1836,6 @@ getIntroducedGroupMemberIds db invitee = "SELECT re_group_member_id FROM group_member_intros WHERE to_group_member_id = ?" (Only $ groupMemberId' invitee) -getForwardIntroducedMembers :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> Bool -> IO [GroupMember] -getForwardIntroducedMembers db vr user invitee highlyAvailable = do - memberIds <- map fromOnly <$> query - rights <$> mapM (runExceptT . getGroupMemberById db vr user) memberIds - where - mId = groupMemberId' invitee - query - | highlyAvailable = DB.query db q (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected) - | otherwise = - DB.query - db - (q <> " AND intro_chat_protocol_version >= ?") - (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected, groupForwardVersion) - q = - [sql| - SELECT re_group_member_id - FROM group_member_intros - WHERE to_group_member_id = ? AND intro_status NOT IN (?,?,?) - |] - --- for support scope we don't need to filter by intro_chat_protocol_version for non highly available client, --- as we will filter moderators supporting this feature by a higher version (as opposed to getForwardIntroducedMembers) -getForwardIntroducedModerators :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> IO [GroupMember] -getForwardIntroducedModerators db vr user@User {userContactId} invitee = do - memberIds <- map fromOnly <$> query - rights <$> mapM (runExceptT . getGroupMemberById db vr user) memberIds - where - mId = groupMemberId' invitee - query = - DB.query - db - [sql| - SELECT i.re_group_member_id - FROM group_member_intros i - JOIN group_members m ON m.group_member_id = i.re_group_member_id - WHERE i.to_group_member_id = ? AND i.intro_status NOT IN (?,?,?) - AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?,?) - |] - (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected, userContactId, GRModerator, GRAdmin, GROwner) - -getForwardInvitedMembers :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> Bool -> IO [GroupMember] -getForwardInvitedMembers db vr user forwardMember highlyAvailable = do - memberIds <- map fromOnly <$> query - rights <$> mapM (runExceptT . getGroupMemberById db vr user) memberIds - where - mId = groupMemberId' forwardMember - query - | highlyAvailable = DB.query db q (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected) - | otherwise = - DB.query - db - (q <> " AND intro_chat_protocol_version >= ?") - (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected, groupForwardVersion) - q = - [sql| - SELECT to_group_member_id - FROM group_member_intros - WHERE re_group_member_id = ? AND intro_status NOT IN (?,?,?) - |] - --- for support scope we don't need to filter by intro_chat_protocol_version for non highly available client, --- as we will filter moderators supporting this feature by a higher version (as opposed to getForwardInvitedMembers) -getForwardInvitedModerators :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> IO [GroupMember] -getForwardInvitedModerators db vr user@User {userContactId} forwardMember = do - memberIds <- map fromOnly <$> query - rights <$> mapM (runExceptT . getGroupMemberById db vr user) memberIds - where - mId = groupMemberId' forwardMember - query = - DB.query - db - [sql| - SELECT i.to_group_member_id - FROM group_member_intros i - JOIN group_members m ON m.group_member_id = i.to_group_member_id - WHERE i.re_group_member_id = ? AND i.intro_status NOT IN (?,?,?) - AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?,?) - |] - (mId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected, userContactId, GRModerator, GRAdmin, GROwner) - -getForwardScopeMember :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> GroupMemberId -> IO (Maybe GroupMember) -getForwardScopeMember db vr user GroupMember {groupMemberId = sendingGMId} scopeGMId = do - (introExists_ :: Maybe Int64) <- - liftIO $ maybeFirstRow fromOnly $ - DB.query - db - [sql| - SELECT 1 - FROM group_member_intros - WHERE - ( - (re_group_member_id = ? AND to_group_member_id = ?) OR - (re_group_member_id = ? AND to_group_member_id = ?) - ) - AND intro_status NOT IN (?,?,?) - LIMIT 1 - |] - (sendingGMId, scopeGMId, scopeGMId, sendingGMId, GMIntroReConnected, GMIntroToConnected, GMIntroConnected) - pure introExists_ $>> (eitherToMaybe <$> runExceptT (getGroupMemberById db vr user scopeGMId)) - createIntroReMember :: DB.Connection -> User -> GroupInfo -> GroupMember -> VersionChat -> MemberInfo -> Maybe MemberRestrictions -> (CommandId, ConnId) -> SubscriptionMode -> ExceptT StoreError IO GroupMember createIntroReMember db @@ -1709,11 +1853,10 @@ createIntroReMember currentTs <- liftIO getCurrentTime (localDisplayName, memProfileId) <- createNewMemberProfile_ db user memberProfile currentTs let newMember = NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memRestriction, memInvitedBy = IBUnknown, memInvitedByGroupMemberId = Nothing, localDisplayName, memContactId = Nothing, memProfileId, isChatRelay = False} - liftIO $ do - member <- createNewMember_ db user gInfo newMember currentTs - conn@Connection {connId = groupConnId} <- createMemberConnection_ db userId (groupMemberId' member) groupAgentConnId chatV mcvr memberContactId cLevel currentTs subMode - liftIO $ setCommandConnId db user groupCmdId groupConnId - pure (member :: GroupMember) {activeConn = Just conn} + member <- createNewMember_ db user gInfo newMember currentTs + conn@Connection {connId = groupConnId} <- liftIO $ createMemberConnection_ db userId (groupMemberId' member) groupAgentConnId chatV mcvr memberContactId cLevel currentTs subMode + liftIO $ setCommandConnId db user groupCmdId groupConnId + pure (member :: GroupMember) {activeConn = Just conn} createIntroToMemberContact :: DB.Connection -> User -> GroupMember -> GroupMember -> VersionChat -> VersionRangeChat -> (CommandId, ConnId) -> Maybe (CommandId, ConnId) -> Maybe ProfileId -> SubscriptionMode -> IO () createIntroToMemberContact db user@User {userId} GroupMember {memberContactId = viaContactId, activeConn} _to@GroupMember {groupMemberId, localDisplayName} chatV mcvr (groupCmdId, groupAgentConnId) directConnIds customUserProfileId subMode = do @@ -2473,21 +2616,22 @@ createNewUnknownGroupMember db vr user@User {userId, userContactId} GroupInfo {g currentTs <- liftIO getCurrentTime let memberProfile = profileFromName memberName (localDisplayName, profileId) <- createNewMemberProfile_ db user memberProfile currentTs - groupMemberId <- liftIO $ do + indexInGroup <- getUpdateNextIndexInGroup_ db groupId + liftIO $ DB.execute db [sql| INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, memberId, GRAuthor, GCPreMember, GSMemUnknown, fromInvitedBy userContactId IBUnknown) + ( (groupId, indexInGroup, memberId, GRAuthor, GCPreMember, GSMemUnknown, Binary B.empty, fromInvitedBy userContactId IBUnknown) :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs) :. (minV, maxV) ) - insertedRowId db + groupMemberId <- liftIO $ insertedRowId db getGroupMemberById db vr user groupMemberId where VersionRange minV maxV = vr @@ -2581,6 +2725,25 @@ getUserGroupsToExpire db User {userId} globalTTL = where cond = if globalTTL == 0 then "" else " OR chat_item_ttl IS NULL" +hasMembersWithoutVector :: DB.Connection -> IO Bool +hasMembersWithoutVector db = + fromOnly . head + <$> DB.query_ + db + "SELECT EXISTS (SELECT 1 FROM group_members WHERE member_relations_vector IS NULL LIMIT 1)" + +getGMsWithoutVectorIds :: DB.Connection -> IO [GroupMemberId] +getGMsWithoutVectorIds db = + map fromOnly <$> + DB.query_ + db + [sql| + SELECT group_member_id + FROM group_members + WHERE member_relations_vector IS NULL + LIMIT 1000 + |] + updateGroupAlias :: DB.Connection -> UserId -> GroupInfo -> LocalAlias -> IO GroupInfo updateGroupAlias db userId g@GroupInfo {groupId} localAlias = do updatedAt <- getCurrentTime diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index df5b800c65..0e2db9421b 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -674,7 +674,7 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe [sql| SELECT i.chat_item_id, -- GroupMember - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, @@ -1645,7 +1645,7 @@ getGroupUnreadCount_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatSco getGroupUnreadCount_ db user g scopeInfo_ contentFilter = head <$> queryUnreadGroupItems db user g scopeInfo_ contentFilter baseQuery "" where - baseQuery = "SELECT COUNT(1), COALESCE(SUM(user_mention), 0) FROM chat_items WHERE user_id = ? AND group_id = ? AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL " + baseQuery = "SELECT COUNT(1), COALESCE(SUM(user_mention), 0) FROM chat_items WHERE user_id = ? AND group_id = ? " getGroupReportsCount_ :: DB.Connection -> User -> GroupInfo -> Bool -> IO Int getGroupReportsCount_ db User {userId} GroupInfo {groupId} archived = @@ -2998,7 +2998,7 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do -- CIMeta forwardedByMember, showGroupAsSender i.forwarded_by_group_member_id, i.show_group_as_sender, -- GroupMember - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, @@ -3006,13 +3006,13 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do -- 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.peer_chat_min_version, rm.peer_chat_max_version, rm.member_role, rm.member_category, + rm.group_member_id, rm.group_id, rm.index_in_group, rm.member_id, rm.peer_chat_min_version, rm.peer_chat_max_version, rm.member_role, rm.member_category, rm.member_status, rm.show_messages, rm.member_restriction, rm.is_chat_relay, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, rp.display_name, rp.full_name, rp.short_descr, rp.image, rp.contact_link, rp.chat_peer_type, rp.local_alias, rp.preferences, rm.created_at, rm.updated_at, rm.support_chat_ts, rm.support_chat_items_unread, rm.support_chat_items_member_attention, rm.support_chat_items_mentions, rm.support_chat_last_msg_from_member_ts, -- deleted by GroupMember - dbm.group_member_id, dbm.group_id, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, + dbm.group_member_id, dbm.group_id, dbm.index_in_group, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, dbm.member_status, dbm.show_messages, dbm.member_restriction, dbm.is_chat_relay, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, dbp.display_name, dbp.full_name, dbp.short_descr, dbp.image, dbp.contact_link, dbp.chat_peer_type, dbp.local_alias, dbp.preferences, dbm.created_at, dbm.updated_at, diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index a81a86e6d5..8c59511575 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -21,7 +21,9 @@ import Simplex.Chat.Store.Postgres.Migrations.M20250919_group_summary import Simplex.Chat.Store.Postgres.Migrations.M20250922_remove_unused_connections import Simplex.Chat.Store.Postgres.Migrations.M20251007_connections_sync import Simplex.Chat.Store.Postgres.Migrations.M20251017_chat_tags_cascade -import Simplex.Chat.Store.Postgres.Migrations.M20251018_chat_relays +import Simplex.Chat.Store.Postgres.Migrations.M20251117_member_relations_vector +-- import Simplex.Chat.Store.Postgres.Migrations.M20251128_member_relations_vector_stage_2 +import Simplex.Chat.Store.Postgres.Migrations.M20251212_chat_relays import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -43,7 +45,9 @@ schemaMigrations = ("20250922_remove_unused_connections", m20250922_remove_unused_connections, Just down_m20250922_remove_unused_connections), ("20251007_connections_sync", m20251007_connections_sync, Just down_m20251007_connections_sync), ("20251017_chat_tags_cascade", m20251017_chat_tags_cascade, Just down_m20251017_chat_tags_cascade), - ("20251018_chat_relays", m20251018_chat_relays, Just down_m20251018_chat_relays) + ("20251117_member_relations_vector", m20251117_member_relations_vector, Just down_m20251117_member_relations_vector), + -- ("20251128_member_relations_vector_stage_2", m20251128_member_relations_vector_stage_2, Just down_m20251128_member_relations_vector_stage_2) + ("20251212_chat_relays", m20251212_chat_relays, Just down_m20251212_chat_relays) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20251117_member_relations_vector.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20251117_member_relations_vector.hs new file mode 100644 index 0000000000..33583cc076 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20251117_member_relations_vector.hs @@ -0,0 +1,159 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20251117_member_relations_vector where + +import Data.Text (Text) +import qualified Data.Text as T +import Text.RawString.QQ (r) + +-- This migration creates custom aggregate function migrate_relations_vector(idx, direction, intro_status). +-- Used in live migration and stage 2 migration (M20251128_member_relations_vector_stage_2). +-- +-- Vector byte encoding: 4 reserved | 1 direction | 3 status +-- Direction: 0 = IDSubjectIntroduced, 1 = IDReferencedIntroduced +-- Status values: 0 = MRNew, 1 = MRIntroduced, 2 = MRSubjectConnected, 3 = MRReferencedConnected, 4 = MRConnected +-- +-- The aggregate transforms intro_status into relation status: +-- - intro_status 'new'/'sent'/'rcv'/'fwd': MRIntroduced (1) +-- - intro_status 're-con': if direction=0 then MRSubjectConnected (2), else MRReferencedConnected (3) +-- - intro_status 'to-con': if direction=0 then MRReferencedConnected (3), else MRSubjectConnected (2) +-- - intro_status 'con': MRConnected (4) +-- +-- Final byte combines direction and status: byte = (direction << 3) | status + +m20251117_member_relations_vector :: Text +m20251117_member_relations_vector = + T.pack + [r| +CREATE FUNCTION set_member_vector_new_relation(v BYTEA, idx BIGINT, direction INT, status INT) +RETURNS BYTEA AS $$ +DECLARE + new_len INT; + result BYTEA; + byte_val INT; + old_byte INT; +BEGIN + IF idx < 0 THEN + RETURN v; + END IF; + IF idx < length(v) THEN + old_byte := get_byte(v, idx::INT); + ELSE + old_byte := 0; + END IF; + byte_val := (old_byte & x'F0'::INT) | (direction * 8) | status; + new_len := GREATEST(length(v), idx + 1); + IF new_len > length(v) THEN + result := v || (SELECT string_agg('\x00'::BYTEA, ''::BYTEA) FROM generate_series(1, new_len - length(v))); + ELSE + result := v; + END IF; + result := set_byte(result, idx::INT, byte_val); + RETURN result; +END; +$$ LANGUAGE plpgsql IMMUTABLE; + +CREATE FUNCTION migrate_relations_vector_step(state BYTEA, idx BIGINT, direction INT, intro_status TEXT) +RETURNS BYTEA AS $$ +DECLARE + new_len INT; + result BYTEA; + status INT; + byte_val INT; +BEGIN + IF idx < 0 THEN + RETURN state; + END IF; + IF intro_status = 're-con' THEN + IF direction = 0 THEN status := 2; ELSE status := 3; END IF; + ELSIF intro_status = 'to-con' THEN + IF direction = 0 THEN status := 3; ELSE status := 2; END IF; + ELSIF intro_status = 'con' THEN + status := 4; + ELSE + status := 1; + END IF; + byte_val := (direction * 8) + status; + new_len := GREATEST(length(state), idx + 1); + IF new_len > length(state) THEN + result := state || (SELECT string_agg('\x00'::BYTEA, ''::BYTEA) FROM generate_series(1, new_len - length(state))); + ELSE + result := state; + END IF; + result := set_byte(result, idx::INT, byte_val); + RETURN result; +END; +$$ LANGUAGE plpgsql IMMUTABLE; + +CREATE AGGREGATE migrate_relations_vector(BIGINT, INT, TEXT) ( + SFUNC = migrate_relations_vector_step, + STYPE = BYTEA, + INITCOND = '' +); + +ALTER TABLE group_members ADD COLUMN index_in_group BIGINT NOT NULL DEFAULT 0; + +ALTER TABLE groups ADD COLUMN member_index BIGINT NOT NULL DEFAULT 0; + +ALTER TABLE group_members ADD COLUMN member_relations_vector BYTEA; + +CREATE INDEX tmp_idx_group_members_group_id_group_member_id ON group_members(group_id, group_member_id); + +CREATE TEMPORARY TABLE tmp_members_indexed AS +SELECT + group_member_id, + ROW_NUMBER() OVER ( + PARTITION BY group_id + ORDER BY group_member_id ASC + ) - 1 AS idx_in_group +FROM group_members; + +CREATE INDEX tmp_idx_members_indexed ON tmp_members_indexed(group_member_id); + +UPDATE group_members AS gm +SET index_in_group = tmi.idx_in_group +FROM tmp_members_indexed tmi +WHERE tmi.group_member_id = gm.group_member_id; + +DROP INDEX tmp_idx_group_members_group_id_group_member_id; +DROP INDEX tmp_idx_members_indexed; +DROP TABLE tmp_members_indexed; + +CREATE UNIQUE INDEX idx_group_members_group_id_index_in_group ON group_members(group_id, index_in_group); + +UPDATE groups g +SET member_index = COALESCE(( + SELECT MAX(index_in_group) + 1 + FROM group_members + WHERE group_members.group_id = g.group_id +), 0); + +UPDATE group_members +SET member_relations_vector = ''::BYTEA +WHERE group_id IN ( + SELECT mu.group_id + FROM group_members mu + WHERE mu.member_category = 'user' + AND ( + mu.member_role NOT IN ('admin', 'owner') + OR mu.member_status IN ('removed', 'left', 'deleted') + ) +); +|] + +down_m20251117_member_relations_vector :: Text +down_m20251117_member_relations_vector = + T.pack + [r| +DROP AGGREGATE migrate_relations_vector(BIGINT, INT, TEXT); +DROP FUNCTION migrate_relations_vector_step(BYTEA, BIGINT, INT, TEXT); +DROP FUNCTION set_member_vector_new_relation(BYTEA, BIGINT, INT, INT); + +DROP INDEX idx_group_members_group_id_index_in_group; + +ALTER TABLE group_members DROP COLUMN index_in_group; + +ALTER TABLE groups DROP COLUMN member_index; + +ALTER TABLE group_members DROP COLUMN member_relations_vector; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20251128_member_relations_vector_stage_2.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20251128_member_relations_vector_stage_2.hs new file mode 100644 index 0000000000..7cfc273d62 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20251128_member_relations_vector_stage_2.hs @@ -0,0 +1,45 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20251128_member_relations_vector_stage_2 where + +import Data.Text (Text) +import qualified Data.Text as T +import Text.RawString.QQ (r) + +-- Build member_relations_vector for all members that don't have it yet. +-- Uses custom aggregate function migrate_relations_vector defined in M20251117_member_relations_vector. +-- +-- Query returns (idx, direction, intro_status) for each introduction: +-- - direction 0 (IDSubjectIntroduced): current member (subject) is re_group_member_id, was introduced to referenced member +-- - direction 1 (IDReferencedIntroduced): current member (subject) is to_group_member_id, referenced member was introduced to it + +-- TODO [relations vector] drop group_member_intros in the end of migration +m20251128_member_relations_vector_stage_2 :: Text +m20251128_member_relations_vector_stage_2 = + T.pack + [r| +UPDATE group_members +SET member_relations_vector = ( + SELECT migrate_relations_vector(idx, direction, intro_status) + FROM ( + SELECT m.index_in_group AS idx, 0 AS direction, i.intro_status + FROM group_member_intros i + JOIN group_members m ON m.group_member_id = i.to_group_member_id + WHERE i.re_group_member_id = group_members.group_member_id + UNION ALL + SELECT m.index_in_group AS idx, 1 AS direction, i.intro_status + FROM group_member_intros i + JOIN group_members m ON m.group_member_id = i.re_group_member_id + WHERE i.to_group_member_id = group_members.group_member_id + ) AS relations +) +WHERE member_relations_vector IS NULL; +|] + +-- TODO [relations vector] re-create group_member_intros +down_m20251128_member_relations_vector_stage_2 :: Text +down_m20251128_member_relations_vector_stage_2 = + T.pack + [r| + +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20251018_chat_relays.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20251212_chat_relays.hs similarity index 84% rename from src/Simplex/Chat/Store/Postgres/Migrations/M20251018_chat_relays.hs rename to src/Simplex/Chat/Store/Postgres/Migrations/M20251212_chat_relays.hs index efd43a3a3c..da2b808794 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20251018_chat_relays.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20251212_chat_relays.hs @@ -1,13 +1,13 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Store.Postgres.Migrations.M20251018_chat_relays where +module Simplex.Chat.Store.Postgres.Migrations.M20251212_chat_relays where import Data.Text (Text) import qualified Data.Text as T import Text.RawString.QQ (r) -m20251018_chat_relays :: Text -m20251018_chat_relays = +m20251212_chat_relays :: Text +m20251212_chat_relays = T.pack [r| CREATE TABLE chat_relays( @@ -32,8 +32,8 @@ ALTER TABLE users ADD COLUMN is_user_chat_relay SMALLINT NOT NULL DEFAULT 0; ALTER TABLE group_members ADD COLUMN is_chat_relay SMALLINT NOT NULL DEFAULT 0; |] -down_m20251018_chat_relays :: Text -down_m20251018_chat_relays = +down_m20251212_chat_relays :: Text +down_m20251212_chat_relays = T.pack [r| ALTER TABLE group_members DROP COLUMN is_chat_relay; diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index 601dd97a6e..832b282e70 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -34,6 +34,41 @@ $$; +CREATE FUNCTION test_chat_schema.migrate_relations_vector_step(state bytea, idx bigint, direction integer, intro_status text) RETURNS bytea + LANGUAGE plpgsql IMMUTABLE + AS $$ +DECLARE + new_len INT; + result BYTEA; + status INT; + byte_val INT; +BEGIN + IF idx < 0 THEN + RETURN state; + END IF; + IF intro_status = 're-con' THEN + IF direction = 0 THEN status := 2; ELSE status := 3; END IF; + ELSIF intro_status = 'to-con' THEN + IF direction = 0 THEN status := 3; ELSE status := 2; END IF; + ELSIF intro_status = 'con' THEN + status := 4; + ELSE + status := 1; + END IF; + byte_val := (direction * 8) + status; + new_len := GREATEST(length(state), idx + 1); + IF new_len > length(state) THEN + result := state || (SELECT string_agg('\x00'::BYTEA, ''::BYTEA) FROM generate_series(1, new_len - length(state))); + ELSE + result := state; + END IF; + result := set_byte(result, idx::INT, byte_val); + RETURN result; +END; +$$; + + + CREATE FUNCTION test_chat_schema.on_group_members_delete_update_summary() RETURNS trigger LANGUAGE plpgsql AS $$ @@ -85,6 +120,45 @@ END; $$; + +CREATE FUNCTION test_chat_schema.set_member_vector_new_relation(v bytea, idx bigint, direction integer, status integer) RETURNS bytea + LANGUAGE plpgsql IMMUTABLE + AS $$ +DECLARE + new_len INT; + result BYTEA; + byte_val INT; + old_byte INT; +BEGIN + IF idx < 0 THEN + RETURN v; + END IF; + IF idx < length(v) THEN + old_byte := get_byte(v, idx::INT); + ELSE + old_byte := 0; + END IF; + byte_val := (old_byte & x'F0'::INT) | (direction * 8) | status; + new_len := GREATEST(length(v), idx + 1); + IF new_len > length(v) THEN + result := v || (SELECT string_agg('\x00'::BYTEA, ''::BYTEA) FROM generate_series(1, new_len - length(v))); + ELSE + result := v; + END IF; + result := set_byte(result, idx::INT, byte_val); + RETURN result; +END; +$$; + + + +CREATE AGGREGATE test_chat_schema.migrate_relations_vector(bigint, integer, text) ( + SFUNC = test_chat_schema.migrate_relations_vector_step, + STYPE = bytea, + INITCOND = '' +); + + SET default_table_access_method = heap; @@ -284,6 +358,32 @@ ALTER TABLE test_chat_schema.chat_items ALTER COLUMN chat_item_id ADD GENERATED +CREATE TABLE test_chat_schema.chat_relays ( + chat_relay_id bigint NOT NULL, + address text NOT NULL, + name text NOT NULL, + domains text NOT NULL, + preset smallint DEFAULT 0 NOT NULL, + tested smallint, + enabled smallint DEFAULT 1 NOT NULL, + user_id bigint NOT NULL, + created_at text DEFAULT now() NOT NULL, + updated_at text DEFAULT now() NOT NULL +); + + + +ALTER TABLE test_chat_schema.chat_relays ALTER COLUMN chat_relay_id ADD GENERATED ALWAYS AS IDENTITY ( + SEQUENCE NAME test_chat_schema.chat_relays_chat_relay_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + + CREATE TABLE test_chat_schema.chat_tags ( chat_tag_id bigint NOT NULL, user_id bigint, @@ -706,7 +806,10 @@ CREATE TABLE test_chat_schema.group_members ( support_chat_items_mentions bigint DEFAULT 0 NOT NULL, support_chat_last_msg_from_member_ts timestamp with time zone, member_xcontact_id bytea, - member_welcome_shared_msg_id bytea + member_welcome_shared_msg_id bytea, + index_in_group bigint DEFAULT 0 NOT NULL, + member_relations_vector bytea, + is_chat_relay smallint DEFAULT 0 NOT NULL ); @@ -805,7 +908,8 @@ CREATE TABLE test_chat_schema.groups ( request_shared_msg_id bytea, conn_link_prepared_connection smallint DEFAULT 0 NOT NULL, via_group_link_uri bytea, - summary_current_members_count bigint DEFAULT 0 NOT NULL + summary_current_members_count bigint DEFAULT 0 NOT NULL, + member_index bigint DEFAULT 0 NOT NULL ); @@ -1273,7 +1377,8 @@ CREATE TABLE test_chat_schema.users ( user_member_profile_updated_at timestamp with time zone, ui_themes text, active_order bigint DEFAULT 0 NOT NULL, - auto_accept_member_contacts smallint DEFAULT 0 NOT NULL + auto_accept_member_contacts smallint DEFAULT 0 NOT NULL, + is_user_chat_relay smallint DEFAULT 0 NOT NULL ); @@ -1357,6 +1462,21 @@ ALTER TABLE ONLY test_chat_schema.chat_items +ALTER TABLE ONLY test_chat_schema.chat_relays + ADD CONSTRAINT chat_relays_pkey PRIMARY KEY (chat_relay_id); + + + +ALTER TABLE ONLY test_chat_schema.chat_relays + ADD CONSTRAINT chat_relays_user_id_address_key UNIQUE (user_id, address); + + + +ALTER TABLE ONLY test_chat_schema.chat_relays + ADD CONSTRAINT chat_relays_user_id_name_key UNIQUE (user_id, name); + + + ALTER TABLE ONLY test_chat_schema.chat_tags ADD CONSTRAINT chat_tags_pkey PRIMARY KEY (chat_tag_id); @@ -1837,6 +1957,10 @@ CREATE INDEX idx_chat_items_user_id_item_status ON test_chat_schema.chat_items U +CREATE INDEX idx_chat_relays_user_id ON test_chat_schema.chat_relays USING btree (user_id); + + + CREATE INDEX idx_chat_tags_chats_chat_tag_id ON test_chat_schema.chat_tags_chats USING btree (chat_tag_id); @@ -2081,6 +2205,10 @@ CREATE INDEX idx_group_members_group_id ON test_chat_schema.group_members USING +CREATE UNIQUE INDEX idx_group_members_group_id_index_in_group ON test_chat_schema.group_members USING btree (group_id, index_in_group); + + + CREATE INDEX idx_group_members_invited_by ON test_chat_schema.group_members USING btree (invited_by); @@ -2463,6 +2591,11 @@ ALTER TABLE ONLY test_chat_schema.chat_items +ALTER TABLE ONLY test_chat_schema.chat_relays + ADD CONSTRAINT chat_relays_user_id_fkey FOREIGN KEY (user_id) REFERENCES test_chat_schema.users(user_id) ON DELETE CASCADE; + + + ALTER TABLE ONLY test_chat_schema.chat_tags_chats ADD CONSTRAINT chat_tags_chats_chat_tag_id_fkey FOREIGN KEY (chat_tag_id) REFERENCES test_chat_schema.chat_tags(chat_tag_id) ON DELETE CASCADE; diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 2a67c66696..5eadd086c5 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -144,7 +144,9 @@ import Simplex.Chat.Store.SQLite.Migrations.M20250919_group_summary import Simplex.Chat.Store.SQLite.Migrations.M20250922_remove_unused_connections import Simplex.Chat.Store.SQLite.Migrations.M20251007_connections_sync import Simplex.Chat.Store.SQLite.Migrations.M20251017_chat_tags_cascade -import Simplex.Chat.Store.SQLite.Migrations.M20251018_chat_relays +import Simplex.Chat.Store.SQLite.Migrations.M20251117_member_relations_vector +-- import Simplex.Chat.Store.SQLite.Migrations.M20251128_member_relations_vector_stage_2 +import Simplex.Chat.Store.SQLite.Migrations.M20251212_chat_relays import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -289,7 +291,9 @@ schemaMigrations = ("20250922_remove_unused_connections", m20250922_remove_unused_connections, Just down_m20250922_remove_unused_connections), ("20251007_connections_sync", m20251007_connections_sync, Just down_m20251007_connections_sync), ("20251017_chat_tags_cascade", m20251017_chat_tags_cascade, Just down_m20251017_chat_tags_cascade), - ("20251018_chat_relays", m20251018_chat_relays, Just down_m20251018_chat_relays) + ("20251117_member_relations_vector", m20251117_member_relations_vector, Just down_m20251117_member_relations_vector), + -- ("20251128_member_relations_vector_stage_2", m20251128_member_relations_vector_stage_2, Just down_m20251128_member_relations_vector_stage_2) + ("20251212_chat_relays", m20251212_chat_relays, Just down_m20251212_chat_relays) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20251117_member_relations_vector.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20251117_member_relations_vector.hs new file mode 100644 index 0000000000..3e4b4157f0 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20251117_member_relations_vector.hs @@ -0,0 +1,141 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20251117_member_relations_vector where + +import qualified Data.ByteString as B +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) +import Database.SQLite3 (funcArgBlob, funcArgInt64, funcArgText, funcResultBlob) +import Database.SQLite3.Bindings +import Foreign.C.Types +import Foreign.Ptr +import Simplex.Chat.Types.MemberRelations (IntroductionDirection (..), MemberRelation (..), fromIntroDirInt, fromRelationInt, setNewRelation, setNewRelations) +import Simplex.Messaging.Agent.Store.SQLite.Util (SQLiteFunc, SQLiteFuncFinal, mkSQLiteAggFinal, mkSQLiteAggStep, mkSQLiteFunc) + +-- This module defines custom aggregate function migrate_relations_vector(idx, direction, intro_status). +-- It is passed via DBOpts and registered on DB open. +-- Used in live migration and stage 2 migration (M20251128_member_relations_vector_stage_2). +-- +-- Vector byte encoding: 4 reserved | 1 direction | 3 status +-- Direction: 0 = IDSubjectIntroduced, 1 = IDReferencedIntroduced +-- Status values: 0 = MRNew, 1 = MRIntroduced, 2 = MRSubjectConnected, 3 = MRReferencedConnected, 4 = MRConnected +-- +-- The aggregate transforms intro_status into relation status: +-- - intro_status 'new'/'sent'/'rcv'/'fwd': MRIntroduced (1) +-- - intro_status 're-con': if direction=0 then MRSubjectConnected (2), else MRReferencedConnected (3) +-- - intro_status 'to-con': if direction=0 then MRReferencedConnected (3), else MRSubjectConnected (2) +-- - intro_status 'con': MRConnected (4) +-- +-- The final function builds the vector using setNewRelations. + +foreign export ccall "simplex_member_relations_step" sqliteMemberRelationsStep :: SQLiteFunc + +foreign import ccall "&simplex_member_relations_step" sqliteMemberRelationsStepPtr :: FunPtr SQLiteFunc + +foreign export ccall "simplex_member_relations_final" sqliteMemberRelationsFinal :: SQLiteFuncFinal + +foreign import ccall "&simplex_member_relations_final" sqliteMemberRelationsFinalPtr :: FunPtr SQLiteFuncFinal + +-- Step function for migrate_relations_vector aggregate. +-- Accumulates (idx, direction, relation) tuples. +sqliteMemberRelationsStep :: SQLiteFunc +sqliteMemberRelationsStep = mkSQLiteAggStep [] $ \_ args acc -> do + idx <- funcArgInt64 args 0 + direction <- fromIntroDirInt . fromIntegral <$> funcArgInt64 args 1 + introStatus <- funcArgText args 2 + let relation = introStatusToRelation direction introStatus + pure $ (idx, (direction, relation)) : acc + where + introStatusToRelation dir status = case status of + "re-con" -> if dir == IDSubjectIntroduced then MRSubjectConnected else MRReferencedConnected + "to-con" -> if dir == IDSubjectIntroduced then MRReferencedConnected else MRSubjectConnected + "con" -> MRConnected + _ -> MRIntroduced -- 'new', 'sent', 'rcv', 'fwd' + +-- Final function for migrate_relations_vector aggregate. +-- Builds the vector from accumulated tuples using setNewRelations. +sqliteMemberRelationsFinal :: SQLiteFuncFinal +sqliteMemberRelationsFinal = mkSQLiteAggFinal [] $ \cxt acc -> funcResultBlob cxt $ setNewRelations acc B.empty + +-- Non-aggregate function set_member_vector_new_relation(vector, idx, direction, status). +-- Sets a new relation in the vector and returns the updated vector. + +foreign export ccall "simplex_set_member_vector_new_relation" sqliteSetMemberVectorNewRelation :: SQLiteFunc + +foreign import ccall "&simplex_set_member_vector_new_relation" sqliteSetMemberVectorNewRelationPtr :: FunPtr SQLiteFunc + +sqliteSetMemberVectorNewRelation :: SQLiteFunc +sqliteSetMemberVectorNewRelation = mkSQLiteFunc $ \cxt args -> do + v <- funcArgBlob args 0 + idx <- funcArgInt64 args 1 + direction <- fromIntroDirInt . fromIntegral <$> funcArgInt64 args 2 + status <- fromRelationInt . fromIntegral <$> funcArgInt64 args 3 + funcResultBlob cxt $ setNewRelation idx direction status v + +m20251117_member_relations_vector :: Query +m20251117_member_relations_vector = + [sql| +ALTER TABLE group_members ADD COLUMN index_in_group INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE groups ADD COLUMN member_index INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE group_members ADD COLUMN member_relations_vector BLOB; + +CREATE INDEX tmp_idx_group_members_group_id_group_member_id ON group_members(group_id, group_member_id); + +CREATE TEMPORARY TABLE tmp_members_indexed AS +SELECT + group_member_id, + ROW_NUMBER() OVER ( + PARTITION BY group_id + ORDER BY group_member_id ASC + ) - 1 AS idx_in_group +FROM group_members; + +CREATE INDEX tmp_idx_members_indexed ON tmp_members_indexed(group_member_id); + +UPDATE group_members AS gm +SET index_in_group = ( + SELECT idx_in_group + FROM tmp_members_indexed + WHERE tmp_members_indexed.group_member_id = gm.group_member_id +); + +DROP INDEX tmp_idx_group_members_group_id_group_member_id; +DROP INDEX tmp_idx_members_indexed; +DROP TABLE tmp_members_indexed; + +CREATE UNIQUE INDEX idx_group_members_group_id_index_in_group ON group_members(group_id, index_in_group); + +UPDATE groups AS g +SET member_index = COALESCE(( + SELECT MAX(index_in_group) + 1 + FROM group_members + WHERE group_members.group_id = g.group_id +), 0); + +UPDATE group_members +SET member_relations_vector = x'' +WHERE group_id IN ( + SELECT mu.group_id + FROM group_members mu + WHERE mu.member_category = 'user' + AND ( + mu.member_role NOT IN (CAST('admin' AS BLOB), CAST('owner' AS BLOB)) + OR mu.member_status IN ('removed', 'left', 'deleted') + ) +); +|] + +down_m20251117_member_relations_vector :: Query +down_m20251117_member_relations_vector = + [sql| +DROP INDEX idx_group_members_group_id_index_in_group; + +ALTER TABLE group_members DROP COLUMN index_in_group; + +ALTER TABLE groups DROP COLUMN member_index; + +ALTER TABLE group_members DROP COLUMN member_relations_vector; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20251128_member_relations_vector_stage_2.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20251128_member_relations_vector_stage_2.hs new file mode 100644 index 0000000000..f510a50410 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20251128_member_relations_vector_stage_2.hs @@ -0,0 +1,42 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20251128_member_relations_vector_stage_2 where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +-- Build member_relations_vector for all members that don't have it yet. +-- Uses custom aggregate function migrate_relations_vector defined in M20251117_member_relations_vector. +-- +-- Query returns (idx, direction, intro_status) for each introduction: +-- - direction 0 (IDSubjectIntroduced): current member (subject) is re_group_member_id, was introduced to referenced member +-- - direction 1 (IDReferencedIntroduced): current member (subject) is to_group_member_id, referenced member was introduced to it + +-- TODO [relations vector] drop group_member_intros in the end of migration +m20251128_member_relations_vector_stage_2 :: Query +m20251128_member_relations_vector_stage_2 = + [sql| +UPDATE group_members +SET member_relations_vector = ( + SELECT migrate_relations_vector(idx, direction, intro_status) + FROM ( + SELECT m.index_in_group AS idx, 0 AS direction, i.intro_status + FROM group_member_intros i + JOIN group_members m ON m.group_member_id = i.to_group_member_id + WHERE i.re_group_member_id = group_members.group_member_id + UNION ALL + SELECT m.index_in_group AS idx, 1 AS direction, i.intro_status + FROM group_member_intros i + JOIN group_members m ON m.group_member_id = i.re_group_member_id + WHERE i.to_group_member_id = group_members.group_member_id + ) +) +WHERE member_relations_vector IS NULL; +|] + +-- TODO [relations vector] re-create group_member_intros +down_m20251128_member_relations_vector_stage_2 :: Query +down_m20251128_member_relations_vector_stage_2 = + [sql| + +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20251018_chat_relays.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20251212_chat_relays.hs similarity index 84% rename from src/Simplex/Chat/Store/SQLite/Migrations/M20251018_chat_relays.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20251212_chat_relays.hs index b1d092e89f..bc0241da45 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20251018_chat_relays.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20251212_chat_relays.hs @@ -1,12 +1,12 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Store.SQLite.Migrations.M20251018_chat_relays where +module Simplex.Chat.Store.SQLite.Migrations.M20251212_chat_relays where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) -m20251018_chat_relays :: Query -m20251018_chat_relays = +m20251212_chat_relays :: Query +m20251212_chat_relays = [sql| CREATE TABLE chat_relays( chat_relay_id INTEGER PRIMARY KEY, @@ -30,8 +30,8 @@ ALTER TABLE users ADD COLUMN is_user_chat_relay INTEGER NOT NULL DEFAULT 0; ALTER TABLE group_members ADD COLUMN is_chat_relay INTEGER NOT NULL DEFAULT 0; |] -down_m20251018_chat_relays :: Query -down_m20251018_chat_relays = +down_m20251212_chat_relays :: Query +down_m20251212_chat_relays = [sql| ALTER TABLE group_members DROP COLUMN is_chat_relay; diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index df158b4a5c..c3a1954220 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -22,6 +22,40 @@ Query: Plan: SEARCH contact_requests USING INTEGER PRIMARY KEY (rowid=?) +Query: + INSERT INTO group_members + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH received_probes USING COVERING INDEX idx_received_probes_group_member_id (group_member_id=?) +SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_group_member_id (group_member_id=?) +SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_member_id=?) +SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) +SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_member_id=?) +SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) +SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) +SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) + Query: UPDATE groups SET chat_ts = ?, @@ -42,10 +76,10 @@ SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) Query: INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, sent_inv_queue_info, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) @@ -113,14 +147,14 @@ Query: g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, -- GroupInfo {membership} - mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, + mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.is_chat_relay, mu.invited_by, mu.invited_by_group_member_id, 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.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, -- from GroupMember - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts @@ -224,18 +258,11 @@ Plan: SEARCH contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_1 (user_id=? AND local_display_name=?) SEARCH users USING INTEGER PRIMARY KEY (rowid=?) -Query: - INSERT INTO group_member_intros - (re_group_member_id, to_group_member_id, intro_status, intro_chat_protocol_version, created_at, updated_at) - VALUES (?,?,?,?,?,?) - -Plan: - Query: INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) @@ -266,10 +293,44 @@ SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (conta Query: INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, sent_inv_queue_info, created_at, updated_at, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, member_profile_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH received_probes USING COVERING INDEX idx_received_probes_group_member_id (group_member_id=?) +SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_group_member_id (group_member_id=?) +SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_member_id=?) +SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) +SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_member_id=?) +SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) +SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) +SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) + +Query: + INSERT INTO group_members + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, member_xcontact_id, member_welcome_shared_msg_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) @@ -431,9 +492,9 @@ SEARCH users USING COVERING INDEX sqlite_autoindex_users_1 (contact_id=?) Query: INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) @@ -464,42 +525,8 @@ SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (conta Query: INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, member_profile_id, created_at, updated_at, - peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) - -Plan: -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) -SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) -SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) -SEARCH received_probes USING COVERING INDEX idx_received_probes_group_member_id (group_member_id=?) -SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_group_member_id (group_member_id=?) -SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_member_id=?) -SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) -SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) -SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_member_id=?) -SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) -SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) -SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) -SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) -SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) -SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) -SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) -SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) -SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) -SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) -SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) - -Query: - INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, member_xcontact_id, member_welcome_shared_msg_id, created_at, updated_at, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) @@ -844,7 +871,7 @@ SEARCH s USING COVERING INDEX idx_group_snd_item_statuses_chat_item_id_group_mem Query: SELECT i.chat_item_id, -- GroupMember - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, @@ -972,10 +999,11 @@ Plan: Query: INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, - peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + (group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, + member_restriction, is_chat_relay, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + peer_chat_min_version, peer_chat_max_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) @@ -1072,7 +1100,7 @@ Query: -- CIMeta forwardedByMember, showGroupAsSender i.forwarded_by_group_member_id, i.show_group_as_sender, -- GroupMember - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, @@ -1080,13 +1108,13 @@ Query: -- 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.peer_chat_min_version, rm.peer_chat_max_version, rm.member_role, rm.member_category, + rm.group_member_id, rm.group_id, rm.index_in_group, rm.member_id, rm.peer_chat_min_version, rm.peer_chat_max_version, rm.member_role, rm.member_category, rm.member_status, rm.show_messages, rm.member_restriction, rm.is_chat_relay, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, rp.display_name, rp.full_name, rp.short_descr, rp.image, rp.contact_link, rp.chat_peer_type, rp.local_alias, rp.preferences, rm.created_at, rm.updated_at, rm.support_chat_ts, rm.support_chat_items_unread, rm.support_chat_items_member_attention, rm.support_chat_items_mentions, rm.support_chat_last_msg_from_member_ts, -- deleted by GroupMember - dbm.group_member_id, dbm.group_id, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, + dbm.group_member_id, dbm.group_id, dbm.index_in_group, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, dbm.member_status, dbm.show_messages, dbm.member_restriction, dbm.is_chat_relay, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, dbp.display_name, dbp.full_name, dbp.short_descr, dbp.image, dbp.contact_link, dbp.chat_peer_type, dbp.local_alias, dbp.preferences, dbm.created_at, dbm.updated_at, @@ -1165,24 +1193,6 @@ SEARCH c USING INDEX idx_connections_via_contact_uri_hash (user_id=? AND via_con SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) SEARCH cp USING INTEGER PRIMARY KEY (rowid=?) -Query: - SELECT 1 - FROM group_member_intros - WHERE - ( - (re_group_member_id = ? AND to_group_member_id = ?) OR - (re_group_member_id = ? AND to_group_member_id = ?) - ) - AND intro_status NOT IN (?,?,?) - LIMIT 1 - -Plan: -MULTI-INDEX OR -INDEX 1 -SEARCH group_member_intros USING INDEX sqlite_autoindex_group_member_intros_1 (re_group_member_id=? AND to_group_member_id=?) -INDEX 2 -SEARCH group_member_intros USING INDEX sqlite_autoindex_group_member_intros_1 (re_group_member_id=? AND to_group_member_id=?) - Query: SELECT 1 FROM users WHERE (user_id = ? AND local_display_name = ?) @@ -1429,28 +1439,6 @@ Plan: SEARCH g USING INTEGER PRIMARY KEY (rowid=?) SEARCH i USING INTEGER PRIMARY KEY (rowid=?) -Query: - SELECT i.re_group_member_id - FROM group_member_intros i - JOIN group_members m ON m.group_member_id = i.re_group_member_id - WHERE i.to_group_member_id = ? AND i.intro_status NOT IN (?,?,?) - AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?,?) - -Plan: -SEARCH i USING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) -SEARCH m USING INTEGER PRIMARY KEY (rowid=?) - -Query: - SELECT i.to_group_member_id - FROM group_member_intros i - JOIN group_members m ON m.group_member_id = i.to_group_member_id - WHERE i.re_group_member_id = ? AND i.intro_status NOT IN (?,?,?) - AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?,?) - -Plan: -SEARCH i USING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) -SEARCH m USING INTEGER PRIMARY KEY (rowid=?) - Query: SELECT member_status FROM group_members @@ -1619,44 +1607,10 @@ SEARCH users USING COVERING INDEX sqlite_autoindex_users_1 (contact_id=?) Query: INSERT INTO group_members - ( group_id, member_id, member_role, member_category, member_status, invited_by, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) - -Plan: -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) -SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) -SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) -SEARCH received_probes USING COVERING INDEX idx_received_probes_group_member_id (group_member_id=?) -SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_group_member_id (group_member_id=?) -SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_member_id=?) -SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) -SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) -SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_member_id=?) -SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) -SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) -SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) -SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) -SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) -SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) -SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) -SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) -SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) -SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) -SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) - -Query: - INSERT INTO group_members - (group_id, member_id, member_role, member_category, member_status, member_restriction, is_chat_relay, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, - peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) @@ -3525,6 +3479,14 @@ Plan: SEARCH f USING INTEGER PRIMARY KEY (rowid=?) SEARCH i USING INTEGER PRIMARY KEY (rowid=?) +Query: + SELECT index_in_group, member_relations_vector + FROM group_members + WHERE local_display_name = ? + +Plan: +SCAN group_members + Query: SELECT m.group_member_id FROM group_members m @@ -3575,14 +3537,6 @@ SEARCH r USING INDEX idx_chat_item_mentions_chat_item_id (chat_item_id=?) SEARCH m USING INDEX sqlite_autoindex_group_members_1 (group_id=? AND member_id=?) LEFT-JOIN SEARCH p USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN -Query: - SELECT re_group_member_id - FROM group_member_intros - WHERE to_group_member_id = ? AND intro_status NOT IN (?,?,?) - AND intro_chat_protocol_version >= ? -Plan: -SEARCH group_member_intros USING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) - Query: SELECT reaction FROM chat_item_reactions @@ -3671,14 +3625,6 @@ Query: Plan: SEARCH protocol_servers USING INDEX idx_smp_servers_user_id (user_id=?) -Query: - SELECT to_group_member_id - FROM group_member_intros - WHERE re_group_member_id = ? AND intro_status NOT IN (?,?,?) - AND intro_chat_protocol_version >= ? -Plan: -SEARCH group_member_intros USING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) - Query: SELECT usage_conditions_id, conditions_commit, notified_at, created_at FROM usage_conditions @@ -3798,6 +3744,40 @@ SCAN group_members USING COVERING INDEX idx_group_members_user_id_local_display_ LIST SUBQUERY 2 SCAN group_members USING COVERING INDEX idx_group_members_user_id_local_display_name +Query: + UPDATE group_members + SET + member_relations_vector = ( + SELECT migrate_relations_vector(idx, direction, intro_status) + FROM ( + SELECT m.index_in_group AS idx, 0 AS direction, i.intro_status + FROM group_member_intros i + JOIN group_members m ON m.group_member_id = i.to_group_member_id + WHERE i.re_group_member_id = group_members.group_member_id + UNION ALL + SELECT m.index_in_group AS idx, 1 AS direction, i.intro_status + FROM group_member_intros i + JOIN group_members m ON m.group_member_id = i.re_group_member_id + WHERE i.to_group_member_id = group_members.group_member_id + ) AS relations + ), + updated_at = ? + WHERE group_member_id = ? + AND member_relations_vector IS NULL + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +CORRELATED SCALAR SUBQUERY 3 +CO-ROUTINE relations +COMPOUND QUERY +LEFT-MOST SUBQUERY +SEARCH i USING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) +UNION ALL +SEARCH i USING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) +SCAN relations + Query: UPDATE group_members SET contact_id = ?, local_display_name = ?, contact_profile_id = ?, updated_at = ? @@ -3806,6 +3786,23 @@ Query: Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE group_members + SET member_relations_vector = ? + WHERE local_display_name = ? + +Plan: +SCAN group_members + +Query: + UPDATE groups + SET member_index = member_index + 1 + WHERE group_id = ? + RETURNING member_index - 1 + +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE groups SET via_group_link_uri = ?, via_group_link_uri_hash = ? @@ -4476,7 +4473,7 @@ Query: SELECT contact_profile_id, member_profile_id, local_display_name FROM group_members WHERE group_id = ? Plan: -SEARCH group_members USING INDEX sqlite_autoindex_group_members_1 (group_id=?) +SEARCH group_members USING INDEX idx_group_members_group_id_index_in_group (group_id=?) Query: SELECT DISTINCT group_id, worker_scope @@ -4746,12 +4743,12 @@ Plan: SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) Query: - UPDATE group_member_intros - SET intro_status = ?, updated_at = ? - WHERE group_member_intro_id = ? + UPDATE group_members + SET member_relations_vector = ?, updated_at = ? + WHERE group_member_id = ? Plan: -SEARCH group_member_intros USING INTEGER PRIMARY KEY (rowid=?) +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE group_members @@ -4986,7 +4983,7 @@ Query: g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, -- GroupMember - membership - mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, + mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.is_chat_relay, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, @@ -5020,7 +5017,7 @@ Query: g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, -- GroupMember - membership - mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, + mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.is_chat_relay, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, @@ -5047,7 +5044,7 @@ Query: g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, -- GroupMember - membership - mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, + mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.is_chat_relay, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, @@ -5096,7 +5093,7 @@ SEARCH p USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, @@ -5123,7 +5120,7 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, @@ -5142,7 +5139,45 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, + 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.xcontact_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.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, 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) + LEFT JOIN connections c ON c.group_member_id = m.group_member_id + WHERE m.group_id = ? AND m.index_in_group = ? +Plan: +SEARCH m USING INDEX idx_group_members_group_id_index_in_group (group_id=? AND index_in_group=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN + +Query: + SELECT + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, + 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.xcontact_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.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, 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) + LEFT JOIN connections c ON c.group_member_id = m.group_member_id + WHERE m.group_id = ? AND m.index_in_group = ? AND (m.member_role IN (?,?,?) OR m.group_member_id = ?) +Plan: +SEARCH m USING INDEX idx_group_members_group_id_index_in_group (group_id=? AND index_in_group=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN + +Query: + SELECT + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, @@ -5155,13 +5190,13 @@ Query: LEFT JOIN connections c ON c.group_member_id = m.group_member_id WHERE m.group_id = ? AND m.member_category = ? Plan: -SEARCH m USING INDEX sqlite_autoindex_group_members_1 (group_id=?) +SEARCH m USING INDEX idx_group_members_group_id_index_in_group (group_id=?) SEARCH p USING INTEGER PRIMARY KEY (rowid=?) SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN Query: SELECT - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, @@ -5180,7 +5215,7 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, @@ -5199,7 +5234,7 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, @@ -5218,7 +5253,7 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, @@ -5843,7 +5878,7 @@ SEARCH messages USING COVERING INDEX idx_messages_group_id (group_id=?) SEARCH contact_requests USING COVERING INDEX idx_contact_requests_business_group_id (business_group_id=?) SEARCH user_contact_links USING COVERING INDEX idx_user_contact_links_group_id (group_id=?) SEARCH files USING COVERING INDEX idx_files_group_id (group_id=?) -SEARCH group_members USING COVERING INDEX sqlite_autoindex_group_members_1 (group_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_group_id_index_in_group (group_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_id (grp_direct_inv_from_group_id=?) Query: DELETE FROM messages WHERE connection_id = ? @@ -6076,9 +6111,9 @@ Plan: Query: INSERT INTO xftp_file_descriptions (user_id, file_descr_text, file_descr_part_no, file_descr_complete, created_at, updated_at) VALUES (?,?,?,?,?,?) Plan: -Query: SELECT 1 FROM group_member_intros WHERE re_group_member_id = ? AND to_group_member_id = ? LIMIT 1 +Query: SELECT 1 FROM group_members WHERE group_member_id = ? AND member_relations_vector IS NOT NULL Plan: -SEARCH group_member_intros USING COVERING INDEX sqlite_autoindex_group_member_intros_1 (re_group_member_id=? AND to_group_member_id=?) +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT 1 FROM settings WHERE user_id = ? LIMIT 1 Plan: @@ -6100,7 +6135,7 @@ Query: SELECT COUNT(1) FROM groups WHERE user_id = ? AND chat_item_ttl > 0 Plan: SEARCH groups USING INDEX idx_groups_chat_ts (user_id=?) -Query: SELECT COUNT(1), COALESCE(SUM(user_mention), 0) FROM chat_items WHERE user_id = ? AND group_id = ? AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL AND item_status = ? +Query: SELECT COUNT(1), COALESCE(SUM(user_mention), 0) FROM chat_items WHERE user_id = ? AND group_id = ? AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL AND item_status = ? Plan: SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_stats_all (user_id=? AND group_id=? AND group_scope_tag=? AND group_scope_group_member_id=? AND item_status=?) @@ -6110,6 +6145,12 @@ SCAN CONSTANT ROW SCALAR SUBQUERY 1 SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) +Query: SELECT EXISTS (SELECT 1 FROM group_members WHERE member_relations_vector IS NULL LIMIT 1) +Plan: +SCAN CONSTANT ROW +SCALAR SUBQUERY 1 +SCAN group_members + Query: SELECT accepted_at FROM operator_usage_conditions WHERE server_operator_id = ? AND conditions_commit = ? Plan: SEARCH operator_usage_conditions USING INDEX idx_operator_usage_conditions_conditions_commit (conditions_commit=? AND server_operator_id=?) @@ -6278,6 +6319,14 @@ Query: SELECT max(active_order) FROM users Plan: SEARCH users +Query: SELECT member_relations_vector FROM group_members WHERE group_member_id = ? +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: SELECT member_relations_vector FROM group_members WHERE group_member_id = ? AND member_relations_vector IS NOT NULL +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + Query: SELECT member_xcontact_id, member_welcome_shared_msg_id FROM group_members WHERE user_id = ? AND group_id = ? AND group_member_id = ? Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) @@ -6294,10 +6343,6 @@ Query: SELECT quota_err_counter FROM connections WHERE user_id = ? AND connectio Plan: SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) -Query: SELECT re_group_member_id FROM group_member_intros WHERE to_group_member_id = ? -Plan: -SEARCH group_member_intros USING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) - Query: SELECT sent_inv_queue_info FROM group_members WHERE group_member_id = ? AND user_id = ? Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) @@ -6518,6 +6563,10 @@ Query: UPDATE group_members SET member_profile_id = ?, updated_at = ? WHERE grou Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE group_members SET member_relations_vector = set_member_vector_new_relation(member_relations_vector, ?, ?, ?), updated_at = ? WHERE group_member_id = ? +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE group_members SET member_role = ? WHERE user_id = ? AND group_member_id = ? Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index c47fd1a556..d9586f1a6c 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -156,7 +156,8 @@ CREATE TABLE groups( request_shared_msg_id BLOB, conn_link_prepared_connection INTEGER NOT NULL DEFAULT 0, via_group_link_uri BLOB, - summary_current_members_count INTEGER NOT NULL DEFAULT 0, -- received + summary_current_members_count INTEGER NOT NULL DEFAULT 0, + member_index INTEGER NOT NULL DEFAULT 0, -- received FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE @@ -196,6 +197,8 @@ CREATE TABLE group_members( support_chat_last_msg_from_member_ts TEXT, member_xcontact_id BLOB, member_welcome_shared_msg_id BLOB, + index_in_group INTEGER NOT NULL DEFAULT 0, + member_relations_vector BLOB, is_chat_relay INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) @@ -1200,6 +1203,10 @@ CREATE INDEX idx_connections_to_subscribe ON connections( user_id, to_subscribe ); +CREATE UNIQUE INDEX idx_group_members_group_id_index_in_group ON group_members( + group_id, + index_in_group +); CREATE INDEX idx_chat_relays_user_id ON chat_relays(user_id); CREATE TRIGGER on_group_members_insert_update_summary AFTER INSERT ON group_members diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 7fb3f72951..fdd54dc741 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -93,9 +93,12 @@ data StoreError | SEGroupNotFoundByName {groupName :: GroupName} | SEGroupMemberNameNotFound {groupId :: GroupId, groupMemberName :: ContactName} | SEGroupMemberNotFound {groupMemberId :: GroupMemberId} + | SEGroupMemberNotFoundByIndex {groupMemberIndex :: Int64} + | SEMemberRelationsVectorNotFound {groupMemberId :: GroupMemberId} | SEGroupHostMemberNotFound {groupId :: GroupId} | SEGroupMemberNotFoundByMemberId {memberId :: MemberId} | SEMemberContactGroupMemberNotFound {contactId :: ContactId} + | SEInvalidMemberRelationUpdate | SEGroupWithoutUser | SEDuplicateGroupMember | SEGroupAlreadyJoined @@ -119,7 +122,6 @@ data StoreError | SEConnectionNotFoundById {connId :: Int64} | SEConnectionNotFoundByMemberId {groupMemberId :: GroupMemberId} | SEPendingConnectionNotFound {connId :: Int64} - | SEIntroNotFound | SEUniqueID | SELargeMsg | SEInternalError {message :: String} @@ -656,7 +658,7 @@ type BusinessChatInfoRow = (Maybe BusinessChatType, Maybe MemberId, Maybe Member type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Text, Maybe Text, Maybe ImageData) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences, Maybe GroupMemberAdmission) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. PreparedGroupRow :. BusinessChatInfoRow :. (Maybe UIThemeEntityOverrides, Int64, Maybe CustomData, Maybe Int64, Int, Maybe ConnReqContact) :. GroupMemberRow -type GroupMemberRow = (Int64, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, BoolInt, Maybe MemberRestrictionStatus, BoolInt) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId) :. ProfileRow :. (UTCTime, UTCTime) :. (Maybe UTCTime, Int64, Int64, Int64, Maybe UTCTime) +type GroupMemberRow = (GroupMemberId, GroupId, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, BoolInt, Maybe MemberRestrictionStatus, BoolInt) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId) :. ProfileRow :. (UTCTime, UTCTime) :. (Maybe UTCTime, Int64, Int64, Int64, Maybe UTCTime) type ProfileRow = (ProfileId, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, Maybe Preferences) @@ -678,7 +680,7 @@ toPreparedGroup = \case _ -> Nothing toGroupMember :: Int64 -> GroupMemberRow -> GroupMember -toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, BI showMessages, memberRestriction_, BI isCRelay) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. profileRow :. (createdAt, updatedAt) :. (supportChatTs_, supportChatUnread, supportChatMemberAttention, supportChatMentions, supportChatLastMsgFromMemberTs)) = +toGroupMember userContactId ((groupMemberId, groupId, indexInGroup, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, BI showMessages, memberRestriction_, BI isCRelay) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. profileRow :. (createdAt, updatedAt) :. (supportChatTs_, supportChatUnread, supportChatMemberAttention, supportChatMentions, supportChatLastMsgFromMemberTs)) = let memberProfile = rowToLocalProfile profileRow memberSettings = GroupMemberSettings {showMessages} blockedByAdmin = maybe False mrsBlocked memberRestriction_ @@ -703,7 +705,7 @@ groupMemberQuery :: Query groupMemberQuery = [sql| SELECT - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, @@ -743,7 +745,7 @@ groupInfoQueryFields = g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, -- GroupMember - membership - mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, + mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.is_chat_relay, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 4cffd7ca4b..e449a94d97 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -924,6 +924,7 @@ type GroupMemberId = Int64 data GroupMember = GroupMember { groupMemberId :: GroupMemberId, groupId :: GroupId, + indexInGroup :: Int64, memberId :: MemberId, memberRole :: GroupMemberRole, memberCategory :: GroupMemberCategory, diff --git a/src/Simplex/Chat/Types/MemberRelations.hs b/src/Simplex/Chat/Types/MemberRelations.hs new file mode 100644 index 0000000000..6e0eb379d7 --- /dev/null +++ b/src/Simplex/Chat/Types/MemberRelations.hs @@ -0,0 +1,158 @@ +{-# LANGUAGE LambdaCase #-} + +module Simplex.Chat.Types.MemberRelations + ( IntroductionDirection (..), + MemberRelation (..), + toIntroDirInt, + fromIntroDirInt, + toRelationInt, + fromRelationInt, + getRelation, + getRelation', + getRelationsIndexes, + setRelation, + setRelations, + setRelationConnected, + setNewRelation, + setNewRelations, + ) +where + +import Control.Monad +import Data.Bits (shiftL, shiftR, (.&.), (.|.), complement) +import Data.ByteString (ByteString) +import qualified Data.ByteString as B +import Data.ByteString.Internal (toForeignPtr, unsafeCreate) +import Data.Int (Int64) +import Data.Word (Word8) +import Foreign.ForeignPtr (withForeignPtr) +import Foreign.Marshal.Utils (copyBytes, fillBytes) +import Foreign.Ptr (plusPtr) +import Foreign.Storable (peekByteOff, pokeByteOff) + +data IntroductionDirection + = IDSubjectIntroduced -- Member described by vector (subject member, vector "owner") is introduced to member referenced in vector + | IDReferencedIntroduced -- Member referenced in vector is introduced to subject member + deriving (Eq, Show) + +toIntroDirInt :: IntroductionDirection -> Word8 +toIntroDirInt = \case + IDSubjectIntroduced -> 0 + IDReferencedIntroduced -> 1 + +fromIntroDirInt :: Word8 -> IntroductionDirection +fromIntroDirInt = \case + 0 -> IDSubjectIntroduced + 1 -> IDReferencedIntroduced + _ -> IDSubjectIntroduced + +data MemberRelation + = MRNew + | MRIntroduced + | MRSubjectConnected -- Subject member notified about connection to referenced member + | MRReferencedConnected -- Referenced member notified about connection to subject member + | MRConnected -- Both members notified about connection + deriving (Eq, Ord, Show) + +toRelationInt :: MemberRelation -> Word8 +toRelationInt = \case + MRNew -> 0 + MRIntroduced -> 1 + MRSubjectConnected -> 2 + MRReferencedConnected -> 3 + MRConnected -> 4 + +fromRelationInt :: Word8 -> MemberRelation +fromRelationInt = \case + 0 -> MRNew + 1 -> MRIntroduced + 2 -> MRSubjectConnected + 3 -> MRReferencedConnected + 4 -> MRConnected + _ -> MRNew + +-- Bit layout: 4 reserved | 1 direction | 3 status + +-- | Get the relation status of a member at a given index from the relations vector. +-- Returns 'MRNew' if the vector is not long enough (lazy initialization). +getRelation :: Int64 -> ByteString -> MemberRelation +getRelation i v = snd $ getRelation' i v + +-- | Get both direction and status of a member at a given index from the relations vector. +-- Returns (IDSubjectIntroduced, MRNew) if the vector is not long enough (lazy initialization). +getRelation' :: Int64 -> ByteString -> (IntroductionDirection, MemberRelation) +getRelation' i v + | i < 0 || fromIntegral i >= B.length v = (IDSubjectIntroduced, MRNew) + | otherwise = + let b = v `B.index` fromIntegral i + in (fromIntroDirInt $ (b .&. directionMask) `shiftR` 3, fromRelationInt $ b .&. statusMask) + +-- | Get the indexes of members with the given relation status from the relations vector. +getRelationsIndexes :: MemberRelation -> ByteString -> [Int64] +getRelationsIndexes r v = [i | i <- [0 .. fromIntegral (B.length v) - 1], getRelation i v == r] + +-- | Set the relation status of a member at a given index in the relations vector. +-- Preserves the introduction direction. Expands the vector lazily if needed. +setRelation :: Int64 -> MemberRelation -> ByteString -> ByteString +setRelation i r v + | i >= 0 = setRelations [(i, r)] v + | otherwise = v + +-- | Set multiple relation statuses at once. +-- Preserves the introduction direction. Expands the vector lazily if needed. +setRelations :: [(Int64, MemberRelation)] -> ByteString -> ByteString +setRelations = setRelations_ $ \r b -> (b .&. complement statusMask) .|. toRelationInt r + +-- | Set relation to connected state based on passed status and current status. +-- newStatus should be MRSubjectConnected or MRReferencedConnected, otherwise returns vector unchanged. +-- Logic: +-- - if newStatus is complementary to oldStatus -> set MRConnected +-- - if newStatus > oldStatus (by enum order) -> set newStatus +-- - otherwise don't update +setRelationConnected :: Int64 -> MemberRelation -> ByteString -> ByteString +setRelationConnected i newStatus v + | newStatus /= MRSubjectConnected && newStatus /= MRReferencedConnected = v + | otherwise = case status' of + Nothing -> v + Just s -> setRelation i s v + where + oldStatus = getRelation i v + status' = case (oldStatus, newStatus) of + -- complementary statuses -> MRConnected + (MRSubjectConnected, MRReferencedConnected) -> Just MRConnected + (MRReferencedConnected, MRSubjectConnected) -> Just MRConnected + -- newStatus > oldStatus -> set newStatus + _ | newStatus > oldStatus -> Just newStatus + | otherwise -> Nothing + +-- | Set a new relation with both direction and status at a given index. +-- Expands the vector lazily if needed. +setNewRelation :: Int64 -> IntroductionDirection -> MemberRelation -> ByteString -> ByteString +setNewRelation i dir r v + | i >= 0 = setNewRelations [(i, (dir, r))] v + | otherwise = v + +-- | Set multiple new relations with both direction and status at once. +-- Expands the vector lazily if needed. +setNewRelations :: [(Int64, (IntroductionDirection, MemberRelation))] -> ByteString -> ByteString +setNewRelations = setRelations_ $ \(dir, r) b -> (b .&. relationMask) .|. (toIntroDirInt dir `shiftL` 3) .|. toRelationInt r + where + relationMask = complement (statusMask .|. directionMask) + +setRelations_ :: (r -> Word8 -> Word8) -> [(Int64, r)] -> ByteString -> ByteString +setRelations_ _ [] v = v +setRelations_ updateByte relations v = + let (fp, off, len) = toForeignPtr v + newLen = max len $ fromIntegral $ maximum (map fst relations) + 1 + in unsafeCreate newLen $ \ptr -> do + withForeignPtr fp $ \vPtr -> copyBytes ptr (vPtr `plusPtr` off) len + when (newLen > len) $ fillBytes (ptr `plusPtr` len) 0 (newLen - len) + forM_ relations $ \(ix, r) -> when (ix >= 0) $ + let i = fromIntegral ix + in pokeByteOff ptr i . updateByte r =<< peekByteOff ptr i + +statusMask :: Word8 +statusMask = 0x07 -- bits 0-2 + +directionMask :: Word8 +directionMask = 0x08 -- bit 3 diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 0dde5f02f9..395a6de634 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -483,6 +483,9 @@ testChat3 = testChatCfgOpts3 testCfg testOpts testChatCfg3 :: HasCallStack => ChatConfig -> Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> TestParams -> IO () testChatCfg3 cfg = testChatCfgOpts3 cfg testOpts +testChatOpts3 :: HasCallStack => ChatOpts -> Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> TestParams -> IO () +testChatOpts3 = testChatCfgOpts3 testCfg + testChatCfgOpts3 :: HasCallStack => ChatConfig -> ChatOpts -> Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> IO ()) -> TestParams -> IO () testChatCfgOpts3 cfg opts p1 p2 p3 test = testChatN cfg opts [p1, p2, p3] test_ where diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index e9168ba469..b0f4fc99dc 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -18,6 +18,7 @@ import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) import Control.Monad (forM_, void, when) import Data.Bifunctor (second) +import Data.Maybe (fromMaybe) import qualified Data.ByteString.Char8 as B import Data.Int (Int64) import Data.List (intercalate, isInfixOf) @@ -30,10 +31,12 @@ import Simplex.Chat.Messages (CIMention (..), CIMentionMember (..), ChatItemId) import Simplex.Chat.Options import Simplex.Chat.Protocol (MsgMention (..), MsgContent (..), msgContentText) import Simplex.Chat.Types +import Simplex.Chat.Types.MemberRelations (MemberRelation (..), setRelation) import Simplex.Chat.Types.Shared (GroupMemberRole (..), GroupAcceptance (..)) import Simplex.Messaging.Agent.Env.SQLite import Simplex.Messaging.Agent.RetryInterval import qualified Simplex.Messaging.Agent.Store.DB as DB +import Simplex.Messaging.Agent.Store.DB (Binary (..)) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Server.Env.STM hiding (subscriptions) import Simplex.Messaging.Transport @@ -87,6 +90,7 @@ chatGroupTests = do it "moderate message that arrives after the event of moderation (full delete)" testGroupDelayedModerationFullDelete it "remove member with messages (full deletion is enabled)" testDeleteMemberWithMessages it "remove member with messages mark deleted" testDeleteMemberMarkMessagesDeleted + it "remove member - delete messages of left/removed members" testDeleteMemberMessagesLeftRemoved describe "batch send messages" $ do it "send multiple messages api" testSendMulti it "send multiple timed messages" testSendMultiTimed @@ -198,6 +202,7 @@ chatGroupTests = do it "member was blocked before joining group" testBlockForAllBeforeJoining it "repeat block, unblock" testBlockForAllRepeat it "block multiple members" testBlockForAllMultipleMembers + it "block left/removed members" testBlockForAllLeftRemoved describe "group member inactivity" $ do it "mark member inactive on reaching quota" testGroupMemberInactive describe "group member reports" $ do @@ -219,11 +224,15 @@ chatGroupTests = do it "should forward file inside support scope" testScopedSupportForwardFile it "should forward member removal in support scope in review (x.grp.mem.del)" testScopedSupportForwardMemberRemoval it "should forward admin removal in support scope in review (x.grp.mem.del, relay forwards it was removed)" testScopedSupportForwardAdminRemoval + it "should forward pending member leaving in support scope in review (x.grp.leave)" testScopedSupportForwardLeave it "should forward group deletion in support scope in review (x.grp.del)" testScopedSupportForwardGroupDeletion it "should send messages to admins and members" testSupportCLISendCommand it "should correctly maintain unread stats for support chats on reading chat items" testScopedSupportUnreadStatsOnRead it "should correctly maintain unread stats for support chats on deleting chat items" testScopedSupportUnreadStatsOnDelete it "should correct member attention stat for support chat on opening it" testScopedSupportUnreadStatsCorrectOnOpen + it "should remove support chat with member when member is removed" testScopedSupportMemberRemoved + it "should remove support chat with member when user removes member" testScopedSupportUserRemovesMember + it "should remove support chat with member when member leaves" testScopedSupportMemberLeaves -- TODO [channels fwd] enable tests (requires communicating useRelays to members) -- TODO [channels fwd] add tests for channels -- TODO - tests with multiple relays (all relays should deliver messages, members should deduplicate) @@ -1767,6 +1776,7 @@ testGroupDelayedModeration ps = do -- and forwarding client doesn't check compatibility) void $ withCCTransaction alice $ \db -> DB.execute_ db "UPDATE group_member_intros SET intro_status='con'" + updateGroupForwardingVectors alice "bob" "cath" MRConnected cath #> "#team hi" -- message is pending for bob alice <# "#team cath> hi" @@ -1813,6 +1823,7 @@ testGroupDelayedModerationFullDelete ps = do -- and forwarding client doesn't check compatibility) void $ withCCTransaction alice $ \db -> DB.execute_ db "UPDATE group_member_intros SET intro_status='con'" + updateGroupForwardingVectors alice "bob" "cath" MRConnected cath #> "#team hi" -- message is pending for bob alice <# "#team cath> hi" @@ -1910,6 +1921,59 @@ testDeleteMemberMarkMessagesDeleted = bob #$> ("/_get chat #1 count=2", chat, [(1, "hello [marked deleted by alice]"), (0, "removed you")]) cath #$> ("/_get chat #1 count=2", chat, [(0, "hello [marked deleted by alice]"), (0, "removed bob")]) +testDeleteMemberMessagesLeftRemoved :: HasCallStack => TestParams -> IO () +testDeleteMemberMessagesLeftRemoved = + testChat4 aliceProfile bobProfile cathProfile danProfile $ + \alice bob cath dan -> do + createGroup4 "team" alice (bob, GRMember) (cath, GRMember) (dan, GRMember) + + threadDelay 1000000 + cath #> "#team 1" + [alice, bob, dan] *<# "#team cath> 1" + + threadDelay 1000000 + dan #> "#team 2" + [alice, bob, cath] *<# "#team dan> 2" + + alice #$> ("/_get chat #1 count=2", chat, [(0, "1"), (0, "2")]) + bob #$> ("/_get chat #1 count=2", chat, [(0, "1"), (0, "2")]) + cath #$> ("/_get chat #1 count=2", chat, [(1, "1"), (0, "2")]) + dan #$> ("/_get chat #1 count=2", chat, [(0, "1"), (1, "2")]) + + threadDelay 1000000 + cath ##> "/leave #team" + concurrentlyN_ + [ do + cath <## "#team: you left the group" + cath <## "use /d #team to delete the group", + alice <## "#team: cath left the group", + bob <## "#team: cath left the group", + dan <## "#team: cath left the group" + ] + + threadDelay 1000000 + alice ##> "/rm team dan" + concurrentlyN_ + [ alice <## "#team: you removed dan from the group", + do + dan <## "#team: alice removed you from the group" + dan <## "use /d #team to delete the group", + bob <## "#team: alice removed dan from the group" + ] + + alice ##> "/rm #team cath messages=on" + alice <## "#team: you removed cath from the group with all messages" + bob <## "#team: alice removed cath from the group with all messages" + + alice ##> "/rm #team dan messages=on" + alice <## "#team: you removed dan from the group with all messages" + bob <## "#team: alice removed dan from the group with all messages" + + alice #$> ("/_get chat #1 count=4", chat, [(0, "1 [marked deleted by you]"), (0, "2 [marked deleted by you]"), (0, "left [marked deleted by you]"), (1, "removed dan")]) + bob #$> ("/_get chat #1 count=4", chat, [(0, "1 [marked deleted by alice]"), (0, "2 [marked deleted by alice]"), (0, "left [marked deleted by alice]"), (0, "removed dan")]) + cath #$> ("/_get chat #1 count=3", chat, [(1, "1"), (0, "2"), (1, "left")]) + dan #$> ("/_get chat #1 count=4", chat, [(0, "1"), (1, "2"), (0, "left"), (0, "removed you")]) + testSendMulti :: HasCallStack => TestParams -> IO () testSendMulti = testChat3 aliceProfile bobProfile cathProfile $ @@ -3608,7 +3672,7 @@ testGroupMsgDecryptError ps = withTestChat ps "bob" $ \bob -> do bob <## "subscribed 2 connections on server localhost" alice #> "#team hello again" - bob <# "#team alice> skipped message ID 9..11" + bob <# "#team alice> skipped message ID 8..10" bob <# "#team alice> hello again" bob #> "#team received!" alice <# "#team bob> received!" @@ -4963,7 +5027,7 @@ testGroupMsgForwardReport = setupGroupForwarding :: TestCC -> TestCC -> TestCC -> IO () setupGroupForwarding host invitee1 invitee2 = do - threadDelay 1000000 -- delay so intro_status doesn't get overwritten to connected + threadDelay 1000000 -- delay so member relations don't get overwritten to connected invitee1Name <- userName invitee1 invitee2Name <- userName invitee2 @@ -4995,15 +5059,60 @@ setupGroupForwarding host invitee1 invitee2 = do |] (invitee1Name, invitee2Name) + setupGroupForwardingVectors host invitee1 invitee2 + +setupGroupForwardingVectors :: TestCC -> TestCC -> TestCC -> IO () +setupGroupForwardingVectors host invitee1 invitee2 = do + invitee1Name <- userName invitee1 + invitee2Name <- userName invitee2 + updateGroupForwardingVectors host invitee1Name invitee2Name MRIntroduced + +updateGroupForwardingVectors :: TestCC -> String -> String -> MemberRelation -> IO () +updateGroupForwardingVectors host invitee1Name invitee2Name relation = do + void $ withCCTransaction host $ \db -> do + [(invitee1Index, invitee1Vec)] <- DB.query db + [sql| + SELECT index_in_group, member_relations_vector + FROM group_members + WHERE local_display_name = ? + |] + (Only invitee1Name) + [(invitee2Index, invitee2Vec)] <- DB.query db + [sql| + SELECT index_in_group, member_relations_vector + FROM group_members + WHERE local_display_name = ? + |] + (Only invitee2Name) + + let invitee1Vec' = setRelation invitee2Index relation (fromMaybe B.empty invitee1Vec) + DB.execute db + [sql| + UPDATE group_members + SET member_relations_vector = ? + WHERE local_display_name = ? + |] + (Binary invitee1Vec', invitee1Name) + + let invitee2Vec' = setRelation invitee1Index relation (fromMaybe B.empty invitee2Vec) + DB.execute db + [sql| + UPDATE group_members + SET member_relations_vector = ? + WHERE local_display_name = ? + |] + (Binary invitee2Vec', invitee2Name) + testGroupMsgForwardDeduplicate :: HasCallStack => TestParams -> IO () testGroupMsgForwardDeduplicate = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath - threadDelay 1000000 -- delay so intro_status doesn't get overwritten to connected + threadDelay 1000000 -- delay so member relations don't get overwritten to connected void $ withCCTransaction alice $ \db -> DB.execute_ db "UPDATE group_member_intros SET intro_status='fwd'" + setupGroupForwardingVectors alice bob cath bob #> "#team hi there" alice <# "#team bob> hi there" @@ -6973,6 +7082,39 @@ testBlockForAllMultipleMembers = cath #> "#team 6" [alice, bob, dan] *<# "#team cath> 6" +testBlockForAllLeftRemoved :: HasCallStack => TestParams -> IO () +testBlockForAllLeftRemoved = + testChat4 aliceProfile bobProfile cathProfile danProfile $ + \alice bob cath dan -> do + createGroup4 "team" alice (bob, GRMember) (cath, GRMember) (dan, GRMember) + + cath ##> "/leave #team" + concurrentlyN_ + [ do + cath <## "#team: you left the group" + cath <## "use /d #team to delete the group", + alice <## "#team: cath left the group", + bob <## "#team: cath left the group", + dan <## "#team: cath left the group" + ] + + alice ##> "/rm team dan" + concurrentlyN_ + [ alice <## "#team: you removed dan from the group", + do + dan <## "#team: alice removed you from the group" + dan <## "use /d #team to delete the group", + bob <## "#team: alice removed dan from the group" + ] + + alice ##> "/block for all #team cath" + alice <## "#team: you blocked cath" + bob <## "#team: alice blocked cath" + + alice ##> "/block for all #team dan" + alice <## "#team: you blocked dan" + bob <## "#team: alice blocked dan" + testGroupMemberInactive :: HasCallStack => TestParams -> IO () testGroupMemberInactive ps = do withSmpServer' serverCfg' $ do @@ -7726,9 +7868,9 @@ testScopedSupportForwardMemberRemoval = alice ##> "#team (support: eve) hi" alice <## "bad chat command: support member not current or pending" bob ##> "#team (support: eve) hi" - bob <## "bad chat command: support member not current or pending" + bob <##. "chat db error: SEGroupMemberNameNotFound" dan ##> "#team (support: eve) hi" - dan <## "bad chat command: support member not current or pending" + dan <##. "chat db error: SEGroupMemberNameNotFound" eve ##> "/groups" eve <## "#team (you are removed, delete local copy: /d #team)" @@ -7834,6 +7976,30 @@ testScopedSupportForwardAdminRemoval = alice ##> "/groups" alice <## "#team (you are removed, delete local copy: /d #team)" +testScopedSupportForwardLeave :: HasCallStack => TestParams -> IO () +testScopedSupportForwardLeave = + testChat5 aliceProfile bobProfile cathProfile danProfile eveProfile $ + \alice bob cath dan eve -> do + createGroup4 "team" alice (bob, GRAdmin) (cath, GRMember) (dan, GRModerator) + setupReviewForward alice bob cath dan eve + + -- eve leaves group, bob and dan receive member leave message + eve ##> "/leave #team" + eve <## "#team: you left the group" + eve <## "use /d #team to delete the group" + alice <## "#team: eve left the group" + bob <## "#team: eve left the group" + dan <## "#team: eve left the group" + + alice ##> "#team (support: eve) hi" + alice <## "bad chat command: support member not current or pending" + bob ##> "#team (support: eve) hi" + bob <##. "bad chat command: support member not current or pending" + dan ##> "#team (support: eve) hi" + dan <##. "bad chat command: support member not current or pending" + eve ##> "/groups" + eve <## "#team (you left, delete local copy: /d #team)" + testScopedSupportForwardGroupDeletion :: HasCallStack => TestParams -> IO () testScopedSupportForwardGroupDeletion = testChat5 aliceProfile bobProfile cathProfile danProfile eveProfile $ @@ -8162,6 +8328,99 @@ testScopedSupportUnreadStatsCorrectOnOpen = { markRead = False } +testScopedSupportMemberRemoved :: HasCallStack => TestParams -> IO () +testScopedSupportMemberRemoved = + testChatOpts3 opts aliceProfile bobProfile cathProfile $ \alice bob cath -> do + createGroup3' "team" alice (bob, GRMember) (cath, GRAdmin) + + bob #> "#team (support) 1" + [alice, cath] *<# "#team (support: bob) bob> 1" + + bob #> "#team (support) 2" + [alice, cath] *<# "#team (support: bob) bob> 2" + + alice ##> "/member support chats #team" + alice <## "members require attention: 1" + alice <## "bob (Bob) (id 2): unread: 2, require attention: 2, mentions: 0" + + cath ##> "/rm team bob" + concurrentlyN_ + [ cath <## "#team: you removed bob from the group", + do + bob <## "#team: cath removed you from the group" + bob <## "use /d #team to delete the group", + alice <## "#team: cath removed bob from the group" + ] + + alice ##> "/member support chats #team" + alice <## "members require attention: 0" + where + opts = + testOpts + { markRead = False + } + +testScopedSupportUserRemovesMember :: HasCallStack => TestParams -> IO () +testScopedSupportUserRemovesMember = + testChatOpts2 opts aliceProfile bobProfile $ \alice bob -> do + createGroup2' "team" alice (bob, GRMember) True + + bob #> "#team (support) 1" + alice <# "#team (support: bob) bob> 1" + + bob #> "#team (support) 2" + alice <# "#team (support: bob) bob> 2" + + alice ##> "/member support chats #team" + alice <## "members require attention: 1" + alice <## "bob (Bob) (id 2): unread: 2, require attention: 2, mentions: 0" + + alice ##> "/rm team bob" + concurrentlyN_ + [ alice <## "#team: you removed bob from the group", + do + bob <## "#team: alice removed you from the group" + bob <## "use /d #team to delete the group" + ] + + alice ##> "/member support chats #team" + alice <## "members require attention: 0" + where + opts = + testOpts + { markRead = False + } + +testScopedSupportMemberLeaves :: HasCallStack => TestParams -> IO () +testScopedSupportMemberLeaves = + testChatOpts2 opts aliceProfile bobProfile $ \alice bob -> do + createGroup2' "team" alice (bob, GRMember) True + + bob #> "#team (support) 1" + alice <# "#team (support: bob) bob> 1" + + bob #> "#team (support) 2" + alice <# "#team (support: bob) bob> 2" + + alice ##> "/member support chats #team" + alice <## "members require attention: 1" + alice <## "bob (Bob) (id 2): unread: 2, require attention: 2, mentions: 0" + + bob ##> "/l team" + concurrentlyN_ + [ do + bob <## "#team: you left the group" + bob <## "use /d #team to delete the group", + alice <## "#team: bob left the group" + ] + + alice ##> "/member support chats #team" + alice <## "members require attention: 0" + where + opts = + testOpts + { markRead = False + } testChannelsRelayDeliver :: HasCallStack => TestParams -> IO () testChannelsRelayDeliver = testChat5 aliceProfile bobProfile cathProfile danProfile eveProfile $ \alice bob cath dan eve -> do diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index aa6ba70e7b..87be15afe5 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -93,6 +93,9 @@ xit' = if os == "linux" then xit else it xit'' :: (HasCallStack, Example a) => String -> a -> SpecWith (Arg a) xit'' = ifCI xit Hspec.it +xitMacCI :: HasCallStack => String -> (TestParams -> Expectation) -> SpecWith (Arg (TestParams -> Expectation)) +xitMacCI = ifCI (if os == "darwin" then xit else it) it + xdescribe'' :: HasCallStack => String -> SpecWith a -> SpecWith a xdescribe'' = ifCI xdescribe describe diff --git a/tests/MemberRelationsTests.hs b/tests/MemberRelationsTests.hs new file mode 100644 index 0000000000..c1afbd7f6a --- /dev/null +++ b/tests/MemberRelationsTests.hs @@ -0,0 +1,300 @@ +{-# LANGUAGE OverloadedStrings #-} + +module MemberRelationsTests where + +import Control.Monad +import qualified Data.ByteString as B +import Simplex.Chat.Types.MemberRelations +import Test.Hspec + +memberRelationsTests :: Spec +memberRelationsTests = do + describe "MemberRelation vector operations" $ do + describe "getRelation" $ do + it "returns MRNew for empty vector" $ do + getRelation 0 B.empty `shouldBe` MRNew + getRelation 5 B.empty `shouldBe` MRNew + getRelation 100 B.empty `shouldBe` MRNew + + it "returns MRNew for negative index" $ do + getRelation (-1) B.empty `shouldBe` MRNew + getRelation (-5) (B.pack [0xFF]) `shouldBe` MRNew + + it "returns MRNew for index beyond vector length" $ do + let vec = B.pack [0x00] + getRelation 10 vec `shouldBe` MRNew + + it "reads single relation from byte" $ do + let vec = B.pack [0x01] + getRelation 0 vec `shouldBe` MRIntroduced + + it "reads multiple relations" $ do + let vec = B.pack [0, 0, 1, 2, 3, 4] + getRelation 0 vec `shouldBe` MRNew + getRelation 1 vec `shouldBe` MRNew + getRelation 2 vec `shouldBe` MRIntroduced + getRelation 3 vec `shouldBe` MRSubjectConnected + getRelation 4 vec `shouldBe` MRReferencedConnected + getRelation 5 vec `shouldBe` MRConnected + + it "reads multiple relations 2" $ do + let vec = B.pack [1, 1, 0, 0, 2, 2, 0, 0] + getRelation 0 vec `shouldBe` MRIntroduced + getRelation 1 vec `shouldBe` MRIntroduced + getRelation 4 vec `shouldBe` MRSubjectConnected + getRelation 5 vec `shouldBe` MRSubjectConnected + + it "ignore reserved bits" $ do + let vec = B.pack [0xF1] -- reserved=1111, direction=0, status=001 + getRelation 0 vec `shouldBe` MRIntroduced + + describe "setRelation" $ do + it "sets relation in empty vector (lazy expansion)" $ do + let vec = setRelation 0 MRIntroduced B.empty + getRelation 0 vec `shouldBe` MRIntroduced + + it "ignores negative index" $ do + let vec = setRelation (-1) MRIntroduced B.empty + vec `shouldBe` B.empty + + it "expands vector to required length" $ do + let vec = setRelation 5 MRSubjectConnected B.empty + B.length vec `shouldBe` 6 + getRelation 5 vec `shouldBe` MRSubjectConnected + -- Other positions should be MRNew (0) + getRelation 0 vec `shouldBe` MRNew + getRelation 10 vec `shouldBe` MRNew + B.length vec `shouldBe` 6 + + it "updates existing relation without affecting others" $ do + -- Start: [01][01][00][00] + let vec1 = setRelation 0 MRIntroduced B.empty + let vec2 = setRelation 1 MRIntroduced vec1 + -- Update: [01][10][00][00] + let vec3 = setRelation 1 MRSubjectConnected vec2 + getRelation 0 vec3 `shouldBe` MRIntroduced + getRelation 1 vec3 `shouldBe` MRSubjectConnected + + it "updates relation in specific byte of multi-byte vector" $ do + let vec1 = setRelation 0 MRIntroduced B.empty + let vec2 = setRelation 10 MRSubjectConnected vec1 + B.length vec2 `shouldBe` 11 + getRelation 0 vec2 `shouldBe` MRIntroduced + getRelation 10 vec2 `shouldBe` MRSubjectConnected + forM_ [1..9] $ \i -> getRelation i vec2 `shouldBe` MRNew + + it "handles setting relation at last position in byte" $ do + let vec = setRelation 3 MRSubjectConnected B.empty + getRelation 3 vec `shouldBe` MRSubjectConnected + + it "preserves vector when setting same value" $ do + let vec1 = setRelation 0 MRIntroduced B.empty + let vec2 = setRelation 0 MRIntroduced vec1 + vec2 `shouldBe` vec1 + getRelation 0 vec2 `shouldBe` MRIntroduced + + it "preserves reserved bits and direction" $ do + let v = B.pack [0xF8] -- reserved=1111, direction=1, status=000 + getRelation 0 v `shouldBe` MRNew + let v' = setRelation 0 MRConnected v + getRelation 0 v' `shouldBe` MRConnected + B.unpack v' `shouldBe` [0xFC] -- reserved=1111, direction=1, status=100 + + describe "setNewRelation" $ do + it "sets new relation with direction" $ do + let vec = setNewRelation 0 IDReferencedIntroduced MRSubjectConnected B.empty + getRelation' 0 vec `shouldBe` (IDReferencedIntroduced, MRSubjectConnected) + B.unpack vec `shouldBe` [0x0A] -- direction=1, status=010 + + it "preserves reserved bits" $ do + let v = B.pack [0xF0] -- reserved=1111, direction=0, status=000 + let v' = setNewRelation 0 IDReferencedIntroduced MRConnected v + getRelation 0 v' `shouldBe` MRConnected + B.unpack v' `shouldBe` [0xFC] -- reserved=1111, direction=1, status=100 + + describe "setRelations" $ do + it "returns same vector for empty list" $ do + let vec = B.pack [0x42] + setRelations [] vec `shouldBe` vec + + it "sets multiple relations in empty vector" $ do + let updates = [(0, MRIntroduced), (1, MRSubjectConnected), (2, MRReferencedConnected), (3, MRConnected)] + let vec = setRelations updates B.empty + getRelation 0 vec `shouldBe` MRIntroduced + getRelation 1 vec `shouldBe` MRSubjectConnected + getRelation 2 vec `shouldBe` MRReferencedConnected + getRelation 3 vec `shouldBe` MRConnected + getRelation 4 vec `shouldBe` MRNew -- Unset position + + it "sets multiple relations 1" $ do + let updates = [(0, MRIntroduced), (1, MRSubjectConnected), (2, MRSubjectConnected), (3, MRIntroduced)] + let vec = setRelations updates B.empty + B.length vec `shouldBe` 4 + getRelation 0 vec `shouldBe` MRIntroduced + getRelation 1 vec `shouldBe` MRSubjectConnected + getRelation 2 vec `shouldBe` MRSubjectConnected + getRelation 3 vec `shouldBe` MRIntroduced + + it "sets multiple relations 2" $ do + let updates = [(0, MRIntroduced), (5, MRSubjectConnected), (10, MRIntroduced)] + let vec = setRelations updates B.empty + B.length vec `shouldBe` 11 + getRelation 0 vec `shouldBe` MRIntroduced + getRelation 5 vec `shouldBe` MRSubjectConnected + getRelation 10 vec `shouldBe` MRIntroduced + getRelation 7 vec `shouldBe` MRNew -- Unset position between + + it "handles sparse updates (few indices in large range)" $ do + -- Sparse: 3 updates in large group + let updates = [(0, MRIntroduced), (100, MRSubjectConnected), (5000, MRIntroduced)] + let vec = setRelations updates B.empty + getRelation 0 vec `shouldBe` MRIntroduced + getRelation 100 vec `shouldBe` MRSubjectConnected + getRelation 5000 vec `shouldBe` MRIntroduced + getRelation 50 vec `shouldBe` MRNew -- Untouched position + + it "handles dense updates (many consecutive indices)" $ do + -- Dense: many consecutive updates + let updates = [(i, if even i then MRIntroduced else MRSubjectConnected) | i <- [0 .. 99]] + let vec = setRelations updates B.empty + all (\i -> getRelation i vec == (if even i then MRIntroduced else MRSubjectConnected)) [0 .. 99] `shouldBe` True + + it "handles unsorted input correctly" $ do + let updates = [(10, MRSubjectConnected), (2, MRIntroduced), (5, MRSubjectConnected), (0, MRIntroduced)] + let vec = setRelations updates B.empty + getRelation 0 vec `shouldBe` MRIntroduced + getRelation 2 vec `shouldBe` MRIntroduced + getRelation 5 vec `shouldBe` MRSubjectConnected + getRelation 10 vec `shouldBe` MRSubjectConnected + + it "handles duplicate indices (last one wins)" $ do + let updates = [(0, MRIntroduced), (0, MRSubjectConnected), (0, MRIntroduced)] + let vec = setRelations updates B.empty + getRelation 0 vec `shouldBe` MRIntroduced + + it "preserves existing relations not in update list" $ do + let vec1 = setRelation 0 MRSubjectConnected B.empty + let vec2 = setRelation 5 MRIntroduced vec1 + let updates = [(10, MRSubjectConnected)] + let vec3 = setRelations updates vec2 + getRelation 0 vec3 `shouldBe` MRSubjectConnected + getRelation 5 vec3 `shouldBe` MRIntroduced + getRelation 10 vec3 `shouldBe` MRSubjectConnected + + describe "setNewRelations" $ do + it "sets multiple new relations with direction" $ do + let updates = [(0, (IDSubjectIntroduced, MRIntroduced)), (1, (IDReferencedIntroduced, MRSubjectConnected))] + let vec = setNewRelations updates B.empty + getRelation 0 vec `shouldBe` MRIntroduced + getRelation 1 vec `shouldBe` MRSubjectConnected + B.unpack vec `shouldBe` [0x01, 0x0A] -- [dir=0,status=001], [dir=1,status=010] + + describe "edge cases and invariants" $ do + it "round-trip: set then get returns same value" $ do + let vec1 = setRelation 42 MRSubjectConnected B.empty + getRelation 42 vec1 `shouldBe` MRSubjectConnected + + it "multiple round-trips preserve values" $ do + let vec1 = setRelation 0 MRIntroduced B.empty + let vec2 = setRelation 1 MRSubjectConnected vec1 + let vec3 = setRelation 2 MRReferencedConnected vec2 + let vec4 = setRelation 3 MRConnected vec3 + getRelation 0 vec4 `shouldBe` MRIntroduced + getRelation 1 vec4 `shouldBe` MRSubjectConnected + getRelation 2 vec4 `shouldBe` MRReferencedConnected + getRelation 3 vec4 `shouldBe` MRConnected + + it "setRelations equivalent to multiple setRelation calls" $ do + let updates = [(0, MRIntroduced), (5, MRSubjectConnected), (10, MRConnected)] + let vecBatch = setRelations updates B.empty + let vecSeq = setRelation 10 MRConnected $ setRelation 5 MRSubjectConnected $ setRelation 0 MRIntroduced B.empty + vecBatch `shouldBe` vecSeq + getRelation 0 vecBatch `shouldBe` getRelation 0 vecSeq + getRelation 5 vecBatch `shouldBe` getRelation 5 vecSeq + getRelation 10 vecBatch `shouldBe` getRelation 10 vecSeq + + it "handles large group size (10000 members)" $ do + let updates = [(0, MRIntroduced), (5000, MRSubjectConnected), (9999, MRIntroduced)] + let vec = setRelations updates B.empty + B.length vec `shouldBe` 10000 + getRelation 0 vec `shouldBe` MRIntroduced + getRelation 5000 vec `shouldBe` MRSubjectConnected + getRelation 9999 vec `shouldBe` MRIntroduced + + it "all status values can be stored and retrieved" $ do + let vec1 = setRelation 0 MRNew B.empty + let vec2 = setRelation 1 MRIntroduced vec1 + let vec3 = setRelation 2 MRSubjectConnected vec2 + let vec4 = setRelation 3 MRReferencedConnected vec3 + let vec5 = setRelation 4 MRConnected vec4 + getRelation 0 vec5 `shouldBe` MRNew + getRelation 1 vec5 `shouldBe` MRIntroduced + getRelation 2 vec5 `shouldBe` MRSubjectConnected + getRelation 3 vec5 `shouldBe` MRReferencedConnected + getRelation 4 vec5 `shouldBe` MRConnected + + it "vector length is minimal (lazy expansion)" $ do + let vec = setRelation 3 MRSubjectConnected B.empty + B.length vec `shouldBe` 4 + + it "setRelation preserves existing direction" $ do + let vec1 = setNewRelation 0 IDReferencedIntroduced MRIntroduced B.empty + let vec2 = setRelation 0 MRConnected vec1 + getRelation 0 vec2 `shouldBe` MRConnected + B.unpack vec2 `shouldBe` [0x0C] -- direction=1 preserved, status=100 + + describe "setRelationConnected" $ do + it "MRSubjectConnected on MRIntroduced -> MRSubjectConnected" $ do + let vec1 = setRelation 0 MRIntroduced B.empty + let vec2 = setRelationConnected 0 MRSubjectConnected vec1 + getRelation 0 vec2 `shouldBe` MRSubjectConnected + + it "MRReferencedConnected on MRIntroduced -> MRReferencedConnected" $ do + let vec1 = setRelation 0 MRIntroduced B.empty + let vec2 = setRelationConnected 0 MRReferencedConnected vec1 + getRelation 0 vec2 `shouldBe` MRReferencedConnected + + it "MRSubjectConnected on MRReferencedConnected -> MRConnected (complementary)" $ do + let vec1 = setRelation 0 MRReferencedConnected B.empty + let vec2 = setRelationConnected 0 MRSubjectConnected vec1 + getRelation 0 vec2 `shouldBe` MRConnected + + it "MRReferencedConnected on MRSubjectConnected -> MRConnected (complementary)" $ do + let vec1 = setRelation 0 MRSubjectConnected B.empty + let vec2 = setRelationConnected 0 MRReferencedConnected vec1 + getRelation 0 vec2 `shouldBe` MRConnected + + it "MRSubjectConnected on MRSubjectConnected -> no change" $ do + let vec1 = setRelation 0 MRSubjectConnected B.empty + let vec2 = setRelationConnected 0 MRSubjectConnected vec1 + vec2 `shouldBe` vec1 + + it "MRReferencedConnected on MRReferencedConnected -> no change" $ do + let vec1 = setRelation 0 MRReferencedConnected B.empty + let vec2 = setRelationConnected 0 MRReferencedConnected vec1 + vec2 `shouldBe` vec1 + + it "MRSubjectConnected on MRConnected -> no change" $ do + let vec1 = setRelation 0 MRConnected B.empty + let vec2 = setRelationConnected 0 MRSubjectConnected vec1 + vec2 `shouldBe` vec1 + + it "MRReferencedConnected on MRConnected -> no change" $ do + let vec1 = setRelation 0 MRConnected B.empty + let vec2 = setRelationConnected 0 MRReferencedConnected vec1 + vec2 `shouldBe` vec1 + + it "invalid status (MRConnected) -> no change" $ do + let vec1 = setRelation 0 MRIntroduced B.empty + let vec2 = setRelationConnected 0 MRConnected vec1 + vec2 `shouldBe` vec1 + + it "invalid status (MRNew) -> no change" $ do + let vec1 = setRelation 0 MRIntroduced B.empty + let vec2 = setRelationConnected 0 MRNew vec1 + vec2 `shouldBe` vec1 + + it "setRelationConnected preserves direction when updating" $ do + let vec1 = setNewRelation 0 IDReferencedIntroduced MRIntroduced B.empty + let vec2 = setRelationConnected 0 MRSubjectConnected vec1 + getRelation' 0 vec2 `shouldBe` (IDReferencedIntroduced, MRSubjectConnected) diff --git a/tests/RemoteTests.hs b/tests/RemoteTests.hs index e21f97d69e..9790681f3b 100644 --- a/tests/RemoteTests.hs +++ b/tests/RemoteTests.hs @@ -33,7 +33,7 @@ remoteTests = describe "Remote" $ do it "connects with new pairing (stops mobile)" $ remoteHandshakeTest False it "connects with new pairing (stops desktop)" $ remoteHandshakeTest True it "connects with stored pairing" remoteHandshakeStoredTest - it "connects with multicast discovery" remoteHandshakeDiscoverTest + xitMacCI "connects with multicast discovery" remoteHandshakeDiscoverTest it "refuses invalid client cert" remoteHandshakeRejectTest it "connects with stored server bindings" storedBindingsTest it "sends messages" remoteMessageTest diff --git a/tests/SchemaDump.hs b/tests/SchemaDump.hs index b1b7dbc264..8a716ddf11 100644 --- a/tests/SchemaDump.hs +++ b/tests/SchemaDump.hs @@ -18,6 +18,7 @@ import Data.Text (Text) import qualified Data.Text as T import qualified Data.Text.IO as T import Database.SQLite.Simple (Query (..)) +import Simplex.Chat.Options.SQLite (chatDBFunctions) import Simplex.Chat.Store (createChatStore) import qualified Simplex.Chat.Store as Store import Simplex.Messaging.Agent.Env.SQLite (createAgentStore) @@ -63,7 +64,7 @@ testVerifySchemaDump :: IO () testVerifySchemaDump = withTmpFiles $ do savedSchema <- ifM (doesFileExist appSchema) (readFile appSchema) (pure "") savedSchema `deepseq` pure () - void $ createChatStore (DBOpts testDB "" False True TQOff) (MigrationConfig MCError Nothing) + void $ createChatStore (DBOpts testDB chatDBFunctions "" False True TQOff) (MigrationConfig MCError Nothing) getSchema testDB appSchema `shouldReturn` savedSchema removeFile testDB @@ -71,14 +72,14 @@ testVerifyLintFKeyIndexes :: IO () testVerifyLintFKeyIndexes = withTmpFiles $ do savedLint <- ifM (doesFileExist appLint) (readFile appLint) (pure "") savedLint `deepseq` pure () - void $ createChatStore (DBOpts testDB "" False True TQOff) (MigrationConfig MCError Nothing) + void $ createChatStore (DBOpts testDB chatDBFunctions "" False True TQOff) (MigrationConfig MCError Nothing) getLintFKeyIndexes testDB "tests/tmp/chat_lint.sql" `shouldReturn` savedLint removeFile testDB testSchemaMigrations :: IO () testSchemaMigrations = withTmpFiles $ do let noDownMigrations = dropWhileEnd (\Migration {down} -> isJust down) Store.migrations - Right st <- createDBStore (DBOpts testDB "" False True TQOff) noDownMigrations (MigrationConfig MCError Nothing) + Right st <- createDBStore (DBOpts testDB chatDBFunctions "" False True TQOff) noDownMigrations (MigrationConfig MCError Nothing) mapM_ (testDownMigration st) $ drop (length noDownMigrations) Store.migrations closeDBStore st removeFile testDB @@ -152,7 +153,7 @@ saveQueryPlans = it "verify and overwrite query plans" $ \TestParams {chatQueryS updatePlans appChatQueryPlans chatQueryStats - (createChatStore (DBOpts testDB "" False True TQOff) (MigrationConfig MCError Nothing)) + (createChatStore (DBOpts testDB chatDBFunctions "" False True TQOff) (MigrationConfig MCError Nothing)) (\db -> do DB.execute_ db "CREATE TABLE IF NOT EXISTS temp_conn_ids (conn_id BLOB)" DB.execute_ db "CREATE TABLE IF NOT EXISTS temp_delete_members (contact_profile_id INTEGER, member_profile_id INTEGER, local_display_name TEXT)" @@ -161,7 +162,7 @@ saveQueryPlans = it "verify and overwrite query plans" $ \TestParams {chatQueryS updatePlans appAgentQueryPlans agentQueryStats - (createAgentStore (DBOpts testAgentDB "" False True TQOff) (MigrationConfig MCError Nothing)) + (createAgentStore (DBOpts testAgentDB [] "" False True TQOff) (MigrationConfig MCError Nothing)) (const $ pure ()) chatSavedPlans' == chatSavedPlans `shouldBe` True agentSavedPlans' == agentSavedPlans `shouldBe` True diff --git a/tests/Test.hs b/tests/Test.hs index e4e76fd43e..e1a5a58c7d 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -13,6 +13,7 @@ import Control.Logger.Simple import Data.Time.Clock.System import JSONTests import MarkdownTests +import MemberRelationsTests import MessageBatching import ProtocolTests import OperatorTests @@ -59,6 +60,7 @@ main = do #endif describe "SimpleX chat markdown" markdownTests describe "JSON Tests" jsonTests + describe "Member relations" memberRelationsTests describe "SimpleX chat view" viewTests describe "SimpleX chat protocol" protocolTests describe "Valid names" validNameTests diff --git a/website/langs/de.json b/website/langs/de.json index 4c5925a7b8..18f56b5066 100644 --- a/website/langs/de.json +++ b/website/langs/de.json @@ -283,7 +283,7 @@ "index-token-h2": "Communities, die Bestand haben", "index-token-p1": "Sie werden Ihre Lieblingsgruppen mit zukünftigen Community-Gutscheinen unterstützen.", "index-token-p2": "Server werden mit Gutscheinen bezahlt, damit Ihre Communities kostenlos und unabhängig bleiben können.", - "index-token-cta": "Erfahren Sie mehr und holen Sie sich Ihren kostenlosen NFT
zum frühzeitigen ausprobieren.", + "index-token-cta": "Erfahren Sie mehr und holen Sie sich Ihren kostenlosen NFT zum frühzeitigen ausprobieren.", "index-roadmap-h2": "SimpleX - Roadmap zum freien Internet", "index-roadmap-2025": "2025", "index-roadmap-2025-title": "Skalierung auf große Communities", diff --git a/website/langs/en.json b/website/langs/en.json index 02f370f2cc..dc97d9ab66 100644 --- a/website/langs/en.json +++ b/website/langs/en.json @@ -284,7 +284,7 @@ "index-token-h2": "Communities That Last", "index-token-p1": "You will support your favorite groups with future Community Vouchers.", "index-token-p2": "Vouchers will pay for servers, to let your communities stay free and independent.", - "index-token-cta": "Learn more and get your free NFT
for early testing.", + "index-token-cta": "Learn more and get a free access pass for early testing.", "index-roadmap-h2": "SimpleX Roadmap to Free Internet", "index-roadmap-2025": "2025", "index-roadmap-2025-title": "Scale to Large Communities", @@ -312,5 +312,6 @@ "messengers-comparison-section-list-point-3": "It appears that the usage of cryptographic signatures compromises repudiation (deniability), but it needs to be clarified.", "messengers-comparison-section-list-point-4": "Multi-device implementation compromises post-compromise security of Double Ratchet", "messengers-comparison-section-list-point-5": "2-factor key exchange is optional via security code verification.", - "messengers-comparison-section-list-point-6": "Post-quantum key agreement is \"sparse\" — it protects only some of the ratchet steps." + "messengers-comparison-section-list-point-6": "Post-quantum key agreement is \"sparse\" — it protects only some of the ratchet steps.", + "navbar-old-site": "Old site" } \ No newline at end of file diff --git a/website/langs/es.json b/website/langs/es.json index 9ab62eade5..3489fb3b9b 100644 --- a/website/langs/es.json +++ b/website/langs/es.json @@ -283,7 +283,7 @@ "index-token-h2": "Comunidades Duraderas", "index-token-p1": "Podrás apoyar a tus grupos favoritos con los futuros Vales Comunitarios.", "index-token-p2": "Los vales costearán los servidores para que tus comunidades sigan siendo libres e independientes.", - "index-token-cta": "Descubre más y obtén tu NFT gratuito
por participar en las pruebas.", + "index-token-cta": "Descubre más y obtén tu NFT gratuito por participar en las pruebas.", "index-roadmap-h2": "Ruta SimpleX hacía el Internet Libre", "index-roadmap-2025": "2025", "index-roadmap-2025-title": "Escalar a Comunidades Grandes", diff --git a/website/langs/hu.json b/website/langs/hu.json index 85876c8b92..77d3ce878b 100644 --- a/website/langs/hu.json +++ b/website/langs/hu.json @@ -283,7 +283,7 @@ "index-token-h2": "Időtálló közösségek", "index-token-p1": "A jövőben közösségi utalványokkal támogathatja a kedvenc csoportjait.", "index-token-p2": "Az utalványokkal fizetni tudja a kiszolgálókat, hogy a közösségek szabadok és függetlenek maradhassanak.", - "index-token-cta": "Tudjon meg többet, és szerezzen ingyenes NFT-t
az előzetes tesztelésért.", + "index-token-cta": "Tudjon meg többet, és szerezzen ingyenes NFT-t az előzetes tesztelésért.", "index-roadmap-h2": "A SimpleX ütemterve a szabad internethez", "index-roadmap-2025": "2025", "index-roadmap-2025-title": "Skálázódás nagy közösségekre", diff --git a/website/langs/it.json b/website/langs/it.json index b4db5e2399..f6b35588f6 100644 --- a/website/langs/it.json +++ b/website/langs/it.json @@ -283,7 +283,7 @@ "index-token-h2": "Comunità fatte per restare", "index-token-p1": "Sosterrai i tuoi gruppi preferiti con futuri buoni comunitari.", "index-token-p2": "I buoni pagheranno i server, per consentire alle tue comunità di rimanere libere e indipendenti.", - "index-token-cta": "Scopri di più e ricevi un NFT gratuito
per provarlo in anticipo.", + "index-token-cta": "Scopri di più e ricevi un NFT gratuito per provarlo in anticipo.", "index-roadmap-h2": "Tabella di marcia per un internet libero", "index-roadmap-2025": "2025", "index-roadmap-2025-title": "Scalabilità per comunità numerose", diff --git a/website/langs/ru.json b/website/langs/ru.json index 5c59c3e848..fbfa6d3539 100644 --- a/website/langs/ru.json +++ b/website/langs/ru.json @@ -283,7 +283,7 @@ "index-token-h2": "Стабильные Сообщества", "index-token-p1": "Вы сможете поддерживать Ваши любимые группы с помощью будущих Ваучеров Групп.", "index-token-p2": "Ваучеры будут использоваться для оплаты за серверы, чтобы группы оставались свободными и независимыми.", - "index-token-cta": "Узнайте больше и возьмите бесплатный NFT, чтобы участвовать в тестировании.", + "index-token-cta": "Узнайте больше и возьмите бесплатный пропуск, чтобы участвовать в тестировании.", "index-roadmap-h2": "Путь Сети SimpleX к Свободному Интернету", "index-roadmap-2025": "2025", "index-roadmap-2025-title": "Большие каналы и группы", diff --git a/website/src/_includes/layouts/article.html b/website/src/_includes/layouts/article.html index 628a1a4e05..72d9b10b78 100644 --- a/website/src/_includes/layouts/article.html +++ b/website/src/_includes/layouts/article.html @@ -19,7 +19,7 @@ {% if image %} {% else %} - + {% endif %} diff --git a/website/src/_includes/layouts/doc.html b/website/src/_includes/layouts/doc.html index f26d3296e0..017526d164 100644 --- a/website/src/_includes/layouts/doc.html +++ b/website/src/_includes/layouts/doc.html @@ -77,7 +77,7 @@ {% endif %} -
+
{{ content | safe }}
diff --git a/website/src/_includes/layouts/main.html b/website/src/_includes/layouts/main.html index 669347330c..999e3af901 100644 --- a/website/src/_includes/layouts/main.html +++ b/website/src/_includes/layouts/main.html @@ -22,7 +22,7 @@ - + diff --git a/website/src/_includes/layouts/privacy.html b/website/src/_includes/layouts/privacy.html index eca6bfc6c1..7b41c6e5bc 100644 --- a/website/src/_includes/layouts/privacy.html +++ b/website/src/_includes/layouts/privacy.html @@ -19,7 +19,7 @@ {% if image %} {% else %} - + {% endif %} diff --git a/website/src/_includes/layouts/group_link.html b/website/src/_includes/layouts/redirect.html similarity index 82% rename from website/src/_includes/layouts/group_link.html rename to website/src/_includes/layouts/redirect.html index 7a13ba7a49..b4b7ca8ba6 100644 --- a/website/src/_includes/layouts/group_link.html +++ b/website/src/_includes/layouts/redirect.html @@ -10,12 +10,12 @@ - + - + -

{{ groupLinkText }}

+

{{ destinationText }}

diff --git a/website/src/_includes/layouts/token.html b/website/src/_includes/layouts/token.html index 040b70b532..62e8e386b3 100644 --- a/website/src/_includes/layouts/token.html +++ b/website/src/_includes/layouts/token.html @@ -16,7 +16,7 @@ - + diff --git a/website/src/_includes/navbar.html b/website/src/_includes/navbar.html index 97471ae7f5..97faa42f45 100644 --- a/website/src/_includes/navbar.html +++ b/website/src/_includes/navbar.html @@ -111,9 +111,9 @@ diff --git a/website/src/js/design3.js b/website/src/js/design3.js index 97c1275b05..bc725ac7ce 100644 --- a/website/src/js/design3.js +++ b/website/src/js/design3.js @@ -115,8 +115,6 @@ async function showPromotedGroups() { \\__ \\| || |\\/| | _/ |__| _| / . \\| (__| __ |/ _ \\| | |___/___|_| |_|_| |____|___/_/ \\_\\\\___|_||_/_/ \\_\\_| -Mint SimpleX NFT for SMPX testnet access in 2026: https://simplex.chat/token - SimpleX directory: https://simplex.chat/directory Ask SimpleX team: https://smp6.simplex.im/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw GitHub: https://github.com/simplex-chat/simplex-chat diff --git a/website/src/livestream.html b/website/src/livestream.html deleted file mode 100644 index ee7a96ab30..0000000000 --- a/website/src/livestream.html +++ /dev/null @@ -1,8 +0,0 @@ ---- -layout: layouts/group_link.html -title: "SimpleX Chat: Power to the People" -description: "Join the group for livestream Q&A" -groupLink: "https://simplex.chat/contact#/?v=2-7&smp=smp%3A%2F%2FSkIkI6EPd2D63F4xFKfHk7I1UGZVNn6k1QWZ5rcyr6w%3D%40smp9.simplex.im%2FoVQ-kg2rjMRituleO6t26DhQDPW6OjLL%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEATIRrsU4GwjpF6SeMWa6Li20Rkibgu4ozZMADZfdAZzE%253D%26srv%3Djssqzccmrcws6bhmn77vgmhfjmhwlyr3u7puw4erkyoosywgl67slqqd.onion" -groupLinkText: Open Livestream Q&A group link -templateEngineOverride: njk ---- \ No newline at end of file diff --git a/website/src/messaging.html b/website/src/messaging.html index 7b378e6a73..4ab6c1c2cd 100644 --- a/website/src/messaging.html +++ b/website/src/messaging.html @@ -1,6 +1,6 @@ --- layout: layouts/main.html -title: "SimpleX Chat: Learn more about SimpleX messaging" +title: "SimpleX Chat: The World's Most Secure Messaging" description: "SimpleX Chat - a private and encrypted messenger without any user IDs (not even random ones)! Make a private connection via link / QR code to send messages and make calls." templateEngineOverride: njk --- diff --git a/website/src/token.html b/website/src/token.html new file mode 100644 index 0000000000..120e5c277d --- /dev/null +++ b/website/src/token.html @@ -0,0 +1,8 @@ +--- +layout: layouts/redirect.html +title: "SimpleX Community Vouchers" +description: "" +destinationURI: "/vouchers/" +destinationText: Connect to the team +templateEngineOverride: njk +--- diff --git a/website/src/token.md b/website/src/token.md index d524180215..9a7fde2361 100644 --- a/website/src/token.md +++ b/website/src/token.md @@ -1,42 +1,32 @@ --- layout: layouts/token.html -title: "Community Vouchers: Your Freedom and Security" -permalink: "/token/index.html" +title: "SimpleX Community Vouchers" +permalink: "/vouchers/index.html" --- # SimpleX Community Vouchers -We're developing Community Vouchers as a way to enable secure payments to server operators, to make communities sustainable. +Since we started developing the SimpleX network, the app has been downloaded over 1.5 million times. -Group or channel owners can select network operators for better reliability and censorship resistance than with traditional online publishing methods. +We are not just building a messenger. Unlike today's web dominated by big tech platforms, SimpleX is a network where you fully control your online identity, contacts, groups, and content – without ads tracking you or companies mining your data. -These vouchers are blockchain utility tokens — to focus purely on server capacity usage, like prepaid telephone cards. +The SimpleX network is designed to be private, secure, decentralized (meaning no single company owns it), and truly owned by its users. That's why we develop it as fully open-source code (anyone can review it or build their own apps and servers). -**What these tokens are not**: Community Vouchers are not freely tradable tokens. They are also not a way to raise funds. +To make this vision sustainable, we're introducing Community Vouchers in 2026. Think of them as simple prepaid cards for phone calls, but for funding the servers that power your channels and groups. -**What they are**: A mechanism to pay for servers, privately. Our goal is to make it possible to buy Community Vouchers via usual credit card and in-app payments, so that everybody can use them, not only people who use cryptocurrencies. - -## SMPX: Community Voucher Token planned for 2026 +**How it will work in simple terms**: +- Community Vouchers can be purchased using the usual in-app payments (like via Apple or Google Play) or using cryptocurrency, such as Bitcoin or Monero. +- Community Vouchers are secure and private: their usage can't be linked to their purchase, however you paid for them, thanks to advanced cryptography (zero-knowledge proofs). -SMPX token is v1 of Community Vouchers. We are aiming to launch it in 2026. +Under the hood, Community Vouchers are utility tokens on a blockchain (a secure, shared ledger that no single company controls). But you won't have to use blockchain if you don't want to: we are making vouchers as easy to use as gift cards. -Mint a free SimpleX NFT on Ethereum network (mainnet) for SMPX testnet access and feedback. The NFT is limited to 1 per wallet, non-transferable. +Community Vouchers are not tradable, and there will be no "pre-mine" or public sale. -**Preliminary token overview** -- full name: **SimpleX Community Voucher**. -- symbol: SMPX. -- network: TBC. There are several viable L2 candidates: Arbitrum One, Optimism, Polygon, etc., and we are considering other networks as well. -- standard: ERC20, with contract-enforced supply and other limits on transactions and holdings (TBD based on modeling and testing, these will not be freely tradable ERC20 tokens). +If you have a cryptowallet, and want to test Community Vouchers early, before full release, you can get a free access pass to the test version — a free non-transferrable NFT on Ethereum mainnet, you only need to pay for gas. -**Potential utilities** -- Server messaging and file capacity for large channels and communities beyond free tier (see FAQ). -- Names in SimpleX Name System. - -We're working with blockchain and legal experts for feasibility and compliance, aiming to launch testnet in 2026. Details may change based on input. More in our upcoming whitepaper draft. - -To receive updates, sign up via email or connect to us [via SimpleX Chat](https://smp6.simplex.im/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw). +To receive updates, connect to us [via SimpleX Chat](https://smp6.simplex.im/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw). ## Community Vouchers FAQ — send your feedback @@ -56,7 +46,7 @@ These are early insights into how Community Vouchers can work — some of th - [Why Not Existing Crypto?](#why-not-existing-crypto) - [Why build on Ethereum blockchain?](#why-build-on-ethereum-blockchain) - [Have you considered other blockchains?](#have-you-considered-other-blockchains) -- [Why use ERC20 specification? Isn't it designed for freely tradable tokens?](#why-use-erc20-specification-isnt-it-designed-for-freely-tradable-tokens) +- [Which token specification do you plan to use?](#which-token-specification-do-you-plan-to-use) - [If you build on another blockchain, how the NFT will be used to provide access?](#if-you-build-on-another-blockchain-how-the-nft-will-be-used-to-provide-access) ### Why Community Vouchers? @@ -173,11 +163,11 @@ This was our assessment as well in the past. But the last three years changed it We are actively considering which blockchain to build on. Ethereum ecosystem is the most widely adopted, and has very mature systems and tools, and it appears sufficient, but it has its downsides, as does everything. So we are not yet committed to Ethereum. -### Why use ERC20 specification? Isn't it designed for freely tradable tokens? +### Which token specification do you plan to use? -[ERC20 token specification](https://eips.ethereum.org/EIPS/eip-20) has wider scope. It is very simple, one of the earliest, and the most adopted standard on EVM blockchain. It defines tokens, but they don't have to be freely tradeable — the specification allows any extensions and restrictions implemented on top of it. +Even though these are not freely tradable tokens, we will likely make them compatible with [ERC20 token specification](https://eips.ethereum.org/EIPS/eip-20). It is very simple, one of the earliest, and the most adopted standard on EVM blockchain. It defines tokens, but they don't have to be freely tradeable — the specification allows any extensions and restrictions implemented on top of it. -Because of its wide adoption, this specification is the right choice to build on, at least initially, as it will be compatible with all wallets and existing tools out of the box, making testing, development, and early adoption much easier. +Using this specification would make Community Vouchers partially compatible with wallets and chain explorers, making testing, development, and early adoption easier. ### If you build on another blockchain, how the NFT will be used to provide access?