Merge branch 'master' into master-android

This commit is contained in:
Evgeny Poberezkin
2025-12-11 18:26:19 +00:00
102 changed files with 3539 additions and 1227 deletions
+35 -5
View File
@@ -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}
+10 -3
View File
@@ -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" || :
+23 -5
View File
@@ -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}
+11 -11
View File
@@ -24,15 +24,15 @@
## Install the app
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/apple_store.svg" alt="iOS app" height="42">](https://apps.apple.com/us/app/simplex-chat/id1605771084)
[<img src="https://raw.githubusercontent.com/simplex-chat/.github/refs/heads/master/profile/images/apple_store.svg" alt="iOS app" height="42">](https://apps.apple.com/us/app/simplex-chat/id1605771084)
&nbsp;
[![Android app](https://github.com/simplex-chat/.github/blob/master/profile/images/google_play.svg)](https://play.google.com/store/apps/details?id=chat.simplex.app)
[![Android app](https://raw.githubusercontent.com/simplex-chat/.github/refs/heads/master/profile/images/google_play.svg)](https://play.google.com/store/apps/details?id=chat.simplex.app)
&nbsp;
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/f_droid.svg" alt="F-Droid" height="41">](https://app.simplex.chat)
[<img src="https://raw.githubusercontent.com/simplex-chat/.github/refs/heads/master/profile/images/f_droid.svg" alt="F-Droid" height="41">](https://app.simplex.chat)
&nbsp;
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/testflight.png" alt="iOS TestFlight" height="41">](https://testflight.apple.com/join/DWuT2LQu)
[<img src="https://raw.githubusercontent.com/simplex-chat/.github/refs/heads/master/profile/images/testflight.png" alt="iOS TestFlight" height="41">](https://testflight.apple.com/join/DWuT2LQu)
&nbsp;
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/apk_icon.png" alt="APK" height="41">](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk)
[<img src="https://raw.githubusercontent.com/simplex-chat/.github/refs/heads/master/profile/images/apk_icon.png" alt="APK" height="41">](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk)
- 🖲 Protects your messages and metadata - who you talk to and when.
- 🔐 Double ratchet end-to-end encryption, with additional encryption layer.
@@ -84,7 +84,7 @@ You need to share a link with your friend or scan a QR code from their phone, in
The channel through which you share the link does not have to be secure - it is enough that you can confirm who sent you the message and that your SimpleX connection is established.
<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/app1.png" alt="Make a private connection" height="360"> <img src="https://github.com/simplex-chat/.github/blob/master/profile/images/arrow.png" height="360"> <img src="https://github.com/simplex-chat/.github/blob/master/profile/images/app2.png" alt="Conversation" height="360"> <img src="https://github.com/simplex-chat/.github/blob/master/profile/images/arrow.png" height="360"> <img src="https://github.com/simplex-chat/.github/blob/master/profile/images/app3.png" alt="Video call" height="360">
<img src="https://raw.githubusercontent.com/simplex-chat/.github/refs/heads/master/profile/images/app1.png" alt="Make a private connection" height="360"> <img src="https://raw.githubusercontent.com/simplex-chat/.github/refs/heads/master/profile/images/arrow.png" height="360"> <img src="https://raw.githubusercontent.com/simplex-chat/.github/refs/heads/master/profile/images/app2.png" alt="Conversation" height="360"> <img src="https://raw.githubusercontent.com/simplex-chat/.github/refs/heads/master/profile/images/arrow.png" height="360"> <img src="https://raw.githubusercontent.com/simplex-chat/.github/refs/heads/master/profile/images/app3.png" alt="Video call" height="360">
After you connect, you can [verify connection security code](./blog/20230103-simplex-chat-v4.4-disappearing-messages.md#connection-security-verification).
@@ -427,12 +427,12 @@ This software is licensed under the GNU Affero General Public License version 3
Graphic designs, artworks and layouts are not licensed for re-use. If you want to use them in your publications, please ask for permission. Texts can be used as direct quotes, referencing the source.
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/apple_store.svg" alt="iOS app" height="42">](https://apps.apple.com/us/app/simplex-chat/id1605771084)
[<img src="https://raw.githubusercontent.com/simplex-chat/.github/refs/heads/master/profile/images/apple_store.svg" alt="iOS app" height="42">](https://apps.apple.com/us/app/simplex-chat/id1605771084)
&nbsp;
[![Android app](https://github.com/simplex-chat/.github/blob/master/profile/images/google_play.svg)](https://play.google.com/store/apps/details?id=chat.simplex.app)
[![Android app](https://raw.githubusercontent.com/simplex-chat/.github/refs/heads/master/profile/images/google_play.svg)](https://play.google.com/store/apps/details?id=chat.simplex.app)
&nbsp;
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/f_droid.svg" alt="F-Droid" height="41">](https://app.simplex.chat)
[<img src="https://raw.githubusercontent.com/simplex-chat/.github/refs/heads/master/profile/images/f_droid.svg" alt="F-Droid" height="41">](https://app.simplex.chat)
&nbsp;
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/testflight.png" alt="iOS TestFlight" height="41">](https://testflight.apple.com/join/DWuT2LQu)
[<img src="https://raw.githubusercontent.com/simplex-chat/.github/refs/heads/master/profile/images/testflight.png" alt="iOS TestFlight" height="41">](https://testflight.apple.com/join/DWuT2LQu)
&nbsp;
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/apk_icon.png" alt="APK" height="41">](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk)
[<img src="https://raw.githubusercontent.com/simplex-chat/.github/refs/heads/master/profile/images/apk_icon.png" alt="APK" height="41">](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk)
-5
View File
@@ -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..<im.reversedChatItems.count {
if let updatedItem = removedUpdatedItem(im.reversedChatItems[i]) {
+1 -1
View File
@@ -1837,7 +1837,7 @@ func apiDeleteMemberSupportChat(_ groupId: Int64, _ groupMemberId: Int64) async
throw r.unexpected
}
func apiRemoveMembers(_ groupId: Int64, _ memberIds: [Int64], _ withMessages: Bool = false) async throws -> (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
@@ -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
)
}
@@ -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<V: View>(_ 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?()
}
@@ -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
]}
)
}
+35 -3
View File
@@ -48,7 +48,7 @@ func apiSetEncryptLocalFiles(_ enable: Bool) throws {
throw r.unexpected
}
func apiGetChats(userId: User.ID) throws -> Array<ChatData> {
func apiGetChats(userId: User.ID) throws -> Array<SEChatData> {
let r: APIResult<SEChatResponse> = 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
}
}
+6 -6
View File
@@ -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<Array<ChatData>, ErrorAlert> {
private func fetchChats() -> Result<Array<SEChatData>, 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,
+18 -18
View File
@@ -183,8 +183,8 @@
64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; };
64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829982D54AEED006B9E89 /* libgmp.a */; };
64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829992D54AEEE006B9E89 /* libffi.a */; };
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.3-8ATMbsLjvgc8dH25M4OGyN-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.3-8ATMbsLjvgc8dH25M4OGyN-ghc9.6.3.a */; };
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.3-8ATMbsLjvgc8dH25M4OGyN.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.3-8ATMbsLjvgc8dH25M4OGyN.a */; };
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.4-45BRYFUa7pfAow5e7qNgo6-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.4-45BRYFUa7pfAow5e7qNgo6-ghc9.6.3.a */; };
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.4-45BRYFUa7pfAow5e7qNgo6.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.4-45BRYFUa7pfAow5e7qNgo6.a */; };
64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */; };
64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; };
64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; };
@@ -555,8 +555,8 @@
64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = "<group>"; };
64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.3-8ATMbsLjvgc8dH25M4OGyN-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.0.3-8ATMbsLjvgc8dH25M4OGyN-ghc9.6.3.a"; sourceTree = "<group>"; };
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.3-8ATMbsLjvgc8dH25M4OGyN.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.0.3-8ATMbsLjvgc8dH25M4OGyN.a"; sourceTree = "<group>"; };
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.4-45BRYFUa7pfAow5e7qNgo6-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.0.4-45BRYFUa7pfAow5e7qNgo6-ghc9.6.3.a"; sourceTree = "<group>"; };
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.4-45BRYFUa7pfAow5e7qNgo6.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.0.4-45BRYFUa7pfAow5e7qNgo6.a"; sourceTree = "<group>"; };
64C8299C2D54AEEE006B9E89 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = "<group>"; };
64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = "<group>"; };
@@ -718,8 +718,8 @@
64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */,
64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */,
64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */,
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.3-8ATMbsLjvgc8dH25M4OGyN-ghc9.6.3.a in Frameworks */,
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.3-8ATMbsLjvgc8dH25M4OGyN.a in Frameworks */,
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.4-45BRYFUa7pfAow5e7qNgo6-ghc9.6.3.a in Frameworks */,
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.4-45BRYFUa7pfAow5e7qNgo6.a in Frameworks */,
CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -805,8 +805,8 @@
64C829992D54AEEE006B9E89 /* libffi.a */,
64C829982D54AEED006B9E89 /* libgmp.a */,
64C8299C2D54AEEE006B9E89 /* libgmpxx.a */,
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.3-8ATMbsLjvgc8dH25M4OGyN-ghc9.6.3.a */,
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.3-8ATMbsLjvgc8dH25M4OGyN.a */,
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.4-45BRYFUa7pfAow5e7qNgo6-ghc9.6.3.a */,
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.4-45BRYFUa7pfAow5e7qNgo6.a */,
);
path = Libraries;
sourceTree = "<group>";
@@ -2013,7 +2013,7 @@
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 309;
CURRENT_PROJECT_VERSION = 312;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
@@ -2063,7 +2063,7 @@
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 309;
CURRENT_PROJECT_VERSION = 312;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
@@ -2105,7 +2105,7 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 309;
CURRENT_PROJECT_VERSION = 312;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@@ -2125,7 +2125,7 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 309;
CURRENT_PROJECT_VERSION = 312;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@@ -2150,7 +2150,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 309;
CURRENT_PROJECT_VERSION = 312;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
GCC_OPTIMIZATION_LEVEL = s;
@@ -2187,7 +2187,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 309;
CURRENT_PROJECT_VERSION = 312;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_CODE_COVERAGE = NO;
@@ -2224,7 +2224,7 @@
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 309;
CURRENT_PROJECT_VERSION = 312;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -2275,7 +2275,7 @@
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 309;
CURRENT_PROJECT_VERSION = 312;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -2326,7 +2326,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 309;
CURRENT_PROJECT_VERSION = 312;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -2360,7 +2360,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 309;
CURRENT_PROJECT_VERSION = 312;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
+29 -30
View File
@@ -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
}
}
}
+1 -3
View File
@@ -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 {
@@ -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<GroupMemberRole>? =
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
}
}
@@ -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) {
@@ -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<API> = 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<SMPServerSubs, Boolean>? {
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<Boolean>? = 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<Long>, withMessages: Boolean = false): Pair<GroupInfo, List<GroupMember>>? {
suspend fun apiRemoveMembers(rh: Long?, groupId: Long, memberIds: List<Long>, withMessages: Boolean): Pair<GroupInfo, List<GroupMember>>? {
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))) {
@@ -56,6 +56,7 @@ fun initChatControllerOnStart() {
}
suspend fun initChatController(useKey: String? = null, confirmMigrations: MigrationConfirmation? = null, startChat: () -> CompletableDeferred<Boolean> = { 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()
@@ -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
)
}
)
}
@@ -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,
)
@@ -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<Long>, 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<Long>, onSuccess: () -> Unit = {}) {
fun removeMembers(rhId: Long?, groupInfo: GroupInfo, memberIds: List<Long>, 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()
@@ -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 = {},
@@ -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()
}
}
}
@@ -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()
}
@@ -366,6 +366,7 @@ fun startChat(
chatDbChanged: MutableState<Boolean>,
progressIndicator: MutableState<Boolean>? = 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
@@ -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:
@@ -633,7 +633,7 @@ private fun MutableState<MigrationToState?>.startDownloading(
private fun MutableState<MigrationToState?>.importArchive(archivePath: String, netCfg: NetCfg, networkProxy: NetworkProxy?) {
withLongRunningApi {
try {
if (ChatController.ctrl == null || ChatController.ctrl == -1L) {
if (!ChatController.hasChatCtrl()) {
chatInitControllerRemovingDatabases()
}
controller.apiDeleteStorage()
@@ -1861,14 +1861,19 @@
<!-- GroupMemberInfoView.kt -->
<string name="button_remove_member_question">Remove member?</string>
<string name="button_remove_members_question">Remove members?</string>
<string name="button_delete_member_messages_question">Delete member messages?</string>
<string name="button_remove_member">Remove member</string>
<string name="button_delete_member_messages">Delete member messages</string>
<string name="button_support_chat_member">Chat with member</string>
<string name="button_send_direct_message">Send direct message</string>
<string name="member_will_be_removed_from_group_cannot_be_undone">Member will be removed from group - this cannot be undone!</string>
<string name="members_will_be_removed_from_group_cannot_be_undone">Members will be removed from group - this cannot be undone!</string>
<string name="member_will_be_removed_from_chat_cannot_be_undone">Member will be removed from chat - this cannot be undone!</string>
<string name="members_will_be_removed_from_chat_cannot_be_undone">Members will be removed from chat - this cannot be undone!</string>
<string name="member_messages_will_be_deleted_cannot_be_undone">Member messages will be deleted - this cannot be undone!</string>
<string name="remove_member_confirmation">Remove</string>
<string name="remove_member_delete_messages_confirmation">Remove and delete messages</string>
<string name="delete_member_messages_confirmation">Delete messages</string>
<string name="remove_member_button">Remove member</string>
<string name="block_member_question">Block member?</string>
<string name="block_member_button">Block member</string>
+4 -4
View File
@@ -24,13 +24,13 @@ android.nonTransitiveRClass=true
kotlin.mpp.androidSourceSetLayoutVersion=2
kotlin.jvm.target=11
android.version_name=6.5-beta.0
android.version_code=323
android.version_name=6.5-beta.1
android.version_code=327
android.bundle=false
desktop.version_name=6.5-beta.0
desktop.version_code=122
desktop.version_name=6.5-beta.1
desktop.version_code=124
kotlin.version=2.1.20
gradle.plugin.version=8.7.0
@@ -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}
+12 -3
View File
@@ -2198,6 +2198,7 @@ Known:
**Record type**:
- groupMemberId: int64
- groupId: int64
- indexInGroup: int64
- memberId: string
- memberRole: [GroupMemberRole](#groupmemberrole)
- memberCategory: [GroupMemberCategory](#groupmembercategory)
@@ -3348,6 +3349,14 @@ GroupMemberNotFound:
- type: "groupMemberNotFound"
- groupMemberId: int64
GroupMemberNotFoundByIndex:
- type: "groupMemberNotFoundByIndex"
- groupMemberIndex: int64
MemberRelationsVectorNotFound:
- type: "memberRelationsVectorNotFound"
- groupMemberId: int64
GroupHostMemberNotFound:
- type: "groupHostMemberNotFound"
- groupId: int64
@@ -3360,6 +3369,9 @@ MemberContactGroupMemberNotFound:
- type: "memberContactGroupMemberNotFound"
- contactId: int64
InvalidMemberRelationUpdate:
- type: "invalidMemberRelationUpdate"
GroupWithoutUser:
- type: "groupWithoutUser"
@@ -3446,9 +3458,6 @@ PendingConnectionNotFound:
- type: "pendingConnectionNotFound"
- connId: int64
IntroNotFound:
- type: "introNotFound"
UniqueID:
- type: "uniqueID"
+1 -1
View File
@@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd
source-repository-package
type: git
location: https://github.com/simplex-chat/simplexmq.git
tag: 1ae3e8d0be957aa5090e88f25e6dc42d4af1a334
tag: 2ca440dd2dfd494ff2bb40cc0409d08069d02e04
source-repository-package
type: git
+30
View File
@@ -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
+2 -2
View File
@@ -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.
+36 -18
View File
@@ -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.
<h3>Community Builder</h3>
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**.
@@ -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.
@@ -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<br>- we want to avoid duplicate forwards)
note left of A: Alice vectors<br>For Cath: Dan - MRNew<br>For Dan: Cath - MRNew
note right of B: Bob vectors<br>For Cath: Dan - MRIntroduced<br>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)<br>(x.grp.mem.con from either is enough)
note right of B: Bob vectors<br>For Cath: Dan - MRConnected<br>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.
+41 -10
View File
@@ -40,12 +40,25 @@
src = ./.;
};
sha256map = import ./scripts/nix/sha256map.nix;
modules = [{
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;
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
@@ -176,7 +189,7 @@
# for android we build a shared library, passing these arguments is a bit tricky, as
# we want only the threaded rts (HSrts_thr) and ffi to be linked, but not fed into iserv for
# template haskell cross compilation. Thus we just pass them as linker options (-optl).
setupBuildFlags = map (x: "--ghc-option=${x}") [ "-shared" "-o" "libsimplex.so" "-optl-lHSrts_thr" "-optl-lffi"];
setupBuildFlags = map (x: "--ghc-option=${x}") [ "-shared" "-o" "libsimplex.so" "-optl-lHSrts_thr" "-optl-lffi" "-j1"];
postInstall = ''
set -x
${pkgs.tree}/bin/tree $out
@@ -214,7 +227,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)\"" \
@@ -242,7 +264,7 @@
# for android we build a shared library, passing these arguments is a bit tricky, as
# we want only the threaded rts (HSrts_thr) and ffi to be linked, but not fed into iserv for
# template haskell cross compilation. Thus we just pass them as linker options (-optl).
setupBuildFlags = map (x: "--ghc-option=${x}") [ "-shared" "-o" "libsimplex.so" "-optl-lHSrts_thr" "-optl-lffi"];
setupBuildFlags = map (x: "--ghc-option=${x}") [ "-shared" "-o" "libsimplex.so" "-optl-lHSrts_thr" "-optl-lffi" "-j1"];
postInstall = ''
set -x
${pkgs.tree}/bin/tree $out
@@ -280,7 +302,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)\"" \
@@ -2487,6 +2487,7 @@ export namespace GroupLinkPlan {
export interface GroupMember {
groupMemberId: number // int64
groupId: number // int64
indexInGroup: number // int64
memberId: string
memberRole: GroupMemberRole
memberCategory: GroupMemberCategory
@@ -3734,9 +3735,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
@@ -3760,7 +3764,6 @@ export type StoreError =
| StoreError.ConnectionNotFoundById
| StoreError.ConnectionNotFoundByMemberId
| StoreError.PendingConnectionNotFound
| StoreError.IntroNotFound
| StoreError.UniqueID
| StoreError.LargeMsg
| StoreError.InternalError
@@ -3819,9 +3822,12 @@ export namespace StoreError {
| "groupNotFoundByName"
| "groupMemberNameNotFound"
| "groupMemberNotFound"
| "groupMemberNotFoundByIndex"
| "memberRelationsVectorNotFound"
| "groupHostMemberNotFound"
| "groupMemberNotFoundByMemberId"
| "memberContactGroupMemberNotFound"
| "invalidMemberRelationUpdate"
| "groupWithoutUser"
| "duplicateGroupMember"
| "groupAlreadyJoined"
@@ -3845,7 +3851,6 @@ export namespace StoreError {
| "connectionNotFoundById"
| "connectionNotFoundByMemberId"
| "pendingConnectionNotFound"
| "introNotFound"
| "uniqueID"
| "largeMsg"
| "internalError"
@@ -3987,6 +3992,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
@@ -4002,6 +4017,10 @@ export namespace StoreError {
contactId: number // int64
}
export interface InvalidMemberRelationUpdate extends Interface {
type: "invalidMemberRelationUpdate"
}
export interface GroupWithoutUser extends Interface {
type: "groupWithoutUser"
}
@@ -4111,10 +4130,6 @@ export namespace StoreError {
connId: number // int64
}
export interface IntroNotFound extends Interface {
type: "introNotFound"
}
export interface UniqueID extends Interface {
type: "uniqueID"
}
+7 -2
View File
@@ -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"
+37
View File
@@ -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 <chat@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"
@@ -38,6 +38,48 @@
</description>
<releases>
<release version="6.4.8" date="2025-12-11">
<url type="details">https://simplex.chat/blog/20250729-simplex-chat-v6-4-1-welcome-contacts-protect-groups-app-security.html</url>
<description>
<p>New in v6.4.8:</p>
<ul>
<li>fix stuck message reception and other events after passphrase change (e.g., during desktop app initial start)</li>
</ul>
<p>New in v6.4-6.4.7:</p>
<ul>
<li>new UX to connect.</li>
<li>review new group members.</li>
<li>chat with group admins.</li>
<li>new UI languages: Catalan, Indonesian, Romanian and Vietnamese.</li>
<li>Linux app builds for aarch64 CPUs</li>
<li>UI support for bot commands.</li>
<li>support markdown hyperlinks, such as [click here](https://example.com).</li>
<li>option to remove tracking parameters from the links.</li>
<li>better information about network errors.</li>
</ul>
</description>
</release>
<release version="6.4.7" date="2025-11-03">
<url type="details">https://simplex.chat/blog/20250729-simplex-chat-v6-4-1-welcome-contacts-protect-groups-app-security.html</url>
<description>
<p>New in v6.4.7:</p>
<ul>
<li>fix exporting database larger than 4gb.</li>
</ul>
<p>New in v6.4-6.4.6:</p>
<ul>
<li>new UX to connect.</li>
<li>review new group members.</li>
<li>chat with group admins.</li>
<li>new UI languages: Catalan, Indonesian, Romanian and Vietnamese.</li>
<li>Linux app builds for aarch64 CPUs</li>
<li>UI support for bot commands.</li>
<li>support markdown hyperlinks, such as [click here](https://example.com).</li>
<li>option to remove tracking parameters from the links.</li>
<li>better information about network errors.</li>
</ul>
</description>
</release>
<release version="6.4.6" date="2025-10-05">
<url type="details">https://simplex.chat/blog/20250729-simplex-chat-v6-4-1-welcome-contacts-protect-groups-app-security.html</url>
<description>
+1 -1
View File
@@ -1,5 +1,5 @@
{
"https://github.com/simplex-chat/simplexmq.git"."1ae3e8d0be957aa5090e88f25e6dc42d4af1a334" = "1cwahakq63jk7g0bbkdgpnnwa8i0i8s8j7azdpjral4d6cj4q4q0";
"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";
+117 -99
View File
@@ -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
+7 -1
View File
@@ -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,6 +122,8 @@ 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.M20251117_member_relations_vector
Simplex.Chat.Store.Postgres.Migrations.M20251128_member_relations_vector_stage_2
else
exposed-modules:
Simplex.Chat.Archive
@@ -266,6 +269,8 @@ 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.M20251117_member_relations_vector
Simplex.Chat.Store.SQLite.Migrations.M20251128_member_relations_vector_stage_2
other-modules:
Paths_simplex_chat
hs-source-dirs:
@@ -542,6 +547,7 @@ test-suite simplex-chat-test
JSONFixtures
JSONTests
MarkdownTests
MemberRelationsTests
MessageBatching
OperatorTests
ProtocolTests
+2 -2
View File
@@ -118,8 +118,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
+56 -17
View File
@@ -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 <-
@@ -1774,8 +1781,9 @@ processChatCommand vr nm = \case
incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing
subMode <- chatReadVar subscriptionMode
let userData = contactShortLinkData (userProfileDirect user incognitoProfile Nothing True) Nothing
userLinkData = UserInvLinkData userData
-- TODO [certs rcv]
(connId, (ccLink, _serviceId)) <- withAgent $ \a -> createConnection a nm (aUserId user) True False SCMInvitation (Just userData) Nothing IKPQOn subMode
(connId, (ccLink, _serviceId)) <- withAgent $ \a -> createConnection a nm (aUserId user) True False SCMInvitation (Just userLinkData) Nothing IKPQOn subMode
ccLink' <- shortenCreatedLink ccLink
-- TODO PQ pass minVersion from the current range
conn <- withFastStore' $ \db -> createDirectConnection db user connId ccLink' Nothing ConnNew incognitoProfile subMode initialChatVersion PQSupportOn
@@ -1813,11 +1821,11 @@ processChatCommand vr nm = \case
recreateConn user conn@PendingContactConnection {customUserProfileId, connLinkInv} newUser = do
subMode <- chatReadVar subscriptionMode
let short = isJust $ connShortLink =<< connLinkInv
userData_
| short = Just $ contactShortLinkData (userProfileDirect newUser Nothing Nothing True) Nothing
userLinkData_
| short = Just $ UserInvLinkData $ contactShortLinkData (userProfileDirect newUser Nothing Nothing True) Nothing
| otherwise = Nothing
-- TODO [certs rcv]
(agConnId, (ccLink, _serviceId)) <- withAgent $ \a -> createConnection a nm (aUserId newUser) True False SCMInvitation userData_ Nothing IKPQOn subMode
(agConnId, (ccLink, _serviceId)) <- withAgent $ \a -> createConnection a nm (aUserId newUser) True False SCMInvitation userLinkData_ Nothing IKPQOn subMode
ccLink' <- shortenCreatedLink ccLink
conn' <- withFastStore' $ \db -> do
deleteConnectionRecord db user connId
@@ -2008,8 +2016,9 @@ processChatCommand vr nm = \case
Right _ -> throwError $ ChatErrorStore SEDuplicateContactLink
subMode <- chatReadVar subscriptionMode
let userData = contactShortLinkData (userProfileDirect user Nothing Nothing True) Nothing
userLinkData = UserContactLinkData UserContactData {direct = True, owners = [], relays = [], userData}
-- TODO [certs rcv]
(connId, (ccLink, _serviceId)) <- withAgent $ \a -> createConnection a nm (aUserId user) True True SCMContact (Just userData) Nothing IKPQOn subMode
(connId, (ccLink, _serviceId)) <- withAgent $ \a -> createConnection a nm (aUserId user) True True SCMContact (Just userLinkData) Nothing IKPQOn subMode
ccLink' <- shortenCreatedLink ccLink
withFastStore $ \db -> createUserContactLink db user connId ccLink' subMode
pure $ CRUserContactLinkCreated user ccLink'
@@ -2338,7 +2347,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
@@ -2520,19 +2535,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.
@@ -2631,9 +2652,10 @@ processChatCommand vr nm = \case
groupLinkId <- GroupLinkId <$> drgRandomBytes 16
subMode <- chatReadVar subscriptionMode
let userData = encodeShortLinkData $ GroupShortLinkData groupProfile
userLinkData = UserContactLinkData UserContactData {direct = True, owners = [], relays = [], userData}
crClientData = encodeJSON $ CRDataGroup groupLinkId
-- TODO [certs rcv]
(connId, (ccLink, _serviceId)) <- withAgent $ \a -> createConnection a nm (aUserId user) True True SCMContact (Just userData) (Just crClientData) IKPQOff subMode
(connId, (ccLink, _serviceId)) <- withAgent $ \a -> createConnection a nm (aUserId user) True True SCMContact (Just userLinkData) (Just crClientData) IKPQOff subMode
ccLink' <- createdGroupLink <$> shortenCreatedLink ccLink
gVar <- asks random
gLink <- withFastStore $ \db -> createGroupLink db gVar user gInfo connId ccLink' groupLinkId mRole subMode
@@ -3297,7 +3319,8 @@ processChatCommand vr nm = \case
let shortLinkProfile = userProfileDirect user Nothing Nothing True
-- TODO [short links] do not save address to server if data did not change, spinners, error handling
userData = contactShortLinkData shortLinkProfile $ Just addressSettings
sLnk <- shortenShortLink' =<< withAgent (\a -> setConnShortLink a nm (aConnId conn) SCMContact userData Nothing)
userLinkData = UserContactLinkData UserContactData {direct = True, owners = [], relays = [], userData}
sLnk <- shortenShortLink' =<< withAgent (\a -> setConnShortLink a nm (aConnId conn) SCMContact userLinkData Nothing)
withFastStore' $ \db -> setUserContactLinkShortLink db userContactLinkId sLnk
let autoAccept' = (\aa -> aa {acceptIncognito = False}) <$> autoAccept addressSettings
ucl' = (ucl :: UserContactLink) {connLinkContact = CCLink connFullLink (Just sLnk), shortLinkDataSet = True, shortLinkLargeDataSet = BoolDef True, addressSettings = addressSettings {autoAccept = autoAccept'}}
@@ -3705,7 +3728,7 @@ processChatCommand vr nm = \case
l' <- restoreShortLink' l
(cReq, cData) <- withAgent $ \a -> getConnShortLink a nm (aUserId user) l'
case cData of
ContactLinkData {direct} | not direct -> throwChatError CEUnsupportedConnReq
ContactLinkData _ UserContactData {direct} | not direct -> throwChatError CEUnsupportedConnReq
_ -> pure ()
pure (cReq, cData)
-- This function is needed, as UI uses simplex:/ schema in message view, so that the links can be handled without browser,
@@ -3725,7 +3748,8 @@ processChatCommand vr nm = \case
updatePCCShortLinkData conn@PendingContactConnection {connLinkInv} profile =
forM (connShortLink =<< connLinkInv) $ \_ -> do
let userData = contactShortLinkData profile Nothing
shortenShortLink' =<< withAgent (\a -> setConnShortLink a nm (aConnId' conn) SCMInvitation userData Nothing)
userLinkData = UserInvLinkData userData
shortenShortLink' =<< withAgent (\a -> setConnShortLink a nm (aConnId' conn) SCMInvitation userLinkData Nothing)
updateCIGroupInvitationStatus :: User -> GroupInfo -> CIGroupInvitationStatus -> CM ()
updateCIGroupInvitationStatus user GroupInfo {groupId} newStatus = do
AChatItem _ _ cInfo ChatItem {content, meta = CIMeta {itemId}} <- withFastStore $ \db -> getChatItemByGroupId db vr user groupId
@@ -4152,6 +4176,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)
+76 -39
View File
@@ -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
@@ -1232,8 +1252,9 @@ setGroupLinkData nm user gInfo@GroupInfo {groupProfile} gLink@GroupLink {groupLi
vr <- chatVersionRange
conn <- withFastStore $ \db -> getGroupLinkConnection db vr user gInfo
let userData = encodeShortLinkData $ GroupShortLinkData groupProfile
userLinkData = UserContactLinkData UserContactData {direct = True, owners = [], relays = [], userData}
crClientData = encodeJSON $ CRDataGroup groupLinkId
sLnk <- shortenShortLink' . toShortGroupLink =<< withAgent (\a -> setConnShortLink a nm (aConnId conn) SCMContact userData (Just crClientData))
sLnk <- shortenShortLink' . toShortGroupLink =<< withAgent (\a -> setConnShortLink a nm (aConnId conn) SCMContact userLinkData (Just crClientData))
withFastStore' $ \db -> setGroupLinkShortLink db gLink sLnk
encodeShortLinkData :: J.ToJSON a => a -> UserLinkData
@@ -1656,19 +1677,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
@@ -2046,8 +2083,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
@@ -2055,7 +2092,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
+61 -103
View File
@@ -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 =
+2 -2
View File
@@ -295,8 +295,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
+1 -1
View File
@@ -63,7 +63,7 @@ previousConditionsCommit = "a5061f3147165a05979d6ace33960aced2d6ac03"
usageConditionsText :: Text
usageConditionsText =
$( let s = $(embedFile =<< makeRelativeToProject "PRIVACY.md")
$( let s = $(embedFile "PRIVACY.md")
in [|stripFrontMatter $(lift (safeDecodeUtf8 s))|]
)
+5 -2
View File
@@ -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
+11 -2
View File
@@ -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
+2 -2
View File
@@ -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.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.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.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
+1 -1
View File
@@ -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
+417 -256
View File
@@ -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 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 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') :. (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') :. (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') :. (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') :. (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,
@@ -484,40 +514,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
@@ -542,16 +576,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
@@ -737,16 +772,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
@@ -970,6 +1006,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) $
@@ -993,13 +1045,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)
@@ -1067,21 +1145,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,
@@ -1101,45 +1180,50 @@ createNewContactMember db gVar User {userId, userContactId} GroupInfo {groupId,
supportChat = Nothing
}
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
@@ -1161,26 +1245,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} =
@@ -1242,22 +1328,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)
@@ -1327,9 +1415,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
@@ -1351,11 +1438,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'
@@ -1417,7 +1500,7 @@ createNewGroupMember db user gInfo invitingMember memInfo@MemberInfo {profile} m
memContactId = Nothing,
memProfileId
}
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 =
@@ -1429,7 +1512,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}
@@ -1449,24 +1532,27 @@ 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, 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, 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, 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, invitedById, memInvitedByGroupMemberId)
:. (userId, localDisplayName, memberContactId, memberContactProfileId, createdAt, createdAt)
:. (minV, maxV)
)
groupMemberId <- liftIO $ insertedRowId db
pure
GroupMember
{ groupMemberId,
groupId,
indexInGroup,
memberId,
memberRole,
memberCategory,
@@ -1516,16 +1602,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
@@ -1540,8 +1624,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 =
@@ -1551,6 +1634,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
@@ -1563,9 +1805,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|
@@ -1587,106 +1829,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
@@ -1704,11 +1846,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}
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
@@ -2468,21 +2609,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
@@ -2576,6 +2718,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
+5 -5
View File
@@ -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.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.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.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.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,
@@ -21,6 +21,8 @@ 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.M20251117_member_relations_vector
-- import Simplex.Chat.Store.Postgres.Migrations.M20251128_member_relations_vector_stage_2
import Simplex.Messaging.Agent.Store.Shared (Migration (..))
schemaMigrations :: [(String, Text, Maybe Text)]
@@ -41,7 +43,9 @@ schemaMigrations =
("20250919_group_summary", m20250919_group_summary, Just down_m20250919_group_summary),
("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)
("20251017_chat_tags_cascade", m20251017_chat_tags_cascade, Just down_m20251017_chat_tags_cascade),
("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)
]
-- | The list of migrations in ascending order by date
@@ -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;
|]
@@ -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|
|]
@@ -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;
@@ -706,7 +780,9 @@ 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
);
@@ -805,7 +881,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
);
@@ -2081,6 +2158,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);
+5 -1
View File
@@ -144,6 +144,8 @@ 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.M20251117_member_relations_vector
-- import Simplex.Chat.Store.SQLite.Migrations.M20251128_member_relations_vector_stage_2
import Simplex.Messaging.Agent.Store.Shared (Migration (..))
schemaMigrations :: [(String, Query, Maybe Query)]
@@ -287,7 +289,9 @@ schemaMigrations =
("20250919_group_summary", m20250919_group_summary, Just down_m20250919_group_summary),
("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)
("20251017_chat_tags_cascade", m20251017_chat_tags_cascade, Just down_m20251017_chat_tags_cascade),
("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)
]
-- | The list of migrations in ascending order by date
@@ -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;
|]
@@ -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|
|]
@@ -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.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.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.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.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,
@@ -964,10 +991,10 @@ 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, 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=?)
@@ -1064,7 +1091,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.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,
@@ -1072,13 +1099,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.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.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,
@@ -1157,24 +1184,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 = ?)
@@ -1421,28 +1430,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
@@ -1611,44 +1598,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, 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=?)
@@ -3509,6 +3462,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
@@ -3559,14 +3520,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
@@ -3655,14 +3608,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
@@ -3782,6 +3727,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 = ?
@@ -3790,6 +3769,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 = ?
@@ -4460,7 +4456,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
@@ -4730,12 +4726,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
@@ -4970,7 +4966,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.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,
@@ -5004,7 +5000,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.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,
@@ -5031,7 +5027,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.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,
@@ -5080,7 +5076,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.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.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,
@@ -5107,7 +5103,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.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.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,
@@ -5126,7 +5122,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.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.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.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.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,
@@ -5139,13 +5173,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.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.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,
@@ -5164,7 +5198,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.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.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,
@@ -5183,7 +5217,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.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.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,
@@ -5202,7 +5236,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.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.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,
@@ -5823,7 +5857,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 = ?
@@ -6055,9 +6089,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:
@@ -6079,7 +6113,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=?)
@@ -6089,6 +6123,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=?)
@@ -6257,6 +6297,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=?)
@@ -6273,10 +6321,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=?)
@@ -6497,6 +6541,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=?)
@@ -155,7 +155,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
@@ -195,6 +196,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,
FOREIGN KEY(user_id, local_display_name)
REFERENCES display_names(user_id, local_display_name)
ON DELETE CASCADE
@@ -1184,6 +1187,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 TRIGGER on_group_members_insert_update_summary
AFTER INSERT ON group_members
FOR EACH ROW
+7 -5
View File
@@ -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) :. (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) :. (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_) :. (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_) :. (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_
@@ -702,7 +704,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.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.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,
@@ -742,7 +744,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.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,
+1
View File
@@ -921,6 +921,7 @@ type GroupMemberId = Int64
data GroupMember = GroupMember
{ groupMemberId :: GroupMemberId,
groupId :: GroupId,
indexInGroup :: Int64,
memberId :: MemberId,
memberRole :: GroupMemberRole,
memberCategory :: GroupMemberCategory,
+158
View File
@@ -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
-1
View File
@@ -24,7 +24,6 @@ import Data.Function (on)
import Data.Int (Int64)
import Data.List (groupBy, intercalate, intersperse, sortOn)
import Data.List.NonEmpty (NonEmpty (..))
import qualified Data.List.NonEmpty as L
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as M
import Data.Maybe (fromMaybe, isJust, isNothing, mapMaybe)
+3
View File
@@ -479,6 +479,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
+264 -5
View File
@@ -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
+3
View File
@@ -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
+300
View File
@@ -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)
+1 -1
View File
@@ -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
+6 -5
View File
@@ -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
+2
View File
@@ -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
+3 -2
View File
@@ -264,8 +264,9 @@ module.exports = function (ty) {
else if (obj.lang === "en")
return `${obj.url}`
return `/${obj.lang}${obj.url}`
}
else if (supportedLangs.includes(urlParts[1])) {
} else if (urlParts[1] === "old") {
return `/${obj.lang}${obj.url}`
} else if (supportedLangs.includes(urlParts[1])) {
if (urlParts[2] == "blog")
return `/blog`
else if (obj.lang === "en")
+1 -1
View File
@@ -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 <strong>holen Sie sich Ihren kostenlosen NFT</strong><br>zum frühzeitigen ausprobieren.",
"index-token-cta": "Erfahren Sie mehr und <strong>holen Sie sich Ihren kostenlosen NFT</strong> 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",
+3 -2
View File
@@ -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 <strong>get your free NFT</strong><br>for early testing.",
"index-token-cta": "Learn more and <strong>get a free access pass</strong> 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\" &mdash; it protects only some of the ratchet steps."
"messengers-comparison-section-list-point-6": "Post-quantum key agreement is \"sparse\" &mdash; it protects only some of the ratchet steps.",
"navbar-old-site": "Old site"
}
+1 -1
View File
@@ -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 <strong>obtén tu NFT gratuito</strong><br>por participar en las pruebas.",
"index-token-cta": "Descubre más y <strong>obtén tu NFT gratuito</strong> 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",
+1 -1
View File
@@ -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 <strong>szerezzen ingyenes NFT-t</strong><br>az előzetes tesztelésért.",
"index-token-cta": "Tudjon meg többet, és <strong>szerezzen ingyenes NFT-t</strong> 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",
+1 -1
View File
@@ -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 <strong>ricevi un NFT gratuito</strong><br>per provarlo in anticipo.",
"index-token-cta": "Scopri di più e <strong>ricevi un NFT gratuito</strong> 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",
+1 -1
View File
@@ -283,7 +283,7 @@
"index-token-h2": "Стабильные Сообщества",
"index-token-p1": "Вы сможете поддерживать Ваши любимые группы с помощью будущих Ваучеров Групп.",
"index-token-p2": "Ваучеры будут использоваться для оплаты за серверы, чтобы группы оставались свободными и независимыми.",
"index-token-cta": "Узнайте больше и <strong> возьмите бесплатный NFT</strong>, чтобы участвовать в тестировании.",
"index-token-cta": "Узнайте больше и <strong> возьмите бесплатный пропуск</strong>, чтобы участвовать в тестировании.",
"index-roadmap-h2": "Путь Сети SimpleX к Свободному Интернету",
"index-roadmap-2025": "2025",
"index-roadmap-2025-title": "Большие каналы и группы",
+1 -1
View File
@@ -19,7 +19,7 @@
{% if image %}
<meta property="og:image" content="{% cfg 'siteLocation' %}/blog/{{ image }}" />
{% else %}
<meta property="og:image" content="{% cfg 'siteLocation' %}/img/share_simplex.png" />
<meta property="og:image" content="{% cfg 'siteLocation' %}/img/share_simplex.jpg" />
{% endif %}
<meta name="twitter:card" content="summary" />
<link rel="icon" type="image/png" sizes="96x96" href="/img/favicon.ico" />
+2 -2
View File
@@ -77,7 +77,7 @@
</div>
{% endif %}
<div class="sticky top-[66px] bg-white dark:bg-[#071C46] z-[49] !py-2 lg:hidden">
<!-- <div class="sticky top-[66px] bg-white dark:bg-[#071C46] z-[49] !py-2 lg:hidden">
<div class="relative flex items-center justify-between">
<button class="menu flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false" viewBox="0 0 24 24" class="menu-icon h-4 w-4 fill-[rgb(60,60,60)] dark:fill-white">
@@ -106,7 +106,7 @@
</ul>
</div>
</div>
</div>
</div> -->
<div>{{ content | safe }}</div>
</article>
</main>
+1 -1
View File
@@ -22,7 +22,7 @@
<meta property="og:type" content="website"/>
<meta property="og:title" content="{{ title }}"/>
<meta property="og:description" content="{{ description }}"/>
<meta property="og:image" content="{% cfg 'siteLocation' %}/img/share_simplex.png"/>
<meta property="og:image" content="{% cfg 'siteLocation' %}/img/share_simplex.jpg"/>
<meta name="twitter:card" content="summary"/>
<link rel="icon" type="image/png" sizes="96x96" href="/img/favicon.ico"/>
+1 -1
View File
@@ -19,7 +19,7 @@
{% if image %}
<meta property="og:image" content="{% cfg 'siteLocation' %}/blog/{{ image }}" />
{% else %}
<meta property="og:image" content="{% cfg 'siteLocation' %}/img/share_simplex.png" />
<meta property="og:image" content="{% cfg 'siteLocation' %}/img/share_simplex.jpg" />
{% endif %}
<meta name="twitter:card" content="summary" />
<link rel="icon" type="image/png" sizes="96x96" href="/img/favicon.ico" />
@@ -10,12 +10,12 @@
<meta property="og:type" content="website"/>
<meta property="og:title" content="{{ title }}"/>
<meta property="og:description" content="{{ description }}"/>
<meta property="og:image" content="{% cfg 'siteLocation' %}/img/share_simplex.png"/>
<meta property="og:image" content="{% cfg 'siteLocation' %}/img/share_simplex.jpg"/>
<meta name="twitter:card" content="summary"/>
<link rel="icon" type="image/png" sizes="96x96" href="/img/favicon.ico"/>
<meta http-equiv="refresh" content="0; url={{ groupLink }}" />
<meta http-equiv="refresh" content="0; url={{ destinationURI }}" />
</head>
<body>
<p><a href="{{ groupLink }}">{{ groupLinkText }}</a></p>
<p><a href="{{ destinationURI }}">{{ destinationText }}</a></p>
</body>
</html>
+1 -1
View File
@@ -16,7 +16,7 @@
<meta property="og:url" content="{% cfg 'siteLocation' %}{{ permalink }}" />
<meta property="og:type" content="article" />
<meta property="og:title" content="{{ title }}" />
<meta property="og:image" content="{% cfg 'siteLocation' %}/img/share_simplex.png" />
<meta property="og:image" content="{% cfg 'siteLocation' %}/img/share_simplex.jpg" />
<meta name="twitter:card" content="summary" />
<link rel="icon" type="image/png" sizes="96x96" href="/img/favicon.ico" />
<link href="/css/tailwind.css" rel="stylesheet" />
+23 -34
View File
@@ -111,9 +111,11 @@
</nav>
<div class="right-links">
<a class="box-btn token md:hidden" href="/token">
{{ 'navbar-token' | i18n({}, lang) }}
</a>
{% if ('donate' not in page.url) %}
<a class="box-btn token md:hidden" href="/donate">
{{ 'donate' | i18n({}, lang) }}
</a>
{% endif %}
<button href="#" class="theme-switch-btn">
<svg class="sun" width="469" height="469" viewBox="0 0 469 469" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M225.768 1.44594C220.301 3.84594 214.701 10.6459 213.768 15.9793C213.234 18.3793 213.101 31.7126 213.368 45.7126L213.768 71.1793L217.634 76.1126C226.701 88.1126 242.968 87.5793 252.301 75.0459C254.968 71.5793 255.101 69.5793 255.101 42.2459C255.101 14.9126 254.968 12.9126 252.301 9.44594C250.834 7.44594 248.034 4.51261 246.168 3.17928C241.768 -0.154057 231.101 -1.08739 225.768 1.44594Z"/>
@@ -132,7 +134,7 @@
</svg>
</button>
{% if ('blog' not in page.url) and ('about' not in page.url) and ('privacy' not in page.url) and ('directory' not in page.url) and ('token' not in page.url) %}
{% if ('blog' not in page.url) and ('about' not in page.url) and ('donate' not in page.url) and ('privacy' not in page.url) and ('directory' not in page.url) and ('vouchers' not in page.url) %}
<div class="nav-link flag-container">
<a href="javascript:void(0);">
{% for language in languages.languages %}
@@ -151,15 +153,9 @@
{% for language in languages.languages %}
{% if language.label == supportedLang %}
<li>
{% if language.flag %}
<a href="{% completeRoute {url:page.url,lang:language.label} %}" class="flag-anchor">
<p>{{ language.name }}</p>
</a>
{% else %}
<a href="{% completeRoute {url:page.url,lang:language.label} %}" class="flag-anchor">
<p>{{ language.name }}</p>
</a>
{% endif %}
<a href="{% completeRoute {url: page.url, lang: language.label} %}">
<p>{{ language.name }}</p>
</a>
</li>
{% endif %}
{% endfor %}
@@ -168,15 +164,9 @@
{% for language in languages.languages %}
{% if language.enabled and (language.home or (page.url != '/' and page.url != '/' + lang + '/')) %}
<li>
{% if language.flag %}
<a href="{% completeRoute {url:page.url,lang:language.label} %}" class="flag-anchor">
<p>{{ language.name }}</p>
</a>
{% else %}
<a href="{% completeRoute {url:page.url,lang:language.label} %}" class="flag-anchor">
<p>{{ language.name }}</p>
</a>
{% endif %}
<a href="{% completeRoute {url: page.url, lang: language.label} %}">
<p>{{ language.name }}</p>
</a>
</li>
{% endif %}
{% endfor %}
@@ -215,41 +205,40 @@ const userTheme = localStorage.getItem('theme');
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches;
const prismThemeLink = document.getElementById('prism-theme')
const iconToggle = () => {
function iconToggle () {
sunIcon.classList.toggle('hidden');
moonIcon.classList.toggle('hidden');
}
const themeCheck = () => {
if(userTheme === 'dark' || (!userTheme && systemTheme)){
function themeCheck() {
if (userTheme === 'dark' || (!userTheme && systemTheme)) {
document.documentElement.classList.add('dark');
moonIcon.classList.add('hidden');
if(prismThemeLink){
if (prismThemeLink) {
prismThemeLink.setAttribute('href','/css/prism-dark.min.css')
}
}
else{
sunIcon.classList.add('hidden');
if(prismThemeLink){
if (prismThemeLink) {
prismThemeLink.setAttribute('href','/css/prism-light.min.css')
}
}
}
themeCheck();
const themeSwitch = () => {
if(document.documentElement.classList.contains('dark')){
function themeSwitch () {
if (document.documentElement.classList.contains('dark')) {
document.documentElement.classList.remove('dark');
localStorage.setItem('theme','light');
localStorage.setItem('theme', 'light');
if(prismThemeLink){
prismThemeLink.setAttribute('href','/css/prism-light.min.css')
}
iconToggle();
}
else{
} else {
document.documentElement.classList.add('dark');
localStorage.setItem('theme','dark');
if(prismThemeLink){
localStorage.setItem('theme', 'dark');
if (prismThemeLink) {
prismThemeLink.setAttribute('href','/css/prism-dark.min.css')
}
iconToggle();
+3 -3
View File
@@ -1,8 +1,8 @@
---
layout: layouts/group_link.html
layout: layouts/redirect.html
title: "SimpleX Chat - connect to the team"
description: "Connect to the team via SimpleX Chat to ask any questions and send feedback"
groupLink: "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion"
groupLinkText: Connect to the team
destinationURI: "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion"
destinationText: Connect to the team
templateEngineOverride: njk
---
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

+2 -2
View File
@@ -31,7 +31,7 @@ active_home: true
<meta property="og:type" content="website"/>
<meta property="og:title" content="{{ title }}"/>
<meta property="og:description" content="{{ description }}"/>
<meta property="og:image" content="{% cfg 'siteLocation' %}/img/share_simplex.png"/>
<meta property="og:image" content="{% cfg 'siteLocation' %}/img/share_simplex.jpg"/>
<meta name="twitter:card" content="summary"/>
<link rel="icon" type="image/png" sizes="96x96" href="/img/favicon.ico"/>
@@ -155,7 +155,7 @@ active_home: true
<h2>{{ "index-token-h2" | i18n({}, lang) }}</h2>
<p>{{ "index-token-p1" | i18n({}, lang) }}</p>
<p>{{ "index-token-p2" | i18n({}, lang) }}</p>
<a class="gradient-text" href="/token">{{ "index-token-cta" | i18n({}, lang) | safe }}</a>
<a class="gradient-text" href="/vouchers">{{ "index-token-cta" | i18n({}, lang) | safe }}</a>
</div>
</div>
</section>
-2
View File
@@ -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
-8
View File
@@ -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
---
+1 -1
View File
@@ -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
---
+244
View File
@@ -0,0 +1,244 @@
---
layout: layouts/main.html
title: "SimpleX Chat: private and secure messenger without any user IDs (not even random)"
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
active_home: true
---
{%- from "components/macro.njk" import overlay -%}
{% include "hero.html" %}
<section id="why-privacy" class="bg-secondary-bg-light dark:bg-secondary-bg-dark py-[90px] px-5 lg:h-[888px]">
<div class="container">
<h2 class="text-grey-black dark:text-white text-[35px] leading-[45px] md:leading-[55px] lg:text-[45px] text-center font-bold mb-4 md:mb-8">{{ "privacy-matters-section-header" | i18n({}, lang ) | safe }}</h2>
<p class="text-center text-[18px] md:text-[20px] font-medium mb-7 md:mb-16 lg:mb-20 text-black dark:text-white">{{ "privacy-matters-section-subheader" | i18n({}, lang ) | safe }}</p>
<div class="flex flex-col lg:flex-row gap-[20px] mb-[62px] lg:mb-[90px]">
{% for section in why_privacy_matters.sections %}
<div class="bg-white dark:bg-transparent px-[34px] flex flex-col md:flex-row lg:flex-col items-center rounded-[12px] dark:rounded-[6px] shadow-[0px_50px_61px_rgba(0,0,0,0.12)] dark:shadow-none flex-[1] border-gradient">
<div class="md:w-[30%] lg:w-full flex items-center justify-center h-[215px]">
<img src="{{ section.imgLight }}" alt="">
</div>
<div class="md:w-[70%] lg:w-full flex flex-col items-center md:items-start lg:items-center gap-5 mb-[54px] md:mb-0 lg:mb-[54px]">
<p class="font-medium text-xl text-center text-grey-black dark:text-white">{{ section.title | i18n({}, lang ) | safe }}</p>
{% if section.overlayContent %}
<a href="javascript:void(0);" data-show-overlay="{{ section.overlayContent.overlayId }}" class="open-overlay-btn underline text-primary-light dark:text-primary-dark block text-[16px] text-center underline-offset-2">{{ section.overlayContent.linkText | i18n({}, lang ) | safe }}</a>
{{ overlay(section,lang) }}
{% endif %}
</div>
</div>
{% endfor %}
</div>
<p class="text-center w-full max-w-[900px] text-[20px] leading-[28px] m-auto text-black dark:text-white">{{ "privacy-matters-section-label" | i18n({}, lang ) | safe }}</p>
</div>
</section>
{# Why SimpleX is unique #}
{% include "sections/simplex_unique.html" %}
{# Features #}
<section id="features" class="bg-secondary-bg-light dark:bg-secondary-bg-dark py-[95px] px-5 lg:h-[888px]">
<div class="container">
<h2 class="text-[35px] leading-[45px] md:leading-[55px] lg:text-[45px] text-center font-bold gradient-text mb-20">{{ "features" | i18n({}, lang ) | safe }}</h2>
<div class="mb-[50px] grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-x-10 gap-y-32">
{% for feature in features.sections %}
<div class="flex flex-col items-center">
<div class="bg-white flex items-center justify-center w-[175px] h-[110px] rounded-[20px] border-[#859096] border-solid border-[1px] shadow-[2px_2px_0px_#859096] dark:border-none dark:shadow-none mb-[20px] border-gradient">
<img src="{{ feature.imgLight }}" alt="" class="dark:hidden"/>
<img src="{{ feature.imgDark }}" alt="" class="hidden dark:block"/>
</div>
<p class="text-grey-black dark:text-white text-[16px] font-medium text-center w-full max-w-[265px]">{{ feature.title | i18n({}, lang ) | safe }}</p>
</div>
{% endfor %}
</div>
</div>
</section>
{# what makes simplex private #}
<section id="privacy" class="bg-primary-bg-light dark:bg-primary-bg-dark py-[90px] overflow-hidden px-5 lg:h-[888px]">
<div class="container scale-100">
<h2 class="text-grey-black dark:text-white text-[35px] leading-[45px] md:leading-[55px] lg:text-[45px] text-center font-bold mb-20">{{ "simplex-private-section-header" | i18n({}, lang ) | safe }}</h2>
<div class="swiper private-swiper overflow-hidden px-4 py-2">
<div class="swiper-wrapper mb-16">
{% for section in what_makes_simplex_private.sections %}
<div class="card swiper-slide relative h-[480px] rounded-[20px] bg-card-bg-light dark:bg-card-bg-dark border-[#859096] border-solid border-[1px] dark:border-none shadow-[2px_2px_0px_#859096] dark:shadow-none overflow-hidden">
<div class="flex items-center justify-center py-12 px-6 h-[322px]">
<img class="w-full max-w-[223px] h-full max-h-[226px] dark:hidden" src="{{ section.imgLight }}" alt=""/>
<img class="w-full max-w-[223px] h-full max-h-[226px] hidden dark:block" src="{{ section.imgDark }}" alt=""/>
</div>
<div class="card-content flex flex-col items-center justify-between h-[138px] absolute bottom-0 py-6 px-6 bg-card-desc-bg-light dark:bg-card-desc-bg-dark rounded-b-[20px]">
<h3 class="text-grey-black dark:text-white text-[18px] my-4 font-bold leading-[26px] tracking-[0.01em] text-center">{{ section.title | i18n({}, lang ) | safe }}</h3>
<div class="flex-1 py-3 flex flex-col gap-3">
{% for point in section.points %}
<p class="text-grey-black dark:text-white text-[14px] text-center">{{ point | i18n({}, lang ) | safe }}</p>
{% endfor %}
</div>
<p class="text-grey-black dark:text-white text-[12px] text-center">{{ "tap-to-close" | i18n({}, lang ) | safe }}</p>
</div>
</div>
{% endfor %}
</div>
<img class="fixed left-[-3px] top-[52%] swiper-button-prev private-swiper-button-prev md:inline-block rtl:!hidden dark:!hidden" src="/img/new/arrow-left.svg" alt="">
<img class="fixed right-[-6px] top-[52%] swiper-button-next private-swiper-button-next md:inline-block rtl:!hidden dark:!hidden" src="/img/new/arrow-right.svg" alt="">
<img class="fixed left-[-3px] top-[52%] swiper-button-prev private-swiper-button-prev hidden dark:md:inline-block rtl:!hidden" src="/img/new/arrow-left-dark.svg" alt="">
<img class="fixed right-[-6px] top-[52%] swiper-button-next private-swiper-button-next hidden dark:md:inline-block rtl:!hidden" src="/img/new/arrow-right-dark.svg" alt="">
<img class="fixed left-[-3px] top-[52%] swiper-button-next private-swiper-button-next md:rtl:inline-block ltr:!hidden dark:!hidden" src="/img/new/arrow-left.svg" alt="">
<img class="fixed right-[-6px] top-[52%] swiper-button-prev private-swiper-button-prev md:rtl:inline-block ltr:!hidden dark:!hidden" src="/img/new/arrow-right.svg" alt="">
<img class="fixed left-[-3px] top-[52%] swiper-button-next private-swiper-button-next hidden md:dark:inline-block ltr:!hidden" src="/img/new/arrow-left-dark.svg" alt="">
<img class="fixed right-[-6px] top-[52%] swiper-button-prev private-swiper-button-prev hidden md:dark:inline-block ltr:!hidden" src="/img/new/arrow-right-dark.svg" alt="">
<div class="swiper-scrollbar dark:bg-grey-black dark:bg-opacity-[0.2]"></div>
</div>
</div>
</section>
{# Network #}
<section id="network" class="bg-secondary-bg-light dark:bg-secondary-bg-dark lg:h-[642px] py-[95px] px-5">
<div class="container">
<h2 class="text-grey-black dark:text-white text-[35px] leading-[45px] md:leading-[55px] lg:text-[45px] text-center font-bold mb-5">{{ "simplex-network-section-header" | i18n({}, lang ) | safe }}</h2>
<p class="text-black dark:text-white text-[16px] font-normal text-center mb-16">{{ "simplex-network-section-desc" | i18n({}, lang ) | safe }}</p>
<div class="flex flex-col lg:flex-row justify-between gap-12 md:gap-14 lg:gap-16">
<div class="flex flex-col md:flex-row lg:flex-col items-center md:gap-9 lg:gap-0">
<div class="mb-4 md:mb-0 lg:mb-8 md:flex-[1] flex items-center justify-center">
<img src="/img/new/network-1.svg" alt="" class="dark:hidden"/>
<img src="/img/new/network-1-dark.svg" alt="" class="hidden dark:block"/>
</div>
<div class="md:flex-[2] flex flex-col items-center justify-center">
<h3 class="text-active-blue text-xl font-bold text-center md:text-left lg:text-center self-stretch">{{ "simplex-network-1-header" | i18n({}, lang ) | safe }}</h3>
<p class="text-black dark:text-white text-base font-normal text-center md:text-left lg:text-center">
{{ "simplex-network-1-desc" | i18n({}, lang ) | safe }} <a href="javascript:void(0)" data-show-overlay="{{ simplex_network_overlay.sections[0].overlayContent.overlayId }}" class="open-overlay-btn">{{ "simplex-network-1-overlay-linktext" | i18n({}, lang ) | safe }}</a>.
{{ overlay(simplex_network_overlay.sections[0],lang) }}
</p>
</div>
</div>
<hr class="hidden md:block lg:hidden mx-5 dark:opacity-[0.2]">
<div class="flex flex-col md:flex-row lg:flex-col items-center md:gap-9 lg:gap-0">
<div class="mb-4 md:mb-0 lg:mb-8 md:flex-[1] flex items-center justify-center">
<img src="/img/new/network-2.svg" alt="" class="dark:hidden"/>
<img src="/img/new/network-2-dark.svg" alt="" class="hidden dark:block"/>
</div>
<div class="md:flex-[2] flex flex-col items-center justify-center">
<h3 class="text-active-blue text-xl font-bold text-center md:text-left lg:text-center self-stretch">{{ "simplex-network-2-header" | i18n({}, lang ) | safe }}</h3>
<p class="text-black dark:text-white text-base font-normal text-center md:text-left lg:text-center">
{{ "simplex-network-2-desc" | i18n({}, lang ) | safe }}
</p>
</div>
</div>
<hr class="hidden md:block lg:hidden mx-5 dark:opacity-[0.2]">
<div class="flex flex-col md:flex-row lg:flex-col items-center md:gap-9 lg:gap-0">
<div class="mb-4 md:mb-0 lg:mb-8 md:flex-[1] flex items-center justify-center">
<img src="/img/new/network-3.svg" alt="" class="dark:hidden"/>
<img src="/img/new/network-3-dark.svg" alt="" class="hidden dark:block"/>
</div>
<div class="md:flex-[2] flex flex-col items-center justify-center">
<h3 class="text-active-blue text-xl font-bold text-center md:text-left lg:text-center self-stretch">{{ "simplex-network-3-header" | i18n({}, lang ) | safe }}</h3>
<p class="text-black dark:text-white text-base font-normal text-center md:text-left lg:text-center">
{{ "simplex-network-3-desc" | i18n({}, lang ) | safe }}
</p>
</div>
</div>
</div>
</div>
</section>
{# simplex explained #}
{% include "simplex_explained.html" %}
{# Comparison #}
<section id="comparison" class="bg-secondary-bg-light dark:bg-secondary-bg-dark lg:h-[950px] py-[90px] px-5">
<div class="text-grey-black dark:text-white container flex flex-col">
<h2 class="text-[35px] leading-[43px] md:leading-[55px] lg:leading-[36px] text-center font-bold mb-12 lg:mb-[90px]">{{ "comparison-section-header" | i18n({}, lang ) | safe }}</h2>
<div class="w-full overflow-auto">
<table class="w-full border-separate border-spacing-x-5 border-spacing-y-2 mb-14">
<thead>
<tr>
<th></th>
<th class="flex items-center justify-center pb-4">
<img class="h-[34px] dark:hidden" alt="simplex logo" src="/img/new/logo-light.png"/>
<img class="h-[34px] hidden dark:block" alt="simplex logo" src="/img/new/logo-dark.png"/>
</th>
<th class="pb-4">{{ "protocol-1-text" | i18n({}, lang ) | safe }}</th>
<th class="pb-4">{{ "protocol-2-text" | i18n({}, lang ) | safe }}</th>
<th class="pb-4">{{ "protocol-3-text" | i18n({}, lang ) | safe }}</th>
</tr>
</thead>
<tbody>
<tr>
<td class="min-w-[210px]">{{ "comparison-point-1-text" | i18n({}, lang ) | safe }}</td>
<td class="text-center font-medium min-w-[152px] h-[52px] bg-[#48F6C2] text-grey-black rounded-[4px]">{{ "no-private" | i18n({}, lang ) | safe }}</td>
<td class="text-center font-medium min-w-[152px] h-[52px] bg-[#fff] dark:bg-[#171F3A] text-[#DD0000] rounded-[4px]">{{ "yes" | i18n({}, lang ) | safe }} <sup>1</sup></td>
<td class="text-center font-medium min-w-[152px] h-[52px] bg-[#fff] dark:bg-[#171F3A] text-[#DD0000] rounded-[4px]">{{ "yes" | i18n({}, lang ) | safe }} <sup>2</sup></td>
<td class="text-center font-medium min-w-[152px] h-[52px] bg-[#fff] dark:bg-[#171F3A] text-[#DD0000] rounded-[4px]">{{ "yes" | i18n({}, lang ) | safe }} <sup>3</sup></td>
</tr>
<tr>
<td class="min-w-[210px]">{{ "comparison-point-2-text" | i18n({}, lang ) | safe }}</td>
<td class="text-center font-medium min-w-[152px] h-[52px] bg-[#48F6C2] text-grey-black rounded-[4px]">{{ "no-secure" | i18n({}, lang ) | safe }} <sup>4</sup></td>
<td class="text-center font-medium min-w-[152px] h-[52px] bg-[#fff] dark:bg-[#171F3A] text-[#DD0000] rounded-[4px]">{{ "yes" | i18n({}, lang ) | safe }} <sup>5</sup></td>
<td class="text-center font-medium min-w-[152px] h-[52px] bg-[#fff] dark:bg-[#171F3A] text-[#DD0000] rounded-[4px]">{{ "yes" | i18n({}, lang ) | safe }}</td>
<td class="text-center font-medium min-w-[152px] h-[52px] bg-[#fff] dark:bg-[#171F3A] text-[#DD0000] rounded-[4px]">{{ "yes" | i18n({}, lang ) | safe }}</td>
</tr>
<tr>
<td class="min-w-[210px]">{{ "comparison-point-3-text" | i18n({}, lang ) | safe }}</td>
<td class="text-center font-medium min-w-[152px] h-[52px] bg-[#48F6C2] text-grey-black rounded-[4px]">{{ "no-resilient" | i18n({}, lang ) | safe }}</td>
<td class="text-center font-medium min-w-[152px] h-[52px] bg-[#fff] dark:bg-[#171F3A] text-[#DD0000] rounded-[4px]">{{ "yes" | i18n({}, lang ) | safe }}</td>
<td class="text-center font-medium min-w-[152px] h-[52px] bg-[#fff] dark:bg-[#171F3A] text-[#DD0000] rounded-[4px]">{{ "yes" | i18n({}, lang ) | safe }}</td>
<td class="text-center font-medium min-w-[152px] h-[52px] bg-[#48F6C2] text-grey-black rounded-[4px]">{{ "no" | i18n({}, lang ) | safe }}</td>
</tr>
<tr>
<td class="min-w-[210px]">{{ "comparison-point-4-text" | i18n({}, lang ) | safe }}</td>
<td class="text-center font-medium min-w-[152px] h-[52px] bg-[#48F6C2] text-grey-black rounded-[4px]">{{ "no-decentralized" | i18n({}, lang ) | safe }}</td>
<td class="text-center font-medium min-w-[152px] h-[52px] bg-[#fff] dark:bg-[#171F3A] text-[#DD0000] rounded-[4px]">{{ "yes" | i18n({}, lang ) | safe }}</td>
<td class="text-center font-medium min-w-[152px] h-[52px] bg-[#48F6C2] text-grey-black rounded-[4px]">{{ "no-federated" | i18n({}, lang ) | safe }} <sup>6</sup></td>
<td class="text-center font-medium min-w-[152px] h-[52px] bg-[#fff] dark:bg-[#171F3A] text-[#DD0000] rounded-[4px]">{{ "yes" | i18n({}, lang ) | safe }} <sup>7</sup></td>
</tr>
<tr>
<td class="min-w-[210px]">{{ "comparison-point-5-text" | i18n({}, lang ) | safe }}</td>
<td class="text-center font-medium min-w-[152px] h-[52px] bg-[#48F6C2] text-grey-black rounded-[4px]">{{ "no-resilient" | i18n({}, lang ) | safe }}</td>
<td class="text-center font-medium min-w-[152px] h-[52px] bg-[#fff] dark:bg-[#171F3A] text-[#DD0000] rounded-[4px]">{{ "yes" | i18n({}, lang ) | safe }}</td>
<td class="text-center font-medium min-w-[152px] h-[52px] bg-[#fff] dark:bg-[#171F3A] text-[#DD0000] rounded-[4px]">{{ "yes" | i18n({}, lang ) | safe }} <sup>2</sup></td>
<td class="text-center font-medium min-w-[152px] h-[52px] bg-[#fff] dark:bg-[#171F3A] text-[#DD0000] rounded-[4px]">{{ "yes" | i18n({}, lang ) | safe }} <sup>8</sup></td>
</tr>
</tbody>
</table>
</div>
<hr class="block mb-8 mx-5 dark:opacity-[0.2]">
<div>
<div class="px-5">
<ol class="text-sm lg:text-base font-medium leading-6 list-decimal list-inside">
<li> {{ "comparison-section-list-point-1" | i18n({}, lang ) | safe }}</li>
<li> {{ "comparison-section-list-point-2" | i18n({}, lang ) | safe }}</li>
<li> {{ "comparison-section-list-point-3" | i18n({}, lang ) | safe }}</li>
<li> {{ "comparison-section-list-point-4a" | i18n({}, lang ) | safe }}</li>
<li> {{ "comparison-section-list-point-4" | i18n({}, lang ) | safe }}</li>
<li> {{ "comparison-section-list-point-5" | i18n({}, lang ) | safe }}</li>
<li> {{ "comparison-section-list-point-6" | i18n({}, lang ) | safe }}</li>
<li> {{ "comparison-section-list-point-7" | i18n({}, lang ) | safe }} - <a class="underline text-primary-light dark:text-primary-dark underline-offset-2" href="https://github.com/simplex-chat/simplex-chat/blob/stable/docs/SIMPLEX.md#comparison-with-p2p-messaging-protocols">{{ "see-here" | i18n({}, lang ) | safe }}</a></li>
</ol>
</div>
</div>
</div>
</section>
{# join simplex #}
{% include "sections/join_simplex.html" %}
<script src="/js/animation.js"></script>
+8
View File
@@ -0,0 +1,8 @@
---
layout: layouts/redirect.html
title: "SimpleX Community Vouchers"
description: ""
destinationURI: "/vouchers/"
destinationText: Connect to the team
templateEngineOverride: njk
---

Some files were not shown because too many files have changed in this diff Show More