diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1fc1c18fe1..7f72d114b9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -343,7 +343,7 @@ jobs: cli_asset_name: simplex-chat-macos-aarch64 desktop_asset_name: simplex-desktop-macos-aarch64.dmg openssl_dir: "/opt/homebrew/opt" - - os: macos-13 + - os: macos-15-intel ghc: ${{ needs.variables.outputs.GHC_VER }} cli_asset_name: simplex-chat-macos-x86-64 desktop_asset_name: simplex-desktop-macos-x86_64.dmg diff --git a/.github/workflows/reproduce-schedule.yml b/.github/workflows/reproduce-schedule.yml index 7d28d6f70c..7364976467 100644 --- a/.github/workflows/reproduce-schedule.yml +++ b/.github/workflows/reproduce-schedule.yml @@ -23,6 +23,10 @@ jobs: grep -i "tag_name" | \ awk -F \" '{print "TAG="$4}' >> $GITHUB_ENV + # Otherwise we run out of disk space with Docker build + - name: Free disk space + shell: bash + run: ./scripts/ci/linux_util_free_space.sh - name: Execute reproduce script run: | ${GITHUB_WORKSPACE}/scripts/simplex-chat-reproduce-builds.sh "$TAG" || : diff --git a/.gitignore b/.gitignore index 4560272980..bf565453a5 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,7 @@ website/translations.json website/src/img/images/ website/src/images/ website/src/js/lottie.min.js +website/src/js/ethers* website/src/privacy.md # Generated files website/package/generated* diff --git a/Dockerfile.build b/Dockerfile.build index 3c841cfb25..fddc96b6c2 100644 --- a/Dockerfile.build +++ b/Dockerfile.build @@ -6,7 +6,9 @@ FROM ubuntu:${TAG} AS build ARG GHC=9.6.3 ARG CABAL=3.10.2.0 -ARG JAVA=17 +ARG JAVA_VER=17.0.17.10.1 +ARG JAVA_HASH_AMD64=e3e11daa5c22a45153bbeff1a0c21bf08631791e4e8d8ed14deba31c7cf9af1a +ARG JAVA_HASH_ARM64=2b460859b681757b33a7591b6238ecaf51569d05d2684984e5f0a89c6514acbc ENV TZ=Etc/UTC \ DEBIAN_FRONTEND=noninteractive @@ -44,10 +46,26 @@ RUN apt-get update && \ # depends on libjpeg.so.8 and liblcms2.so.2 which are NOT copied into final # /usr/lib/runtime/lib directory and I do not have time to figure out gradle.kotlin # to fix this :( -RUN curl --proto '=https' --tlsv1.2 -sSf 'https://apt.corretto.aws/corretto.key' | gpg --dearmor -o /usr/share/keyrings/corretto-keyring.gpg &&\ - echo "deb [signed-by=/usr/share/keyrings/corretto-keyring.gpg] https://apt.corretto.aws stable main" > /etc/apt/sources.list.d/corretto.list &&\ - apt update &&\ - apt install -y java-${JAVA}-amazon-corretto-jdk +RUN export JAVA_FILENAME='java-corretto.deb' \ + JAVA_VER_MAJOR=$(printf "${JAVA_VER}" | awk -F. '{print $1}') \ + JAVA_VER_DEB=$(printf "${JAVA_VER}" | sed 's/\.1$/-1/') && \ + case "$(uname -m)" in \ + x86_64) export JAVA_ARCH='amd64' JAVA_HASH="$JAVA_HASH_AMD64" ;; \ + aarch64) export JAVA_ARCH='arm64' JAVA_HASH="$JAVA_HASH_ARM64" ;; \ + *) echo "unknown arch $(uname -m)" && exit 1 ;; \ + esac && \ + curl --proto '=https' --tlsv1.2 -sSf \ + "https://corretto.aws/downloads/resources/${JAVA_VER}/java-${JAVA_VER_MAJOR}-amazon-corretto-jdk_${JAVA_VER_DEB}_${JAVA_ARCH}.deb" \ + -o "${JAVA_FILENAME}" && \ + if echo "${JAVA_HASH} ${JAVA_FILENAME}" | sha256sum -c -; then \ + if apt install -y ./"${JAVA_FILENAME}"; then \ + rm ./"${JAVA_FILENAME}"; \ + else \ + echo "Failed to install Java Corretto" && exit 1; \ + fi \ + else \ + echo "Checksum mismatch" && exit 1; \ + fi # Specify bootstrap Haskell versions ENV BOOTSTRAP_HASKELL_GHC_VERSION=${GHC} diff --git a/README.md b/README.md index dc4b9658f7..b1d2556072 100644 --- a/README.md +++ b/README.md @@ -24,15 +24,15 @@ ## Install the app -[iOS app](https://apps.apple.com/us/app/simplex-chat/id1605771084) +[iOS app](https://apps.apple.com/us/app/simplex-chat/id1605771084)   -[![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)   -[F-Droid](https://app.simplex.chat) +[F-Droid](https://app.simplex.chat)   -[iOS TestFlight](https://testflight.apple.com/join/DWuT2LQu) +[iOS TestFlight](https://testflight.apple.com/join/DWuT2LQu)   -[APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk) +[APK](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. @@ -54,24 +54,10 @@ If you are interested in helping us to integrate open-source language models, an ## Join user groups -You can join the groups created by other users via the new [directory service](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion). We are not responsible for the content shared in these groups. +You can find the groups created by users in [SimpleX Directory](https://simplex.chat/directory/). It is also available as [SimpleX bot](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) that allows to add your own groups and communities to the directory. We are not responsible for the content shared in these groups. **Please note**: The groups below are created for the users to be able to ask questions, make suggestions and ask questions about SimpleX Chat only. -You also can: -- criticize the app, and make comparisons with other messengers. -- share new messengers you think could be interesting for privacy, as long as you don't spam. -- share some privacy related publications, infrequently. -- having preliminary approved with the admin in direct message, share the link to a group you created, but only once. Once the group has more than 10 members it can be submitted to [SimpleX Directory Service](./docs/DIRECTORY.md) where the new users will be able to discover it. - -You must: -- be polite to other users -- avoid spam (too frequent messages, even if they are relevant) -- avoid any personal attacks or hostility. -- avoid sharing any content that is not relevant to the above (that includes, but is not limited to, discussing politics or any aspects of society other than privacy, security, technology and communications, sharing any content that may be found offensive by other users, etc.). - -Messages not following these rules will be deleted, the right to send messages may be revoked, and the access to the new members to the group may be temporarily restricted, to prevent re-joining under a different name - our imperfect group moderation does not have a better solution at the moment. - You can join an English-speaking users group if you want to ask any questions: [#SimpleX users group](https://smp4.simplex.im/g#hr4lvFeBmndWMKTwqiodPz3VBo_6UmdGWocXd1SupsM) There is also a group [#simplex-devs](https://smp6.simplex.im/g#Drx3efC-n418AuSpzTspw9SER0iJwrQTmKBafQHwkKM) for developers who build on SimpleX platform: @@ -81,11 +67,7 @@ There is also a group [#simplex-devs](https://smp6.simplex.im/g#Drx3efC-n418AuSp - social apps and services - etc. -There are groups in other languages, that we have the apps interface translated into. These groups are for testing, and asking questions to other SimpleX Chat users: - -[\#SimpleX-DE](https://smp6.simplex.im/g#V6tQ-lJqsdgJJdJiLPtP326oQFKHvwinIbgruZ9K2oU) (German-speaking), [\#SimpleX-ES](https://smp5.simplex.im/g#xJ5kwDLq2305O5FmpUzvgRIXXAcAJ9S5BItCd2Wmloc) (Spanish-speaking), [\#SimpleX-FR](https://smp6.simplex.im/g#cVOpB0CKd6hEf2aWQ6sJ22E2DVgQLtdHoiSdKxXeKqk) (French-speaking), [\#SimpleX-RU](https://smp5.simplex.im/g#vwXRdfG5SgtaG6aVcITiUGd--Ux0rY1IuH4QXYxlq3U) (Russian-speaking), [\#SimpleX-IT](https://smp5.simplex.im/g#BtRcjsl29ULFNBSE2OPhp1UwZfW7PW9gUYFQTKHdjqU) (Italian-speaking). - -You can join either by opening these links in the app or by opening them in a desktop browser and scanning the QR code. +You can join these and other groups by opening these links in the app or by opening them in a desktop browser and scanning the QR code. ## Follow our updates @@ -102,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. -Make a private connection Conversation Video call +Make a private connection Conversation Video call After you connect, you can [verify connection security code](./blog/20230103-simplex-chat-v4.4-disappearing-messages.md#connection-security-verification). @@ -165,8 +147,7 @@ It is possible to donate via: - BTC: bc1q2gy6f02nn6vvcxs0pnu29tpnpyz0qf66505d4u - XMR: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt - BCH: bitcoincash:qq6c8vfvxqrk6rhdysgvkhqc24sggkfsx5nqvdlqcg -- ETH: 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692 -- USDT (Ethereum): 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692 +- ETH/USDT (Ethereum, Arbitrum One): 0xD7047Fe3Eecb2f2FF78d839dD927Be27Bc12c86a - ZEC: t1fwjQW5gpFhDqXNhxqDWyF9j9WeKvVS5Jg - ZEC shielded: u16rnvkflumf5uw9frngc2lymvmzgdr2mmc9unyu0l44unwfmdcpfm0axujd2w34ct3ye709azxsqge45705lpvvqu264ltzvfay55ygyq - DOGE: D99pV4n9TrPxBPCkQGx4w4SMSa6QjRBxPf @@ -442,14 +423,16 @@ Please do NOT report security vulnerabilities via GitHub issues. ## License -[AGPL v3](./LICENSE) +This software is licensed under the GNU Affero General Public License version 3 (AGPLv3). See the [LICENSE](./LICENSE) file for details. The SimpleX and SimpleX Chat name, logo, and associated branding materials are not covered by this license and are subject to the terms outlined in the [TRADEMARK](./docs/TRADEMARK.md) file. -[iOS app](https://apps.apple.com/us/app/simplex-chat/id1605771084) +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. + +[iOS app](https://apps.apple.com/us/app/simplex-chat/id1605771084)   -[![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)   -[F-Droid](https://app.simplex.chat) +[F-Droid](https://app.simplex.chat)   -[iOS TestFlight](https://testflight.apple.com/join/DWuT2LQu) +[iOS TestFlight](https://testflight.apple.com/join/DWuT2LQu)   -[APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk) +[APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk) diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 00cc25c1d2..7adf7a0435 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -430,19 +430,27 @@ struct ContentView: View { let m = ChatModel.shared if let url = m.appOpenUrl { m.appOpenUrl = nil - dismissAllSheets() { - var path = url.path - if (path == "/contact" || path == "/invitation" || path == "/a" || path == "/c" || path == "/g" || path == "/i") { - path.removeFirst() - let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)") - planAndConnect( - link, - theme: theme, - dismiss: false - ) - } else { - AlertManager.shared.showAlert(Alert(title: Text("Error: URL is invalid"))) - } + connectViaUrl_(url) + } else if let url = m.appOpenUrlLater, AppChatState.shared.value == .active, scenePhase == .active { + // correcting branch in case .onChange(of: scenePhase) in SimpleXApp doesn't trigger and transfer appOpenUrlLater into appOpenUrl + m.appOpenUrlLater = nil + connectViaUrl_(url) + } + } + + func connectViaUrl_(_ url: URL) { + dismissAllSheets() { + var path = url.path + if (path == "/contact" || path == "/invitation" || path == "/a" || path == "/c" || path == "/g" || path == "/i") { + path.removeFirst() + let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)") + planAndConnect( + link, + theme: theme, + dismiss: false + ) + } else { + AlertManager.shared.showAlert(Alert(title: Text("Error: URL is invalid"))) } } } diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index 2055a0ab99..193b675a57 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -158,7 +158,6 @@ enum ChatCommand: ChatCmdProtocol { case apiGetCallInvitations case apiCallStatus(contact: Contact, callStatus: WebRTCCallStatus) // WebRTC calls / - case apiGetNetworkStatuses case apiChatRead(type: ChatType, id: Int64, scope: GroupChatScope?) case apiChatItemsRead(type: ChatType, id: Int64, scope: GroupChatScope?, itemIds: [Int64]) case apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) @@ -359,7 +358,6 @@ enum ChatCommand: ChatCmdProtocol { case let .apiEndCall(contact): return "/_call end @\(contact.apiId)" case .apiGetCallInvitations: return "/_call get" case let .apiCallStatus(contact, callStatus): return "/_call status @\(contact.apiId) \(callStatus.rawValue)" - case .apiGetNetworkStatuses: return "/_network_statuses" case let .apiChatRead(type, id, scope): return "/_read chat \(ref(type, id, scope: scope))" case let .apiChatItemsRead(type, id, scope, itemIds): return "/_read chat items \(ref(type, id, scope: scope)) \(joinedIds(itemIds))" case let .apiChatUnread(type, id, unreadChat): return "/_unread chat \(ref(type, id, scope: nil)) \(onOff(unreadChat))" @@ -534,7 +532,6 @@ enum ChatCommand: ChatCmdProtocol { case .apiEndCall: return "apiEndCall" case .apiGetCallInvitations: return "apiGetCallInvitations" case .apiCallStatus: return "apiCallStatus" - case .apiGetNetworkStatuses: return "apiGetNetworkStatuses" case .apiChatRead: return "apiChatRead" case .apiChatItemsRead: return "apiChatItemsRead" case .apiChatUnread: return "apiChatUnread" @@ -574,6 +571,8 @@ enum ChatCommand: ChatCmdProtocol { } else { "(_support)" } + case .reports: + "(reports, prohibited)" // can't use surrogate Reports scope } } @@ -791,7 +790,6 @@ enum ChatResponse1: Decodable, ChatAPIResult { case userContactLinkDeleted(user: User) case acceptingContactRequest(user: UserRef, contact: Contact) case contactRequestRejected(user: UserRef, contactRequest: UserContactRequest, contact_: Contact?) - case networkStatuses(user_: UserRef?, networkStatuses: [ConnNetworkStatus]) case newChatItems(user: UserRef, chatItems: [AChatItem]) case groupChatItemsDeleted(user: UserRef, groupInfo: GroupInfo, chatItemIDs: Set, byUser: Bool, member_: GroupMember?) case forwardPlan(user: UserRef, chatItemIds: [Int64], forwardConfirmation: ForwardConfirmation?) @@ -835,7 +833,6 @@ enum ChatResponse1: Decodable, ChatAPIResult { case .userContactLinkDeleted: "userContactLinkDeleted" case .acceptingContactRequest: "acceptingContactRequest" case .contactRequestRejected: "contactRequestRejected" - case .networkStatuses: "networkStatuses" case .newChatItems: "newChatItems" case .groupChatItemsDeleted: "groupChatItemsDeleted" case .forwardPlan: "forwardPlan" @@ -868,7 +865,6 @@ enum ChatResponse1: Decodable, ChatAPIResult { case .userContactLinkDeleted: return noDetails case let .acceptingContactRequest(u, contact): return withUser(u, String(describing: contact)) case let .contactRequestRejected(u, contactRequest, contact_): return withUser(u, "contactRequest: \(String(describing: contactRequest))\ncontact_: \(String(describing: contact_))") - case let .networkStatuses(u, statuses): return withUser(u, String(describing: statuses)) case let .newChatItems(u, chatItems): let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n") return withUser(u, itemsString) @@ -910,6 +906,7 @@ enum ChatResponse2: Decodable, ChatAPIResult { case leftMemberUser(user: UserRef, groupInfo: GroupInfo) case groupMembers(user: UserRef, group: SimpleXChat.Group) case memberAccepted(user: UserRef, groupInfo: GroupInfo, member: GroupMember) + case memberSupportChatRead(user: UserRef, groupInfo: GroupInfo, member: GroupMember) case memberSupportChatDeleted(user: UserRef, groupInfo: GroupInfo, member: GroupMember) case membersRoleUser(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], toRole: GroupMemberRole) case membersBlockedForAllUser(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], blocked: Bool) @@ -959,6 +956,7 @@ enum ChatResponse2: Decodable, ChatAPIResult { case .leftMemberUser: "leftMemberUser" case .groupMembers: "groupMembers" case .memberAccepted: "memberAccepted" + case .memberSupportChatRead: "memberSupportChatRead" case .memberSupportChatDeleted: "memberSupportChatDeleted" case .membersRoleUser: "membersRoleUser" case .membersBlockedForAllUser: "membersBlockedForAllUser" @@ -1004,6 +1002,7 @@ enum ChatResponse2: Decodable, ChatAPIResult { case let .leftMemberUser(u, groupInfo): return withUser(u, String(describing: groupInfo)) case let .groupMembers(u, group): return withUser(u, String(describing: group)) case let .memberAccepted(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") + case let .memberSupportChatRead(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") case let .memberSupportChatDeleted(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") case let .membersRoleUser(u, groupInfo, members, toRole): return withUser(u, "groupInfo: \(groupInfo)\nmembers: \(members)\ntoRole: \(toRole)") case let .membersBlockedForAllUser(u, groupInfo, members, blocked): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(members)\nblocked: \(blocked)") @@ -1054,9 +1053,7 @@ enum ChatEvent: Decodable, ChatAPIResult { case receivedContactRequest(user: UserRef, contactRequest: UserContactRequest, chat_: ChatData?) case contactUpdated(user: UserRef, toContact: Contact) case groupMemberUpdated(user: UserRef, groupInfo: GroupInfo, fromMember: GroupMember, toMember: GroupMember) - case contactsMerged(user: UserRef, intoContact: Contact, mergedContact: Contact) - case networkStatus(networkStatus: NetworkStatus, connections: [String]) - case networkStatuses(user_: UserRef?, networkStatuses: [ConnNetworkStatus]) + case subscriptionStatus(subscriptionStatus: SubscriptionStatus, connections: [String]) case chatInfoUpdated(user: UserRef, chatInfo: ChatInfo) case newChatItems(user: UserRef, chatItems: [AChatItem]) case chatItemsStatusesUpdated(user: UserRef, chatItems: [AChatItem]) @@ -1133,9 +1130,7 @@ enum ChatEvent: Decodable, ChatAPIResult { case .receivedContactRequest: "receivedContactRequest" case .contactUpdated: "contactUpdated" case .groupMemberUpdated: "groupMemberUpdated" - case .contactsMerged: "contactsMerged" - case .networkStatus: "networkStatus" - case .networkStatuses: "networkStatuses" + case .subscriptionStatus: "subscriptionStatus" case .chatInfoUpdated: "chatInfoUpdated" case .newChatItems: "newChatItems" case .chatItemsStatusesUpdated: "chatItemsStatusesUpdated" @@ -1207,9 +1202,7 @@ enum ChatEvent: Decodable, ChatAPIResult { case let .receivedContactRequest(u, contactRequest, chat_): return withUser(u, "contactRequest: \(String(describing: contactRequest))\nchat_: \(String(describing: chat_))") case let .contactUpdated(u, toContact): return withUser(u, String(describing: toContact)) case let .groupMemberUpdated(u, groupInfo, fromMember, toMember): return withUser(u, "groupInfo: \(groupInfo)\nfromMember: \(fromMember)\ntoMember: \(toMember)") - case let .contactsMerged(u, intoContact, mergedContact): return withUser(u, "intoContact: \(intoContact)\nmergedContact: \(mergedContact)") - case let .networkStatus(status, conns): return "networkStatus: \(String(describing: status))\nconnections: \(String(describing: conns))" - case let .networkStatuses(u, statuses): return withUser(u, String(describing: statuses)) + case let .subscriptionStatus(status, conns): return "subscriptionStatus: \(String(describing: status))\nconnections: \(String(describing: conns))" case let .chatInfoUpdated(u, chatInfo): return withUser(u, String(describing: chatInfo)) case let .newChatItems(u, chatItems): let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n") @@ -1369,38 +1362,6 @@ enum ChatDeleteMode: Codable { } } -enum NetworkStatus: Decodable, Equatable { - case unknown - case connected - case disconnected - case error(connectionError: String) - - var statusString: LocalizedStringKey { - switch self { - case .connected: "connected" - case .error: "error" - default: "connecting" - } - } - - var statusExplanation: LocalizedStringKey { - switch self { - case .connected: "You are connected to the server used to receive messages from this contact." - case let .error(err): "Trying to connect to the server used to receive messages from this contact (error: \(err))." - default: "Trying to connect to the server used to receive messages from this contact." - } - } - - var imageName: String { - switch self { - case .unknown: "circle.dotted" - case .connected: "circle.fill" - case .disconnected: "ellipsis.circle.fill" - case .error: "exclamationmark.circle.fill" - } - } -} - enum ForwardConfirmation: Decodable, Hashable { case filesNotAccepted(fileIds: [Int64]) case filesInProgress(filesCount: Int) @@ -1408,11 +1369,6 @@ enum ForwardConfirmation: Decodable, Hashable { case filesFailed(filesCount: Int) } -struct ConnNetworkStatus: Decodable { - var agentConnId: String - var networkStatus: NetworkStatus -} - struct UserMsgReceiptSettings: Codable { var enable: Bool var clearOverrides: Bool @@ -1985,13 +1941,13 @@ struct ProtocolTestFailure: Decodable, Error, Equatable { let err = String.localizedStringWithFormat(NSLocalizedString("Test failed at step %@.", comment: "server test failure"), testStep.text) switch testError { case .SMP(_, .AUTH): - return err + " " + NSLocalizedString("Server requires authorization to create queues, check password", comment: "server test error") + return err + " " + NSLocalizedString("Server requires authorization to create queues, check password.", comment: "server test error") case .XFTP(.AUTH): - return err + " " + NSLocalizedString("Server requires authorization to upload, check password", comment: "server test error") - case .BROKER(_, .NETWORK): - return err + " " + NSLocalizedString("Possibly, certificate fingerprint in server address is incorrect", comment: "server test error") + return err + " " + NSLocalizedString("Server requires authorization to upload, check password.", comment: "server test error") + case .BROKER(_, .NETWORK(.unknownCAError)): + return err + " " + NSLocalizedString("Fingerprint in server address does not match certificate.", comment: "server test error") default: - return err + return err + " " + String.localizedStringWithFormat(NSLocalizedString("Error: %@.", comment: "server test error"), String(describing: testError)) } } } diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 33b3fd8009..f1f4e686bd 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -283,29 +283,6 @@ class ChatTagsModel: ObservableObject { } } -class NetworkModel: ObservableObject { - // map of connections network statuses, key is agent connection id - @Published var networkStatuses: Dictionary = [:] - - static let shared = NetworkModel() - - private init() { } - - func setContactNetworkStatus(_ contact: Contact, _ status: NetworkStatus) { - if let conn = contact.activeConn { - networkStatuses[conn.agentConnId] = status - } - } - - func contactNetworkStatus(_ contact: Contact) -> NetworkStatus { - if let conn = contact.activeConn { - networkStatuses[conn.agentConnId] ?? .unknown - } else { - .unknown - } - } -} - /// ChatItemWithMenu can depend on previous or next item for it's appearance /// This dummy model is used to force an update of all chat items, /// when they might have changed appearance. @@ -374,6 +351,8 @@ final class ChatModel: ObservableObject { @Published var deletedChats: Set = [] // current chat @Published var chatId: String? + @Published var chatAgentConnId: String? + @Published var chatSubStatus: SubscriptionStatus? @Published var openAroundItemId: ChatItem.ID? = nil @Published var chatToTop: String? @Published var groupMembers: [GMember] = [] @@ -385,6 +364,7 @@ final class ChatModel: ObservableObject { @Published var userAddress: UserContactLink? @Published var chatItemTTL: ChatItemTTL = .none @Published var appOpenUrl: URL? + @Published var appOpenUrlLater: URL? @Published var deviceToken: DeviceToken? @Published var savedToken: DeviceToken? @Published var tokenRegistered = false @@ -667,20 +647,24 @@ final class ChatModel: ObservableObject { func getCIItemsModel(_ cInfo: ChatInfo, _ ci: ChatItem) -> ItemsModel? { let cInfoScope = cInfo.groupChatScope() - if let cInfoScope = cInfoScope { - switch cInfoScope { - case .memberSupport: - switch secondaryIM?.secondaryIMFilter { - case .none: - return nil - case let .groupChatScopeContext(groupScopeInfo): - return (cInfo.id == chatId && sameChatScope(cInfoScope, groupScopeInfo.toChatScope())) ? secondaryIM : nil - case let .msgContentTagContext(contentTag): - return (cInfo.id == chatId && ci.isReport && contentTag == .report) ? secondaryIM : nil - } + return if let cInfoScope = cInfoScope { + switch (cInfoScope, secondaryIM?.secondaryIMFilter) { + case let (.memberSupport, .some(.groupChatScopeContext(groupScopeInfo))): + // Chat with member or Chat with admins opened (secondaryIM has .groupChatScopeContext filter), cInfo has matching scope + (cInfo.id == chatId && sameChatScope(cInfoScope, groupScopeInfo.toChatScope())) ? secondaryIM : nil + + case let (.memberSupport, .some(.msgContentTagContext(contentTag))): + // Reports view opened (secondaryIM has .msgContentTagContext(.report) filter), we process event (cInfo has proper .memberSupport scope) + (cInfo.id == chatId && ci.isReport && contentTag == .report) ? secondaryIM : nil + + case let (.reports, .some(.msgContentTagContext(contentTag))): + // Reports view opened (secondaryIM has .msgContentTagContext(.report) filter), we process user action (cInfo has surrogate .reports scope) + (cInfo.id == chatId && ci.isReport && contentTag == .report) ? secondaryIM : nil + default: + nil } } else { - return cInfo.id == chatId ? im : nil + cInfo.id == chatId ? im : nil } } @@ -782,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.. (title: String, message: Stri title: NSLocalizedString("Connection timeout", comment: "alert title"), message: serverErrorAlertMessage(addr) ) + case let .errorAgent(.BROKER(addr, .NETWORK(.unknownCAError))): nil case let .errorAgent(.BROKER(addr, .NETWORK)): ( title: NSLocalizedString("Connection error", comment: "alert title"), message: serverErrorAlertMessage(addr) @@ -177,6 +176,7 @@ func retryableNetworkErrorAlert(_ e: ChatError) -> (title: String, message: Stri title: NSLocalizedString("Private routing timeout", comment: "alert title"), message: proxyErrorAlertMessage(serverAddress) ) + case let .errorAgent(.SMP(serverAddress, .PROXY(.BROKER(.NETWORK(.unknownCAError))))): nil case let .errorAgent(.SMP(serverAddress, .PROXY(.BROKER(.NETWORK)))): ( title: NSLocalizedString("Private routing error", comment: "alert title"), message: proxyErrorAlertMessage(serverAddress) @@ -185,6 +185,7 @@ func retryableNetworkErrorAlert(_ e: ChatError) -> (title: String, message: Stri title: NSLocalizedString("Private routing timeout", comment: "alert title"), message: proxyDestinationErrorAlertMessage(proxyServer: proxyServer, destServer: destServer) ) + case let .errorAgent(.PROXY(proxyServer, destServer, .protocolError(.PROXY(.BROKER(.NETWORK(.unknownCAError)))))): nil case let .errorAgent(.PROXY(proxyServer, destServer, .protocolError(.PROXY(.BROKER(.NETWORK))))): ( title: NSLocalizedString("Private routing error", comment: "alert title"), message: proxyDestinationErrorAlertMessage(proxyServer: proxyServer, destServer: destServer) @@ -1316,6 +1317,10 @@ func apiCreateUserAddress() async throws -> CreatedConnLink? { let userId = try currentUserId("apiCreateUserAddress") let r: APIResult? = await chatApiSendCmdWithRetry(.apiCreateMyAddress(userId: userId)) if case let .result(.userContactLinkCreated(_, connLink)) = r { return connLink } + if case let .error(.errorAgent(.NOTICE(server, preset, expires))) = r { + showClientNotice(server, preset, expires) + return nil + } if let r { throw r.unexpected } else { return nil } } @@ -1391,8 +1396,14 @@ func apiRejectContactRequest(contactReqId: Int64) async throws -> Contact? { throw r.unexpected } -func apiChatRead(type: ChatType, id: Int64, scope: GroupChatScope?) async throws { - try await sendCommandOkResp(.apiChatRead(type: type, id: id, scope: scope)) +func apiChatRead(type: ChatType, id: Int64) async throws { + try await sendCommandOkResp(.apiChatRead(type: type, id: id, scope: nil)) +} + +func apiSupportChatRead(type: ChatType, id: Int64, scope: GroupChatScope) async throws -> (GroupInfo, GroupMember) { + let r: ChatResponse2 = try await chatSendCmd(.apiChatRead(type: type, id: id, scope: scope)) + if case let .memberSupportChatRead(_, groupInfo, member) = r { return (groupInfo, member) } + throw r.unexpected } func apiChatItemsRead(type: ChatType, id: Int64, scope: GroupChatScope?, itemIds: [Int64]) async throws -> ChatInfo { @@ -1631,7 +1642,6 @@ func acceptContactRequest(incognito: Bool, contactRequestId: Int64, inProgress: } else { ChatModel.shared.replaceChat(contactRequestChatId(contactRequestId), chat) } - NetworkModel.shared.setContactNetworkStatus(contact, .connected) inProgress?.wrappedValue = false } if contact.sndReady { @@ -1719,17 +1729,11 @@ func apiCallStatus(_ contact: Contact, _ status: String) async throws { } } -func apiGetNetworkStatuses() throws -> [ConnNetworkStatus] { - let r: ChatResponse1 = try chatSendCmdSync(.apiGetNetworkStatuses) - if case let .networkStatuses(_, statuses) = r { return statuses } - throw r.unexpected -} - func markChatRead(_ im: ItemsModel, _ chat: Chat) async { do { if chat.chatStats.unreadCount > 0 { let cInfo = chat.chatInfo - try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, scope: cInfo.groupChatScope()) + try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId) await MainActor.run { withAnimation { ChatModel.shared.markAllChatItemsRead(im, cInfo) } } @@ -1754,6 +1758,20 @@ func markChatUnread(_ chat: Chat, unreadChat: Bool = true) async { } } +func markSupportChatRead(_ groupInfo: GroupInfo, _ member: GroupMember) async { + do { + if member.supportChatNotRead { + let (updatedGroupInfo, updatedMember) = try await apiSupportChatRead(type: .group, id: groupInfo.apiId, scope: .memberSupport(groupMemberId_: member.groupMemberId)) + await MainActor.run { + _ = ChatModel.shared.upsertGroupMember(updatedGroupInfo, updatedMember) + ChatModel.shared.updateGroup(updatedGroupInfo) + } + } + } catch { + logger.error("markSupportChatRead apiChatRead error: \(responseError(error))") + } +} + func apiMarkChatItemsRead(_ im: ItemsModel, _ cInfo: ChatInfo, _ itemIds: [ChatItem.ID], mentionsRead: Int) async { do { let updatedChatInfo = try await apiChatItemsRead(type: cInfo.chatType, id: cInfo.apiId, scope: cInfo.groupChatScope(), itemIds: itemIds) @@ -1819,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 @@ -1876,6 +1894,10 @@ func apiUpdateGroup(_ groupId: Int64, _ groupProfile: GroupProfile) async throws func apiCreateGroupLink(_ groupId: Int64, memberRole: GroupMemberRole = .member) async throws -> GroupLink? { let r: APIResult? = await chatApiSendCmdWithRetry(.apiCreateGroupLink(groupId: groupId, memberRole: memberRole)) if case let .result(.groupLinkCreated(_, _, groupLink)) = r { return groupLink } + if case let .error(.errorAgent(.NOTICE(server, preset, expires))) = r { + showClientNotice(server, preset, expires) + return nil + } if let r { throw r.unexpected } else { return nil } } @@ -1932,7 +1954,6 @@ func acceptMemberContact(contactId: Int64, inProgress: Binding? = nil) asy if let contact = await apiAcceptMemberContact(contactId: contactId) { await MainActor.run { ChatModel.shared.updateContact(contact) - NetworkModel.shared.setContactNetworkStatus(contact, .connected) inProgress?.wrappedValue = false } if contact.sndReady { @@ -2218,7 +2239,6 @@ class ChatReceiver { func processReceivedMsg(_ res: ChatEvent) async { let m = ChatModel.shared - let n = NetworkModel.shared logger.debug("processReceivedMsg: \(res.responseType)") switch res { case let .contactDeletedByContact(user, contact): @@ -2235,14 +2255,15 @@ func processReceivedMsg(_ res: ChatEvent) async { m.dismissConnReqView(conn.id) m.removeChat(conn.id) } + if contact.id == m.chatId, let conn = contact.activeConn { + m.chatAgentConnId = conn.agentConnId + m.chatSubStatus = .active + } } } if contact.directOrUsed { NtfManager.shared.notifyContactConnected(user, contact) } - await MainActor.run { - n.setContactNetworkStatus(contact, .connected) - } case let .contactConnecting(user, contact): if active(user) && contact.directOrUsed { await MainActor.run { @@ -2263,9 +2284,6 @@ func processReceivedMsg(_ res: ChatEvent) async { } } } - await MainActor.run { - n.setContactNetworkStatus(contact, .connected) - } case let .receivedContactRequest(user, contactRequest, chat_): if active(user) { await MainActor.run { @@ -2304,39 +2322,10 @@ func processReceivedMsg(_ res: ChatEvent) async { _ = m.upsertGroupMember(groupInfo, toMember) } } - case let .contactsMerged(user, intoContact, mergedContact): - if active(user) && m.hasChat(mergedContact.id) { + case let .subscriptionStatus(status, connections): + if let chatAgentConnId = m.chatAgentConnId, connections.contains(chatAgentConnId) { await MainActor.run { - if m.chatId == mergedContact.id { - ItemsModel.shared.loadOpenChat(mergedContact.id) - } - m.removeChat(mergedContact.id) - } - } - case let .networkStatus(status, connections): - // dispatch queue to synchronize access - networkStatusesLock.sync { - var ns = n.networkStatuses - // slow loop is on the background thread - for cId in connections { - ns[cId] = status - } - // fast model update is on the main thread - DispatchQueue.main.sync { - n.networkStatuses = ns - } - } - case let .networkStatuses(_, statuses): () - // dispatch queue to synchronize access - networkStatusesLock.sync { - var ns = n.networkStatuses - // slow loop is on the background thread - for s in statuses { - ns[s.agentConnId] = s.networkStatus - } - // fast model update is on the main thread - DispatchQueue.main.sync { - n.networkStatuses = ns + m.chatSubStatus = status } } case let .chatInfoUpdated(user, chatInfo): @@ -2540,11 +2529,6 @@ func processReceivedMsg(_ res: ChatEvent) async { _ = m.upsertGroupMember(groupInfo, member) } } - if let contact = memberContact { - await MainActor.run { - n.setContactNetworkStatus(contact, .connected) - } - } case let .groupUpdated(user, toGroup): if active(user) { await MainActor.run { @@ -2770,13 +2754,10 @@ func processReceivedMsg(_ res: ChatEvent) async { func switchToLocalSession() { let m = ChatModel.shared - let n = NetworkModel.shared m.remoteCtrlSession = nil do { m.users = try listUsers() try getUserChatData() - let statuses = (try apiGetNetworkStatuses()).map { s in (s.agentConnId, s.networkStatus) } - n.networkStatuses = Dictionary(uniqueKeysWithValues: statuses) } catch let error { logger.debug("error updating chat data: \(responseError(error))") } @@ -2884,3 +2865,26 @@ private struct UserResponse: Decodable { var user: User? var error: String? } + +private func showClientNotice(_ server: String, _ preset: Bool, _ expiresAt: Date?) { + DispatchQueue.main.async { + var message = "Server: \(server).\nConditions of use violation notice received from \(preset ? "preset" : "this") server.\nNo IDs shared, see How it works." + if let expiresAt { + message += "\n\nNew addresses can be created after \(expiresAt.formatted(date: .abbreviated, time: .shortened))." + } + showAlert("Not allowed", message: message) { + let howItWorks = UIAlertAction(title: NSLocalizedString("How it works", comment: "alert button"), style: .default, handler: { _ in + UIApplication.shared.open(contentModerationPostLink) + }) + return preset + ? [ + okAlertAction, + UIAlertAction(title: NSLocalizedString("Conditions of use", comment: "alert button"), style: .default, handler: { _ in + UIApplication.shared.open(conditionsURL) + }), + howItWorks + ] + : [okAlertAction, howItWorks] + } + } +} diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 5fbdafe19f..e1a6bb61e8 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -19,7 +19,6 @@ struct SimpleXApp: App { @Environment(\.scenePhase) var scenePhase @State private var enteredBackgroundAuthenticated: TimeInterval? = nil - @State private var appOpenUrlLater: URL? init() { DispatchQueue.global(qos: .background).sync { @@ -46,7 +45,7 @@ struct SimpleXApp: App { if AppChatState.shared.value == .active { chatModel.appOpenUrl = url } else { - appOpenUrlLater = url + chatModel.appOpenUrlLater = url } } .onAppear() { @@ -98,15 +97,15 @@ struct SimpleXApp: App { if !chatModel.showCallView && !CallController.shared.hasActiveCalls() { await updateCallInvitations() } - if let url = appOpenUrlLater { + if let url = chatModel.appOpenUrlLater { await MainActor.run { - appOpenUrlLater = nil + chatModel.appOpenUrlLater = nil chatModel.appOpenUrl = url } } } - } else if let url = appOpenUrlLater { - appOpenUrlLater = nil + } else if let url = chatModel.appOpenUrlLater { + chatModel.appOpenUrlLater = nil chatModel.appOpenUrl = url } } diff --git a/apps/ios/Shared/Theme/Theme.swift b/apps/ios/Shared/Theme/Theme.swift index de67390026..3bd8f00c25 100644 --- a/apps/ios/Shared/Theme/Theme.swift +++ b/apps/ios/Shared/Theme/Theme.swift @@ -42,12 +42,14 @@ class AppTheme: ObservableObject, Equatable { } func updateFromCurrentColors() { - objectWillChange.send() - name = CurrentColors.name - base = CurrentColors.base - colors.updateColorsFrom(CurrentColors.colors) - appColors.updateColorsFrom(CurrentColors.appColors) - wallpaper.updateWallpaperFrom(CurrentColors.wallpaper) + DispatchQueue.main.async { + self.objectWillChange.send() + self.name = CurrentColors.name + self.base = CurrentColors.base + self.colors.updateColorsFrom(CurrentColors.colors) + self.appColors.updateColorsFrom(CurrentColors.appColors) + self.wallpaper.updateWallpaperFrom(CurrentColors.wallpaper) + } } } diff --git a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift index b60842a4a0..37f3b982a1 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift @@ -12,6 +12,7 @@ import SimpleXChat struct ChatInfoToolbar: View { @Environment(\.colorScheme) var colorScheme @EnvironmentObject var theme: AppTheme + @EnvironmentObject var m: ChatModel @ObservedObject var chat: Chat var imageSize: CGFloat = 32 @@ -56,6 +57,13 @@ struct ChatInfoToolbar: View { .padding(.top, -2) } } + if let contact = chat.chatInfo.contact, + contact.ready && contact.active, + let chatSubStatus = m.chatSubStatus, + chatSubStatus != .active { + SubStatusView(status: chatSubStatus) + .padding(.leading, 4) + } } .foregroundColor(theme.colors.onBackground) .frame(width: 220) @@ -68,6 +76,30 @@ struct ChatInfoToolbar: View { .baselineOffset(1) .kerning(-2) } + + struct SubStatusView: View { + @Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize + @EnvironmentObject var theme: AppTheme + var status: SubscriptionStatus + + var body: some View { + switch status { + case .active: EmptyView() + case .pending: ProgressView() + case .removed: subStatusError() + case .noSub: subStatusError() + } + } + + @ViewBuilder private func subStatusError() -> some View { + let dynamicChatInfoSize = dynamicSize(userFont).chatInfoSize + Image(systemName: "exclamationmark.circle") + .resizable() + .scaledToFit() + .frame(width: dynamicChatInfoSize, height: dynamicChatInfoSize) + .foregroundColor(theme.colors.secondary) + } + } } struct ChatInfoToolbar_Previews: PreviewProvider { diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index 77c1db341a..ad82af05e2 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -92,7 +92,6 @@ struct ChatInfoView: View { @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var theme: AppTheme @Environment(\.dismiss) var dismiss: DismissAction - @ObservedObject var networkModel = NetworkModel.shared @ObservedObject var chat: Chat @State var contact: Contact @State var localAlias: String @@ -115,7 +114,7 @@ struct ChatInfoView: View { enum ChatInfoViewAlert: Identifiable { case clearChatAlert - case networkStatusAlert + case subStatusAlert(status: SubscriptionStatus) case switchAddressAlert case abortSwitchAddressAlert case syncConnectionForceAlert @@ -126,7 +125,7 @@ struct ChatInfoView: View { var id: String { switch self { case .clearChatAlert: return "clearChatAlert" - case .networkStatusAlert: return "networkStatusAlert" + case let .subStatusAlert(status): return "subStatusAlert \(status)" case .switchAddressAlert: return "switchAddressAlert" case .abortSwitchAddressAlert: return "abortSwitchAddressAlert" case .syncConnectionForceAlert: return "syncConnectionForceAlert" @@ -236,10 +235,12 @@ struct ChatInfoView: View { if contact.ready && contact.active { Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) { - networkStatusRow() - .onTapGesture { - alert = .networkStatusAlert - } + if let chatSubStatus = chatModel.chatSubStatus { + SubStatusRow(status: chatSubStatus) + .onTapGesture { + alert = .subStatusAlert(status: chatSubStatus) + } + } if let connStats = connectionStats { Button("Change receiving address") { alert = .switchAddressAlert @@ -325,7 +326,7 @@ struct ChatInfoView: View { .alert(item: $alert) { alertItem in switch(alertItem) { case .clearChatAlert: return clearChatAlert() - case .networkStatusAlert: return networkStatusAlert() + case let .subStatusAlert(status): return subStatusAlert(status) case .switchAddressAlert: return switchAddressAlert(switchContactAddress) case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchContactAddress) case .syncConnectionForceAlert: @@ -546,26 +547,6 @@ struct ChatInfoView: View { } } - private func networkStatusRow() -> some View { - HStack { - Text("Network status") - Image(systemName: "info.circle") - .foregroundColor(theme.colors.primary) - .font(.system(size: 14)) - Spacer() - Text(networkModel.contactNetworkStatus(contact).statusString) - .foregroundColor(theme.colors.secondary) - serverImage() - } - } - - private func serverImage() -> some View { - let status = networkModel.contactNetworkStatus(contact) - return Image(systemName: status.imageName) - .foregroundColor(status == .connected ? .green : theme.colors.secondary) - .font(.system(size: 12)) - } - private func deleteContactButton() -> some View { Button(role: .destructive) { deleteContactDialog( @@ -605,10 +586,10 @@ struct ChatInfoView: View { ) } - private func networkStatusAlert() -> Alert { + private func subStatusAlert(_ status: SubscriptionStatus) -> Alert { Alert( title: Text("Network status"), - message: Text(networkModel.contactNetworkStatus(contact).statusExplanation) + message: Text(status.statusExplanation) ) } @@ -667,6 +648,30 @@ struct ChatInfoView: View { } } +struct SubStatusRow: View { + @EnvironmentObject var theme: AppTheme + var status: SubscriptionStatus + + var body: some View { + HStack { + Text("Network status") + Image(systemName: "info.circle") + .foregroundColor(theme.colors.primary) + .font(.system(size: 14)) + Spacer() + Text(status.statusString) + .foregroundColor(theme.colors.secondary) + serverImage(status) + } + } + + private func serverImage(_ status: SubscriptionStatus) -> some View { + return Image(systemName: status.imageName) + .foregroundColor(status == .active ? .green : theme.colors.secondary) + .font(.system(size: 12)) + } +} + struct ChatTTLOption: View { @ObservedObject var chat: Chat @Binding var progressIndicator: Bool diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 2f8d6f2acd..709758655f 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -60,6 +60,7 @@ struct ChatView: View { @State private var animatedScrollingInProgress: Bool = false @State private var showUserSupportChatSheet = false @State private var showCommandsMenu = false + @State private var supportChatMemberInfoLinkActive = false @State private var scrollView: EndlessScrollView = EndlessScrollView(frame: .zero) @@ -178,6 +179,28 @@ struct ChatView: View { if im.showLoadingProgress == chat.id { ProgressView().scaleEffect(2) } + if case let .group(groupInfo, _) = chat.chatInfo, + case let .groupChatScopeContext(groupScopeInfo) = im.secondaryIMFilter, + case let .memberSupport(groupMember_) = groupScopeInfo, + let groupMember = groupMember_ { + NavigationLink(isActive: $supportChatMemberInfoLinkActive) { + GroupMemberInfoView( + groupInfo: groupInfo, + chat: chat, + groupMember: GMember(groupMember), + scrollToItemId: $scrollToItemId, + openedFromSupportChat: true + ) + .navigationBarHidden(false) + .modifier(BackButton(disabled: Binding.constant(false)) { + supportChatMemberInfoLinkActive = false + }) + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() + } } .safeAreaInset(edge: .top) { VStack(spacing: .zero) { @@ -211,18 +234,20 @@ struct ChatView: View { .confirmationDialog(selectedChatItems?.count == 1 ? "Archive report?" : "Archive \((selectedChatItems?.count ?? 0)) reports?", isPresented: $showArchiveSelectedReports, titleVisibility: .visible) { Button("For me", role: .destructive) { if let selected = selectedChatItems { - archiveReports(chat.chatInfo, selected.sorted(), false, deletedSelectedMessages) + archiveReports(chat, selected.sorted(), false, deletedSelectedMessages) } } if case let ChatInfo.group(groupInfo, _) = chat.chatInfo, groupInfo.membership.memberActive { Button("For all moderators", role: .destructive) { if let selected = selectedChatItems { - archiveReports(chat.chatInfo, selected.sorted(), true, deletedSelectedMessages) + archiveReports(chat, selected.sorted(), true, deletedSelectedMessages) } } } } - .appSheet(item: $selectedMember) { member in + .appSheet(item: $selectedMember, onDismiss: { + chatModel.secondaryIM = nil + }) { member in if case let .group(groupInfo, _) = chat.chatInfo { GroupMemberInfoView( groupInfo: groupInfo, @@ -335,7 +360,10 @@ struct ChatView: View { } .onChange(of: chatModel.secondaryPendingInviteeChatOpened) { opened in if im.secondaryIMFilter != nil && !opened { - dismiss() + Task { + try? await Task.sleep(nanoseconds: 650_000000) + dismiss() + } } } .onChange(of: chatModel.openAroundItemId) { openAround in @@ -365,6 +393,8 @@ struct ChatView: View { if chatModel.chatId == cInfo.id && !presentationMode.wrappedValue.isPresented { DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { if chatModel.chatId == nil { + chatModel.chatAgentConnId = nil + chatModel.chatSubStatus = nil im.reversedChatItems = [] im.chatState.clear() chatModel.groupMembers = [] @@ -459,7 +489,10 @@ struct ChatView: View { ChatInfoToolbar(chat: chat) .tint(theme.colors.primary) } - .appSheet(isPresented: $showChatInfoSheet, onDismiss: { theme = buildTheme() }) { + .appSheet(isPresented: $showChatInfoSheet, onDismiss: { + chatModel.secondaryIM = nil + theme = buildTheme() + }) { GroupChatInfoView( chat: chat, groupInfo: Binding( @@ -562,10 +595,16 @@ struct ChatView: View { switch groupScopeInfo { case let .memberSupport(groupMember_): if let groupMember = groupMember_ { - MemberSupportChatToolbar(groupMember: groupMember) + Button { + supportChatMemberInfoLinkActive = true + } label: { + MemberSupportChatToolbar(groupMember: groupMember) + } } else { textChatToolbar("Chat with admins") } + case .reports: + textChatToolbar("Member reports") } case let .msgContentTagContext(contentTag): switch contentTag { @@ -620,18 +659,30 @@ struct ChatView: View { private func initChatView() { let cInfo = chat.chatInfo // This check prevents the call to apiContactInfo after the app is suspended, and the database is closed. - if case .active = scenePhase, - case let .direct(contact) = cInfo { - Task { - do { - let (stats, _) = try await apiContactInfo(chat.chatInfo.apiId) - await MainActor.run { - if let s = stats { - chatModel.updateContactConnectionStats(contact, s) + if case .active = scenePhase { + if case let .direct(contact) = cInfo { + Task { + do { + let (stats, _) = try await apiContactInfo(chat.chatInfo.apiId) + await MainActor.run { + if let s = stats { + chatModel.updateContactConnectionStats(contact, s) + if let conn = contact.activeConn { + chatModel.chatAgentConnId = conn.agentConnId + chatModel.chatSubStatus = s.subStatus + } + } } + } catch let error { + logger.error("apiContactInfo error: \(responseError(error))") + } + } + } else { + Task { + await MainActor.run { + chatModel.chatAgentConnId = nil + chatModel.chatSubStatus = nil } - } catch let error { - logger.error("apiContactInfo error: \(responseError(error))") } } } @@ -1881,14 +1932,14 @@ struct ChatView: View { .confirmationDialog(archivingReports?.count == 1 ? "Archive report?" : "Archive \(archivingReports?.count ?? 0) reports?", isPresented: $showArchivingReports, titleVisibility: .visible) { Button("For me", role: .destructive) { if let reports = self.archivingReports { - archiveReports(chat.chatInfo, reports.sorted(), false) + archiveReports(chat, reports.sorted(), false) self.archivingReports = [] } } if case let ChatInfo.group(groupInfo, _) = chat.chatInfo, groupInfo.membership.memberActive { Button("For all moderators", role: .destructive) { if let reports = self.archivingReports { - archiveReports(chat.chatInfo, reports.sorted(), true) + archiveReports(chat, reports.sorted(), true) self.archivingReports = [] } } @@ -2680,13 +2731,13 @@ private func deleteMessages(_ chat: Chat, _ deletingItems: [Int64], _ mode: CIDe await MainActor.run { for di in deletedItems { if let toItem = di.toChatItem { - _ = ChatModel.shared.upsertChatItem(chat.chatInfo, toItem.chatItem) + _ = ChatModel.shared.upsertChatItem(chatInfo, toItem.chatItem) } else { ChatModel.shared.removeChatItem(chatInfo, di.deletedChatItem.chatItem) } let deletedItem = di.deletedChatItem.chatItem if deletedItem.isActiveReport { - ChatModel.shared.decreaseGroupReportsCounter(chat.chatInfo.id) + ChatModel.shared.decreaseGroupReportsCounter(chatInfo.id) } } if let updatedChatInfo = deletedItems.last?.deletedChatItem.chatInfo { @@ -2701,8 +2752,9 @@ private func deleteMessages(_ chat: Chat, _ deletingItems: [Int64], _ mode: CIDe } } -func archiveReports(_ chatInfo: ChatInfo, _ itemIds: [Int64], _ forAll: Bool, _ onSuccess: @escaping () async -> Void = {}) { +func archiveReports(_ chat: Chat, _ itemIds: [Int64], _ forAll: Bool, _ onSuccess: @escaping () async -> Void = {}) { if itemIds.count > 0 { + let chatInfo = chat.chatInfo Task { do { let deleted = try await apiDeleteReceivedReports( diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 683dea0f56..3745d0f0b8 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -788,7 +788,6 @@ struct ComposeView: View { await MainActor.run { self.chatModel.updateContact(contact) clearState() - NetworkModel.shared.setContactNetworkStatus(contact, .connected) } } else { AlertManager.shared.showAlertMsg(title: "Empty message!") @@ -827,7 +826,6 @@ struct ComposeView: View { if let contact = await apiConnectPreparedContact(contactId: chat.chatInfo.apiId, incognito: incognito, msg: mc) { await MainActor.run { self.chatModel.updateContact(contact) - NetworkModel.shared.setContactNetworkStatus(contact, .connected) clearState() } } else { diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift index 143bf42ea4..e9913053ea 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift @@ -48,7 +48,7 @@ func showRejectMemberAlert(_ groupInfo: GroupInfo, _ member: GroupMember, dismis showAlert( title: NSLocalizedString("Reject member?", comment: "alert title"), buttonTitle: "Reject", - buttonAction: { removeMember(groupInfo, member, dismiss: dismiss) }, + buttonAction: { removeMember(groupInfo, member, withMessages: false, dismiss: dismiss) }, cancelButton: true ) } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift index 31d4ceecc6..c5fd8e39d0 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift @@ -71,13 +71,17 @@ struct NativeTextEditor: UIViewRepresentable { if field.selectedRange != selectedRange { field.selectedRange = selectedRange } - if focused && !field.isFocused { - DispatchQueue.main.async { - if !field.isFocused { - field.becomeFirstResponder() - } - } - } +// This block causes delays in closing keyboard when navigating from chat view to chat list. +// It is also a candidate for iOS 26.1 freeze. +// This was added in commit below to open keyboard programmatically via a passed binding but this approach is not reliable. +// https://github.com/simplex-chat/simplex-chat/pull/6003/commits/cb666de51375623451a5e80dcf59449adc7d2a5f +// if focused && !field.isFocused { +// DispatchQueue.main.async { +// if !field.isFocused { +// field.becomeFirstResponder() +// } +// } +// } } } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 872e65c7a3..96b5e2898a 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -46,7 +46,6 @@ struct GroupChatInfoView: View { case unblockMemberAlert(mem: GroupMember) case blockForAllAlert(mem: GroupMember) case unblockForAllAlert(mem: GroupMember) - case removeMemberAlert(mem: GroupMember) case error(title: LocalizedStringKey, error: LocalizedStringKey?) var id: String { @@ -60,7 +59,6 @@ struct GroupChatInfoView: View { case let .unblockMemberAlert(mem): return "unblockMemberAlert \(mem.groupMemberId)" case let .blockForAllAlert(mem): return "blockForAllAlert \(mem.groupMemberId)" case let .unblockForAllAlert(mem): return "unblockForAllAlert \(mem.groupMemberId)" - case let .removeMemberAlert(mem): return "removeMemberAlert \(mem.groupMemberId)" case let .error(title, _): return "error \(title)" } } @@ -98,7 +96,7 @@ struct GroupChatInfoView: View { memberSupportButton() } if groupInfo.canModerate { - GroupReportsChatNavLink(chat: chat, scrollToItemId: $scrollToItemId) + GroupReportsChatNavLink(chat: chat, groupInfo: groupInfo, scrollToItemId: $scrollToItemId) } if groupInfo.membership.memberActive && (groupInfo.membership.memberRole < .moderator || groupInfo.membership.supportChat != nil) { @@ -212,7 +210,6 @@ struct GroupChatInfoView: View { case let .unblockMemberAlert(mem): return unblockMemberAlert(groupInfo, mem) case let .blockForAllAlert(mem): return blockForAllAlert(groupInfo, mem) case let .unblockForAllAlert(mem): return unblockForAllAlert(groupInfo, mem) - case let .removeMemberAlert(mem): return removeMemberAlert(mem) case let .error(title, error): return mkAlert(title: title, message: error) } } @@ -517,7 +514,7 @@ struct GroupChatInfoView: View { private func removeSwipe(_ member: GroupMember, _ v: V) -> some View { v.swipeActions(edge: .trailing) { Button(role: .destructive) { - alert = .removeMemberAlert(mem: member) + showRemoveMemberAlert(groupInfo, member) } label: { Label("Remove member", systemImage: "trash") .foregroundColor(Color.red) @@ -612,15 +609,19 @@ struct GroupChatInfoView: View { } struct GroupReportsChatNavLink: View { - @EnvironmentObject var chatModel: ChatModel - @EnvironmentObject var theme: AppTheme - @State private var navLinkActive = false @ObservedObject var chat: Chat + @EnvironmentObject var theme: AppTheme + var groupInfo: GroupInfo + @EnvironmentObject var chatModel: ChatModel @Binding var scrollToItemId: ChatItem.ID? + @State private var navLinkActive = false var body: some View { NavigationLink(isActive: $navLinkActive) { - SecondaryChatView(chat: chat, scrollToItemId: $scrollToItemId) + SecondaryChatView( + chat: Chat(chatInfo: .group(groupInfo: groupInfo, groupChatScope: .reports), chatItems: [], chatStats: ChatStats()), + scrollToItemId: $scrollToItemId + ) } label: { HStack { Label { @@ -787,32 +788,38 @@ struct GroupChatInfoView: View { alert = .largeGroupReceiptsDisabled } } - - private func removeMemberAlert(_ mem: GroupMember) -> Alert { - let messageLabel: LocalizedStringKey = ( - groupInfo.businessChat == nil - ? "Member will be removed from group - this cannot be undone!" - : "Member will be removed from chat - this cannot be undone!" - ) - return Alert( - title: Text("Remove member?"), - message: Text(messageLabel), - primaryButton: .destructive(Text("Remove")) { - removeMember(groupInfo, mem) - }, - secondaryButton: .cancel() - ) - } } -func removeMember(_ groupInfo: GroupInfo, _ mem: GroupMember, dismiss: DismissAction? = nil) { +func showRemoveMemberAlert(_ groupInfo: GroupInfo, _ mem: GroupMember, dismiss: DismissAction? = nil) { + showAlert( + NSLocalizedString("Remove member?", comment: "alert title"), + message: + groupInfo.businessChat == nil + ? NSLocalizedString("Member will be removed from group - this cannot be undone!", comment: "alert message") + : NSLocalizedString("Member will be removed from chat - this cannot be undone!", comment: "alert message"), + actions: {[ + UIAlertAction(title: NSLocalizedString("Remove", comment: "alert action"), style: .destructive) { _ in + removeMember(groupInfo, mem, withMessages: false, dismiss: dismiss) + }, + UIAlertAction(title: NSLocalizedString("Remove and delete messages", comment: "alert action"), style: .destructive) { _ in + removeMember(groupInfo, mem, withMessages: true, dismiss: dismiss) + }, + cancelAlertAction + ]} + ) +} + +func removeMember(_ groupInfo: GroupInfo, _ mem: GroupMember, withMessages: Bool, dismiss: DismissAction?) { Task { do { - let (updatedGroupInfo, updatedMembers) = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId]) + let (updatedGroupInfo, updatedMembers) = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId], withMessages) await MainActor.run { ChatModel.shared.updateGroup(updatedGroupInfo) updatedMembers.forEach { updatedMember in _ = ChatModel.shared.upsertGroupMember(updatedGroupInfo, updatedMember) + if withMessages { + ChatModel.shared.removeMemberItems(updatedMember, byMember: groupInfo.membership, groupInfo) + } } dismiss?() } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 2057b9b43c..207c2170a3 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -18,6 +18,7 @@ struct GroupMemberInfoView: View { @ObservedObject var groupMember: GMember @Binding var scrollToItemId: ChatItem.ID? var navigation: Bool = false + var openedFromSupportChat: Bool = false @State private var connectionStats: ConnectionStats? = nil @State private var connectionCode: String? = nil @State private var connectionLoaded: Bool = false @@ -35,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 @@ -50,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" @@ -101,7 +100,8 @@ struct GroupMemberInfoView: View { if member.memberActive { Section { - if groupInfo.membership.memberRole >= .moderator + if !openedFromSupportChat + && groupInfo.membership.memberRole >= .moderator && (member.memberRole < .moderator || member.supportChat != nil) { MemberInfoSupportChatNavLink(groupInfo: groupInfo, member: groupMember, scrollToItemId: $scrollToItemId) } @@ -158,7 +158,15 @@ struct GroupMemberInfoView: View { if let connStats = connectionStats { Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) { - // TODO network connection status + if let subStatus = connStats.subStatus { + SubStatusRow(status: subStatus) + .onTapGesture { + showAlert( + NSLocalizedString("Network status", comment: "alert title"), + message: subStatus.statusExplanation + ) + } + } Button("Change receiving address") { alert = .switchAddressAlert } @@ -263,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) @@ -394,7 +401,6 @@ struct GroupMemberInfoView: View { ItemsModel.shared.loadOpenChat(memberContact.id) { dismissAllSheets(animated: true) } - NetworkModel.shared.setContactNetworkStatus(memberContact, .connected) } } catch let error { logger.error("createMemberContactButton apiCreateMemberContact error: \(responseError(error))") @@ -570,7 +576,11 @@ struct GroupMemberInfoView: View { } } if canRemove { - removeMemberButton(mem) + if mem.memberStatus == .memRemoved || mem.memberStatus == .memLeft { + deleteMemberMessagesButton(mem) + } else { + removeMemberButton(mem) + } } } } @@ -625,41 +635,32 @@ struct GroupMemberInfoView: View { private func removeMemberButton(_ mem: GroupMember) -> some View { Button(role: .destructive) { - alert = .removeMemberAlert(mem: mem) + showRemoveMemberAlert(groupInfo, mem, dismiss: dismiss) } label: { Label("Remove member", systemImage: "trash") .foregroundColor(.red) } } - private func removeMemberAlert(_ mem: GroupMember) -> Alert { - let label: LocalizedStringKey = ( - groupInfo.businessChat == nil - ? "Member will be removed from group - this cannot be undone!" - : "Member will be removed from chat - this cannot be undone!" - ) - return Alert( - title: Text("Remove member?"), - message: Text(label), - primaryButton: .destructive(Text("Remove")) { - Task { - do { - let (updatedGroupInfo, updatedMembers) = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId]) - await MainActor.run { - chatModel.updateGroup(updatedGroupInfo) - updatedMembers.forEach { updatedMember in - _ = chatModel.upsertGroupMember(updatedGroupInfo, updatedMember) - } - dismiss() - } - } catch let error { - logger.error("apiRemoveMembers error: \(responseError(error))") - let a = getErrorAlert(error, "Error removing member") - alert = .error(title: a.title, error: a.message) - } - } - }, - secondaryButton: .cancel() + private func deleteMemberMessagesButton(_ mem: GroupMember) -> some View { + Button(role: .destructive) { + showDeleteMemberMessagesAlert(mem) + } label: { + Label("Delete member messages", systemImage: "trash") + .foregroundColor(.red) + } + } + + func showDeleteMemberMessagesAlert(_ mem: GroupMember) { + showAlert( + NSLocalizedString("Delete member messages?", comment: "alert title"), + message: NSLocalizedString("Member messages will be deleted - this cannot be undone!", comment: "alert message"), + actions: {[ + UIAlertAction(title: NSLocalizedString("Delete messages", comment: "alert action"), style: .destructive) { _ in + removeMember(groupInfo, mem, withMessages: true, dismiss: dismiss) + }, + cancelAlertAction + ]} ) } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift b/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift index 440ed5227d..cdbed7fe30 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMentions.swift @@ -107,6 +107,8 @@ struct GroupMentionsView: View { } else { return member.memberRole >= .moderator } + case .reports: + return false } case .msgContentTagContext: return false diff --git a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift index 7f3672ea17..75a6840c4e 100644 --- a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift +++ b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift @@ -92,6 +92,16 @@ struct MemberSupportView: View { .frame(width: 1, height: 1) .hidden() } + .if(!memberWithChat.wrapped.memberPending && memberWithChat.wrapped.supportChatNotRead) { v in + v.swipeActions(edge: .leading, allowsFullSwipe: true) { + Button { + Task { await markSupportChatRead(groupInfo, memberWithChat.wrapped) } + } label: { + Label("Read", systemImage: "checkmark") + } + .tint(theme.colors.primary) + } + } .swipeActions(edge: .trailing, allowsFullSwipe: true) { if memberWithChat.wrapped.memberPending { Button { @@ -271,7 +281,7 @@ func deleteMemberSupportChat(_ groupInfo: GroupInfo, _ member: GroupMember) { logger.error("apiDeleteMemberSupportChat error: \(responseError(error))") await MainActor.run { showAlert( - NSLocalizedString("Error deleting chat with member", comment: "alert title"), + NSLocalizedString("Error deleting chat", comment: "alert title"), message: responseError(error) ) } diff --git a/apps/ios/Shared/Views/Chat/Group/SecondaryChatView.swift b/apps/ios/Shared/Views/Chat/Group/SecondaryChatView.swift index 47c5df264f..e2092f7a24 100644 --- a/apps/ios/Shared/Views/Chat/Group/SecondaryChatView.swift +++ b/apps/ios/Shared/Views/Chat/Group/SecondaryChatView.swift @@ -10,6 +10,7 @@ import SwiftUI import SimpleXChat struct SecondaryChatView: View { + @Environment(\.dismiss) var dismiss @EnvironmentObject var chatModel: ChatModel @ObservedObject var chat: Chat @Binding var scrollToItemId: ChatItem.ID? @@ -23,9 +24,10 @@ struct SecondaryChatView: View { floatingButtonModel: FloatingButtonModel(im: im), scrollToItemId: $scrollToItemId ) - .onDisappear { + .modifier(BackButton(disabled: Binding.constant(false)) { chatModel.secondaryIM = nil - } + dismiss() + }) } } } diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index c56d947a5a..be2c456802 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -460,12 +460,6 @@ struct ChatPreviewView: View { @ViewBuilder private func chatStatusImage() -> some View { let size = dynamicSize(userFont).incognitoSize switch chat.chatInfo { - case let .direct(contact): - if contact.active, let status = contact.activeConn?.connStatus, status == .ready || status == .sndReady { - NetworkStatusView(contact: contact, size: size) - } else { - incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size) - } case .group: if progressByTimeout { ProgressView() @@ -482,30 +476,6 @@ struct ChatPreviewView: View { incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size) } } - - struct NetworkStatusView: View { - @Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize - @EnvironmentObject var theme: AppTheme - @ObservedObject var networkModel = NetworkModel.shared - - let contact: Contact - let size: CGFloat - - var body: some View { - let dynamicChatInfoSize = dynamicSize(userFont).chatInfoSize - switch (networkModel.contactNetworkStatus(contact)) { - case .connected: incognitoIcon(contact.contactConnIncognito, theme.colors.secondary, size: size) - case .error: - Image(systemName: "exclamationmark.circle") - .resizable() - .scaledToFit() - .frame(width: dynamicChatInfoSize, height: dynamicChatInfoSize) - .foregroundColor(theme.colors.secondary) - default: - ProgressView() - } - } - } } @ViewBuilder func incognitoIcon(_ incognito: Bool, _ secondaryColor: Color, size: CGFloat) -> some View { diff --git a/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift index 656cef4a04..33ffa04a50 100644 --- a/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift +++ b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift @@ -63,8 +63,6 @@ struct OnboardingConditionsView: View { @State private var notificationsModeNavLinkActive = false @State private var justOpened = true - var selectedOperators: [ServerOperator] { serverOperators.filter { selectedOperatorIds.contains($0.operatorId) } } - var body: some View { GeometryReader { g in let v = ScrollView { @@ -169,9 +167,7 @@ struct OnboardingConditionsView: View { Task { do { let conditionsId = ChatModel.shared.conditions.currentConditions.conditionsId - let acceptForOperators = selectedOperators.filter { !$0.conditionsAcceptance.conditionsAccepted } - let operatorIds = acceptForOperators.map { $0.operatorId } - let r = try await acceptConditions(conditionsId: conditionsId, operatorIds: operatorIds) + let r = try await acceptConditions(conditionsId: conditionsId, operatorIds: Array(selectedOperatorIds)) await MainActor.run { ChatModel.shared.conditions = r } diff --git a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift index 1e5b4bff16..d40ec116f4 100644 --- a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift @@ -196,12 +196,13 @@ struct UserAddressView: View { progressIndicator = true Task { do { - if let connLinkContact = try await apiCreateUserAddress() { - DispatchQueue.main.async { + let connLinkContact = try await apiCreateUserAddress() + DispatchQueue.main.async { + if let connLinkContact { chatModel.userAddress = UserContactLink(connLinkContact) alert = .shareOnCreate - progressIndicator = false } + progressIndicator = false } } catch let error { logger.error("UserAddressView apiCreateUserAddress: \(responseError(error))") diff --git a/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff b/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff index 2331fc9a32..427430b833 100644 --- a/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff +++ b/apps/ios/SimpleX Localizations/ar.xcloc/Localized Contents/ar.xliff @@ -2231,8 +2231,8 @@ We will be adding server redundancy to prevent lost messages. Please store passphrase securely, you will NOT be able to change it if you lose it. No comment provided by engineer. - - Possibly, certificate fingerprint in server address is incorrect + + Fingerprint in server address does not match certificate. server test error @@ -2559,8 +2559,8 @@ We will be adding server redundancy to prevent lost messages. Sent messages will be deleted after set time. No comment provided by engineer. - - Server requires authorization to create queues, check password + + Server requires authorization to create queues, check password. يتطلب الخادم إذنًا لإنشاء قوائم انتظار، تحقق من كلمة المرور server test error @@ -4249,8 +4249,8 @@ SimpleX servers cannot see your profile. Server type نوع الخادم - - Server requires authorization to upload, check password + + Server requires authorization to upload, check password. يتطلب الخادم إذنًا للرفع، تحقق من كلمة المرور diff --git a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff index 32d32aeb67..a59179ddfa 100644 --- a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff +++ b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff @@ -362,6 +362,7 @@ **Scan / Paste link**: to connect via a link you received. + **Сканирай / Постави линк**: за свързване чрез получения линк. No comment provided by engineer. @@ -475,6 +476,7 @@ time interval 1-time link can be used *with one contact only* - share in person or via any messenger. + Еднократният линк може да се използва само веднъж *с един контакт* - споделете го лично или чрез някой месинджър. No comment provided by engineer. @@ -602,6 +604,7 @@ swipe action Accept member + Приеми член alert title @@ -621,6 +624,7 @@ swipe action Active + Активен token status text @@ -640,10 +644,12 @@ swipe action Add list + Добави списък No comment provided by engineer. Add message + Добави съобщение placeholder for sending contact request @@ -673,6 +679,7 @@ swipe action Add to list + Добави към списъка No comment provided by engineer. @@ -752,6 +759,7 @@ swipe action All + Всички No comment provided by engineer. @@ -766,6 +774,7 @@ swipe action All chats will be removed from the list %@, and the list deleted. + Всички чатове ще бъдат премахнати от списъка %@, а списъкът ще бъде изтрит. alert message @@ -785,6 +794,7 @@ swipe action All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. + Всички съобщения и файлове се изпращат с **криптиране от край до край**, с постквантова сигурност в директните съобщения. No comment provided by engineer. @@ -809,10 +819,12 @@ swipe action All reports will be archived for you. + Всички доклади за нарушения ще бъдат архивирани за вас. No comment provided by engineer. All servers + Всички сървъри No comment provided by engineer. @@ -857,6 +869,7 @@ swipe action Allow files and media only if your contact allows them. + Разреши файлове и медия само ако вашият контакт ги разрешава. No comment provided by engineer. @@ -896,6 +909,7 @@ swipe action Allow to report messsages to moderators. + Позволи докладването на съобщения на модераторите. No comment provided by engineer. @@ -945,6 +959,7 @@ swipe action Allow your contacts to send files and media. + Позволи на вашите контактите да изпращат файлове и медия. No comment provided by engineer. @@ -984,6 +999,7 @@ swipe action Another reason + Друга причина report reason @@ -1013,6 +1029,7 @@ swipe action App group: + Група приложения: No comment provided by engineer. @@ -1062,14 +1079,17 @@ swipe action Archive + Архивирай No comment provided by engineer. Archive %lld reports? + Архивирай %lld доклад(а)? No comment provided by engineer. Archive all reports? + Архивиране на всички доклади за нарушения? No comment provided by engineer. @@ -1084,14 +1104,17 @@ swipe action Archive report + Архивирай доклад за нарушения No comment provided by engineer. Archive report? + Архивирай доклад за нарушения? No comment provided by engineer. Archive reports + Архивирай докладите за нарушения swipe action @@ -1201,6 +1224,7 @@ swipe action Better groups performance + По-добра производителност на групите No comment provided by engineer. @@ -1225,6 +1249,7 @@ swipe action Better privacy and security + По-добра поверителност и сигурност No comment provided by engineer. @@ -1239,10 +1264,12 @@ swipe action Bio + Био No comment provided by engineer. Bio too large + Биографията е твърде дълга alert title @@ -1297,6 +1324,7 @@ swipe action Bot + Бот No comment provided by engineer. @@ -1321,6 +1349,7 @@ swipe action Both you and your contact can send files and media. + И вие, и вашият контакт можете да изпращате файлове и медия. No comment provided by engineer. @@ -1345,10 +1374,12 @@ swipe action Business connection + Бизнес връзка No comment provided by engineer. Businesses + Бизнеси No comment provided by engineer. @@ -1360,6 +1391,9 @@ swipe action By using SimpleX Chat you agree to: - send only legal content in public groups. - respect other users – no spam. + С използването на SimpleX Chat вие се съгласявате със: +- изпращане само на легално съдържание в публични групи. +- уважение към другите потребители – без спам. No comment provided by engineer. @@ -1394,6 +1428,7 @@ swipe action Can't change profile + Промяната на профила е невъзможна alert title @@ -1408,6 +1443,7 @@ swipe action Can't message member + Изпращането на съобщения на груповия член не е налично No comment provided by engineer. @@ -1429,6 +1465,7 @@ new chat action Cannot forward message + Съобщение не може да бъде препратено No comment provided by engineer. @@ -1453,6 +1490,7 @@ new chat action Change automatic message deletion? + Промяна на автоматичното изтриване на съобщения? alert title @@ -1508,18 +1546,22 @@ set passcode view Chat + Чат No comment provided by engineer. Chat already exists + Чатът вече съществува No comment provided by engineer. Chat already exists! + Чатът вече съществува! new chat sheet title Chat colors + Цветове на чата No comment provided by engineer. @@ -1539,6 +1581,7 @@ set passcode view Chat database exported + Базата данни е експортирана No comment provided by engineer. @@ -1563,6 +1606,7 @@ set passcode view Chat list + Списък с чатове No comment provided by engineer. @@ -1577,6 +1621,7 @@ set passcode view Chat preferences were changed. + Настройките на чата бяха променени. alert message @@ -1586,26 +1631,32 @@ set passcode view Chat theme + Тема на чата No comment provided by engineer. Chat will be deleted for all members - this cannot be undone! + Чатът ще бъде изтрит за всички членове - това не може да бъде отменено! No comment provided by engineer. Chat will be deleted for you - this cannot be undone! + Чатът ще бъде изтрит за вас - това не може да бъде отменено! No comment provided by engineer. Chat with admins + Чат с администраторите chat toolbar Chat with member + Чат с член No comment provided by engineer. Chat with members before they join. + Разговаряйте с членовете, преди да се присъединят. No comment provided by engineer. @@ -1615,14 +1666,17 @@ set passcode view Chats with members + Чатове с членовете No comment provided by engineer. Check messages every 20 min. + Проверявай за съобщенията на всеки 20 минути. No comment provided by engineer. Check messages when allowed. + Проверявай за съобщенията, когато е разрешено. No comment provided by engineer. @@ -1652,14 +1706,17 @@ set passcode view Chunks deleted + Изтрити парчета No comment provided by engineer. Chunks downloaded + Изтеглени парчета No comment provided by engineer. Chunks uploaded + Качени парчета No comment provided by engineer. @@ -1679,10 +1736,12 @@ set passcode view Clear group? + Изчисти група? No comment provided by engineer. Clear or delete group? + Изчисти или изтрий група? No comment provided by engineer. @@ -1697,14 +1756,17 @@ set passcode view Color chats with the new themes. + Цветни чатове с нови теми. No comment provided by engineer. Color mode + Цветен режим No comment provided by engineer. Community guidelines violation + Нарушение на правилата на общността report reason @@ -1719,34 +1781,42 @@ set passcode view Completed + Завършен No comment provided by engineer. Conditions accepted on: %@. + Условия, приети на: %@. No comment provided by engineer. Conditions are accepted for the operator(s): **%@**. + Условията са приети за оператора(ите): **%@**. No comment provided by engineer. Conditions are already accepted for these operator(s): **%@**. + Условията вече са приети за тези оператори: **%@**. No comment provided by engineer. Conditions of use - No comment provided by engineer. + Условия за ползване + alert button Conditions will be accepted for the operator(s): **%@**. + Условията ще бъдат приети за операторите: **%@**. No comment provided by engineer. Conditions will be accepted on: %@. + Условията ще бъдат приети на: %@. No comment provided by engineer. Conditions will be automatically accepted for enabled operators on: %@. + Условията ще бъдат автоматично приети за активираните оператори на: %@. No comment provided by engineer. @@ -1756,6 +1826,7 @@ set passcode view Configure server operators + Конфигуриране на сървърни оператори No comment provided by engineer. @@ -1770,6 +1841,7 @@ set passcode view Confirm contact deletion? + Потвърди изтриването на контакта? No comment provided by engineer. @@ -1779,6 +1851,7 @@ set passcode view Confirm files from unknown servers. + Потвърди файлове от неизвестни сървъри. No comment provided by engineer. @@ -1808,6 +1881,7 @@ set passcode view Confirmed + Потвърдено token status text @@ -1822,6 +1896,7 @@ set passcode view Connect faster! 🚀 + Свържете се по-бързо! 🚀 No comment provided by engineer. @@ -1831,6 +1906,7 @@ set passcode view Connect to your friends faster. + Свържете се с приятелите си по-бързо. No comment provided by engineer. @@ -1869,6 +1945,7 @@ This is your own one-time link! Connected + Свързан No comment provided by engineer. @@ -1878,6 +1955,7 @@ This is your own one-time link! Connected servers + Свързани сървъри No comment provided by engineer. @@ -1887,6 +1965,7 @@ This is your own one-time link! Connecting + Свързване No comment provided by engineer. @@ -1901,6 +1980,7 @@ This is your own one-time link! Connecting to contact, please wait or check later! + Тече свързване с контакт, моля изчакайте или проверете по-късно! No comment provided by engineer. @@ -1915,10 +1995,12 @@ This is your own one-time link! Connection and servers status. + Състояние на връзката и сървърите. No comment provided by engineer. Connection blocked + Връзката е блокирана No comment provided by engineer. @@ -1934,14 +2016,18 @@ This is your own one-time link! Connection is blocked by server operator: %@ + Връзката е блокирана от оператора на сървъра: +%@ No comment provided by engineer. Connection not ready. + Връзката не е готова. No comment provided by engineer. Connection notifications + Известия за връзката No comment provided by engineer. @@ -1951,6 +2037,7 @@ This is your own one-time link! Connection requires encryption renegotiation. + Връзката изисква предоговаряне на криптирането. No comment provided by engineer. @@ -3152,6 +3239,10 @@ chat item action Error connecting to forwarding server %@. Please try later. alert message + + Error connecting to the server used to receive messages from this connection: %@ + subscription status explanation + Error creating address Грешка при създаване на адрес @@ -3195,15 +3286,15 @@ chat item action Грешка при декриптирането на файла No comment provided by engineer. + + Error deleting chat + alert title + Error deleting chat database Грешка при изтриване на базата данни alert title - - Error deleting chat with member - alert title - Error deleting chat! Грешка при изтриването на чата! @@ -3465,6 +3556,10 @@ chat item action file error text snd error text + + Error: %@. + server test error + Error: URL is invalid Грешка: URL адресът е невалиден @@ -3671,6 +3766,23 @@ snd error text Намирайте чатове по-бързо No comment provided by engineer. + + Fingerprint in destination server address does not match certificate: %@. + No comment provided by engineer. + + + Fingerprint in forwarding server address does not match certificate: %@. + No comment provided by engineer. + + + Fingerprint in server address does not match certificate. + Въжможно е пръстовият отпечатък на сертификата в адреса на сървъра да е неправилен + server test error + + + Fingerprint in server address does not match certificate: %@. + No comment provided by engineer. + Fix Поправи @@ -4645,6 +4757,10 @@ This is your link for group %@! Член No comment provided by engineer. + + Member %@ + past/unknown group member + Member admission No comment provided by engineer. @@ -5022,7 +5138,7 @@ This is your link for group %@! Network status Състояние на мрежата - No comment provided by engineer. + alert title New @@ -5613,11 +5729,6 @@ Requires compatible VPN. Парола за показване No comment provided by engineer. - - Past member %@ - Бивш член %@ - past/unknown group member - Paste desktop address Постави адрес на настолно устройство @@ -5767,11 +5878,6 @@ Error: %@ Port No comment provided by engineer. - - Possibly, certificate fingerprint in server address is incorrect - Въжможно е пръстовият отпечатък на сертификата в адреса на сървъра да е неправилен - server test error - Preserve the last message draft, with attachments. Запазете последната чернова на съобщението с прикачени файлове. @@ -6052,11 +6158,6 @@ Enable in *Network & servers* settings. Получено в: %@ copied message info - - Received file event - Събитие за получен файл - notification - Received message Получено съобщение @@ -6827,11 +6928,6 @@ chat item action Sent directly No comment provided by engineer. - - Sent file event - Събитие за изпратен файл - notification - Sent message Изпратено съобщение @@ -6890,13 +6986,13 @@ chat item action Server protocol changed. alert title - - Server requires authorization to create queues, check password + + Server requires authorization to create queues, check password. Сървърът изисква оторизация за създаване на опашки, проверете паролата server test error - - Server requires authorization to upload, check password + + Server requires authorization to upload, check password. Сървърът изисква оторизация за качване, проверете паролата server test error @@ -7923,15 +8019,9 @@ You will be prompted to complete authentication before this feature is enabled.< Transport sessions No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - Опит за свързване със сървъра, използван за получаване на съобщения от този контакт (грешка: %@). - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. - Опит за свързване със сървъра, използван за получаване на съобщения от този контакт. - No comment provided by engineer. + + Trying to connect to the server used to receive messages from this connection. + subscription status explanation Turkish interface @@ -8602,16 +8692,19 @@ Repeat join request? Изпрати отново заявката за присъединяване? new chat sheet title - - You are connected to the server used to receive messages from this contact. - Вие сте свързани към сървъра, използван за получаване на съобщения от този контакт. - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + subscription status explanation You are invited to group Поканени сте в групата No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. No comment provided by engineer. @@ -9627,6 +9720,10 @@ pref value липсва e2e криптиране No comment provided by engineer. + + no subscription + No comment provided by engineer. + no text няма текст diff --git a/apps/ios/SimpleX Localizations/bn.xcloc/Localized Contents/bn.xliff b/apps/ios/SimpleX Localizations/bn.xcloc/Localized Contents/bn.xliff index c6420fb34d..fbda1abd29 100644 --- a/apps/ios/SimpleX Localizations/bn.xcloc/Localized Contents/bn.xliff +++ b/apps/ios/SimpleX Localizations/bn.xcloc/Localized Contents/bn.xliff @@ -2597,8 +2597,8 @@ Polish interface No comment provided by engineer. - - Possibly, certificate fingerprint in server address is incorrect + + Fingerprint in server address does not match certificate. server test error @@ -3073,12 +3073,12 @@ Sent messages will be deleted after set time. No comment provided by engineer. - - Server requires authorization to create queues, check password + + Server requires authorization to create queues, check password. server test error - - Server requires authorization to upload, check password + + Server requires authorization to upload, check password. server test error diff --git a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff index 4bfe80e388..ace3079550 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff @@ -410,6 +410,9 @@ - optionally notify deleted contacts. - profile names with spaces. - and more! + - volitelně informuje smazané kontakty. +- profilová jména s mezerami. +- a více! No comment provided by engineer. @@ -423,6 +426,7 @@ 0 sec + 0 sek time to disappear @@ -460,6 +464,7 @@ time interval 1 year + 1 rok delete after time @@ -468,6 +473,7 @@ time interval 1-time link can be used *with one contact only* - share in person or via any messenger. + Jednorázový odkaz lze použít *pouze s jedním kontaktem* - sdílejte osobně nebo prostřednictvím libovolného komunikátoru. No comment provided by engineer. @@ -541,6 +547,7 @@ time interval About operators + O operátorech No comment provided by engineer. @@ -557,14 +564,17 @@ swipe action Accept as member + Přijmout za člena alert action Accept as observer + Přijmout jako pozorovatele alert action Accept conditions + Přijmout podmínky No comment provided by engineer. @@ -574,6 +584,7 @@ swipe action Accept contact request + Přijmout žádost o kontakt alert title @@ -589,10 +600,12 @@ swipe action Accept member + Přijmout člena alert title Accepted conditions + Přijaté podmínky No comment provided by engineer. @@ -605,10 +618,12 @@ swipe action Active + Aktivní token status text Active connections + Aktivní spojení No comment provided by engineer. @@ -618,6 +633,7 @@ swipe action Add friends + Přidat přátele No comment provided by engineer. @@ -645,6 +661,7 @@ swipe action Add team members + Přidat členy týmu No comment provided by engineer. @@ -654,6 +671,7 @@ swipe action Add to list + Přidat do seznamu No comment provided by engineer. @@ -663,6 +681,7 @@ swipe action Add your team members to the conversations. + Přidat členy týmu do konverzace. No comment provided by engineer. @@ -671,14 +690,17 @@ swipe action Added message servers + Přidané servery zpráv No comment provided by engineer. Additional accent + Další zbarvení No comment provided by engineer. Additional accent 2 + Další zbarvení 2 No comment provided by engineer. @@ -697,14 +719,17 @@ swipe action Address or 1-time link? + Adresa nebo jednorázový odkaz? No comment provided by engineer. Address settings + Nastavení adresy No comment provided by engineer. Admins can block a member for all. + Správci mohou blokovat člena pro všechny. No comment provided by engineer. @@ -719,10 +744,12 @@ swipe action Advanced settings + Pokročilá nastavení No comment provided by engineer. All + Vše No comment provided by engineer. @@ -737,6 +764,7 @@ swipe action All chats will be removed from the list %@, and the list deleted. + Všechny chaty budou odstraněny ze seznamu %@ a seznam bude odstraněn. alert message @@ -746,6 +774,7 @@ swipe action All data is kept private on your device. + Všechna data jsou uchována ve vašem zařízení. No comment provided by engineer. @@ -768,10 +797,12 @@ swipe action All new messages from %@ will be hidden! + Všechny nové zprávy od %@ budou skryté! No comment provided by engineer. All profiles + Všechny profily profile dropdown @@ -780,6 +811,7 @@ swipe action All servers + Všechny servery No comment provided by engineer. @@ -808,6 +840,7 @@ swipe action Allow calls? + Povolit volání? No comment provided by engineer. @@ -850,6 +883,7 @@ swipe action Allow sharing + Povolit sdílení No comment provided by engineer. @@ -859,10 +893,12 @@ swipe action Allow to report messsages to moderators. + Povolit nahlášení zpráv moderátorům. No comment provided by engineer. Allow to send SimpleX links. + Povolit odesílání SimpleX odkazů. No comment provided by engineer. @@ -907,6 +943,7 @@ swipe action Allow your contacts to send files and media. + Povolit vašim kontaktům odesílání souborů a médii. No comment provided by engineer. @@ -921,14 +958,17 @@ swipe action Already connecting! + Již připojováno! new chat sheet title Already joining the group! + Již se ke skupině připojujete! new chat sheet title Always use private routing. + Vždy používat soukromé směrování. No comment provided by engineer. @@ -943,6 +983,7 @@ swipe action Another reason + Jiný důvod report reason @@ -962,6 +1003,7 @@ swipe action App data migration + Přenos dat aplikace No comment provided by engineer. @@ -1009,6 +1051,7 @@ swipe action Apply + Použít No comment provided by engineer. @@ -1017,6 +1060,7 @@ swipe action Archive + Archiv No comment provided by engineer. @@ -1025,10 +1069,12 @@ swipe action Archive all reports? + Archivovat všechny hlášení? No comment provided by engineer. Archive and upload + Archivovat a nahrát No comment provided by engineer. @@ -1049,6 +1095,7 @@ swipe action Archived contacts + Archivované kontakty No comment provided by engineer. @@ -1122,6 +1169,7 @@ swipe action Background + Pozadí No comment provided by engineer. @@ -1140,18 +1188,22 @@ swipe action Better calls + Lepší volání No comment provided by engineer. Better groups + Lepší skupiny No comment provided by engineer. Better groups performance + Lepší výkon skupin No comment provided by engineer. Better message dates. + Lepší datumy zpráv. No comment provided by engineer. @@ -1161,18 +1213,22 @@ swipe action Better networking + Lepší sítě No comment provided by engineer. Better notifications + Lepší upozornění No comment provided by engineer. Better privacy and security + Lepší soukromí a zabezpečení No comment provided by engineer. Better security ✅ + Lepší zabezpečení ✅ No comment provided by engineer. @@ -1205,6 +1261,7 @@ swipe action Block member + Blokovat člena No comment provided by engineer. @@ -1213,10 +1270,12 @@ swipe action Block member? + Blokovat člena? No comment provided by engineer. Blocked by admin + Blokován správcem No comment provided by engineer. @@ -1225,6 +1284,7 @@ swipe action Blur media + Rozmazat média No comment provided by engineer. @@ -1253,6 +1313,7 @@ swipe action Both you and your contact can send files and media. + Vy i vaše kontakty můžete posílat soubory a média. No comment provided by engineer. @@ -1267,6 +1328,7 @@ swipe action Business address + Obchodní adresa No comment provided by engineer. @@ -1304,22 +1366,27 @@ swipe action Calls prohibited! + Volání zakázáno! No comment provided by engineer. Camera not available + Kamera není k dispozici No comment provided by engineer. Can't call contact + Kontaktu nelze volat No comment provided by engineer. Can't call member + Členu nelze volat No comment provided by engineer. Can't change profile + Nelze změnit profil alert title @@ -1334,6 +1401,7 @@ swipe action Can't message member + Členu nelze poslat zprávu No comment provided by engineer. @@ -1345,6 +1413,7 @@ new chat action Cancel migration + Zrušit přesun No comment provided by engineer. @@ -1354,6 +1423,7 @@ new chat action Cannot forward message + Zprávu nelze přeposlat No comment provided by engineer. @@ -1363,10 +1433,12 @@ new chat action Capacity exceeded - recipient did not receive previously sent messages. + Kapacita překročena - příjemce neobdrží dříve poslané zprávy. snd error text Cellular + Mobilní No comment provided by engineer. @@ -1376,10 +1448,12 @@ new chat action Change automatic message deletion? + Změnit automatické mazání zpráv? alert title Change chat profiles + Změnit chat profily authentication reason @@ -1442,6 +1516,7 @@ set passcode view Chat colors + Barvy chatu No comment provided by engineer. @@ -1461,6 +1536,7 @@ set passcode view Chat database exported + Chat databáze exportována No comment provided by engineer. @@ -1488,6 +1564,7 @@ set passcode view Chat migrated! + Chat přesunut! No comment provided by engineer. @@ -1653,7 +1730,7 @@ set passcode view Conditions of use - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -1719,6 +1796,7 @@ set passcode view Confirm upload + Potvrdit nahrání No comment provided by engineer. @@ -1732,6 +1810,7 @@ set passcode view Connect automatically + Připojit automaticky No comment provided by engineer. @@ -1754,6 +1833,8 @@ This is your own SimpleX address! Connect to yourself? This is your own one-time link! + Připojit se k sobě? +Toto je váš vlastní jednorázový odkaz! new chat sheet title @@ -3024,6 +3105,10 @@ chat item action Error connecting to forwarding server %@. Please try later. alert message + + Error connecting to the server used to receive messages from this connection: %@ + subscription status explanation + Error creating address Chyba při vytváření adresy @@ -3066,15 +3151,15 @@ chat item action Chyba dešifrování souboru No comment provided by engineer. + + Error deleting chat + alert title + Error deleting chat database Chyba při mazání databáze chatu alert title - - Error deleting chat with member - alert title - Error deleting chat! Chyba při mazání chatu! @@ -3330,6 +3415,10 @@ chat item action file error text snd error text + + Error: %@. + server test error + Error: URL is invalid Chyba: Adresa URL je neplatná @@ -3530,6 +3619,23 @@ snd error text Najděte chaty rychleji No comment provided by engineer. + + Fingerprint in destination server address does not match certificate: %@. + No comment provided by engineer. + + + Fingerprint in forwarding server address does not match certificate: %@. + No comment provided by engineer. + + + Fingerprint in server address does not match certificate. + Je možné, že otisk certifikátu v adrese serveru je nesprávný + server test error + + + Fingerprint in server address does not match certificate: %@. + No comment provided by engineer. + Fix Opravit @@ -4472,6 +4578,10 @@ This is your link for group %@! Člen No comment provided by engineer. + + Member %@ + past/unknown group member + Member admission No comment provided by engineer. @@ -4833,7 +4943,7 @@ This is your link for group %@! Network status Stav sítě - No comment provided by engineer. + alert title New @@ -5412,10 +5522,6 @@ Vyžaduje povolení sítě VPN. Heslo k zobrazení No comment provided by engineer. - - Past member %@ - past/unknown group member - Paste desktop address No comment provided by engineer. @@ -5558,11 +5664,6 @@ Error: %@ Port No comment provided by engineer. - - Possibly, certificate fingerprint in server address is incorrect - Je možné, že otisk certifikátu v adrese serveru je nesprávný - server test error - Preserve the last message draft, with attachments. Zachování posledního návrhu zprávy s přílohami. @@ -5837,11 +5938,6 @@ Enable in *Network & servers* settings. Přijato v: %@ copied message info - - Received file event - Událost přijatého souboru - notification - Received message Přijatá zpráva @@ -6598,11 +6694,6 @@ chat item action Sent directly No comment provided by engineer. - - Sent file event - Odeslaná událost souboru - notification - Sent message Poslaná zpráva @@ -6661,13 +6752,13 @@ chat item action Server protocol changed. alert title - - Server requires authorization to create queues, check password + + Server requires authorization to create queues, check password. Server vyžaduje autorizaci pro vytváření front, zkontrolujte heslo server test error - - Server requires authorization to upload, check password + + Server requires authorization to upload, check password. Server vyžaduje autorizaci pro nahrávání, zkontrolujte heslo server test error @@ -7672,15 +7763,9 @@ Před zapnutím této funkce budete vyzváni k dokončení ověření. Transport sessions No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - Pokus o připojení k serveru používanému k přijímání zpráv od tohoto kontaktu (chyba: %@). - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. - Pokus o připojení k serveru používanému pro příjem zpráv od tohoto kontaktu. - No comment provided by engineer. + + Trying to connect to the server used to receive messages from this connection. + subscription status explanation Turkish interface @@ -8312,16 +8397,19 @@ Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu Repeat join request? new chat sheet title - - You are connected to the server used to receive messages from this contact. - Jste připojeni k serveru, který se používá k přijímání zpráv od tohoto kontaktu. - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + subscription status explanation You are invited to group Jste pozváni do skupiny No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. No comment provided by engineer. @@ -9319,6 +9407,10 @@ pref value bez šifrování e2e No comment provided by engineer. + + no subscription + No comment provided by engineer. + no text žádný text diff --git a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff index 4db5d3a826..34bbab2a6a 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -1802,7 +1802,7 @@ set passcode view Conditions of use Nutzungsbedingungen - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -2016,7 +2016,7 @@ Das ist Ihr eigener Einmal-Link! Connection is blocked by server operator: %@ - Die Verbindung wurde vom Server-Betreiber blockiert: + Die Verbindung wurde vom Serverbetreiber blockiert: %@ No comment provided by engineer. @@ -3310,6 +3310,11 @@ chat item action Fehler beim Verbinden mit dem Weiterleitungsserver %@. Bitte versuchen Sie es später erneut. alert message + + Error connecting to the server used to receive messages from this connection: %@ + Fehler beim Herstellen der Verbindung zum Server, der für den Empfang von Nachrichten dieser Verbindung genutzt wird: %@ + subscription status explanation + Error creating address Fehler beim Erstellen der Adresse @@ -3355,16 +3360,16 @@ chat item action Fehler beim Entschlüsseln der Datei No comment provided by engineer. + + Error deleting chat + Fehler beim Löschen des Chats mit dem Mitglied + alert title + Error deleting chat database Fehler beim Löschen der Chat-Datenbank alert title - - Error deleting chat with member - Fehler beim Löschen des Chats mit dem Mitglied - alert title - Error deleting chat! Fehler beim Löschen des Chats! @@ -3642,6 +3647,11 @@ chat item action file error text snd error text + + Error: %@. + Fehler: %@. + server test error + Error: URL is invalid Fehler: URL ist ungültig @@ -3762,7 +3772,7 @@ snd error text File is blocked by server operator: %@. - Datei wurde vom Server-Betreiber blockiert: + Die Datei wurde vom Serverbetreiber blockiert: %@. file error text @@ -3866,6 +3876,26 @@ snd error text Chats schneller finden No comment provided by engineer. + + Fingerprint in destination server address does not match certificate: %@. + Fingerabdruck in der Zielserveradresse stimmt nicht mit dem Zertifikat überein: %@. + No comment provided by engineer. + + + Fingerprint in forwarding server address does not match certificate: %@. + Fingerabdruck in der Weiterleitungsserveradresse stimmt nicht mit dem Zertifikat überein: %@. + No comment provided by engineer. + + + Fingerprint in server address does not match certificate. + Fingerabdruck in der Serveradresse stimmt nicht mit dem Zertifikat überein. + server test error + + + Fingerprint in server address does not match certificate: %@. + Fingerabdruck in der Serveradresse stimmt nicht mit dem Zertifikat überein: %@. + No comment provided by engineer. + Fix Reparieren @@ -4892,6 +4922,11 @@ Das ist Ihr Link für die Gruppe %@! Mitglied No comment provided by engineer. + + Member %@ + Mitglied %@ + past/unknown group member + Member admission Aufnahme von Mitgliedern @@ -5300,7 +5335,7 @@ Das ist Ihr Link für die Gruppe %@! Network status Netzwerkstatus - No comment provided by engineer. + alert title New @@ -5949,11 +5984,6 @@ Dies erfordert die Aktivierung eines VPNs. Passwort anzeigen No comment provided by engineer. - - Past member %@ - Ehemaliges Mitglied %@ - past/unknown group member - Paste desktop address Desktop-Adresse einfügen @@ -6113,11 +6143,6 @@ Fehler: %@ Port No comment provided by engineer. - - Possibly, certificate fingerprint in server address is incorrect - Der Fingerabdruck des Zertifikats in der Serveradresse ist wahrscheinlich ungültig - server test error - Preserve the last message draft, with attachments. Den letzten Nachrichtenentwurf, auch mit seinen Anhängen, aufbewahren. @@ -6420,11 +6445,6 @@ Aktivieren Sie es in den *Netzwerk & Server* Einstellungen. Empfangen um: %@ copied message info - - Received file event - Datei-Ereignis empfangen - notification - Received message Empfangene Nachricht @@ -7040,7 +7060,7 @@ chat item action Security assessment - Sicherheits-Gutachten + Security-Gutachten No comment provided by engineer. @@ -7258,11 +7278,6 @@ chat item action Direkt gesendet No comment provided by engineer. - - Sent file event - Datei-Ereignis wurde gesendet - notification - Sent message Gesendete Nachricht @@ -7333,14 +7348,14 @@ chat item action Das Server-Protokoll wurde geändert. alert title - - Server requires authorization to create queues, check password - Um Warteschlangen zu erzeugen benötigt der Server eine Authentifizierung. Bitte überprüfen Sie das Passwort + + Server requires authorization to create queues, check password. + Der Server erfordert zum Erstellen von Warteschlangen eine Autorisierung. Bitte überprüfen Sie das Passwort. server test error - - Server requires authorization to upload, check password - Bitte das Passwort überprüfen - für den Upload benötigt der Server eine Berechtigung + + Server requires authorization to upload, check password. + Der Server erfordert zum Hochladen eine Autorisierung. Bitte überprüfen Sie das Passwort. server test error @@ -8461,15 +8476,10 @@ Sie werden aufgefordert, die Authentifizierung abzuschließen, bevor diese Funkt Transport-Sitzungen No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - Beim Versuch die Verbindung mit dem Server aufzunehmen, der für den Empfang von Nachrichten mit diesem Kontakt genutzt wird, ist ein Fehler aufgetreten (Fehler: %@). - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. - Versuche die Verbindung mit dem Server aufzunehmen, der für den Empfang von Nachrichten mit diesem Kontakt genutzt wird. - No comment provided by engineer. + + Trying to connect to the server used to receive messages from this connection. + Versuche eine Verbindung mit dem Server aufzunehmen, der für den Empfang von Nachrichten dieser Verbindung genutzt wird. + subscription status explanation Turkish interface @@ -9180,16 +9190,21 @@ Repeat join request? Verbindungsanfrage wiederholen? new chat sheet title - - You are connected to the server used to receive messages from this contact. - Sie sind mit dem Server verbunden, der für den Empfang von Nachrichten mit diesem Kontakt genutzt wird. - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + Sie sind mit dem Server verbunden, der für den Empfang von Nachrichten dieser Verbindung genutzt wird. + subscription status explanation You are invited to group Sie sind zu der Gruppe eingeladen No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + Sie sind nicht mit dem Server verbunden, der für den Empfang von Nachrichten dieser Verbindung genutzt wird (kein Abonnement). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. Sie sind nicht mit diesen Servern verbunden. Zur Auslieferung von Nachrichten an diese Server wird privates Routing genutzt. @@ -9714,7 +9729,7 @@ Verbindungsanfrage wiederholen? blocked %@ - %@ wurde blockiert + hat %@ blockiert rcv group event chat item @@ -10249,6 +10264,11 @@ pref value Keine E2E-Verschlüsselung No comment provided by engineer. + + no subscription + Kein Abonnement + No comment provided by engineer. + no text Kein Text @@ -10493,7 +10513,7 @@ Zuletzt empfangene Nachricht: %2$@ unblocked %@ - %@ wurde freigegeben + hat %@ freigegeben rcv group event chat item @@ -10727,7 +10747,7 @@ Zuletzt empfangene Nachricht: %2$@ Copyright © 2022 SimpleX Chat. All rights reserved. - Copyright © 2024 SimpleX Chat. All rights reserved. + Copyright © 2025 SimpleX Chat. All rights reserved. Copyright (human-readable) @@ -10923,12 +10943,12 @@ Zuletzt empfangene Nachricht: %2$@ Please create a profile in the SimpleX app - Bitte erstellen Sie ein Profil in der SimpleX-App + Bitte erstellen Sie in der SimpleX-App ein Profil No comment provided by engineer. Selected chat preferences prohibit this message. - Diese Nachricht ist wegen der gewählten Chat-Einstellungen nicht erlaubt. + Die gewählten Chat-Einstellungen erlauben diese Nachricht nicht. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/el.xcloc/Localized Contents/el.xliff b/apps/ios/SimpleX Localizations/el.xcloc/Localized Contents/el.xliff index cb7a61636e..7a560bb41b 100644 --- a/apps/ios/SimpleX Localizations/el.xcloc/Localized Contents/el.xliff +++ b/apps/ios/SimpleX Localizations/el.xcloc/Localized Contents/el.xliff @@ -2350,8 +2350,8 @@ Available in v5.1 Polish interface No comment provided by engineer. - - Possibly, certificate fingerprint in server address is incorrect + + Fingerprint in server address does not match certificate. server test error @@ -2726,12 +2726,12 @@ Available in v5.1 Sent messages will be deleted after set time. No comment provided by engineer. - - Server requires authorization to create queues, check password + + Server requires authorization to create queues, check password. server test error - - Server requires authorization to upload, check password + + Server requires authorization to upload, check password. server test error diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff index 849e75a6ec..2f5a0acbb1 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -1802,7 +1802,7 @@ set passcode view Conditions of use Conditions of use - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -3310,6 +3310,11 @@ chat item action Error connecting to forwarding server %@. Please try later. alert message + + Error connecting to the server used to receive messages from this connection: %@ + Error connecting to the server used to receive messages from this connection: %@ + subscription status explanation + Error creating address Error creating address @@ -3355,16 +3360,16 @@ chat item action Error decrypting file No comment provided by engineer. + + Error deleting chat + Error deleting chat + alert title + Error deleting chat database Error deleting chat database alert title - - Error deleting chat with member - Error deleting chat with member - alert title - Error deleting chat! Error deleting chat! @@ -3642,6 +3647,11 @@ chat item action file error text snd error text + + Error: %@. + Error: %@. + server test error + Error: URL is invalid Error: URL is invalid @@ -3866,6 +3876,26 @@ snd error text Find chats faster No comment provided by engineer. + + Fingerprint in destination server address does not match certificate: %@. + Fingerprint in destination server address does not match certificate: %@. + No comment provided by engineer. + + + Fingerprint in forwarding server address does not match certificate: %@. + Fingerprint in forwarding server address does not match certificate: %@. + No comment provided by engineer. + + + Fingerprint in server address does not match certificate. + Fingerprint in server address does not match certificate. + server test error + + + Fingerprint in server address does not match certificate: %@. + Fingerprint in server address does not match certificate: %@. + No comment provided by engineer. + Fix Fix @@ -4892,6 +4922,11 @@ This is your link for group %@! Member No comment provided by engineer. + + Member %@ + Member %@ + past/unknown group member + Member admission Member admission @@ -5300,7 +5335,7 @@ This is your link for group %@! Network status Network status - No comment provided by engineer. + alert title New @@ -5949,11 +5984,6 @@ Requires compatible VPN. Password to show No comment provided by engineer. - - Past member %@ - Past member %@ - past/unknown group member - Paste desktop address Paste desktop address @@ -6113,11 +6143,6 @@ Error: %@ Port No comment provided by engineer. - - Possibly, certificate fingerprint in server address is incorrect - Possibly, certificate fingerprint in server address is incorrect - server test error - Preserve the last message draft, with attachments. Preserve the last message draft, with attachments. @@ -6420,11 +6445,6 @@ Enable in *Network & servers* settings. Received at: %@ copied message info - - Received file event - Received file event - notification - Received message Received message @@ -7258,11 +7278,6 @@ chat item action Sent directly No comment provided by engineer. - - Sent file event - Sent file event - notification - Sent message Sent message @@ -7333,14 +7348,14 @@ chat item action Server protocol changed. alert title - - Server requires authorization to create queues, check password - Server requires authorization to create queues, check password + + Server requires authorization to create queues, check password. + Server requires authorization to create queues, check password. server test error - - Server requires authorization to upload, check password - Server requires authorization to upload, check password + + Server requires authorization to upload, check password. + Server requires authorization to upload, check password. server test error @@ -8461,15 +8476,10 @@ You will be prompted to complete authentication before this feature is enabled.< Transport sessions No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - Trying to connect to the server used to receive messages from this contact (error: %@). - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. - Trying to connect to the server used to receive messages from this contact. - No comment provided by engineer. + + Trying to connect to the server used to receive messages from this connection. + Trying to connect to the server used to receive messages from this connection. + subscription status explanation Turkish interface @@ -9180,16 +9190,21 @@ Repeat join request? Repeat join request? new chat sheet title - - You are connected to the server used to receive messages from this contact. - You are connected to the server used to receive messages from this contact. - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + You are connected to the server used to receive messages from this connection. + subscription status explanation You are invited to group You are invited to group No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + You are not connected to the server used to receive messages from this connection (no subscription). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. You are not connected to these servers. Private routing is used to deliver messages to them. @@ -10249,6 +10264,11 @@ pref value no e2e encryption No comment provided by engineer. + + no subscription + no subscription + No comment provided by engineer. + no text no text diff --git a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff index 1bcda9cc39..30c69af755 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -1802,7 +1802,7 @@ set passcode view Conditions of use Condiciones de uso - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -2486,7 +2486,7 @@ swipe action Delete chat with member? - ¿Eliminar chat con el miembro? + ¿Eliminar chat con miembro? alert title @@ -3062,7 +3062,7 @@ chat item action Enable instant notifications? - ¿Activar notificación instantánea? + ¿Activar notificaciones instantáneas? No comment provided by engineer. @@ -3310,6 +3310,11 @@ chat item action Error al conectar con el servidor de reenvío %@. Por favor, inténtalo más tarde. alert message + + Error connecting to the server used to receive messages from this connection: %@ + Error al conectar con el servidor usado para recibir mensajes de esta conexión: %@ + subscription status explanation + Error creating address Error al crear dirección @@ -3355,16 +3360,16 @@ chat item action Error al descifrar el archivo No comment provided by engineer. + + Error deleting chat + Error al eliminar el chat + alert title + Error deleting chat database Error al eliminar base de datos alert title - - Error deleting chat with member - Error al eliminar el chat con el miembro - alert title - Error deleting chat! ¡Error al eliminar chat! @@ -3642,6 +3647,11 @@ chat item action file error text snd error text + + Error: %@. + Error: %@. + server test error + Error: URL is invalid Error: la URL no es válida @@ -3866,6 +3876,26 @@ snd error text Encuentra chats mas rápido No comment provided by engineer. + + Fingerprint in destination server address does not match certificate: %@. + La huella en la dirección del servidor de destino no coincide con el certificado: %@. + No comment provided by engineer. + + + Fingerprint in forwarding server address does not match certificate: %@. + La huella en la dirección del servidor de reenvío no coincide con el certificado: %@. + No comment provided by engineer. + + + Fingerprint in server address does not match certificate. + La huella en la dirección del servidor no coincide con el certificado. + server test error + + + Fingerprint in server address does not match certificate: %@. + La huella en la dirección del servidor no coincide con el certificado: %@. + No comment provided by engineer. + Fix Reparar @@ -4892,6 +4922,11 @@ This is your link for group %@! Miembro No comment provided by engineer. + + Member %@ + Miembro %@ + past/unknown group member + Member admission Admisión de miembros @@ -5300,7 +5335,7 @@ This is your link for group %@! Network status Estado de la red - No comment provided by engineer. + alert title New @@ -5949,11 +5984,6 @@ Requiere activación de la VPN. Contraseña para hacerlo visible No comment provided by engineer. - - Past member %@ - Miembro pasado %@ - past/unknown group member - Paste desktop address Pegar dirección de ordenador @@ -6113,11 +6143,6 @@ Error: %@ Puerto No comment provided by engineer. - - Possibly, certificate fingerprint in server address is incorrect - Posiblemente la huella del certificado en la dirección del servidor es incorrecta - server test error - Preserve the last message draft, with attachments. Conserva el último borrador del mensaje con los datos adjuntos. @@ -6420,11 +6445,6 @@ Actívalo en ajustes de *Servidores y Redes*. Recibido: %@ copied message info - - Received file event - Evento de archivo recibido - notification - Received message Mensaje entrante @@ -7258,11 +7278,6 @@ chat item action Directamente No comment provided by engineer. - - Sent file event - Evento de archivo enviado - notification - Sent message Mensaje saliente @@ -7333,14 +7348,14 @@ chat item action El protocolo del servidor ha cambiado. alert title - - Server requires authorization to create queues, check password - El servidor requiere autorización para crear colas, comprueba la contraseña + + Server requires authorization to create queues, check password. + El servidor requiere autorización para crear colas, comprueba la contraseña. server test error - - Server requires authorization to upload, check password - El servidor requiere autorización para subir, comprueba la contraseña + + Server requires authorization to upload, check password. + El servidor requiere autorización para subir, comprueba la contraseña. server test error @@ -7969,7 +7984,7 @@ report reason Take picture - Tomar foto + Hacer foto No comment provided by engineer. @@ -8461,15 +8476,10 @@ Se te pedirá que completes la autenticación antes de activar esta función.Sesiones de transporte No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - Intentando conectar con el servidor usado para recibir mensajes de este contacto (error: %@). - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. - Intentando conectar con el servidor usado para recibir mensajes de este contacto. - No comment provided by engineer. + + Trying to connect to the server used to receive messages from this connection. + Intentando conectar con el servidor usado para recibir mensajes de esta conexión. + subscription status explanation Turkish interface @@ -9180,16 +9190,21 @@ Repeat join request? ¿Repetir solicitud de admisión? new chat sheet title - - You are connected to the server used to receive messages from this contact. - Estás conectado al servidor usado para recibir mensajes de este contacto. - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + Estás conectado al servidor usado para recibir mensajes de esta conexión. + subscription status explanation You are invited to group Has sido invitado a un grupo No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + No estás conectado al servidor usado para recibir mensajes de esta conexión (no suscrito). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. No tienes conexión directa a estos servidores. Los mensajes destinados a estos usan enrutamiento privado. @@ -9559,7 +9574,7 @@ Repeat connection request? Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile. - Tu perfil se almacena en tu dispositivo y sólo se comparte con tus contactos. Los servidores SimpleX no pueden ver tu perfil. + Tu perfil se almacena en tu dispositivo y se comparte sólo con tus contactos. Los servidores SimpleX no pueden ver tu perfil. No comment provided by engineer. @@ -10249,6 +10264,11 @@ pref value sin cifrar No comment provided by engineer. + + no subscription + sin suscripciones + No comment provided by engineer. + no text sin texto diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff index 8ab115b001..56fa4a1485 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff @@ -1624,7 +1624,7 @@ set passcode view Conditions of use - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -2994,6 +2994,10 @@ chat item action Error connecting to forwarding server %@. Please try later. alert message + + Error connecting to the server used to receive messages from this connection: %@ + subscription status explanation + Error creating address Virhe osoitteen luomisessa @@ -3035,15 +3039,15 @@ chat item action Virhe tiedoston salauksen purussa No comment provided by engineer. + + Error deleting chat + alert title + Error deleting chat database Virhe keskustelujen tietokannan poistamisessa alert title - - Error deleting chat with member - alert title - Error deleting chat! Virhe keskutelun poistamisessa! @@ -3298,6 +3302,10 @@ chat item action file error text snd error text + + Error: %@. + server test error + Error: URL is invalid Virhe: URL on virheellinen @@ -3498,6 +3506,23 @@ snd error text Löydä keskustelut nopeammin No comment provided by engineer. + + Fingerprint in destination server address does not match certificate: %@. + No comment provided by engineer. + + + Fingerprint in forwarding server address does not match certificate: %@. + No comment provided by engineer. + + + Fingerprint in server address does not match certificate. + Palvelimen osoitteen varmenteen sormenjälki on mahdollisesti virheellinen + server test error + + + Fingerprint in server address does not match certificate: %@. + No comment provided by engineer. + Fix Korjaa @@ -4440,6 +4465,10 @@ This is your link for group %@! Jäsen No comment provided by engineer. + + Member %@ + past/unknown group member + Member admission No comment provided by engineer. @@ -4801,7 +4830,7 @@ This is your link for group %@! Network status Verkon tila - No comment provided by engineer. + alert title New @@ -5378,10 +5407,6 @@ Edellyttää VPN:n sallimista. Salasana näytettäväksi No comment provided by engineer. - - Past member %@ - past/unknown group member - Paste desktop address No comment provided by engineer. @@ -5524,11 +5549,6 @@ Error: %@ Port No comment provided by engineer. - - Possibly, certificate fingerprint in server address is incorrect - Palvelimen osoitteen varmenteen sormenjälki on mahdollisesti virheellinen - server test error - Preserve the last message draft, with attachments. Säilytä viimeinen viestiluonnos liitteineen. @@ -5803,11 +5823,6 @@ Enable in *Network & servers* settings. Vastaanotettu klo: %@ copied message info - - Received file event - Tiedoston vastaanottotapahtuma - notification - Received message Vastaanotettu viesti @@ -6563,11 +6578,6 @@ chat item action Sent directly No comment provided by engineer. - - Sent file event - Lähetetty tiedosto tapahtuma - notification - Sent message Lähetetty viesti @@ -6626,13 +6636,13 @@ chat item action Server protocol changed. alert title - - Server requires authorization to create queues, check password + + Server requires authorization to create queues, check password. Palvelin vaatii valtuutuksen jonojen luomiseen, tarkista salasana server test error - - Server requires authorization to upload, check password + + Server requires authorization to upload, check password. Palvelin vaatii valtuutuksen tiedoston lataamiseksi, tarkista salasana server test error @@ -7635,15 +7645,9 @@ Sinua kehotetaan suorittamaan todennus loppuun, ennen kuin tämä ominaisuus ote Transport sessions No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - Yritetään muodostaa yhteyttä palvelimeen, jota käytetään tämän kontaktin viestien vastaanottamiseen (virhe: %@). - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. - Yritetään muodostaa yhteys palvelimeen, jota käytetään viestien vastaanottamiseen tältä kontaktilta. - No comment provided by engineer. + + Trying to connect to the server used to receive messages from this connection. + subscription status explanation Turkish interface @@ -8275,16 +8279,19 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja Repeat join request? new chat sheet title - - You are connected to the server used to receive messages from this contact. - Olet yhteydessä palvelimeen, jota käytetään vastaanottamaan viestejä tältä kontaktilta. - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + subscription status explanation You are invited to group Sinut on kutsuttu ryhmään No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. No comment provided by engineer. @@ -9282,6 +9289,10 @@ pref value ei e2e-salausta No comment provided by engineer. + + no subscription + No comment provided by engineer. + no text ei tekstiä diff --git a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff index 221618b691..67485353d2 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -1791,7 +1791,7 @@ set passcode view Conditions of use Conditions d'utilisation - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -3288,6 +3288,10 @@ chat item action Erreur de connexion au serveur de redirection %@. Veuillez réessayer plus tard. alert message + + Error connecting to the server used to receive messages from this connection: %@ + subscription status explanation + Error creating address Erreur lors de la création de l'adresse @@ -3333,15 +3337,15 @@ chat item action Erreur lors du déchiffrement du fichier No comment provided by engineer. + + Error deleting chat + alert title + Error deleting chat database Erreur lors de la suppression de la base de données du chat alert title - - Error deleting chat with member - alert title - Error deleting chat! Erreur lors de la suppression du chat ! @@ -3616,6 +3620,10 @@ chat item action file error text snd error text + + Error: %@. + server test error + Error: URL is invalid Erreur : URL invalide @@ -3839,6 +3847,23 @@ snd error text Recherche de message plus rapide No comment provided by engineer. + + Fingerprint in destination server address does not match certificate: %@. + No comment provided by engineer. + + + Fingerprint in forwarding server address does not match certificate: %@. + No comment provided by engineer. + + + Fingerprint in server address does not match certificate. + Il est possible que l'empreinte du certificat dans l'adresse du serveur soit incorrecte + server test error + + + Fingerprint in server address does not match certificate: %@. + No comment provided by engineer. + Fix Réparer @@ -4845,6 +4870,10 @@ Voici votre lien pour le groupe %@ ! Membre No comment provided by engineer. + + Member %@ + past/unknown group member + Member admission No comment provided by engineer. @@ -5242,7 +5271,7 @@ Voici votre lien pour le groupe %@ ! Network status État du réseau - No comment provided by engineer. + alert title New @@ -5863,11 +5892,6 @@ Nécessite l'activation d'un VPN. Mot de passe à entrer No comment provided by engineer. - - Past member %@ - Ancien membre %@ - past/unknown group member - Paste desktop address Coller l'adresse du bureau @@ -6023,11 +6047,6 @@ Erreur : %@ Port No comment provided by engineer. - - Possibly, certificate fingerprint in server address is incorrect - Il est possible que l'empreinte du certificat dans l'adresse du serveur soit incorrecte - server test error - Preserve the last message draft, with attachments. Conserver le brouillon du dernier message, avec les pièces jointes. @@ -6324,11 +6343,6 @@ Activez-le dans les paramètres *Réseau et serveurs*. Reçu le : %@ copied message info - - Received file event - Événement de fichier reçu - notification - Received message Message reçu @@ -7133,11 +7147,6 @@ chat item action Envoyé directement No comment provided by engineer. - - Sent file event - Événement de fichier envoyé - notification - Sent message Message envoyé @@ -7208,13 +7217,13 @@ chat item action Le protocole du serveur a été modifié. alert title - - Server requires authorization to create queues, check password + + Server requires authorization to create queues, check password. Le serveur requiert une autorisation pour créer des files d'attente, vérifiez le mot de passe server test error - - Server requires authorization to upload, check password + + Server requires authorization to upload, check password. Le serveur requiert une autorisation pour téléverser, vérifiez le mot de passe server test error @@ -8305,15 +8314,9 @@ Vous serez invité à confirmer l'authentification avant que cette fonction ne s Sessions de transport No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - Tentative de connexion au serveur utilisé pour recevoir les messages de ce contact (erreur : %@). - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. - Tentative de connexion au serveur utilisé pour recevoir les messages de ce contact. - No comment provided by engineer. + + Trying to connect to the server used to receive messages from this connection. + subscription status explanation Turkish interface @@ -9011,16 +9014,19 @@ Repeat join request? Répéter la demande d'adhésion ? new chat sheet title - - You are connected to the server used to receive messages from this contact. - Vous êtes connecté·e au serveur utilisé pour recevoir les messages de ce contact. - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + subscription status explanation You are invited to group Vous êtes invité·e au groupe No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. Vous n'êtes pas connecté à ces serveurs. Le routage privé est utilisé pour leur délivrer des messages. @@ -10060,6 +10066,10 @@ pref value sans chiffrement de bout en bout No comment provided by engineer. + + no subscription + No comment provided by engineer. + no text aucun texte diff --git a/apps/ios/SimpleX Localizations/he.xcloc/Localized Contents/he.xliff b/apps/ios/SimpleX Localizations/he.xcloc/Localized Contents/he.xliff index a2170ab941..f94d6cefd8 100644 --- a/apps/ios/SimpleX Localizations/he.xcloc/Localized Contents/he.xliff +++ b/apps/ios/SimpleX Localizations/he.xcloc/Localized Contents/he.xliff @@ -2876,8 +2876,8 @@ Available in v5.1 Polish interface No comment provided by engineer. - - Possibly, certificate fingerprint in server address is incorrect + + Fingerprint in server address does not match certificate. server test error @@ -3252,12 +3252,12 @@ Available in v5.1 Sent messages will be deleted after set time. No comment provided by engineer. - - Server requires authorization to create queues, check password + + Server requires authorization to create queues, check password. server test error - - Server requires authorization to upload, check password + + Server requires authorization to upload, check password. server test error diff --git a/apps/ios/SimpleX Localizations/hr.xcloc/Localized Contents/hr.xliff b/apps/ios/SimpleX Localizations/hr.xcloc/Localized Contents/hr.xliff index 5f491fe76e..2aa945f603 100644 --- a/apps/ios/SimpleX Localizations/hr.xcloc/Localized Contents/hr.xliff +++ b/apps/ios/SimpleX Localizations/hr.xcloc/Localized Contents/hr.xliff @@ -2043,8 +2043,8 @@ We will be adding server redundancy to prevent lost messages. Please store passphrase securely, you will NOT be able to change it if you lose it. No comment provided by engineer. - - Possibly, certificate fingerprint in server address is incorrect + + Fingerprint in server address does not match certificate. server test error @@ -2371,8 +2371,8 @@ We will be adding server redundancy to prevent lost messages. Sent messages will be deleted after set time. No comment provided by engineer. - - Server requires authorization to create queues, check password + + Server requires authorization to create queues, check password. server test error diff --git a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff index fd2412e0ec..22d223ffd9 100644 --- a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff +++ b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff @@ -272,7 +272,7 @@ %lldd - %lldn + %lldnap No comment provided by engineer. @@ -292,7 +292,7 @@ %lldmth - %lldh + %lldhónap No comment provided by engineer. @@ -1014,7 +1014,7 @@ swipe action App build: %@ - Az alkalmazás összeállítási száma: %@ + Alkalmazás összeállítási száma: %@ No comment provided by engineer. @@ -1054,12 +1054,12 @@ swipe action App version - Az alkalmazás verziója + Alkalmazás verziója No comment provided by engineer. App version: v%@ - Az alkalmazás verziója: v%@ + Alkalmazás verziója: v%@ No comment provided by engineer. @@ -1199,17 +1199,17 @@ swipe action Bad desktop address - Érvénytelen számítógépcím + Hibás a számítógép címe No comment provided by engineer. Bad message ID - Téves üzenet ID + Hibás az üzenet azonosítója No comment provided by engineer. Bad message hash - Érvénytelen az üzenet hasítóértéke + Hibás az üzenet kivonata No comment provided by engineer. @@ -1239,7 +1239,7 @@ swipe action Better networking - Jobb hálózatkezelés + Továbbfejlesztett hálózatkezelés No comment provided by engineer. @@ -1264,12 +1264,12 @@ swipe action Bio - Bemutatkozás + Névjegy No comment provided by engineer. Bio too large - A bemutatkozás túl hosszú + A névjegy túl hosszú alert title @@ -1802,7 +1802,7 @@ set passcode view Conditions of use Használati feltételek - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -2222,7 +2222,7 @@ Ez a saját egyszer használható meghívója! Create queue - Sorba állítás létrehozása + Várólista létrehozása server test step @@ -2267,7 +2267,7 @@ Ez a saját egyszer használható meghívója! Current conditions text couldn't be loaded, you can review conditions via this link: - A jelenlegi feltételek szövegét nem lehetett betölteni, a feltételeket a következő hivatkozáson keresztül vizsgálhatja felül: + A jelenlegi feltételek szövegét nem sikerült betölteni, a feltételeket a következő hivatkozáson keresztül vizsgálhatja felül: No comment provided by engineer. @@ -2621,7 +2621,7 @@ swipe action Delete queue - Sorba állítás törlése + Várólista törlése server test step @@ -2771,7 +2771,7 @@ swipe action Different names, avatars and transport isolation. - Különböző nevek, profilképek és átvitelizoláció. + Különböző nevek, profilképek és átvitelelkülönítés. No comment provided by engineer. @@ -3310,6 +3310,11 @@ chat item action Hiba történt a(z) %@ továbbítókiszolgálóhoz való kapcsolódáskor. Próbálja meg később. alert message + + Error connecting to the server used to receive messages from this connection: %@ + Hiba történt a kapcsolódáskor ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál: %@ + subscription status explanation + Error creating address Hiba történt a cím létrehozásakor @@ -3355,16 +3360,16 @@ chat item action Hiba történt a fájl visszafejtésekor No comment provided by engineer. + + Error deleting chat + Hiba a csevegés törlésekor + alert title + Error deleting chat database Hiba történt a csevegési adatbázis törlésekor alert title - - Error deleting chat with member - Hiba a taggal való csevegés törlésekor - alert title - Error deleting chat! Hiba történt a csevegés törlésekor! @@ -3452,7 +3457,7 @@ chat item action Error opening group - Hiba a csoport előkészítésekor + Hiba történt a csoport megnyitásakor No comment provided by engineer. @@ -3642,6 +3647,11 @@ chat item action file error text snd error text + + Error: %@. + Hiba: %@. + server test error + Error: URL is invalid Hiba: a webcím érvénytelen @@ -3833,7 +3843,7 @@ snd error text Files and media not allowed - A fájlok és a médiatartalmak nincsenek engedélyezve + A fájlok és a médiatartalmak küldése nincs engedélyezve No comment provided by engineer. @@ -3866,6 +3876,26 @@ snd error text Csevegési üzenetek gyorsabb megtalálása No comment provided by engineer. + + Fingerprint in destination server address does not match certificate: %@. + A célkiszolgáló címében szereplő ujjlenyomat nem egyezik a tanúsítvánnyal: %@. + No comment provided by engineer. + + + Fingerprint in forwarding server address does not match certificate: %@. + A továbbítókiszolgáló címében szereplő ujjlenyomat nem egyezik a tanúsítvánnyal: %@. + No comment provided by engineer. + + + Fingerprint in server address does not match certificate. + A kiszolgáló címében szereplő ujjlenyomat nem egyezik a tanúsítvánnyal. + server test error + + + Fingerprint in server address does not match certificate: %@. + A kiszolgáló címében szereplő ujjlenyomat nem egyezik a tanúsítvánnyal: %@. + No comment provided by engineer. + Fix Javítás @@ -4332,7 +4362,7 @@ Hiba: %2$@ Import failed - Sikertelen importálás + Nem sikerült az importálás No comment provided by engineer. @@ -4359,12 +4389,12 @@ További fejlesztések hamarosan! Improved privacy and security - Fejlesztett adatvédelem és biztonság + Továbbfejlesztett adatvédelem és biztonság No comment provided by engineer. Improved server configuration - Javított kiszolgáló konfiguráció + Továbbfejlesztett kiszolgálókonfiguráció No comment provided by engineer. @@ -4642,17 +4672,17 @@ További fejlesztések hamarosan! Join as %@ - csatlakozás mint %@ + Csatlakozás mint: %@ No comment provided by engineer. Join group - Csatlakozás csoporthoz + Csatlakozás a csoporthoz new chat sheet title Join group conversations - Csatlakozás csoportos beszélgetésekhez + Csatlakozás a csoportbeszélgetésekhez No comment provided by engineer. @@ -4892,6 +4922,11 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Tag No comment provided by engineer. + + Member %@ + %@ ismeretlen vagy már nem tag + past/unknown group member + Member admission Tagbefogadás @@ -5029,7 +5064,7 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Message queue info - Üzenetsorbaállítási információ + Üzenet várólista információi No comment provided by engineer. @@ -5300,7 +5335,7 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Network status Hálózat állapota - No comment provided by engineer. + alert title New @@ -5539,7 +5574,7 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! No servers to send files. - Nincsenek fájlküldő-kiszolgálók. + Nincsenek fájlküldési kiszolgálók. servers error @@ -5749,7 +5784,7 @@ VPN engedélyezése szükséges. Only your contact can send files and media. - Csak a partnere küldhet fájlokat és a médiatartalmakat. + Csak a partnere küldhet fájlokat és médiatartalmakat. No comment provided by engineer. @@ -5949,11 +5984,6 @@ VPN engedélyezése szükséges. Jelszó a megjelenítéshez No comment provided by engineer. - - Past member %@ - (Már nem tag) %@ - past/unknown group member - Paste desktop address Számítógép címének beillesztése @@ -6113,11 +6143,6 @@ Hiba: %@ Port No comment provided by engineer. - - Possibly, certificate fingerprint in server address is incorrect - Lehetséges, hogy a kiszolgáló címében szereplő tanúsítvány-ujjlenyomat helytelen - server test error - Preserve the last message draft, with attachments. Az utolsó üzenet tervezetének megőrzése a mellékletekkel együtt. @@ -6420,11 +6445,6 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Fogadva: %@ copied message info - - Received file event - Fogadott fájlesemény - notification - Received message Fogadott üzenetbuborék színe @@ -7030,7 +7050,7 @@ chat item action Secure queue - Biztonságos sorba állítás + Biztonságos várólista server test step @@ -7125,7 +7145,7 @@ chat item action Send link previews - Hivatkozás előnézete + Hivatkozások előnézetének megjelenítése No comment provided by engineer. @@ -7258,11 +7278,6 @@ chat item action Közvetlenül küldött No comment provided by engineer. - - Sent file event - Elküldött fájlesemény - notification - Sent message Üzenetbuborék színe @@ -7333,14 +7348,14 @@ chat item action A kiszolgálóprotokoll módosult. alert title - - Server requires authorization to create queues, check password - A kiszolgálónak engedélyre van szüksége a sorba állítás létrehozásához, ellenőrizze a jelszavát + + Server requires authorization to create queues, check password. + A kiszolgálónak engedélyre van szüksége a várólisták létrehozásához, ellenőrizze a jelszavát. server test error - - Server requires authorization to upload, check password - A kiszolgálónak hitelesítésre van szüksége a feltöltéshez, ellenőrizze jelszavát + + Server requires authorization to upload, check password. + A kiszolgálónak hitelesítésre van szüksége a feltöltéshez, ellenőrizze a jelszavát. server test error @@ -7440,7 +7455,7 @@ chat item action Set profile bio and welcome message. - Bemutatkozószöveg és üdvözlőüzenet beállítása a profilokhoz. + Névjegy és üdvözlőüzenet beállítása a profilokhoz. No comment provided by engineer. @@ -7521,7 +7536,7 @@ chat item action Share old link - Teljes hivatkozás megosztása + Régi (hosszú) hivatkozás megosztása alert button @@ -7581,7 +7596,7 @@ chat item action Show last messages - Legutóbbi üzenet előnézetének megjelenítése + Legutóbbi üzenetek előnézetének megjelenítése No comment provided by engineer. @@ -7596,7 +7611,7 @@ chat item action Show preview - Értesítés előnézete + Értesítésekben megjelenő információk No comment provided by engineer. @@ -8146,7 +8161,7 @@ Ez valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. The hash of the previous message is different. - Az előző üzenet hasítóértéke különbözik. + Az előző üzenet kivonata különbözik. No comment provided by engineer. @@ -8461,15 +8476,10 @@ A funkció bekapcsolása előtt a rendszer felszólítja a képernyőzár beáll Munkamenetek átvitele No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál (hiba: %@). - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. + + Trying to connect to the server used to receive messages from this connection. Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál. - No comment provided by engineer. + subscription status explanation Turkish interface @@ -8585,7 +8595,7 @@ A kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcso Unlink - Szétkapcsolás + Leválasztás No comment provided by engineer. @@ -8745,7 +8755,7 @@ A kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcso Use TCP port 443 for preset servers only. - A 443-as TCP-port használata kizárólag az előre beállított kiszolgálokhoz. + A 443-as TCP-port használata kizárólag az előre beállított kiszolgálókhoz. No comment provided by engineer. @@ -8805,7 +8815,7 @@ A kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcso Use private routing with unknown servers. - Privát útválasztás használata ismeretlen kiszolgálókkal. + Privát útválasztás használata az ismeretlen kiszolgálókhoz. No comment provided by engineer. @@ -9180,16 +9190,21 @@ Repeat join request? Megismétli a csatlakozási kérést? new chat sheet title - - You are connected to the server used to receive messages from this contact. - Ön már kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál. - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + Ön kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál. + subscription status explanation You are invited to group Ön meghívást kapott a csoportba No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + Ön nem kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál (nincs előfizetés). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. Ön nem kapcsolódik ezekhez a kiszolgálókhoz. A privát útválasztás az üzenetek kézbesítésére szolgál. @@ -9302,7 +9317,7 @@ Megismétli a csatlakozási kérést? You can't send messages! - Nem lehet üzeneteket küldeni! + Ön nem tud üzeneteket küldeni! alert title @@ -9514,7 +9529,7 @@ Megismétli a kapcsolódási kérést? Your credentials may be sent unencrypted. - A hitelesítési adati titkosítatlanul is elküldhetők. + A hitelesítési adatai titkosítatlanul is elküldhetők. No comment provided by engineer. @@ -9544,7 +9559,7 @@ Megismétli a kapcsolódási kérést? Your profile - Profil + Saját profil No comment provided by engineer. @@ -9704,7 +9719,7 @@ Megismétli a kapcsolódási kérést? bad message hash - hibás az üzenet hasítóértéke + hibás az üzenet kivonata integrity error chat item @@ -10249,6 +10264,11 @@ pref value nincs e2e titkosítás No comment provided by engineer. + + no subscription + nincs előfizetés + No comment provided by engineer. + no text nincs szöveg @@ -10449,14 +10469,14 @@ time to disappear security code changed - a biztonsági kód módosult + biztonsági kódja módosult chat item text server queue info: %1$@ last received msg: %2$@ - a kiszolgáló sorbaállítási információi: %1$@ + a kiszolgáló várólista információi: %1$@ utoljára fogadott üzenet: %2$@ queue info @@ -10503,7 +10523,7 @@ utoljára fogadott üzenet: %2$@ unknown servers - ismeretlen átjátszók + ismeretlen kiszolgálók No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff index ad0db1ad18..5f057cd8bb 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -1802,7 +1802,7 @@ set passcode view Conditions of use Condizioni d'uso - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -3310,6 +3310,11 @@ chat item action Errore di connessione al server di inoltro %@. Riprova più tardi. alert message + + Error connecting to the server used to receive messages from this connection: %@ + Errore di connessione al server usato per ricevere messaggi da questa connessione: %@ + subscription status explanation + Error creating address Errore nella creazione dell'indirizzo @@ -3355,16 +3360,16 @@ chat item action Errore decifrando il file No comment provided by engineer. + + Error deleting chat + Errore di eliminazione della chat con il membro + alert title + Error deleting chat database Errore nell'eliminazione del database della chat alert title - - Error deleting chat with member - Errore di eliminazione della chat con il membro - alert title - Error deleting chat! Errore nell'eliminazione della chat! @@ -3642,6 +3647,11 @@ chat item action file error text snd error text + + Error: %@. + Errore: %@. + server test error + Error: URL is invalid Errore: l'URL non è valido @@ -3866,6 +3876,26 @@ snd error text Trova le chat più velocemente No comment provided by engineer. + + Fingerprint in destination server address does not match certificate: %@. + L'impronta digitale nell'indirizzo del server di destinazione non corrisponde al certificato: %@. + No comment provided by engineer. + + + Fingerprint in forwarding server address does not match certificate: %@. + L'impronta digitale nell'indirizzo del server di inoltro non corrisponde al certificato: %@. + No comment provided by engineer. + + + Fingerprint in server address does not match certificate. + L'impronta digitale nell'indirizzo del server non corrisponde al certificato. + server test error + + + Fingerprint in server address does not match certificate: %@. + L'impronta digitale nell'indirizzo del server non corrisponde al certificato: %@. + No comment provided by engineer. + Fix Correggi @@ -4892,6 +4922,11 @@ Questo è il tuo link per il gruppo %@! Membro No comment provided by engineer. + + Member %@ + Membro %@ + past/unknown group member + Member admission Ammissione dei membri @@ -5300,7 +5335,7 @@ Questo è il tuo link per il gruppo %@! Network status Stato della rete - No comment provided by engineer. + alert title New @@ -5949,11 +5984,6 @@ Richiede l'attivazione della VPN. Password per mostrare No comment provided by engineer. - - Past member %@ - Membro passato %@ - past/unknown group member - Paste desktop address Incolla l'indirizzo desktop @@ -6113,11 +6143,6 @@ Errore: %@ Porta No comment provided by engineer. - - Possibly, certificate fingerprint in server address is incorrect - Probabilmente l'impronta del certificato nell'indirizzo del server è sbagliata - server test error - Preserve the last message draft, with attachments. Conserva la bozza dell'ultimo messaggio, con gli allegati. @@ -6420,11 +6445,6 @@ Attivalo nelle impostazioni *Rete e server*. Ricevuto il: %@ copied message info - - Received file event - Evento file ricevuto - notification - Received message Messaggio ricevuto @@ -7258,11 +7278,6 @@ chat item action Inviato direttamente No comment provided by engineer. - - Sent file event - Evento file inviato - notification - Sent message Messaggio inviato @@ -7333,14 +7348,14 @@ chat item action Il protocollo del server è cambiato. alert title - - Server requires authorization to create queues, check password - Il server richiede l'autorizzazione di creare code, controlla la password + + Server requires authorization to create queues, check password. + Il server richiede l'autorizzazione di creare code, controlla la password. server test error - - Server requires authorization to upload, check password - Il server richiede l'autorizzazione per il caricamento, controllare la password + + Server requires authorization to upload, check password. + Il server richiede l'autorizzazione per l'invio, controlla la password. server test error @@ -8461,15 +8476,10 @@ Ti verrà chiesto di completare l'autenticazione prima di attivare questa funzio Sessioni di trasporto No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - Tentativo di connessione al server usato per ricevere messaggi da questo contatto (errore: %@). - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. - Tentativo di connessione al server usato per ricevere messaggi da questo contatto. - No comment provided by engineer. + + Trying to connect to the server used to receive messages from this connection. + Tentativo di connessione al server usato per ricevere messaggi da questa connessione. + subscription status explanation Turkish interface @@ -9180,16 +9190,21 @@ Repeat join request? Ripetere la richiesta di ingresso? new chat sheet title - - You are connected to the server used to receive messages from this contact. - Sei connesso/a al server usato per ricevere messaggi da questo contatto. - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + Sei connesso/a al server usato per ricevere messaggi da questa connessione. + subscription status explanation You are invited to group Sei stato/a invitato/a al gruppo No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + Non sei connesso/a al server usato per ricevere messaggi da questa connessione (nessuna iscrizione). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. Non sei connesso/a a questi server. L'instradamento privato è usato per consegnare loro i messaggi. @@ -10249,6 +10264,11 @@ pref value nessuna crittografia e2e No comment provided by engineer. + + no subscription + nessuna iscrizione + No comment provided by engineer. + no text nessun testo diff --git a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff index 2e71c2b37a..9a42ab3f7e 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff @@ -612,6 +612,7 @@ swipe action Acknowledged + 了承済み No comment provided by engineer. @@ -1688,7 +1689,7 @@ set passcode view Conditions of use - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -3075,6 +3076,10 @@ chat item action Error connecting to forwarding server %@. Please try later. alert message + + Error connecting to the server used to receive messages from this connection: %@ + subscription status explanation + Error creating address アドレス作成にエラー発生 @@ -3117,15 +3122,15 @@ chat item action ファイルの復号エラー No comment provided by engineer. + + Error deleting chat + alert title + Error deleting chat database チャットデータベース削除にエラー発生 alert title - - Error deleting chat with member - alert title - Error deleting chat! チャット削除にエラー発生! @@ -3379,6 +3384,10 @@ chat item action file error text snd error text + + Error: %@. + server test error + Error: URL is invalid エラー: 無効なURL @@ -3579,6 +3588,23 @@ snd error text チャットを素早く検索 No comment provided by engineer. + + Fingerprint in destination server address does not match certificate: %@. + No comment provided by engineer. + + + Fingerprint in forwarding server address does not match certificate: %@. + No comment provided by engineer. + + + Fingerprint in server address does not match certificate. + サーバアドレスの証明証IDが正しくないかもしれません + server test error + + + Fingerprint in server address does not match certificate: %@. + No comment provided by engineer. + Fix 修正 @@ -4521,6 +4547,10 @@ This is your link for group %@! メンバー No comment provided by engineer. + + Member %@ + past/unknown group member + Member admission No comment provided by engineer. @@ -4884,7 +4914,7 @@ This is your link for group %@! Network status ネットワーク状況 - No comment provided by engineer. + alert title New @@ -5463,10 +5493,6 @@ VPN を有効にする必要があります。 パスワードを表示する No comment provided by engineer. - - Past member %@ - past/unknown group member - Paste desktop address No comment provided by engineer. @@ -5609,11 +5635,6 @@ Error: %@ Port No comment provided by engineer. - - Possibly, certificate fingerprint in server address is incorrect - サーバアドレスの証明証IDが正しくないかもしれません - server test error - Preserve the last message draft, with attachments. 添付を含めて、下書きを保存する。 @@ -5888,11 +5909,6 @@ Enable in *Network & servers* settings. 受信: %@ copied message info - - Received file event - ファイル受信イベント - notification - Received message 受信したメッセージ @@ -6641,11 +6657,6 @@ chat item action Sent directly No comment provided by engineer. - - Sent file event - 送信済みファイルイベント - notification - Sent message 送信 @@ -6704,13 +6715,13 @@ chat item action Server protocol changed. alert title - - Server requires authorization to create queues, check password + + Server requires authorization to create queues, check password. キューを作成するにはサーバーの認証が必要です。パスワードを確認してください server test error - - Server requires authorization to upload, check password + + Server requires authorization to upload, check password. アップロードにはサーバーの認証が必要です。パスワードを確認してください server test error @@ -7713,15 +7724,9 @@ You will be prompted to complete authentication before this feature is enabled.< Transport sessions No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - この連絡先からのメッセージの受信に使用されるサーバーに接続しようとしています (エラー: %@)。 - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. - このコンタクトから受信するメッセージのサーバに接続しようとしてます。 - No comment provided by engineer. + + Trying to connect to the server used to receive messages from this connection. + subscription status explanation Turkish interface @@ -8353,16 +8358,19 @@ To connect, please ask your contact to create another connection link and check Repeat join request? new chat sheet title - - You are connected to the server used to receive messages from this contact. - この連絡先から受信するメッセージのサーバに既に接続してます。 - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + subscription status explanation You are invited to group グループ招待が届きました No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. No comment provided by engineer. @@ -9361,6 +9369,10 @@ pref value エンドツーエンド暗号化がありません No comment provided by engineer. + + no subscription + No comment provided by engineer. + no text テキストなし diff --git a/apps/ios/SimpleX Localizations/ko.xcloc/Localized Contents/ko.xliff b/apps/ios/SimpleX Localizations/ko.xcloc/Localized Contents/ko.xliff index 655b01dfe3..ca51a875c7 100644 --- a/apps/ios/SimpleX Localizations/ko.xcloc/Localized Contents/ko.xliff +++ b/apps/ios/SimpleX Localizations/ko.xcloc/Localized Contents/ko.xliff @@ -2250,8 +2250,8 @@ We will be adding server redundancy to prevent lost messages. Please store passphrase securely, you will NOT be able to change it if you lose it. No comment provided by engineer. - - Possibly, certificate fingerprint in server address is incorrect + + Fingerprint in server address does not match certificate. server test error @@ -2603,8 +2603,8 @@ We will be adding server redundancy to prevent lost messages. Sent messages will be deleted after set time. No comment provided by engineer. - - Server requires authorization to create queues, check password + + Server requires authorization to create queues, check password. server test error diff --git a/apps/ios/SimpleX Localizations/lt.xcloc/Localized Contents/lt.xliff b/apps/ios/SimpleX Localizations/lt.xcloc/Localized Contents/lt.xliff index 87823abeca..4b51d66a34 100644 --- a/apps/ios/SimpleX Localizations/lt.xcloc/Localized Contents/lt.xliff +++ b/apps/ios/SimpleX Localizations/lt.xcloc/Localized Contents/lt.xliff @@ -2047,8 +2047,8 @@ We will be adding server redundancy to prevent lost messages. Please store passphrase securely, you will NOT be able to change it if you lose it. No comment provided by engineer. - - Possibly, certificate fingerprint in server address is incorrect + + Fingerprint in server address does not match certificate. server test error @@ -2375,8 +2375,8 @@ We will be adding server redundancy to prevent lost messages. Sent messages will be deleted after set time. No comment provided by engineer. - - Server requires authorization to create queues, check password + + Server requires authorization to create queues, check password. server test error diff --git a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff index 079e4a8775..8e0cdee3ca 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -1791,7 +1791,7 @@ set passcode view Conditions of use Gebruiksvoorwaarden - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -3290,6 +3290,10 @@ chat item action Fout bij het verbinden met doorstuurserver %@. Probeer het later opnieuw. alert message + + Error connecting to the server used to receive messages from this connection: %@ + subscription status explanation + Error creating address Fout bij aanmaken van adres @@ -3335,16 +3339,16 @@ chat item action Fout bij het ontsleutelen van bestand No comment provided by engineer. + + Error deleting chat + Fout bij het verwijderen van chat met lid + alert title + Error deleting chat database Fout bij het verwijderen van de chat database alert title - - Error deleting chat with member - Fout bij het verwijderen van chat met lid - alert title - Error deleting chat! Fout bij verwijderen gesprek! @@ -3619,6 +3623,10 @@ chat item action file error text snd error text + + Error: %@. + server test error + Error: URL is invalid Fout: URL is ongeldig @@ -3842,6 +3850,23 @@ snd error text Vind chats sneller No comment provided by engineer. + + Fingerprint in destination server address does not match certificate: %@. + No comment provided by engineer. + + + Fingerprint in forwarding server address does not match certificate: %@. + No comment provided by engineer. + + + Fingerprint in server address does not match certificate. + Mogelijk is de certificaat vingerafdruk in het server adres onjuist + server test error + + + Fingerprint in server address does not match certificate: %@. + No comment provided by engineer. + Fix Herstel @@ -4864,6 +4889,10 @@ Dit is jouw link voor groep %@! Lid No comment provided by engineer. + + Member %@ + past/unknown group member + Member admission Toelating van leden @@ -5269,7 +5298,7 @@ Dit is jouw link voor groep %@! Network status Netwerk status - No comment provided by engineer. + alert title New @@ -5906,11 +5935,6 @@ Vereist het inschakelen van VPN. Wachtwoord om weer te geven No comment provided by engineer. - - Past member %@ - Voormalig lid %@ - past/unknown group member - Paste desktop address Desktopadres plakken @@ -6070,11 +6094,6 @@ Fout: %@ Poort No comment provided by engineer. - - Possibly, certificate fingerprint in server address is incorrect - Mogelijk is de certificaat vingerafdruk in het server adres onjuist - server test error - Preserve the last message draft, with attachments. Bewaar het laatste berichtconcept, met bijlagen. @@ -6375,11 +6394,6 @@ Schakel dit in in *Netwerk en servers*-instellingen. Ontvangen op: %@ copied message info - - Received file event - Ontvangen bestandsgebeurtenis - notification - Received message Ontvangen bericht @@ -7204,11 +7218,6 @@ chat item action Direct verzonden No comment provided by engineer. - - Sent file event - Verzonden bestandsgebeurtenis - notification - Sent message Verzonden bericht @@ -7279,13 +7288,13 @@ chat item action Serverprotocol gewijzigd. alert title - - Server requires authorization to create queues, check password + + Server requires authorization to create queues, check password. Server vereist autorisatie om wachtrijen te maken, controleer wachtwoord server test error - - Server requires authorization to upload, check password + + Server requires authorization to upload, check password. Server vereist autorisatie om te uploaden, wachtwoord controleren server test error @@ -8389,15 +8398,9 @@ U wordt gevraagd de authenticatie te voltooien voordat deze functie wordt ingesc Transportsessies No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - Proberen verbinding te maken met de server die wordt gebruikt om berichten van dit contact te ontvangen (fout: %@). - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. - Proberen verbinding te maken met de server die wordt gebruikt om berichten van dit contact te ontvangen. - No comment provided by engineer. + + Trying to connect to the server used to receive messages from this connection. + subscription status explanation Turkish interface @@ -9100,16 +9103,19 @@ Repeat join request? Deelnameverzoek herhalen? new chat sheet title - - You are connected to the server used to receive messages from this contact. - U bent verbonden met de server die wordt gebruikt om berichten van dit contact te ontvangen. - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + subscription status explanation You are invited to group Je bent uitgenodigd voor de groep No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. U bent niet verbonden met deze servers. Privéroutering wordt gebruikt om berichten bij hen af te leveren. @@ -10162,6 +10168,10 @@ pref value geen e2e versleuteling No comment provided by engineer. + + no subscription + No comment provided by engineer. + no text geen tekst diff --git a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff index 0791436e94..fb46bcd1d9 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -1775,7 +1775,7 @@ set passcode view Conditions of use Warunki użytkowania - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -3237,6 +3237,10 @@ chat item action Błąd połączenia z serwerem przekierowania %@. Spróbuj ponownie później. alert message + + Error connecting to the server used to receive messages from this connection: %@ + subscription status explanation + Error creating address Błąd tworzenia adresu @@ -3280,15 +3284,15 @@ chat item action Błąd odszyfrowania pliku No comment provided by engineer. + + Error deleting chat + alert title + Error deleting chat database Błąd usuwania bazy danych czatu alert title - - Error deleting chat with member - alert title - Error deleting chat! Błąd usuwania czatu! @@ -3556,6 +3560,10 @@ chat item action file error text snd error text + + Error: %@. + server test error + Error: URL is invalid Błąd: URL jest nieprawidłowy @@ -3772,6 +3780,23 @@ snd error text Szybciej znajduj czaty No comment provided by engineer. + + Fingerprint in destination server address does not match certificate: %@. + No comment provided by engineer. + + + Fingerprint in forwarding server address does not match certificate: %@. + No comment provided by engineer. + + + Fingerprint in server address does not match certificate. + Możliwe, że odcisk palca certyfikatu w adresie serwera jest nieprawidłowy + server test error + + + Fingerprint in server address does not match certificate: %@. + No comment provided by engineer. + Fix Napraw @@ -4766,6 +4791,10 @@ To jest twój link do grupy %@! Członek No comment provided by engineer. + + Member %@ + past/unknown group member + Member admission No comment provided by engineer. @@ -5158,7 +5187,7 @@ To jest twój link do grupy %@! Network status Status sieci - No comment provided by engineer. + alert title New @@ -5763,11 +5792,6 @@ Wymaga włączenia VPN. Hasło do wyświetlenia No comment provided by engineer. - - Past member %@ - Były członek %@ - past/unknown group member - Paste desktop address Wklej adres komputera @@ -5923,11 +5947,6 @@ Błąd: %@ Port No comment provided by engineer. - - Possibly, certificate fingerprint in server address is incorrect - Możliwe, że odcisk palca certyfikatu w adresie serwera jest nieprawidłowy - server test error - Preserve the last message draft, with attachments. Zachowaj ostatnią wersję roboczą wiadomości wraz z załącznikami. @@ -6222,11 +6241,6 @@ Włącz w ustawianiach *Sieć i serwery* . Otrzymane o: %@ copied message info - - Received file event - Otrzymano zdarzenie pliku - notification - Received message Otrzymano wiadomość @@ -7030,11 +7044,6 @@ chat item action Wysłano bezpośrednio No comment provided by engineer. - - Sent file event - Wyślij zdarzenie pliku - notification - Sent message Wyślij wiadomość @@ -7101,13 +7110,13 @@ chat item action Server protocol changed. alert title - - Server requires authorization to create queues, check password + + Server requires authorization to create queues, check password. Serwer wymaga autoryzacji do tworzenia kolejek, sprawdź hasło server test error - - Server requires authorization to upload, check password + + Server requires authorization to upload, check password. Serwer wymaga autoryzacji do przesłania, sprawdź hasło server test error @@ -8176,15 +8185,9 @@ Przed włączeniem tej funkcji zostanie wyświetlony monit uwierzytelniania.Sesje transportowe No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - Próbowanie połączenia z serwerem używanym do odbierania wiadomości od tego kontaktu (błąd: %@). - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. - Próbowanie połączenia z serwerem używanym do odbierania wiadomości od tego kontaktu. - No comment provided by engineer. + + Trying to connect to the server used to receive messages from this connection. + subscription status explanation Turkish interface @@ -8873,16 +8876,19 @@ Repeat join request? Powtórzyć prośbę dołączenia? new chat sheet title - - You are connected to the server used to receive messages from this contact. - Jesteś połączony z serwerem używanym do odbierania wiadomości od tego kontaktu. - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + subscription status explanation You are invited to group Jesteś zaproszony do grupy No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. Nie jesteś połączony z tymi serwerami. Prywatne trasowanie jest używane do dostarczania do nich wiadomości. @@ -9918,6 +9924,10 @@ pref value brak szyfrowania e2e No comment provided by engineer. + + no subscription + No comment provided by engineer. + no text brak tekstu diff --git a/apps/ios/SimpleX Localizations/pt-BR.xcloc/Localized Contents/pt-BR.xliff b/apps/ios/SimpleX Localizations/pt-BR.xcloc/Localized Contents/pt-BR.xliff index 31b220a17d..d9af0624bf 100644 --- a/apps/ios/SimpleX Localizations/pt-BR.xcloc/Localized Contents/pt-BR.xliff +++ b/apps/ios/SimpleX Localizations/pt-BR.xcloc/Localized Contents/pt-BR.xliff @@ -2352,8 +2352,8 @@ We will be adding server redundancy to prevent lost messages. Guarde a senha em um local seguro, você NÃO poderá alterá-la se a perder. No comment provided by engineer. - - Possibly, certificate fingerprint in server address is incorrect + + Fingerprint in server address does not match certificate. Possivelmente, a impressão digital do certificado no endereço do servidor está incorreta server test error @@ -2727,8 +2727,8 @@ We will be adding server redundancy to prevent lost messages. Mensagens enviadas serão excluídas depois do tempo definido. No comment provided by engineer. - - Server requires authorization to create queues, check password + + Server requires authorization to create queues, check password. O servidor requer autorização para criar filas, verifique a senha server test error @@ -4610,8 +4610,8 @@ Disponível na 5.1 Salvar e atualizar perfil do grupo No comment provided by engineer. - - Server requires authorization to upload, check password + + Server requires authorization to upload, check password. O servidor requer autorização para fazer upload, verifique a senha server test error diff --git a/apps/ios/SimpleX Localizations/pt.xcloc/Localized Contents/pt.xliff b/apps/ios/SimpleX Localizations/pt.xcloc/Localized Contents/pt.xliff index 01b8f7e244..e4fac55bcb 100644 --- a/apps/ios/SimpleX Localizations/pt.xcloc/Localized Contents/pt.xliff +++ b/apps/ios/SimpleX Localizations/pt.xcloc/Localized Contents/pt.xliff @@ -2453,8 +2453,8 @@ Available in v5.1 Polish interface No comment provided by engineer. - - Possibly, certificate fingerprint in server address is incorrect + + Fingerprint in server address does not match certificate. server test error @@ -2829,12 +2829,12 @@ Available in v5.1 Sent messages will be deleted after set time. No comment provided by engineer. - - Server requires authorization to create queues, check password + + Server requires authorization to create queues, check password. server test error - - Server requires authorization to upload, check password + + Server requires authorization to upload, check password. server test error diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index 1bd12beddb..d885db1350 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -1802,7 +1802,7 @@ set passcode view Conditions of use Условия использования - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -3310,6 +3310,10 @@ chat item action Ошибка подключения к пересылающему серверу %@. Попробуйте позже. alert message + + Error connecting to the server used to receive messages from this connection: %@ + subscription status explanation + Error creating address Ошибка при создании адреса @@ -3355,16 +3359,16 @@ chat item action Ошибка расшифровки файла No comment provided by engineer. + + Error deleting chat + Ошибка при удалении чата с членом группы + alert title + Error deleting chat database Ошибка при удалении данных чата alert title - - Error deleting chat with member - Ошибка при удалении чата с членом группы - alert title - Error deleting chat! Ошибка при удалении чата! @@ -3642,6 +3646,11 @@ chat item action file error text snd error text + + Error: %@. + Ошибка: %@. + server test error + Error: URL is invalid Ошибка: неверная ссылка @@ -3866,6 +3875,26 @@ snd error text Быстро найти чаты No comment provided by engineer. + + Fingerprint in destination server address does not match certificate: %@. + Хэш в адресе сервера назначения не соответствует сертификату: %@. + No comment provided by engineer. + + + Fingerprint in forwarding server address does not match certificate: %@. + Хэш в адресе пересылающего сервера не соответствует сертификату: %@. + No comment provided by engineer. + + + Fingerprint in server address does not match certificate. + Возможно, хэш сертификата в адресе сервера неверный. + server test error + + + Fingerprint in server address does not match certificate: %@. + Хэш в адресе сервера не соответствует сертификату: %@. + No comment provided by engineer. + Fix Починить @@ -4891,6 +4920,11 @@ This is your link for group %@! Член группы No comment provided by engineer. + + Member %@ + Член группы %@ + past/unknown group member + Member admission Приём членов в группу @@ -5263,7 +5297,7 @@ This is your link for group %@! Network & servers - Сеть & серверы + Сеть и серверы No comment provided by engineer. @@ -5299,7 +5333,7 @@ This is your link for group %@! Network status Состояние сети - No comment provided by engineer. + alert title New @@ -5948,11 +5982,6 @@ Requires compatible VPN. Пароль чтобы раскрыть No comment provided by engineer. - - Past member %@ - Бывший член %@ - past/unknown group member - Paste desktop address Вставить адрес компьютера @@ -6112,11 +6141,6 @@ Error: %@ Порт No comment provided by engineer. - - Possibly, certificate fingerprint in server address is incorrect - Возможно, хэш сертификата в адресе сервера неверный - server test error - Preserve the last message draft, with attachments. Сохранить последний черновик, вместе с вложениями. @@ -6419,11 +6443,6 @@ Enable in *Network & servers* settings. Получено: %@ copied message info - - Received file event - Загрузка файла - notification - Received message Полученное сообщение @@ -6803,7 +6822,7 @@ swipe action Review members before admitting ("knocking"). - Одобрять членов для вступления в группу. + Вручную одобрять членов для вступления в группу. admission stage description @@ -7257,11 +7276,6 @@ chat item action Отправлено напрямую No comment provided by engineer. - - Sent file event - Отправка файла - notification - Sent message Отправленное сообщение @@ -7332,14 +7346,14 @@ chat item action Протокол сервера изменен. alert title - - Server requires authorization to create queues, check password - Сервер требует авторизации для создания очередей, проверьте пароль + + Server requires authorization to create queues, check password. + Сервер требует авторизации для создания очередей, проверьте пароль. server test error - - Server requires authorization to upload, check password - Сервер требует авторизации для загрузки, проверьте пароль + + Server requires authorization to upload, check password. + Сервер требует авторизации для загрузки, проверьте пароль. server test error @@ -7715,6 +7729,7 @@ chat item action SimpleX relay link + Ссылка SimpleX relay simplex link type @@ -7982,7 +7997,7 @@ report reason Tap Connect to use bot - Нажмите Соединиться, чтобы использовать бот. + Нажмите Соединиться, чтобы использовать бот No comment provided by engineer. @@ -8459,15 +8474,9 @@ You will be prompted to complete authentication before this feature is enabled.< Транспортные сессии No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - Устанавливается соединение с сервером, через который Вы получаете сообщения от этого контакта (ошибка: %@). - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. - Устанавливается соединение с сервером, через который Вы получаете сообщения от этого контакта. - No comment provided by engineer. + + Trying to connect to the server used to receive messages from this connection. + subscription status explanation Turkish interface @@ -9178,16 +9187,19 @@ Repeat join request? Повторить запрос на вступление? new chat sheet title - - You are connected to the server used to receive messages from this contact. - Установлено соединение с сервером, через который Вы получаете сообщения от этого контакта. - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + subscription status explanation You are invited to group Вы приглашены в группу No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. Вы не подключены к этим серверам. Для доставки сообщений на них используется конфиденциальная доставка. @@ -10247,6 +10259,10 @@ pref value нет e2e шифрования No comment provided by engineer. + + no subscription + No comment provided by engineer. + no text нет текста diff --git a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff index 7b4c27e61e..ecb4d20fbb 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff +++ b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff @@ -1616,7 +1616,7 @@ set passcode view Conditions of use - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -2980,6 +2980,10 @@ chat item action Error connecting to forwarding server %@. Please try later. alert message + + Error connecting to the server used to receive messages from this connection: %@ + subscription status explanation + Error creating address เกิดข้อผิดพลาดในการสร้างที่อยู่ @@ -3020,15 +3024,15 @@ chat item action Error decrypting file No comment provided by engineer. + + Error deleting chat + alert title + Error deleting chat database เกิดข้อผิดพลาดในการลบฐานข้อมูลแชท alert title - - Error deleting chat with member - alert title - Error deleting chat! เกิดข้อผิดพลาดในการลบแชท! @@ -3283,6 +3287,10 @@ chat item action file error text snd error text + + Error: %@. + server test error + Error: URL is invalid เกิดข้อผิดพลาด: URL ไม่ถูกต้อง @@ -3483,6 +3491,23 @@ snd error text ค้นหาแชทได้เร็วขึ้น No comment provided by engineer. + + Fingerprint in destination server address does not match certificate: %@. + No comment provided by engineer. + + + Fingerprint in forwarding server address does not match certificate: %@. + No comment provided by engineer. + + + Fingerprint in server address does not match certificate. + อาจเป็นไปได้ว่าลายนิ้วมือของ certificate ในที่อยู่เซิร์ฟเวอร์ไม่ถูกต้อง + server test error + + + Fingerprint in server address does not match certificate: %@. + No comment provided by engineer. + Fix แก้ไข @@ -4423,6 +4448,10 @@ This is your link for group %@! สมาชิก No comment provided by engineer. + + Member %@ + past/unknown group member + Member admission No comment provided by engineer. @@ -4783,7 +4812,7 @@ This is your link for group %@! Network status สถานะเครือข่าย - No comment provided by engineer. + alert title New @@ -5357,10 +5386,6 @@ Requires compatible VPN. รหัสผ่านที่จะแสดง No comment provided by engineer. - - Past member %@ - past/unknown group member - Paste desktop address No comment provided by engineer. @@ -5503,11 +5528,6 @@ Error: %@ Port No comment provided by engineer. - - Possibly, certificate fingerprint in server address is incorrect - อาจเป็นไปได้ว่าลายนิ้วมือของ certificate ในที่อยู่เซิร์ฟเวอร์ไม่ถูกต้อง - server test error - Preserve the last message draft, with attachments. เก็บข้อความที่ร่างไว้ล่าสุดพร้อมไฟล์แนบ @@ -5781,11 +5801,6 @@ Enable in *Network & servers* settings. ได้รับเมื่อ: %@ copied message info - - Received file event - ได้รับไฟล์ - notification - Received message ได้รับข้อความ @@ -6538,11 +6553,6 @@ chat item action Sent directly No comment provided by engineer. - - Sent file event - เหตุการณ์ไฟล์ที่ส่ง - notification - Sent message ข้อความที่ส่งแล้ว @@ -6601,13 +6611,13 @@ chat item action Server protocol changed. alert title - - Server requires authorization to create queues, check password + + Server requires authorization to create queues, check password. เซิร์ฟเวอร์ต้องการการอนุญาตในการสร้างคิว โปรดตรวจสอบรหัสผ่าน server test error - - Server requires authorization to upload, check password + + Server requires authorization to upload, check password. เซิร์ฟเวอร์ต้องการการอนุญาตในการอัปโหลด โปรดตรวจสอบรหัสผ่าน server test error @@ -7607,15 +7617,9 @@ You will be prompted to complete authentication before this feature is enabled.< Transport sessions No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - กำลังพยายามเชื่อมต่อกับเซิร์ฟเวอร์ที่ใช้รับข้อความจากผู้ติดต่อนี้ (ข้อผิดพลาด: %@) - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. - พยายามเชื่อมต่อกับเซิร์ฟเวอร์ที่ใช้รับข้อความจากผู้ติดต่อนี้ - No comment provided by engineer. + + Trying to connect to the server used to receive messages from this connection. + subscription status explanation Turkish interface @@ -8245,16 +8249,19 @@ To connect, please ask your contact to create another connection link and check Repeat join request? new chat sheet title - - You are connected to the server used to receive messages from this contact. - คุณเชื่อมต่อกับเซิร์ฟเวอร์ที่ใช้รับข้อความจากผู้ติดต่อนี้ - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + subscription status explanation You are invited to group คุณได้รับเชิญให้เข้าร่วมกลุ่ม No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. No comment provided by engineer. @@ -9249,6 +9256,10 @@ pref value ไม่มีการ encrypt จากต้นจนจบ No comment provided by engineer. + + no subscription + No comment provided by engineer. + no text ไม่มีข้อความ diff --git a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff index c06e95ecaf..57151a95b5 100644 --- a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff +++ b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff @@ -869,6 +869,7 @@ swipe action Allow files and media only if your contact allows them. + Dosyalara ve medyaya yalnızca iletişiminiz izin verdiğinde izin verin. No comment provided by engineer. @@ -958,6 +959,7 @@ swipe action Allow your contacts to send files and media. + Kişilerinizin dosya ve medya göndermesine izin verin. No comment provided by engineer. @@ -1322,6 +1324,7 @@ swipe action Bot + Bot No comment provided by engineer. @@ -1346,6 +1349,7 @@ swipe action Both you and your contact can send files and media. + Sen de kişilerin de medya ve dosya gönderebilir. No comment provided by engineer. @@ -1798,7 +1802,7 @@ set passcode view Conditions of use Kullanım koşulları - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -2682,6 +2686,7 @@ swipe action Deprecated options + Kullanımdan kaldırılan seçenekler No comment provided by engineer. @@ -3305,6 +3310,10 @@ chat item action Yönlendirme sunucusu %@'ya bağlanırken hata oluştu. Lütfen daha sonra deneyin. alert message + + Error connecting to the server used to receive messages from this connection: %@ + subscription status explanation + Error creating address Adres oluşturulurken hata oluştu @@ -3350,16 +3359,16 @@ chat item action Dosya şifresi çözülürken hata oluştu No comment provided by engineer. + + Error deleting chat + Üye ile sohbet silme hatası + alert title + Error deleting chat database Sohbet veritabanı silinirken sorun oluştu alert title - - Error deleting chat with member - Üye ile sohbet silme hatası - alert title - Error deleting chat! Sohbet silinirken hata oluştu! @@ -3552,6 +3561,7 @@ chat item action Error setting auto-accept + Otomatik kabul ayarında hata No comment provided by engineer. @@ -3636,6 +3646,10 @@ chat item action file error text snd error text + + Error: %@. + server test error + Error: URL is invalid Hata: URL geçersiz @@ -3817,6 +3831,7 @@ snd error text Files and media are prohibited in this chat. + Bu sohbette dosyalar ve medya yasaktır. No comment provided by engineer. @@ -3859,6 +3874,23 @@ snd error text Sohbetleri daha hızlı bul No comment provided by engineer. + + Fingerprint in destination server address does not match certificate: %@. + No comment provided by engineer. + + + Fingerprint in forwarding server address does not match certificate: %@. + No comment provided by engineer. + + + Fingerprint in server address does not match certificate. + Muhtemelen, sunucu adresindeki parmakizi sertifikası doğru değil + server test error + + + Fingerprint in server address does not match certificate: %@. + No comment provided by engineer. + Fix Düzelt @@ -4687,6 +4719,7 @@ Bu senin grup için bağlantın %@! Keep your chats clean + Sohbetlerinizi temiz tutun No comment provided by engineer. @@ -4884,6 +4917,11 @@ Bu senin grup için bağlantın %@! Kişi No comment provided by engineer. + + Member %@ + Üye%@ + past/unknown group member + Member admission Üye kabulü @@ -4896,6 +4934,7 @@ Bu senin grup için bağlantın %@! Member is deleted - can't accept request + Üye silinmiş - istek kabul edilemez No comment provided by engineer. @@ -5291,7 +5330,7 @@ Bu senin grup için bağlantın %@! Network status Ağ durumu - No comment provided by engineer. + alert title New @@ -5710,6 +5749,7 @@ VPN'nin etkinleştirilmesi gerekir. Only you can send files and media. + Sadece sen dosya ve medya gönderebilirsin. No comment provided by engineer. @@ -5739,6 +5779,7 @@ VPN'nin etkinleştirilmesi gerekir. Only your contact can send files and media. + Sadece kişilerin dosya ve medya gönderebilir. No comment provided by engineer. @@ -5773,6 +5814,7 @@ VPN'nin etkinleştirilmesi gerekir. Open clean link + Temiz linki aç alert action @@ -5782,6 +5824,7 @@ VPN'nin etkinleştirilmesi gerekir. Open full link + Tam linki aç alert action @@ -5826,6 +5869,7 @@ VPN'nin etkinleştirilmesi gerekir. Open to use bot + Botu kullanmak için aç No comment provided by engineer. @@ -5935,11 +5979,6 @@ VPN'nin etkinleştirilmesi gerekir. Gösterilecek şifre No comment provided by engineer. - - Past member %@ - Geçmiş üye %@ - past/unknown group member - Paste desktop address Bilgisayar adresini yapıştır @@ -6099,11 +6138,6 @@ Hata: %@ Port No comment provided by engineer. - - Possibly, certificate fingerprint in server address is incorrect - Muhtemelen, sunucu adresindeki parmakizi sertifikası doğru değil - server test error - Preserve the last message draft, with attachments. Son mesaj taslağını ekleriyle birlikte koru. @@ -6406,11 +6440,6 @@ Enable in *Network & servers* settings. Şuradan alındı: %@ copied message info - - Received file event - Dosya etkinliği alındı - notification - Received message Mesaj alındı @@ -6575,6 +6604,7 @@ swipe action Remove link tracking + Bağlantı izlemeyi kaldır No comment provided by engineer. @@ -7243,11 +7273,6 @@ chat item action Direkt gönderildi No comment provided by engineer. - - Sent file event - Dosya etkinliği gönderildi - notification - Sent message Mesaj gönderildi @@ -7318,13 +7343,13 @@ chat item action Sunucu protokolü değişti. alert title - - Server requires authorization to create queues, check password + + Server requires authorization to create queues, check password. Sunucunun sıra oluşturması için yetki gereklidir, şifreyi kontrol edin server test error - - Server requires authorization to upload, check password + + Server requires authorization to upload, check password. Sunucunun yükleme yapması için yetki gereklidir, şifreyi kontrol edin server test error @@ -7425,6 +7450,7 @@ chat item action Set profile bio and welcome message. + Profil biyografisi ve hoşgeldiniz mesajı düzenle. No comment provided by engineer. @@ -7500,10 +7526,12 @@ chat item action Share old address + Eski adresi paylaş alert button Share old link + Eski bağlantıyı paylaş alert button @@ -7528,10 +7556,12 @@ chat item action Share your address + Adresini paylaş No comment provided by engineer. Short SimpleX address + Kısa SimpleX adresi No comment provided by engineer. @@ -7696,6 +7726,7 @@ chat item action SimpleX relay link + SimpleX aktarıcı bağlantısı simplex link type @@ -7963,6 +7994,7 @@ report reason Tap Connect to use bot + Botu kullanmak için Bağlan tuşuna bas No comment provided by engineer. @@ -8064,6 +8096,7 @@ Bazı hatalar nedeniyle veya bağlantı tehlikeye girdiğinde meydana gelebilir. The address will be short, and your profile will be shared via the address. + Adres kısa olacak ve profiliniz bu adres üzerinden paylaşılacaktır. alert message @@ -8128,6 +8161,7 @@ Bazı hatalar nedeniyle veya bağlantı tehlikeye girdiğinde meydana gelebilir. The link will be short, and group profile will be shared via the link. + Bağlantı kısa olacak ve grup profili bağlantı üzerinden paylaşılacaktır. alert message @@ -8287,10 +8321,12 @@ Bazı hatalar nedeniyle veya bağlantı tehlikeye girdiğinde meydana gelebilir. This setting is for your current profile **%@**. + Bu ayar, mevcut profiliniz içindir. No comment provided by engineer. Time to disappear is set only for new contacts. + Kaybolma süresi yalnızca yeni kişiler için ayarlanır. No comment provided by engineer. @@ -8377,6 +8413,7 @@ Bu özellik etkinleştirilmeden önce kimlik doğrulamayı tamamlamanız istenec To send commands you must be connected. + Komut göndermek için bağlı olmanız gerekir. alert message @@ -8434,15 +8471,9 @@ Bu özellik etkinleştirilmeden önce kimlik doğrulamayı tamamlamanız istenec Taşıma oturumları No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - Bu kişiden mesaj almak için kullanılan sunucuya bağlanılmaya çalışılıyor (hata: %@). - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. - Bu kişiden mesaj almak için kullanılan sunucuya bağlanılmaya çalışılıyor. - No comment provided by engineer. + + Trying to connect to the server used to receive messages from this connection. + subscription status explanation Turkish interface @@ -8628,14 +8659,17 @@ Bağlanmak için lütfen kişinizden başka bir bağlantı oluşturmasını iste Upgrade + Yükseltme alert button Upgrade address + Adres güncelleme No comment provided by engineer. Upgrade address? + Adres güncellensin mi? alert message @@ -8645,14 +8679,17 @@ Bağlanmak için lütfen kişinizden başka bir bağlantı oluşturmasını iste Upgrade group link? + Grub linki güncellensin mi? alert message Upgrade link + Linki güncelle No comment provided by engineer. Upgrade your address + Adresini yükselt No comment provided by engineer. @@ -8752,6 +8789,7 @@ Bağlanmak için lütfen kişinizden başka bir bağlantı oluşturmasını iste Use incognito profile + Gizli profil kullan No comment provided by engineer. @@ -8991,6 +9029,7 @@ Bağlanmak için lütfen kişinizden başka bir bağlantı oluşturmasını iste Welcome your contacts 👋 + Kişilerine hoş geldin👋 No comment provided by engineer. @@ -9145,16 +9184,19 @@ Repeat join request? Katılma isteği tekrarlansın mı? new chat sheet title - - You are connected to the server used to receive messages from this contact. - Bu kişiden mesaj almak için kullanılan sunucuya bağlısınız. - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + subscription status explanation You are invited to group Gruba davet edildiniz No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. Bu sunuculara bağlı değilsiniz. Mesajları onlara iletmek için özel yönlendirme kullanılır. @@ -10214,6 +10256,10 @@ pref value uçtan uca şifreleme yok No comment provided by engineer. + + no subscription + No comment provided by engineer. + no text metin yok @@ -10359,10 +10405,12 @@ time to disappear requested connection + istenilen bağlantı rcv group event chat item requested connection from group %@ + %@ grubundan bağlantı isteği rcv direct event chat item diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff index 10cb209a8b..7980685349 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff @@ -1798,7 +1798,7 @@ set passcode view Conditions of use Умови використання - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -3304,6 +3304,10 @@ chat item action Помилка підключення до сервера переадресації %@. Спробуйте пізніше. alert message + + Error connecting to the server used to receive messages from this connection: %@ + subscription status explanation + Error creating address Помилка створення адреси @@ -3349,16 +3353,16 @@ chat item action Помилка розшифрування файлу No comment provided by engineer. + + Error deleting chat + Помилка при видаленні чату з учасником + alert title + Error deleting chat database Помилка видалення бази даних чату alert title - - Error deleting chat with member - Помилка при видаленні чату з учасником - alert title - Error deleting chat! Помилка видалення чату! @@ -3635,6 +3639,10 @@ chat item action file error text snd error text + + Error: %@. + server test error + Error: URL is invalid Помилка: URL-адреса невірна @@ -3858,6 +3866,23 @@ snd error text Швидше знаходьте чати No comment provided by engineer. + + Fingerprint in destination server address does not match certificate: %@. + No comment provided by engineer. + + + Fingerprint in forwarding server address does not match certificate: %@. + No comment provided by engineer. + + + Fingerprint in server address does not match certificate. + Відбиток в адресі сервера не співпадає з сертифікатом. + server test error + + + Fingerprint in server address does not match certificate: %@. + No comment provided by engineer. + Fix Виправити @@ -4884,6 +4909,10 @@ This is your link for group %@! Учасник No comment provided by engineer. + + Member %@ + past/unknown group member + Member admission Прийом членів @@ -5291,7 +5320,7 @@ This is your link for group %@! Network status Стан мережі - No comment provided by engineer. + alert title New @@ -5935,11 +5964,6 @@ Requires compatible VPN. Показати пароль No comment provided by engineer. - - Past member %@ - Колишній учасник %@ - past/unknown group member - Paste desktop address Вставте адресу робочого столу @@ -6099,11 +6123,6 @@ Error: %@ Порт No comment provided by engineer. - - Possibly, certificate fingerprint in server address is incorrect - Можливо, в адресі сервера неправильно вказано відбиток сертифіката - server test error - Preserve the last message draft, with attachments. Зберегти чернетку останнього повідомлення з вкладеннями. @@ -6406,11 +6425,6 @@ Enable in *Network & servers* settings. Отримано за: %@ copied message info - - Received file event - Подія отримання файлу - notification - Received message Отримано повідомлення @@ -7243,11 +7257,6 @@ chat item action Відправлено напряму No comment provided by engineer. - - Sent file event - Подія надісланого файлу - notification - Sent message Надіслано повідомлення @@ -7318,14 +7327,14 @@ chat item action Протокол сервера змінено. alert title - - Server requires authorization to create queues, check password - Сервер вимагає авторизації для створення черг, перевірте пароль + + Server requires authorization to create queues, check password. + Сервер вимагає авторизації для створення черг, перевірте пароль. server test error - - Server requires authorization to upload, check password - Сервер вимагає авторизації для завантаження, перевірте пароль + + Server requires authorization to upload, check password. + Сервер вимагає авторизації для завантаження, перевірте пароль. server test error @@ -8442,15 +8451,9 @@ You will be prompted to complete authentication before this feature is enabled.< Транспортні сесії No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - Спроба з'єднатися з сервером, який використовується для отримання повідомлень від цього контакту (помилка: %@). - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. - Спроба з'єднатися з сервером, який використовується для отримання повідомлень від цього контакту. - No comment provided by engineer. + + Trying to connect to the server used to receive messages from this connection. + subscription status explanation Turkish interface @@ -9161,16 +9164,19 @@ Repeat join request? Повторити запит на приєднання? new chat sheet title - - You are connected to the server used to receive messages from this contact. - Ви підключені до сервера, який використовується для отримання повідомлень від цього контакту. - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + subscription status explanation You are invited to group Запрошуємо вас до групи No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. Не підключені до цих серверів. Для доставлення повідомлень до них використовується приватна маршрутизація. @@ -10230,6 +10236,10 @@ pref value без шифрування e2e No comment provided by engineer. + + no subscription + No comment provided by engineer. + no text без тексту diff --git a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff index a92005b8b7..e1ce65b5ce 100644 --- a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff +++ b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff @@ -1785,7 +1785,7 @@ set passcode view Conditions of use 使用条款 - No comment provided by engineer. + alert button Conditions will be accepted for the operator(s): **%@**. @@ -3280,6 +3280,10 @@ chat item action 连接到转发服务器 %@ 时出错。请稍后尝试。 alert message + + Error connecting to the server used to receive messages from this connection: %@ + subscription status explanation + Error creating address 创建地址错误 @@ -3325,15 +3329,15 @@ chat item action 解密文件时出错 No comment provided by engineer. + + Error deleting chat + alert title + Error deleting chat database 删除聊天数据库错误 alert title - - Error deleting chat with member - alert title - Error deleting chat! 删除聊天错误! @@ -3608,6 +3612,10 @@ chat item action file error text snd error text + + Error: %@. + server test error + Error: URL is invalid 错误:URL 无效 @@ -3831,6 +3839,23 @@ snd error text 更快地查找聊天记录 No comment provided by engineer. + + Fingerprint in destination server address does not match certificate: %@. + No comment provided by engineer. + + + Fingerprint in forwarding server address does not match certificate: %@. + No comment provided by engineer. + + + Fingerprint in server address does not match certificate. + 服务器地址中的证书指纹可能不正确 + server test error + + + Fingerprint in server address does not match certificate: %@. + No comment provided by engineer. + Fix 修复 @@ -4853,6 +4878,10 @@ This is your link for group %@! 成员 No comment provided by engineer. + + Member %@ + past/unknown group member + Member admission No comment provided by engineer. @@ -5256,7 +5285,7 @@ This is your link for group %@! Network status 网络状态 - No comment provided by engineer. + alert title New @@ -5888,11 +5917,6 @@ Requires compatible VPN. 显示密码 No comment provided by engineer. - - Past member %@ - 前任成员 %@ - past/unknown group member - Paste desktop address 粘贴桌面地址 @@ -6047,11 +6071,6 @@ Error: %@ Port No comment provided by engineer. - - Possibly, certificate fingerprint in server address is incorrect - 服务器地址中的证书指纹可能不正确 - server test error - Preserve the last message draft, with attachments. 保留最后的消息草稿及其附件。 @@ -6347,11 +6366,6 @@ Enable in *Network & servers* settings. 已收到于:%@ copied message info - - Received file event - 收到文件项目 - notification - Received message 收到的信息 @@ -7154,11 +7168,6 @@ chat item action 直接发送 No comment provided by engineer. - - Sent file event - 已发送文件项目 - notification - Sent message 已发信息 @@ -7224,13 +7233,13 @@ chat item action Server protocol changed. alert title - - Server requires authorization to create queues, check password + + Server requires authorization to create queues, check password. 服务器需要授权才能创建队列,检查密码 server test error - - Server requires authorization to upload, check password + + Server requires authorization to upload, check password. 服务器需要授权来上传,检查密码 server test error @@ -8298,15 +8307,9 @@ You will be prompted to complete authentication before this feature is enabled.< 传输会话 No comment provided by engineer. - - Trying to connect to the server used to receive messages from this contact (error: %@). - 正在尝试连接到用于从该联系人接收消息的服务器(错误:%@)。 - No comment provided by engineer. - - - Trying to connect to the server used to receive messages from this contact. - 正在尝试连接到用于从该联系人接收消息的服务器。 - No comment provided by engineer. + + Trying to connect to the server used to receive messages from this connection. + subscription status explanation Turkish interface @@ -8993,16 +8996,19 @@ Repeat join request? 重复加入请求? new chat sheet title - - You are connected to the server used to receive messages from this contact. - 您已连接到用于接收该联系人消息的服务器。 - No comment provided by engineer. + + You are connected to the server used to receive messages from this connection. + subscription status explanation You are invited to group 您被邀请加入群组 No comment provided by engineer. + + You are not connected to the server used to receive messages from this connection (no subscription). + subscription status explanation + You are not connected to these servers. Private routing is used to deliver messages to them. 您未连接到这些服务器。私有路由用于向他们发送消息。 @@ -10033,6 +10039,10 @@ pref value 无端到端加密 No comment provided by engineer. + + no subscription + No comment provided by engineer. + no text 无文本 diff --git a/apps/ios/SimpleX Localizations/zh-Hant.xcloc/Localized Contents/zh-Hant.xliff b/apps/ios/SimpleX Localizations/zh-Hant.xcloc/Localized Contents/zh-Hant.xliff index ac3a01c965..0e4e383b52 100644 --- a/apps/ios/SimpleX Localizations/zh-Hant.xcloc/Localized Contents/zh-Hant.xliff +++ b/apps/ios/SimpleX Localizations/zh-Hant.xcloc/Localized Contents/zh-Hant.xliff @@ -2367,8 +2367,8 @@ We will be adding server redundancy to prevent lost messages. 請放置你的密碼於安全的地方,如果你遺失了密碼,將不可能修改你的密碼。 No comment provided by engineer. - - Possibly, certificate fingerprint in server address is incorrect + + Fingerprint in server address does not match certificate. 伺服器地址的憑證指紋可能不正確 server test error @@ -2757,8 +2757,8 @@ We will be adding server redundancy to prevent lost messages. 已傳送的訊息將在設定的時間後被刪除。 No comment provided by engineer. - - Server requires authorization to create queues, check password + + Server requires authorization to create queues, check password. 伺服器需要授權才能建立佇列,請檢查密碼 server test error @@ -5130,8 +5130,8 @@ Available in v5.1 儲存歡迎訊息? No comment provided by engineer. - - Server requires authorization to upload, check password + + Server requires authorization to upload, check password. 伺服器需要認證後才能上傳,檢查密碼 server test error diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index efed487739..5d619ac130 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -1196,8 +1196,6 @@ func defaultBestAttemptNtf(_ ntfConn: NtfConn) -> NSENotificationData { groupInfo.chatSettings.enableNtfs == .all ? .connectionEvent(user, connEntity) : .noNtf - case .sndFileConnection: .noNtf - case .rcvFileConnection: .noNtf case let .userContactConnection(_, userContact): userContact.groupId == nil ? .connectionEvent(user, connEntity) diff --git a/apps/ios/SimpleX NSE/de.lproj/InfoPlist.strings b/apps/ios/SimpleX NSE/de.lproj/InfoPlist.strings index 6cc768efe1..2ea2a332d4 100644 --- a/apps/ios/SimpleX NSE/de.lproj/InfoPlist.strings +++ b/apps/ios/SimpleX NSE/de.lproj/InfoPlist.strings @@ -5,5 +5,5 @@ "CFBundleName" = "SimpleX NSE"; /* Copyright (human-readable) */ -"NSHumanReadableCopyright" = "Copyright © 2024 SimpleX Chat. All rights reserved."; +"NSHumanReadableCopyright" = "Copyright © 2025 SimpleX Chat. All rights reserved."; diff --git a/apps/ios/SimpleX SE/ShareAPI.swift b/apps/ios/SimpleX SE/ShareAPI.swift index 2b3e8068ae..4dad9d5d15 100644 --- a/apps/ios/SimpleX SE/ShareAPI.swift +++ b/apps/ios/SimpleX SE/ShareAPI.swift @@ -160,6 +160,8 @@ enum SEChatCommand: ChatCmdProtocol { } else { "(_support)" } + case .reports: + "(reports, prohibited)" // can't use surrogate Reports scope } } } diff --git a/apps/ios/SimpleX SE/de.lproj/Localizable.strings b/apps/ios/SimpleX SE/de.lproj/Localizable.strings index 4c10694986..ed96f44a15 100644 --- a/apps/ios/SimpleX SE/de.lproj/Localizable.strings +++ b/apps/ios/SimpleX SE/de.lproj/Localizable.strings @@ -77,10 +77,10 @@ "Passphrase" = "Passwort"; /* No comment provided by engineer. */ -"Please create a profile in the SimpleX app" = "Bitte erstellen Sie ein Profil in der SimpleX-App"; +"Please create a profile in the SimpleX app" = "Bitte erstellen Sie in der SimpleX-App ein Profil"; /* No comment provided by engineer. */ -"Selected chat preferences prohibit this message." = "Diese Nachricht ist wegen der gewählten Chat-Einstellungen nicht erlaubt."; +"Selected chat preferences prohibit this message." = "Die gewählten Chat-Einstellungen erlauben diese Nachricht nicht."; /* No comment provided by engineer. */ "Sending a message takes longer than expected." = "Das Senden einer Nachricht dauert länger als erwartet."; diff --git a/apps/ios/SimpleX--iOS--Info.plist b/apps/ios/SimpleX--iOS--Info.plist index 6f7d6c2395..72bd9b0dc3 100644 --- a/apps/ios/SimpleX--iOS--Info.plist +++ b/apps/ios/SimpleX--iOS--Info.plist @@ -56,5 +56,7 @@ remote-notification voip + UIDesignRequiresCompatibility + diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 4a6e7d3936..28763511b8 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -178,8 +178,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.4.4.1-E5t4CKBkMyT7dUtbJzIAJ5-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.4.1-E5t4CKBkMyT7dUtbJzIAJ5-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.4.4.1-E5t4CKBkMyT7dUtbJzIAJ5.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.4.1-E5t4CKBkMyT7dUtbJzIAJ5.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 */; }; @@ -545,8 +545,8 @@ 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = ""; }; 64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.4.1-E5t4CKBkMyT7dUtbJzIAJ5-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.4.1-E5t4CKBkMyT7dUtbJzIAJ5-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.4.1-E5t4CKBkMyT7dUtbJzIAJ5.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.4.1-E5t4CKBkMyT7dUtbJzIAJ5.a"; sourceTree = ""; }; + 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 = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.4-45BRYFUa7pfAow5e7qNgo6.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.0.4-45BRYFUa7pfAow5e7qNgo6.a"; sourceTree = ""; }; 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = ""; }; 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = ""; }; @@ -708,8 +708,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.4.1-E5t4CKBkMyT7dUtbJzIAJ5-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.4.4.1-E5t4CKBkMyT7dUtbJzIAJ5.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; @@ -795,8 +795,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.4.1-E5t4CKBkMyT7dUtbJzIAJ5-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.4.1-E5t4CKBkMyT7dUtbJzIAJ5.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 = ""; @@ -2003,7 +2003,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 296; + CURRENT_PROJECT_VERSION = 312; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2028,7 +2028,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES_THIN; - MARKETING_VERSION = 6.4.4; + MARKETING_VERSION = 6.5; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2053,7 +2053,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 296; + CURRENT_PROJECT_VERSION = 312; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2078,7 +2078,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.4.4; + MARKETING_VERSION = 6.5; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2095,11 +2095,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 296; + CURRENT_PROJECT_VERSION = 312; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.4.4; + MARKETING_VERSION = 6.5; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2115,11 +2115,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 296; + CURRENT_PROJECT_VERSION = 312; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.4.4; + MARKETING_VERSION = 6.5; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2140,7 +2140,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 296; + CURRENT_PROJECT_VERSION = 312; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2155,7 +2155,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.4.4; + MARKETING_VERSION = 6.5; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2177,7 +2177,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 296; + CURRENT_PROJECT_VERSION = 312; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2192,7 +2192,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.4.4; + MARKETING_VERSION = 6.5; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2214,7 +2214,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 296; + CURRENT_PROJECT_VERSION = 312; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2240,7 +2240,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.4.4; + MARKETING_VERSION = 6.5; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2265,7 +2265,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 296; + CURRENT_PROJECT_VERSION = 312; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2291,7 +2291,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.4.4; + MARKETING_VERSION = 6.5; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2316,7 +2316,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 296; + CURRENT_PROJECT_VERSION = 312; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2331,7 +2331,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.4.4; + MARKETING_VERSION = 6.5; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2350,7 +2350,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 296; + CURRENT_PROJECT_VERSION = 312; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2365,7 +2365,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.4.4; + MARKETING_VERSION = 6.5; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 5335a04ae3..fce0f100f2 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -545,6 +545,7 @@ public struct ConnectionStats: Decodable, Hashable { public var sndQueuesInfo: [SndQueueInfo] public var ratchetSyncState: RatchetSyncState public var ratchetSyncSupported: Bool + public var subStatus: SubscriptionStatus? public var ratchetSyncAllowed: Bool { ratchetSyncSupported && [.allowed, .required].contains(ratchetSyncState) @@ -559,25 +560,28 @@ public struct ConnectionStats: Decodable, Hashable { } } -public struct RcvQueueInfo: Codable, Hashable { +public struct RcvQueueInfo: Decodable, Hashable { public var rcvServer: String + public var status: QueueStatus public var rcvSwitchStatus: RcvSwitchStatus? public var canAbortSwitch: Bool + public var subStatus: SubscriptionStatus } -public enum RcvSwitchStatus: String, Codable, Hashable { +public enum RcvSwitchStatus: String, Decodable, Hashable { case switchStarted = "switch_started" case sendingQADD = "sending_qadd" case sendingQUSE = "sending_quse" case receivedMessage = "received_message" } -public struct SndQueueInfo: Codable, Hashable { +public struct SndQueueInfo: Decodable, Hashable { public var sndServer: String + public var status: QueueStatus public var sndSwitchStatus: SndSwitchStatus? } -public enum SndSwitchStatus: String, Codable, Hashable { +public enum SndSwitchStatus: String, Decodable, Hashable { case sendingQKEY = "sending_qkey" case sendingQTEST = "sending_qtest" } @@ -606,6 +610,48 @@ public enum RatchetSyncState: String, Decodable { case agreed } +public enum QueueStatus: String, Decodable, Hashable { + case new + case confirmed + case secured + case active + case disabled +} + +public enum SubscriptionStatus: Decodable, Hashable { + case active + case pending + case removed(subError: String) + case noSub + + public var statusString: LocalizedStringKey { + switch self { + case .active: "connected" + case .pending: "connecting" + case .removed: "error" + case .noSub: "no subscription" + } + } + + public var statusExplanation: String { + switch self { + case .active: NSLocalizedString("You are connected to the server used to receive messages from this connection.", comment: "subscription status explanation") + case .pending: NSLocalizedString("Trying to connect to the server used to receive messages from this connection.", comment: "subscription status explanation") + case let .removed(err): String.localizedStringWithFormat(NSLocalizedString("Error connecting to the server used to receive messages from this connection: %@", comment: "subscription status explanation"), err) + case .noSub: NSLocalizedString("You are not connected to the server used to receive messages from this connection (no subscription).", comment: "subscription status explanation") + } + } + + public var imageName: String { + switch self { + case .active: "circle.fill" + case .pending: "ellipsis.circle.fill" + case .removed: "exclamationmark.circle.fill" + case .noSub: "circle.dotted" + } + } +} + public protocol SelectableItem: Identifiable, Equatable { var label: LocalizedStringKey { get } static var values: [Self] { get } @@ -714,7 +760,6 @@ public enum ChatErrorType: Decodable, Hashable { case fileCancelled(message: String) case fileCancel(fileId: Int64, message: String) case fileAlreadyExists(filePath: String) - case fileRead(filePath: String, message: String) case fileWrite(filePath: String, message: String) case fileSend(fileId: Int64, agentError: String) case fileRcvChunk(message: String) @@ -834,6 +879,7 @@ public enum AgentErrorType: Decodable, Hashable { case RCP(rcpErr: RCErrorType) case BROKER(brokerAddress: String, brokerErr: BrokerErrorType) case AGENT(agentErr: SMPAgentError) + case NOTICE(server: String, preset: Bool, expiresAt: Date?) case INTERNAL(internalErr: String) case CRITICAL(offerRestart: Bool, criticalErr: String) case INACTIVE @@ -858,7 +904,7 @@ public enum ConnectionErrorType: Decodable, Hashable { public enum BrokerErrorType: Decodable, Hashable { case RESPONSE(smpErr: String) case UNEXPECTED - case NETWORK + case NETWORK(networkError: NetworkError) case HOST case TRANSPORT(transportErr: ProtocolTransportError) case TIMEOUT @@ -954,6 +1000,15 @@ public enum ProtocolCommandError: Decodable, Hashable { case NO_ENTITY } +public enum NetworkError: Decodable, Hashable { + case connectError(connectError: String) + case tLSError(tlsError: String) + case unknownCAError + case failedError + case timeoutError + case subscribeError(subscribeError: String) +} + public enum ProtocolTransportError: Decodable, Hashable { case badBlock case version diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index aed5373664..5d1d5b4302 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1584,6 +1584,8 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { return nil case .some(.memberSupport(groupMember_: .none)): return nil + case .some(.reports): + return ("can't send messages", nil) } } else if groupInfo.nextConnectPrepared { return nil @@ -1895,26 +1897,35 @@ public struct ChatStats: Decodable, Hashable { public enum GroupChatScope: Decodable { case memberSupport(groupMemberId_: Int64?) + case reports // surrogate scope used for matching new items to opened Reports "chat scope" in UI, this type is not present in backend } public func sameChatScope(_ scope1: GroupChatScope, _ scope2: GroupChatScope) -> Bool { - switch (scope1, scope2) { + return switch (scope1, scope2) { case let (.memberSupport(groupMemberId1_), .memberSupport(groupMemberId2_)): - return groupMemberId1_ == groupMemberId2_ + groupMemberId1_ == groupMemberId2_ + case (.reports, .reports): + true + case (.reports, .memberSupport): + false + case (.memberSupport(groupMemberId_: let groupMemberId_), .reports): + false } } public enum GroupChatScopeInfo: Decodable, Hashable { case memberSupport(groupMember_: GroupMember?) + case reports // surrogate scope used for matching new items to opened Reports "chat scope" in UI, this type is not present in backend public func toChatScope() -> GroupChatScope { - switch self { + return switch self { case let .memberSupport(groupMember_): if let groupMember = groupMember_ { - return .memberSupport(groupMemberId_: groupMember.groupMemberId) + .memberSupport(groupMemberId_: groupMember.groupMemberId) } else { - return .memberSupport(groupMemberId_: nil) + .memberSupport(groupMemberId_: nil) } + case .reports: .reports } } } @@ -2075,11 +2086,6 @@ public struct ContactRef: Decodable, Equatable, Hashable { public var id: ChatId { get { "@\(contactId)" } } } -public struct ContactSubStatus: Decodable, Hashable { - public var contact: Contact - public var contactError: ChatError? -} - public struct Connection: Decodable, Hashable { public var connId: Int64 public var agentConnId: String @@ -2629,26 +2635,35 @@ 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) { 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 } public var canReceiveReports: Bool { memberRole >= .moderator && versionRange.maxVersion >= REPORTS_VERSION } + public var supportChatNotRead: Bool { + if let supportChat = supportChat, + supportChat.memberAttention > 0 || supportChat.mentions > 0 || supportChat.unread > 0 { + true + } else { + false + } + } + public var versionRange: VersionRange { if let activeConn { activeConn.peerChatVRange @@ -2845,16 +2860,9 @@ public enum InvitedBy: Decodable, Hashable { case unknown } -public struct MemberSubError: Decodable, Hashable { - var member: GroupMemberIds - var memberError: ChatError -} - public enum ConnectionEntity: Decodable, Hashable { case rcvDirectMsgConnection(entityConnection: Connection, contact: Contact?) case rcvGroupMsgConnection(entityConnection: Connection, groupInfo: GroupInfo, groupMember: GroupMember) - case sndFileConnection(entityConnection: Connection, sndFileTransfer: SndFileTransfer) - case rcvFileConnection(entityConnection: Connection, rcvFileTransfer: RcvFileTransfer) case userContactConnection(entityConnection: Connection, userContact: UserContact) public var id: String? { @@ -2865,8 +2873,6 @@ public enum ConnectionEntity: Decodable, Hashable { groupMember.id case let .userContactConnection(_, userContact): userContact.id - default: - nil } } @@ -2887,8 +2893,6 @@ public enum ConnectionEntity: Decodable, Hashable { switch self { case let .rcvDirectMsgConnection(entityConnection, _): entityConnection case let .rcvGroupMsgConnection(entityConnection, _, _): entityConnection - case let .sndFileConnection(entityConnection, _): entityConnection - case let .rcvFileConnection(entityConnection, _): entityConnection case let .userContactConnection(entityConnection, _): entityConnection } } @@ -3066,33 +3070,33 @@ public struct ChatItem: Identifiable, Decodable, Hashable { } public var mergeCategory: CIMergeCategory? { - switch content { - case .rcvChatFeature: .chatFeature - case .sndChatFeature: .chatFeature - case .rcvGroupFeature: .chatFeature - case .sndGroupFeature: .chatFeature - case let.rcvGroupEvent(event): - switch event { - case .userRole: nil - case .userDeleted: nil - case .groupDeleted: nil - case .memberCreatedContact: nil - case .newMemberPendingReview: nil - default: .rcvGroupEvent - } - case let .sndGroupEvent(event): - switch event { - case .userRole: nil - case .userLeft: nil - case .memberAccepted: nil - case .userPendingReview: nil - default: .sndGroupEvent - } - default: - if meta.itemDeleted == nil { + if meta.itemDeleted != nil { + chatDir.sent ? .sndItemDeleted : .rcvItemDeleted + } else { + switch content { + case .rcvChatFeature: .chatFeature + case .sndChatFeature: .chatFeature + case .rcvGroupFeature: .chatFeature + case .sndGroupFeature: .chatFeature + case let.rcvGroupEvent(event): + switch event { + case .userRole: nil + case .userDeleted: nil + case .groupDeleted: nil + case .memberCreatedContact: nil + case .newMemberPendingReview: nil + default: .rcvGroupEvent + } + case let .sndGroupEvent(event): + switch event { + case .userRole: nil + case .userLeft: nil + case .memberAccepted: nil + case .userPendingReview: nil + default: .sndGroupEvent + } + default: nil - } else { - chatDir.sent ? .sndItemDeleted : .rcvItemDeleted } } } diff --git a/apps/ios/SimpleXChat/ErrorAlert.swift b/apps/ios/SimpleXChat/ErrorAlert.swift index 2f2dd31a83..2920c2383c 100644 --- a/apps/ios/SimpleXChat/ErrorAlert.swift +++ b/apps/ios/SimpleXChat/ErrorAlert.swift @@ -87,6 +87,8 @@ public func getNetworkErrorAlert(_ e: ChatError) -> ErrorAlert? { switch e { case let .errorAgent(.BROKER(addr, .TIMEOUT)): ErrorAlert(title: "Connection timeout", message: "Please check your network connection with \(serverHostname(addr)) and try again.") + case let .errorAgent(.BROKER(addr, .NETWORK(.unknownCAError))): + ErrorAlert(title: "Connection error", message: "Fingerprint in server address does not match certificate: \(serverHostname(addr)).") case let .errorAgent(.BROKER(addr, .NETWORK)): ErrorAlert(title: "Connection error", message: "Please check your network connection with \(serverHostname(addr)) and try again.") case let .errorAgent(.BROKER(addr, .HOST)): @@ -105,6 +107,8 @@ private func smpProxyErrorAlert(_ proxyErr: ProxyError, _ srvAddr: String) -> Er switch proxyErr { case .BROKER(brokerErr: .TIMEOUT): return ErrorAlert(title: "Private routing error", message: "Error connecting to forwarding server \(serverHostname(srvAddr)). Please try later.") + case .BROKER(brokerErr: .NETWORK(.unknownCAError)): + return ErrorAlert(title: "Private routing error", message: "Fingerprint in forwarding server address does not match certificate: \(serverHostname(srvAddr)).") case .BROKER(brokerErr: .NETWORK): return ErrorAlert(title: "Private routing error", message: "Error connecting to forwarding server \(serverHostname(srvAddr)). Please try later.") case .BROKER(brokerErr: .HOST): @@ -120,6 +124,8 @@ private func proxyDestinationErrorAlert(_ proxyErr: ProxyError, _ proxyServer: S switch proxyErr { case .BROKER(brokerErr: .TIMEOUT): return ErrorAlert(title: "Private routing error", message: "Forwarding server \(serverHostname(proxyServer)) failed to connect to destination server \(serverHostname(relayServer)). Please try later.") + case .BROKER(brokerErr: .NETWORK(.unknownCAError)): + return ErrorAlert(title: "Private routing error", message: "Fingerprint in destination server address does not match certificate: \(serverHostname(relayServer)).") case .BROKER(brokerErr: .NETWORK): return ErrorAlert(title: "Private routing error", message: "Forwarding server \(serverHostname(proxyServer)) failed to connect to destination server \(serverHostname(relayServer)). Please try later.") case .NO_SESSION: diff --git a/apps/ios/SimpleXChat/Notifications.swift b/apps/ios/SimpleXChat/Notifications.swift index 70db4476d5..31b7ef83ff 100644 --- a/apps/ios/SimpleXChat/Notifications.swift +++ b/apps/ios/SimpleXChat/Notifications.swift @@ -111,10 +111,6 @@ public func createConnectionEventNtf(_ user: User, _ connEntity: ConnectionEntit title = groupMsgNtfTitle(groupInfo, groupMember, hideContent: hideContent) body = NSLocalizedString("message received", comment: "notification") targetContentIdentifier = groupInfo.id - case .sndFileConnection: - title = NSLocalizedString("Sent file event", comment: "notification") - case .rcvFileConnection: - title = NSLocalizedString("Received file event", comment: "notification") case .userContactConnection: title = NSLocalizedString("New contact request", comment: "notification") } diff --git a/apps/ios/bg.lproj/Localizable.strings b/apps/ios/bg.lproj/Localizable.strings index 3cc59b898f..038546f889 100644 --- a/apps/ios/bg.lproj/Localizable.strings +++ b/apps/ios/bg.lproj/Localizable.strings @@ -61,6 +61,9 @@ /* No comment provided by engineer. */ "**Recommended**: device token and end-to-end encrypted notifications are sent to SimpleX Chat push server, but it does not see the message content, size or who it is from." = "**Препоръчително**: токенът на устройството и известията се изпращат до сървъра за уведомяване на SimpleX Chat, но не и съдържанието, размерът на съобщението или от кого е."; +/* No comment provided by engineer. */ +"**Scan / Paste link**: to connect via a link you received." = "**Сканирай / Постави линк**: за свързване чрез получения линк."; + /* No comment provided by engineer. */ "**Warning**: Instant push notifications require passphrase saved in Keychain." = "**Внимание**: Незабавните push известия изискват парола, запазена в Keychain."; @@ -289,6 +292,9 @@ time interval */ /* No comment provided by engineer. */ "1-time link" = "Еднократен линк"; +/* No comment provided by engineer. */ +"1-time link can be used *with one contact only* - share in person or via any messenger." = "Еднократният линк може да се използва само веднъж *с един контакт* - споделете го лично или чрез някой месинджър."; + /* No comment provided by engineer. */ "5 minutes" = "5 минути"; @@ -362,6 +368,9 @@ swipe action */ swipe action */ "Accept incognito" = "Приеми инкогнито"; +/* alert title */ +"Accept member" = "Приеми член"; + /* call status */ "accepted call" = "обаждането прието"; @@ -374,6 +383,9 @@ swipe action */ /* No comment provided by engineer. */ "Acknowledgement errors" = "Грешки при потвърждението"; +/* token status text */ +"Active" = "Активен"; + /* No comment provided by engineer. */ "Active connections" = "Активни връзки"; @@ -383,6 +395,12 @@ swipe action */ /* No comment provided by engineer. */ "Add friends" = "Добави приятели"; +/* No comment provided by engineer. */ +"Add list" = "Добави списък"; + +/* placeholder for sending contact request */ +"Add message" = "Добави съобщение"; + /* No comment provided by engineer. */ "Add profile" = "Добави профил"; @@ -398,6 +416,9 @@ swipe action */ /* No comment provided by engineer. */ "Add to another device" = "Добави към друго устройство"; +/* No comment provided by engineer. */ +"Add to list" = "Добави към списъка"; + /* No comment provided by engineer. */ "Add welcome message" = "Добави съобщение при посрещане"; @@ -455,12 +476,18 @@ swipe action */ /* chat item text */ "agreeing encryption…" = "съгласуване на криптиране…"; +/* No comment provided by engineer. */ +"All" = "Всички"; + /* No comment provided by engineer. */ "All app data is deleted." = "Всички данни от приложението бяха изтрити."; /* No comment provided by engineer. */ "All chats and messages will be deleted - this cannot be undone!" = "Всички чатове и съобщения ще бъдат изтрити - това не може да бъде отменено!"; +/* alert message */ +"All chats will be removed from the list %@, and the list deleted." = "Всички чатове ще бъдат премахнати от списъка %@, а списъкът ще бъде изтрит."; + /* No comment provided by engineer. */ "All data is erased when it is entered." = "Всички данни се изтриват при въвеждане."; @@ -473,6 +500,9 @@ swipe action */ /* feature role */ "all members" = "всички членове"; +/* No comment provided by engineer. */ +"All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages." = "Всички съобщения и файлове се изпращат с **криптиране от край до край**, с постквантова сигурност в директните съобщения."; + /* No comment provided by engineer. */ "All messages will be deleted - this cannot be undone!" = "Всички съобщения ще бъдат изтрити - това не може да бъде отменено!"; @@ -485,6 +515,12 @@ swipe action */ /* profile dropdown */ "All profiles" = "Всички профили"; +/* No comment provided by engineer. */ +"All reports will be archived for you." = "Всички доклади за нарушения ще бъдат архивирани за вас."; + +/* No comment provided by engineer. */ +"All servers" = "Всички сървъри"; + /* No comment provided by engineer. */ "All your contacts will remain connected." = "Всички ваши контакти ще останат свързани."; @@ -509,6 +545,9 @@ swipe action */ /* No comment provided by engineer. */ "Allow downgrade" = "Позволи понижаване"; +/* No comment provided by engineer. */ +"Allow files and media only if your contact allows them." = "Разреши файлове и медия само ако вашият контакт ги разрешава."; + /* No comment provided by engineer. */ "Allow irreversible message deletion only if your contact allows it to you. (24 hours)" = "Позволи необратимо изтриване на съобщение само ако вашият контакт го рарешава. (24 часа)"; @@ -530,6 +569,9 @@ swipe action */ /* No comment provided by engineer. */ "Allow to irreversibly delete sent messages. (24 hours)" = "Позволи необратимо изтриване на изпратените съобщения. (24 часа)"; +/* No comment provided by engineer. */ +"Allow to report messsages to moderators." = "Позволи докладването на съобщения на модераторите."; + /* No comment provided by engineer. */ "Allow to send files and media." = "Позволи изпращане на файлове и медия."; @@ -557,6 +599,9 @@ swipe action */ /* No comment provided by engineer. */ "Allow your contacts to send disappearing messages." = "Позволи на вашите контакти да изпращат изчезващи съобщения."; +/* No comment provided by engineer. */ +"Allow your contacts to send files and media." = "Позволи на вашите контактите да изпращат файлове и медия."; + /* No comment provided by engineer. */ "Allow your contacts to send voice messages." = "Позволи на вашите контакти да изпращат гласови съобщения."; @@ -584,6 +629,9 @@ swipe action */ /* No comment provided by engineer. */ "and %lld other events" = "и %lld други събития"; +/* report reason */ +"Another reason" = "Друга причина"; + /* No comment provided by engineer. */ "Answer call" = "Отговор на повикване"; @@ -599,6 +647,9 @@ swipe action */ /* No comment provided by engineer. */ "App encrypts new local files (except videos)." = "Приложението криптира нови локални файлове (с изключение на видеоклипове)."; +/* No comment provided by engineer. */ +"App group:" = "Група приложения:"; + /* No comment provided by engineer. */ "App icon" = "Икона на приложението"; @@ -626,12 +677,30 @@ swipe action */ /* No comment provided by engineer. */ "Apply to" = "Приложи към"; +/* No comment provided by engineer. */ +"Archive" = "Архивирай"; + +/* No comment provided by engineer. */ +"Archive %lld reports?" = "Архивирай %lld доклад(а)?"; + +/* No comment provided by engineer. */ +"Archive all reports?" = "Архивиране на всички доклади за нарушения?"; + /* No comment provided by engineer. */ "Archive and upload" = "Архивиране и качване"; /* No comment provided by engineer. */ "Archive contacts to chat later." = "Архивирайте контактите, за да разговаряте по-късно."; +/* No comment provided by engineer. */ +"Archive report" = "Архивирай доклад за нарушения"; + +/* No comment provided by engineer. */ +"Archive report?" = "Архивирай доклад за нарушения?"; + +/* swipe action */ +"Archive reports" = "Архивирай докладите за нарушения"; + /* No comment provided by engineer. */ "Archived contacts" = "Архивирани контакти"; @@ -707,6 +776,9 @@ swipe action */ /* No comment provided by engineer. */ "Better groups" = "По-добри групи"; +/* No comment provided by engineer. */ +"Better groups performance" = "По-добра производителност на групите"; + /* No comment provided by engineer. */ "Better message dates." = "По-добри дати на съобщението."; @@ -719,12 +791,21 @@ swipe action */ /* No comment provided by engineer. */ "Better notifications" = "Подобрени известия"; +/* No comment provided by engineer. */ +"Better privacy and security" = "По-добра поверителност и сигурност"; + /* No comment provided by engineer. */ "Better security ✅" = "По-добра сигурност ✅"; /* No comment provided by engineer. */ "Better user experience" = "Подобрен интерфейс"; +/* No comment provided by engineer. */ +"Bio" = "Био"; + +/* alert title */ +"Bio too large" = "Биографията е твърде дълга"; + /* No comment provided by engineer. */ "Black" = "Черна"; @@ -768,6 +849,9 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "bold" = "удебелен"; +/* No comment provided by engineer. */ +"Bot" = "Бот"; + /* No comment provided by engineer. */ "Both you and your contact can add message reactions." = "И вие, и вашият контакт можете да добавяте реакции към съобщението."; @@ -780,6 +864,9 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "Both you and your contact can send disappearing messages." = "И вие, и вашият контакт можете да изпращате изчезващи съобщения."; +/* No comment provided by engineer. */ +"Both you and your contact can send files and media." = "И вие, и вашият контакт можете да изпращате файлове и медия."; + /* No comment provided by engineer. */ "Both you and your contact can send voice messages." = "И вие, и вашият контакт можете да изпращате гласови съобщения."; @@ -792,9 +879,18 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "Business chats" = "Бизнес чатове"; +/* No comment provided by engineer. */ +"Business connection" = "Бизнес връзка"; + +/* No comment provided by engineer. */ +"Businesses" = "Бизнеси"; + /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Чрез чат профил (по подразбиране) или [чрез връзка](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (БЕТА)."; +/* No comment provided by engineer. */ +"By using SimpleX Chat you agree to:\n- send only legal content in public groups.\n- respect other users – no spam." = "С използването на SimpleX Chat вие се съгласявате със:\n- изпращане само на легално съдържание в публични групи.\n- уважение към другите потребители – без спам."; + /* No comment provided by engineer. */ "Call already ended!" = "Разговорът вече приключи!"; @@ -822,12 +918,18 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "Can't call member" = "Обаждането на члена не е позволено"; +/* alert title */ +"Can't change profile" = "Промяната на профила е невъзможна"; + /* No comment provided by engineer. */ "Can't invite contact!" = "Не може да покани контакта!"; /* No comment provided by engineer. */ "Can't invite contacts!" = "Не може да поканят контактите!"; +/* No comment provided by engineer. */ +"Can't message member" = "Изпращането на съобщения на груповия член не е налично"; + /* alert action alert button new chat action */ @@ -842,6 +944,9 @@ new chat action */ /* No comment provided by engineer. */ "Cannot access keychain to save database password" = "Няма достъп до Keychain за запазване на паролата за базата данни"; +/* No comment provided by engineer. */ +"Cannot forward message" = "Съобщение не може да бъде препратено"; + /* alert title */ "Cannot receive file" = "Файлът не може да бъде получен"; @@ -854,6 +959,9 @@ new chat action */ /* No comment provided by engineer. */ "Change" = "Промени"; +/* alert title */ +"Change automatic message deletion?" = "Промяна на автоматичното изтриване на съобщения?"; + /* authentication reason */ "Change chat profiles" = "Промени чат профилите"; @@ -900,6 +1008,18 @@ set passcode view */ /* chat item text */ "changing address…" = "промяна на адреса…"; +/* No comment provided by engineer. */ +"Chat" = "Чат"; + +/* No comment provided by engineer. */ +"Chat already exists" = "Чатът вече съществува"; + +/* new chat sheet title */ +"Chat already exists!" = "Чатът вече съществува!"; + +/* No comment provided by engineer. */ +"Chat colors" = "Цветове на чата"; + /* No comment provided by engineer. */ "Chat console" = "Конзола"; @@ -909,6 +1029,9 @@ set passcode view */ /* No comment provided by engineer. */ "Chat database deleted" = "Базата данни на чата е изтрита"; +/* No comment provided by engineer. */ +"Chat database exported" = "Базата данни е експортирана"; + /* No comment provided by engineer. */ "Chat database imported" = "Базата данни на е импортирана"; @@ -921,18 +1044,51 @@ set passcode view */ /* No comment provided by engineer. */ "Chat is stopped. If you already used this database on another device, you should transfer it back before starting chat." = "Чатът е спрян. Ако вече сте използвали тази база данни на друго устройство, трябва да я прехвърлите обратно, преди да стартирате чата отново."; +/* No comment provided by engineer. */ +"Chat list" = "Списък с чатове"; + /* No comment provided by engineer. */ "Chat migrated!" = "Чатът е мигриран!"; /* No comment provided by engineer. */ "Chat preferences" = "Чат настройки"; +/* alert message */ +"Chat preferences were changed." = "Настройките на чата бяха променени."; + /* No comment provided by engineer. */ "Chat profile" = "Потребителски профил"; +/* No comment provided by engineer. */ +"Chat theme" = "Тема на чата"; + +/* No comment provided by engineer. */ +"Chat will be deleted for all members - this cannot be undone!" = "Чатът ще бъде изтрит за всички членове - това не може да бъде отменено!"; + +/* No comment provided by engineer. */ +"Chat will be deleted for you - this cannot be undone!" = "Чатът ще бъде изтрит за вас - това не може да бъде отменено!"; + +/* chat toolbar */ +"Chat with admins" = "Чат с администраторите"; + +/* No comment provided by engineer. */ +"Chat with member" = "Чат с член"; + +/* No comment provided by engineer. */ +"Chat with members before they join." = "Разговаряйте с членовете, преди да се присъединят."; + /* No comment provided by engineer. */ "Chats" = "Чатове"; +/* No comment provided by engineer. */ +"Chats with members" = "Чатове с членовете"; + +/* No comment provided by engineer. */ +"Check messages every 20 min." = "Проверявай за съобщенията на всеки 20 минути."; + +/* No comment provided by engineer. */ +"Check messages when allowed." = "Проверявай за съобщенията, когато е разрешено."; + /* alert title */ "Check server address and try again." = "Проверете адреса на сървъра и опитайте отново."; @@ -948,6 +1104,15 @@ set passcode view */ /* No comment provided by engineer. */ "Choose from library" = "Избери от библиотеката"; +/* No comment provided by engineer. */ +"Chunks deleted" = "Изтрити парчета"; + +/* No comment provided by engineer. */ +"Chunks downloaded" = "Изтеглени парчета"; + +/* No comment provided by engineer. */ +"Chunks uploaded" = "Качени парчета"; + /* swipe action */ "Clear" = "Изчисти"; @@ -957,15 +1122,30 @@ set passcode view */ /* No comment provided by engineer. */ "Clear conversation?" = "Изчисти разговора?"; +/* No comment provided by engineer. */ +"Clear group?" = "Изчисти група?"; + +/* No comment provided by engineer. */ +"Clear or delete group?" = "Изчисти или изтрий група?"; + /* No comment provided by engineer. */ "Clear private notes?" = "Изчистване на лични бележки?"; /* No comment provided by engineer. */ "Clear verification" = "Изчисти проверката"; +/* No comment provided by engineer. */ +"Color chats with the new themes." = "Цветни чатове с нови теми."; + +/* No comment provided by engineer. */ +"Color mode" = "Цветен режим"; + /* No comment provided by engineer. */ "colored" = "цветен"; +/* report reason */ +"Community guidelines violation" = "Нарушение на правилата на общността"; + /* server test step */ "Compare file" = "Сравни файл"; @@ -975,15 +1155,48 @@ set passcode view */ /* No comment provided by engineer. */ "complete" = "завършен"; +/* No comment provided by engineer. */ +"Completed" = "Завършен"; + +/* No comment provided by engineer. */ +"Conditions accepted on: %@." = "Условия, приети на: %@."; + +/* No comment provided by engineer. */ +"Conditions are accepted for the operator(s): **%@**." = "Условията са приети за оператора(ите): **%@**."; + +/* No comment provided by engineer. */ +"Conditions are already accepted for these operator(s): **%@**." = "Условията вече са приети за тези оператори: **%@**."; + +/* alert button */ +"Conditions of use" = "Условия за ползване"; + +/* No comment provided by engineer. */ +"Conditions will be accepted for the operator(s): **%@**." = "Условията ще бъдат приети за операторите: **%@**."; + +/* No comment provided by engineer. */ +"Conditions will be accepted on: %@." = "Условията ще бъдат приети на: %@."; + +/* No comment provided by engineer. */ +"Conditions will be automatically accepted for enabled operators on: %@." = "Условията ще бъдат автоматично приети за активираните оператори на: %@."; + /* No comment provided by engineer. */ "Configure ICE servers" = "Конфигурирай ICE сървъри"; +/* No comment provided by engineer. */ +"Configure server operators" = "Конфигуриране на сървърни оператори"; + /* No comment provided by engineer. */ "Confirm" = "Потвърди"; +/* No comment provided by engineer. */ +"Confirm contact deletion?" = "Потвърди изтриването на контакта?"; + /* No comment provided by engineer. */ "Confirm database upgrades" = "Потвърди актуализаациите на базата данни"; +/* No comment provided by engineer. */ +"Confirm files from unknown servers." = "Потвърди файлове от неизвестни сървъри."; + /* No comment provided by engineer. */ "Confirm network settings" = "Потвърди мрежовите настройки"; @@ -1002,18 +1215,27 @@ set passcode view */ /* No comment provided by engineer. */ "Confirm upload" = "Потвърди качването"; +/* token status text */ +"Confirmed" = "Потвърдено"; + /* server test step */ "Connect" = "Свързване"; /* No comment provided by engineer. */ "Connect automatically" = "Автоматично свъзрване"; +/* No comment provided by engineer. */ +"Connect faster! 🚀" = "Свържете се по-бързо! 🚀"; + /* No comment provided by engineer. */ "Connect to desktop" = "Свързване с настолно устройство"; /* No comment provided by engineer. */ "connect to SimpleX Chat developers." = "свържете се с разработчиците на SimpleX Chat."; +/* No comment provided by engineer. */ +"Connect to your friends faster." = "Свържете се с приятелите си по-бързо."; + /* new chat sheet title */ "Connect to yourself?\nThis is your own one-time link!" = "Свърване със себе си?\nТова е вашят еднократен линк за връзка!"; @@ -1035,15 +1257,24 @@ set passcode view */ /* No comment provided by engineer. */ "connected" = "свързан"; +/* No comment provided by engineer. */ +"Connected" = "Свързан"; + /* No comment provided by engineer. */ "Connected desktop" = "Свързано настолно устройство"; +/* No comment provided by engineer. */ +"Connected servers" = "Свързани сървъри"; + /* No comment provided by engineer. */ "Connected to desktop" = "Свързан с настолно устройство"; /* No comment provided by engineer. */ "connecting" = "свързване"; +/* No comment provided by engineer. */ +"Connecting" = "Свързване"; + /* No comment provided by engineer. */ "connecting (accepted)" = "свързване (прието)"; @@ -1065,6 +1296,9 @@ set passcode view */ /* No comment provided by engineer. */ "Connecting server… (error: %@)" = "Свързване със сървър…(грешка: %@)"; +/* No comment provided by engineer. */ +"Connecting to contact, please wait or check later!" = "Тече свързване с контакт, моля изчакайте или проверете по-късно!"; + /* No comment provided by engineer. */ "Connecting to desktop" = "Свързване с настолно устройство"; @@ -1074,6 +1308,12 @@ set passcode view */ /* No comment provided by engineer. */ "Connection" = "Връзка"; +/* No comment provided by engineer. */ +"Connection and servers status." = "Състояние на връзката и сървърите."; + +/* No comment provided by engineer. */ +"Connection blocked" = "Връзката е блокирана"; + /* alert title */ "Connection error" = "Грешка при свързване"; @@ -1083,9 +1323,21 @@ set passcode view */ /* chat list item title (it should not be shown */ "connection established" = "установена е връзка"; +/* No comment provided by engineer. */ +"Connection is blocked by server operator:\n%@" = "Връзката е блокирана от оператора на сървъра:\n%@"; + +/* No comment provided by engineer. */ +"Connection not ready." = "Връзката не е готова."; + +/* No comment provided by engineer. */ +"Connection notifications" = "Известия за връзката"; + /* No comment provided by engineer. */ "Connection request sent!" = "Заявката за връзка е изпратена!"; +/* No comment provided by engineer. */ +"Connection requires encryption renegotiation." = "Връзката изисква предоговаряне на криптирането."; + /* No comment provided by engineer. */ "Connection terminated" = "Връзката е прекратена"; @@ -1958,6 +2210,9 @@ snd error text */ /* No comment provided by engineer. */ "Find chats faster" = "Намирайте чатове по-бързо"; +/* server test error */ +"Fingerprint in server address does not match certificate." = "Въжможно е пръстовият отпечатък на сертификата в адреса на сървъра да е неправилен"; + /* No comment provided by engineer. */ "Fix" = "Поправи"; @@ -2657,7 +2912,7 @@ snd error text */ /* No comment provided by engineer. */ "Network settings" = "Мрежови настройки"; -/* No comment provided by engineer. */ +/* alert title */ "Network status" = "Състояние на мрежата"; /* delete after time */ @@ -2905,9 +3160,6 @@ new chat action */ /* No comment provided by engineer. */ "Password to show" = "Парола за показване"; -/* past/unknown group member */ -"Past member %@" = "Бивш член %@"; - /* No comment provided by engineer. */ "Paste desktop address" = "Постави адрес на настолно устройство"; @@ -2983,9 +3235,6 @@ new chat action */ /* No comment provided by engineer. */ "Polish interface" = "Полски интерфейс"; -/* server test error */ -"Possibly, certificate fingerprint in server address is incorrect" = "Въжможно е пръстовият отпечатък на сертификата в адреса на сървъра да е неправилен"; - /* No comment provided by engineer. */ "Preserve the last message draft, with attachments." = "Запазете последната чернова на съобщението с прикачени файлове."; @@ -3112,9 +3361,6 @@ new chat action */ /* No comment provided by engineer. */ "received confirmation…" = "получено потвърждение…"; -/* notification */ -"Received file event" = "Събитие за получен файл"; - /* message info title */ "Received message" = "Получено съобщение"; @@ -3463,9 +3709,6 @@ chat item action */ /* copied message info */ "Sent at: %@" = "Изпратено на: %@"; -/* notification */ -"Sent file event" = "Събитие за изпратен файл"; - /* message info title */ "Sent message" = "Изпратено съобщение"; @@ -3473,10 +3716,10 @@ chat item action */ "Sent messages will be deleted after set time." = "Изпратените съобщения ще бъдат изтрити след зададеното време."; /* server test error */ -"Server requires authorization to create queues, check password" = "Сървърът изисква оторизация за създаване на опашки, проверете паролата"; +"Server requires authorization to create queues, check password." = "Сървърът изисква оторизация за създаване на опашки, проверете паролата"; /* server test error */ -"Server requires authorization to upload, check password" = "Сървърът изисква оторизация за качване, проверете паролата"; +"Server requires authorization to upload, check password." = "Сървърът изисква оторизация за качване, проверете паролата"; /* No comment provided by engineer. */ "Server test failed!" = "Тестът на сървъра е неуспешен!"; @@ -3881,12 +4124,6 @@ chat item action */ /* No comment provided by engineer. */ "Transport isolation" = "Транспортна изолация"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Опит за свързване със сървъра, използван за получаване на съобщения от този контакт (грешка: %@)."; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "Опит за свързване със сървъра, използван за получаване на съобщения от този контакт."; - /* No comment provided by engineer. */ "Turkish interface" = "Турски интерфейс"; @@ -4244,9 +4481,6 @@ chat item action */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "Вече се присъединихте към групата!\nИзпрати отново заявката за присъединяване?"; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "Вие сте свързани към сървъра, използван за получаване на съобщения от този контакт."; - /* No comment provided by engineer. */ "You are invited to group" = "Поканени сте в групата"; diff --git a/apps/ios/cs.lproj/Localizable.strings b/apps/ios/cs.lproj/Localizable.strings index 47bfd2aa12..dd486001c7 100644 --- a/apps/ios/cs.lproj/Localizable.strings +++ b/apps/ios/cs.lproj/Localizable.strings @@ -10,6 +10,9 @@ /* No comment provided by engineer. */ "- more stable message delivery.\n- a bit better groups.\n- and more!" = "- více stabilní doručování zpráv.\n- o trochu lepší skupiny.\n- a více!"; +/* No comment provided by engineer. */ +"- optionally notify deleted contacts.\n- profile names with spaces.\n- and more!" = "- volitelně informuje smazané kontakty.\n- profilová jména s mezerami.\n- a více!"; + /* No comment provided by engineer. */ "- voice messages up to 5 minutes.\n- custom time to disappear.\n- editing history." = "- 5 minutové hlasové zprávy.\n- vlastní čas mizení.\n- historie úprav."; @@ -253,6 +256,9 @@ /* No comment provided by engineer. */ "~strike~" = "\\~stávka~"; +/* time to disappear */ +"0 sec" = "0 sek"; + /* No comment provided by engineer. */ "0s" = "0s"; @@ -274,6 +280,12 @@ time interval */ time interval */ "1 week" = "1 týden"; +/* delete after time */ +"1 year" = "1 rok"; + +/* No comment provided by engineer. */ +"1-time link can be used *with one contact only* - share in person or via any messenger." = "Jednorázový odkaz lze použít *pouze s jedním kontaktem* - sdílejte osobně nebo prostřednictvím libovolného komunikátoru."; + /* No comment provided by engineer. */ "5 minutes" = "5 minut"; @@ -307,6 +319,9 @@ time interval */ /* No comment provided by engineer. */ "Abort changing address?" = "Přerušit změnu adresy?"; +/* No comment provided by engineer. */ +"About operators" = "O operátorech"; + /* No comment provided by engineer. */ "About SimpleX Chat" = "O SimpleX chat"; @@ -319,9 +334,21 @@ alert action swipe action */ "Accept" = "Přijmout"; +/* alert action */ +"Accept as member" = "Přijmout za člena"; + +/* alert action */ +"Accept as observer" = "Přijmout jako pozorovatele"; + +/* No comment provided by engineer. */ +"Accept conditions" = "Přijmout podmínky"; + /* No comment provided by engineer. */ "Accept connection request?" = "Přijmout kontakt?"; +/* alert title */ +"Accept contact request" = "Přijmout žádost o kontakt"; + /* notification body */ "Accept contact request from %@?" = "Přijmout žádost o kontakt od %@?"; @@ -329,12 +356,27 @@ swipe action */ swipe action */ "Accept incognito" = "Přijmout inkognito"; +/* alert title */ +"Accept member" = "Přijmout člena"; + /* call status */ "accepted call" = "přijatý hovor"; +/* No comment provided by engineer. */ +"Accepted conditions" = "Přijaté podmínky"; + +/* token status text */ +"Active" = "Aktivní"; + +/* No comment provided by engineer. */ +"Active connections" = "Aktivní spojení"; + /* No comment provided by engineer. */ "Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." = "Přidejte adresu do svého profilu, aby ji vaše kontakty mohly sdílet s dalšími lidmi. Aktualizace profilu bude zaslána vašim kontaktům."; +/* No comment provided by engineer. */ +"Add friends" = "Přidat přátele"; + /* No comment provided by engineer. */ "Add profile" = "Přidat profil"; @@ -344,48 +386,96 @@ swipe action */ /* No comment provided by engineer. */ "Add servers by scanning QR codes." = "Přidejte servery skenováním QR kódů."; +/* No comment provided by engineer. */ +"Add team members" = "Přidat členy týmu"; + /* No comment provided by engineer. */ "Add to another device" = "Přidat do jiného zařízení"; +/* No comment provided by engineer. */ +"Add to list" = "Přidat do seznamu"; + /* No comment provided by engineer. */ "Add welcome message" = "Přidat uvítací zprávu"; +/* No comment provided by engineer. */ +"Add your team members to the conversations." = "Přidat členy týmu do konverzace."; + +/* No comment provided by engineer. */ +"Added message servers" = "Přidané servery zpráv"; + +/* No comment provided by engineer. */ +"Additional accent" = "Další zbarvení"; + +/* No comment provided by engineer. */ +"Additional accent 2" = "Další zbarvení 2"; + /* No comment provided by engineer. */ "Address" = "Adresa"; /* No comment provided by engineer. */ "Address change will be aborted. Old receiving address will be used." = "Změna adresy bude přerušena. Budou použity staré přijímací adresy."; +/* No comment provided by engineer. */ +"Address or 1-time link?" = "Adresa nebo jednorázový odkaz?"; + +/* No comment provided by engineer. */ +"Address settings" = "Nastavení adresy"; + /* member role */ "admin" = "správce"; +/* No comment provided by engineer. */ +"Admins can block a member for all." = "Správci mohou blokovat člena pro všechny."; + /* No comment provided by engineer. */ "Admins can create the links to join groups." = "Správci mohou vytvářet odkazy pro připojení ke skupinám."; /* No comment provided by engineer. */ "Advanced network settings" = "Pokročilá nastavení sítě"; +/* No comment provided by engineer. */ +"Advanced settings" = "Pokročilá nastavení"; + /* chat item text */ "agreeing encryption for %@…" = "povoluji šifrování pro %@…"; /* chat item text */ "agreeing encryption…" = "povoluji šifrování…"; +/* No comment provided by engineer. */ +"All" = "Vše"; + /* No comment provided by engineer. */ "All app data is deleted." = "Všechna data aplikace jsou smazána."; /* No comment provided by engineer. */ "All chats and messages will be deleted - this cannot be undone!" = "Všechny chaty a zprávy budou smazány – tuto akci nelze vrátit zpět!"; +/* alert message */ +"All chats will be removed from the list %@, and the list deleted." = "Všechny chaty budou odstraněny ze seznamu %@ a seznam bude odstraněn."; + /* No comment provided by engineer. */ "All data is erased when it is entered." = "Všechna data se při zadání vymažou."; +/* No comment provided by engineer. */ +"All data is kept private on your device." = "Všechna data jsou uchována ve vašem zařízení."; + /* No comment provided by engineer. */ "All group members will remain connected." = "Všichni členové skupiny zůstanou připojeni."; /* No comment provided by engineer. */ "All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you." = "Všechny zprávy budou smazány – tuto akci nelze vrátit zpět! Zprávy budou smazány POUZE pro vás."; +/* No comment provided by engineer. */ +"All new messages from %@ will be hidden!" = "Všechny nové zprávy od %@ budou skryté!"; + +/* profile dropdown */ +"All profiles" = "Všechny profily"; + +/* No comment provided by engineer. */ +"All servers" = "Všechny servery"; + /* No comment provided by engineer. */ "All your contacts will remain connected." = "Všechny vaše kontakty zůstanou připojeny."; @@ -398,6 +488,9 @@ swipe action */ /* No comment provided by engineer. */ "Allow calls only if your contact allows them." = "Povolte hovory, pouze pokud je váš kontakt povolí."; +/* No comment provided by engineer. */ +"Allow calls?" = "Povolit volání?"; + /* No comment provided by engineer. */ "Allow disappearing messages only if your contact allows it to you." = "Povolte mizící zprávy, pouze pokud vám to váš kontakt dovolí."; @@ -416,12 +509,21 @@ swipe action */ /* No comment provided by engineer. */ "Allow sending disappearing messages." = "Povolit odesílání mizících zpráv."; +/* No comment provided by engineer. */ +"Allow sharing" = "Povolit sdílení"; + /* No comment provided by engineer. */ "Allow to irreversibly delete sent messages. (24 hours)" = "Povolit nevratné smazání odeslaných zpráv. (24 hodin)"; +/* No comment provided by engineer. */ +"Allow to report messsages to moderators." = "Povolit nahlášení zpráv moderátorům."; + /* No comment provided by engineer. */ "Allow to send files and media." = "Povolit odesílání souborů a médii."; +/* No comment provided by engineer. */ +"Allow to send SimpleX links." = "Povolit odesílání SimpleX odkazů."; + /* No comment provided by engineer. */ "Allow to send voice messages." = "Povolit odesílání hlasových zpráv."; @@ -443,21 +545,36 @@ swipe action */ /* No comment provided by engineer. */ "Allow your contacts to send disappearing messages." = "Umožněte svým kontaktům odesílat mizící zprávy."; +/* No comment provided by engineer. */ +"Allow your contacts to send files and media." = "Povolit vašim kontaktům odesílání souborů a médii."; + /* No comment provided by engineer. */ "Allow your contacts to send voice messages." = "Povolte svým kontaktům odesílání hlasových zpráv."; /* No comment provided by engineer. */ "Already connected?" = "Již připojeno?"; +/* new chat sheet title */ +"Already connecting!" = "Již připojováno!"; + +/* new chat sheet title */ +"Already joining the group!" = "Již se ke skupině připojujete!"; + /* pref value */ "always" = "vždy"; +/* No comment provided by engineer. */ +"Always use private routing." = "Vždy používat soukromé směrování."; + /* No comment provided by engineer. */ "Always use relay" = "Spojení přes relé"; /* No comment provided by engineer. */ "An empty chat profile with the provided name is created, and the app opens as usual." = "Vytvořit prázdný chat profil se zadaným názvem a otevřít aplikaci jako obvykle."; +/* report reason */ +"Another reason" = "Jiný důvod"; + /* No comment provided by engineer. */ "Answer call" = "Přijmout hovor"; @@ -467,6 +584,9 @@ swipe action */ /* No comment provided by engineer. */ "App build: %@" = "Sestavení aplikace: %@"; +/* No comment provided by engineer. */ +"App data migration" = "Přenos dat aplikace"; + /* No comment provided by engineer. */ "App encrypts new local files (except videos)." = "Aplikace šifruje nové místní soubory (s výjimkou videí)."; @@ -488,6 +608,21 @@ swipe action */ /* No comment provided by engineer. */ "Appearance" = "Vzhled"; +/* No comment provided by engineer. */ +"Apply" = "Použít"; + +/* No comment provided by engineer. */ +"Archive" = "Archiv"; + +/* No comment provided by engineer. */ +"Archive all reports?" = "Archivovat všechny hlášení?"; + +/* No comment provided by engineer. */ +"Archive and upload" = "Archivovat a nahrát"; + +/* No comment provided by engineer. */ +"Archived contacts" = "Archivované kontakty"; + /* No comment provided by engineer. */ "Attach" = "Připojit"; @@ -530,6 +665,9 @@ swipe action */ /* No comment provided by engineer. */ "Back" = "Zpět"; +/* No comment provided by engineer. */ +"Background" = "Pozadí"; + /* integrity error chat item */ "bad message hash" = "špatný hash zprávy"; @@ -542,9 +680,45 @@ swipe action */ /* No comment provided by engineer. */ "Bad message ID" = "Špatné ID zprávy"; +/* No comment provided by engineer. */ +"Better calls" = "Lepší volání"; + +/* No comment provided by engineer. */ +"Better groups" = "Lepší skupiny"; + +/* No comment provided by engineer. */ +"Better groups performance" = "Lepší výkon skupin"; + +/* No comment provided by engineer. */ +"Better message dates." = "Lepší datumy zpráv."; + /* No comment provided by engineer. */ "Better messages" = "Lepší zprávy"; +/* No comment provided by engineer. */ +"Better networking" = "Lepší sítě"; + +/* No comment provided by engineer. */ +"Better notifications" = "Lepší upozornění"; + +/* No comment provided by engineer. */ +"Better privacy and security" = "Lepší soukromí a zabezpečení"; + +/* No comment provided by engineer. */ +"Better security ✅" = "Lepší zabezpečení ✅"; + +/* No comment provided by engineer. */ +"Block member" = "Blokovat člena"; + +/* No comment provided by engineer. */ +"Block member?" = "Blokovat člena?"; + +/* No comment provided by engineer. */ +"Blocked by admin" = "Blokován správcem"; + +/* No comment provided by engineer. */ +"Blur media" = "Rozmazat média"; + /* No comment provided by engineer. */ "bold" = "tučně"; @@ -560,12 +734,18 @@ swipe action */ /* No comment provided by engineer. */ "Both you and your contact can send disappearing messages." = "Vy i váš kontakt můžete posílat mizící zprávy."; +/* No comment provided by engineer. */ +"Both you and your contact can send files and media." = "Vy i vaše kontakty můžete posílat soubory a média."; + /* No comment provided by engineer. */ "Both you and your contact can send voice messages." = "Hlasové zprávy můžete posílat vy i váš kontakt."; /* No comment provided by engineer. */ "Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Bulharský, finský, thajský a ukrajinský - díky uživatelům a [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!"; +/* No comment provided by engineer. */ +"Business address" = "Obchodní adresa"; + /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Podle chat profilu (výchozí) nebo [podle připojení](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; @@ -584,29 +764,65 @@ swipe action */ /* No comment provided by engineer. */ "Calls" = "Hovory"; +/* No comment provided by engineer. */ +"Calls prohibited!" = "Volání zakázáno!"; + +/* No comment provided by engineer. */ +"Camera not available" = "Kamera není k dispozici"; + +/* No comment provided by engineer. */ +"Can't call contact" = "Kontaktu nelze volat"; + +/* No comment provided by engineer. */ +"Can't call member" = "Členu nelze volat"; + +/* alert title */ +"Can't change profile" = "Nelze změnit profil"; + /* No comment provided by engineer. */ "Can't invite contact!" = "Nelze pozvat kontakt!"; /* No comment provided by engineer. */ "Can't invite contacts!" = "Nelze pozvat kontakty!"; +/* No comment provided by engineer. */ +"Can't message member" = "Členu nelze poslat zprávu"; + /* alert action alert button new chat action */ "Cancel" = "Zrušit"; +/* No comment provided by engineer. */ +"Cancel migration" = "Zrušit přesun"; + /* feature offered item */ "cancelled %@" = "zrušeno %@"; /* No comment provided by engineer. */ "Cannot access keychain to save database password" = "Nelze získat přístup ke klíčence pro uložení hesla databáze"; +/* No comment provided by engineer. */ +"Cannot forward message" = "Zprávu nelze přeposlat"; + /* alert title */ "Cannot receive file" = "Nelze přijmout soubor"; +/* snd error text */ +"Capacity exceeded - recipient did not receive previously sent messages." = "Kapacita překročena - příjemce neobdrží dříve poslané zprávy."; + +/* No comment provided by engineer. */ +"Cellular" = "Mobilní"; + /* No comment provided by engineer. */ "Change" = "Změnit"; +/* alert title */ +"Change automatic message deletion?" = "Změnit automatické mazání zpráv?"; + +/* authentication reason */ +"Change chat profiles" = "Změnit chat profily"; + /* No comment provided by engineer. */ "Change database passphrase?" = "Změnit přístupovou frázi databáze?"; @@ -650,6 +866,9 @@ set passcode view */ /* chat item text */ "changing address…" = "změna adresy…"; +/* No comment provided by engineer. */ +"Chat colors" = "Barvy chatu"; + /* No comment provided by engineer. */ "Chat console" = "Konzola pro chat"; @@ -659,6 +878,9 @@ set passcode view */ /* No comment provided by engineer. */ "Chat database deleted" = "Databáze chatu odstraněna"; +/* No comment provided by engineer. */ +"Chat database exported" = "Chat databáze exportována"; + /* No comment provided by engineer. */ "Chat database imported" = "Importovaná databáze chatu"; @@ -668,6 +890,9 @@ set passcode view */ /* No comment provided by engineer. */ "Chat is stopped" = "Chat je zastaven"; +/* No comment provided by engineer. */ +"Chat migrated!" = "Chat přesunut!"; + /* No comment provided by engineer. */ "Chat preferences" = "Předvolby chatu"; @@ -731,12 +956,21 @@ set passcode view */ /* No comment provided by engineer. */ "Confirm password" = "Potvrdit heslo"; +/* No comment provided by engineer. */ +"Confirm upload" = "Potvrdit nahrání"; + /* server test step */ "Connect" = "Připojit"; +/* No comment provided by engineer. */ +"Connect automatically" = "Připojit automaticky"; + /* No comment provided by engineer. */ "connect to SimpleX Chat developers." = "připojit se k vývojářům SimpleX Chat."; +/* new chat sheet title */ +"Connect to yourself?\nThis is your own one-time link!" = "Připojit se k sobě?\nToto je váš vlastní jednorázový odkaz!"; + /* new chat sheet title */ "Connect via link" = "Připojte se prostřednictvím odkazu"; @@ -1518,6 +1752,9 @@ snd error text */ /* No comment provided by engineer. */ "Find chats faster" = "Najděte chaty rychleji"; +/* server test error */ +"Fingerprint in server address does not match certificate." = "Je možné, že otisk certifikátu v adrese serveru je nesprávný"; + /* No comment provided by engineer. */ "Fix" = "Opravit"; @@ -2070,7 +2307,7 @@ snd error text */ /* No comment provided by engineer. */ "Network settings" = "Nastavení sítě"; -/* No comment provided by engineer. */ +/* alert title */ "Network status" = "Stav sítě"; /* delete after time */ @@ -2336,9 +2573,6 @@ new chat action */ /* No comment provided by engineer. */ "Polish interface" = "Polské rozhraní"; -/* server test error */ -"Possibly, certificate fingerprint in server address is incorrect" = "Je možné, že otisk certifikátu v adrese serveru je nesprávný"; - /* No comment provided by engineer. */ "Preserve the last message draft, with attachments." = "Zachování posledního návrhu zprávy s přílohami."; @@ -2444,9 +2678,6 @@ new chat action */ /* No comment provided by engineer. */ "received confirmation…" = "obdržel potvrzení…"; -/* notification */ -"Received file event" = "Událost přijatého souboru"; - /* message info title */ "Received message" = "Přijatá zpráva"; @@ -2741,9 +2972,6 @@ chat item action */ /* copied message info */ "Sent at: %@" = "Posláno v: % @"; -/* notification */ -"Sent file event" = "Odeslaná událost souboru"; - /* message info title */ "Sent message" = "Poslaná zpráva"; @@ -2751,10 +2979,10 @@ chat item action */ "Sent messages will be deleted after set time." = "Odeslané zprávy se po uplynutí nastavené doby odstraní."; /* server test error */ -"Server requires authorization to create queues, check password" = "Server vyžaduje autorizaci pro vytváření front, zkontrolujte heslo"; +"Server requires authorization to create queues, check password." = "Server vyžaduje autorizaci pro vytváření front, zkontrolujte heslo"; /* server test error */ -"Server requires authorization to upload, check password" = "Server vyžaduje autorizaci pro nahrávání, zkontrolujte heslo"; +"Server requires authorization to upload, check password." = "Server vyžaduje autorizaci pro nahrávání, zkontrolujte heslo"; /* No comment provided by engineer. */ "Server test failed!" = "Test serveru se nezdařil!"; @@ -3084,12 +3312,6 @@ chat item action */ /* No comment provided by engineer. */ "Transport isolation" = "Izolace transportu"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Pokus o připojení k serveru používanému k přijímání zpráv od tohoto kontaktu (chyba: %@)."; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "Pokus o připojení k serveru používanému pro příjem zpráv od tohoto kontaktu."; - /* No comment provided by engineer. */ "Turn off" = "Vypnout"; @@ -3318,9 +3540,6 @@ chat item action */ /* No comment provided by engineer. */ "You are already connected to %@." = "Již jste připojeni k %@."; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "Jste připojeni k serveru, který se používá k přijímání zpráv od tohoto kontaktu."; - /* No comment provided by engineer. */ "You are invited to group" = "Jste pozváni do skupiny"; diff --git a/apps/ios/de.lproj/Localizable.strings b/apps/ios/de.lproj/Localizable.strings index b64b7b3ec4..caf58399de 100644 --- a/apps/ios/de.lproj/Localizable.strings +++ b/apps/ios/de.lproj/Localizable.strings @@ -849,7 +849,7 @@ swipe action */ "blocked" = "Blockiert"; /* rcv group event chat item */ -"blocked %@" = "%@ wurde blockiert"; +"blocked %@" = "hat %@ blockiert"; /* blocked chat item marked deleted chat item preview text */ @@ -1191,7 +1191,7 @@ set passcode view */ /* No comment provided by engineer. */ "Conditions are already accepted for these operator(s): **%@**." = "Die Nutzungsbedingungen der/des folgenden Betreiber(s) wurden schon akzeptiert: **%@**."; -/* No comment provided by engineer. */ +/* alert button */ "Conditions of use" = "Nutzungsbedingungen"; /* No comment provided by engineer. */ @@ -1348,7 +1348,7 @@ set passcode view */ "connection established" = "Verbindung hergestellt"; /* No comment provided by engineer. */ -"Connection is blocked by server operator:\n%@" = "Die Verbindung wurde vom Server-Betreiber blockiert:\n%@"; +"Connection is blocked by server operator:\n%@" = "Die Verbindung wurde vom Serverbetreiber blockiert:\n%@"; /* No comment provided by engineer. */ "Connection not ready." = "Verbindung noch nicht bereit."; @@ -2238,6 +2238,9 @@ chat item action */ /* alert message */ "Error connecting to forwarding server %@. Please try later." = "Fehler beim Verbinden mit dem Weiterleitungsserver %@. Bitte versuchen Sie es später erneut."; +/* subscription status explanation */ +"Error connecting to the server used to receive messages from this connection: %@" = "Fehler beim Herstellen der Verbindung zum Server, der für den Empfang von Nachrichten dieser Verbindung genutzt wird: %@"; + /* No comment provided by engineer. */ "Error creating address" = "Fehler beim Erstellen der Adresse"; @@ -2266,10 +2269,10 @@ chat item action */ "Error decrypting file" = "Fehler beim Entschlüsseln der Datei"; /* alert title */ -"Error deleting chat database" = "Fehler beim Löschen der Chat-Datenbank"; +"Error deleting chat" = "Fehler beim Löschen des Chats mit dem Mitglied"; /* alert title */ -"Error deleting chat with member" = "Fehler beim Löschen des Chats mit dem Mitglied"; +"Error deleting chat database" = "Fehler beim Löschen der Chat-Datenbank"; /* alert title */ "Error deleting chat!" = "Fehler beim Löschen des Chats!"; @@ -2438,6 +2441,9 @@ file error text snd error text */ "Error: %@" = "Fehler: %@"; +/* server test error */ +"Error: %@." = "Fehler: %@."; + /* No comment provided by engineer. */ "Error: no database file" = "Fehler: Keine Datenbankdatei"; @@ -2511,7 +2517,7 @@ snd error text */ "File errors:\n%@" = "Datei-Fehler:\n%@"; /* file error text */ -"File is blocked by server operator:\n%@." = "Datei wurde vom Server-Betreiber blockiert:\n%@."; +"File is blocked by server operator:\n%@." = "Die Datei wurde vom Serverbetreiber blockiert:\n%@."; /* file error text */ "File not found - most likely file was deleted or cancelled." = "Datei nicht gefunden - höchstwahrscheinlich wurde die Datei gelöscht oder der Transfer abgebrochen."; @@ -2573,6 +2579,18 @@ snd error text */ /* No comment provided by engineer. */ "Find chats faster" = "Chats schneller finden"; +/* No comment provided by engineer. */ +"Fingerprint in destination server address does not match certificate: %@." = "Fingerabdruck in der Zielserveradresse stimmt nicht mit dem Zertifikat überein: %@."; + +/* No comment provided by engineer. */ +"Fingerprint in forwarding server address does not match certificate: %@." = "Fingerabdruck in der Weiterleitungsserveradresse stimmt nicht mit dem Zertifikat überein: %@."; + +/* No comment provided by engineer. */ +"Fingerprint in server address does not match certificate: %@." = "Fingerabdruck in der Serveradresse stimmt nicht mit dem Zertifikat überein: %@."; + +/* server test error */ +"Fingerprint in server address does not match certificate." = "Fingerabdruck in der Serveradresse stimmt nicht mit dem Zertifikat überein."; + /* No comment provided by engineer. */ "Fix" = "Reparieren"; @@ -3257,6 +3275,9 @@ snd error text */ /* No comment provided by engineer. */ "Member" = "Mitglied"; +/* past/unknown group member */ +"Member %@" = "Mitglied %@"; + /* profile update event chat item */ "member %@ changed to %@" = "Der Mitgliedsname von %1$@ wurde auf %2$@ geändert"; @@ -3533,7 +3554,7 @@ snd error text */ /* No comment provided by engineer. */ "Network settings" = "Netzwerkeinstellungen"; -/* No comment provided by engineer. */ +/* alert title */ "Network status" = "Netzwerkstatus"; /* delete after time */ @@ -3692,6 +3713,9 @@ snd error text */ /* servers error */ "No servers to send files." = "Keine Server für das Versenden von Dateien."; +/* No comment provided by engineer. */ +"no subscription" = "Kein Abonnement"; + /* copied message info in history */ "no text" = "Kein Text"; @@ -3961,9 +3985,6 @@ new chat action */ /* No comment provided by engineer. */ "Password to show" = "Passwort anzeigen"; -/* past/unknown group member */ -"Past member %@" = "Ehemaliges Mitglied %@"; - /* No comment provided by engineer. */ "Paste desktop address" = "Desktop-Adresse einfügen"; @@ -4075,9 +4096,6 @@ new chat action */ /* No comment provided by engineer. */ "Port" = "Port"; -/* server test error */ -"Possibly, certificate fingerprint in server address is incorrect" = "Der Fingerabdruck des Zertifikats in der Serveradresse ist wahrscheinlich ungültig"; - /* No comment provided by engineer. */ "Preserve the last message draft, with attachments." = "Den letzten Nachrichtenentwurf, auch mit seinen Anhängen, aufbewahren."; @@ -4267,9 +4285,6 @@ new chat action */ /* No comment provided by engineer. */ "received confirmation…" = "Bestätigung erhalten…"; -/* notification */ -"Received file event" = "Datei-Ereignis empfangen"; - /* message info title */ "Received message" = "Empfangene Nachricht"; @@ -4697,7 +4712,7 @@ chat item action */ "Secured" = "Abgesichert"; /* No comment provided by engineer. */ -"Security assessment" = "Sicherheits-Gutachten"; +"Security assessment" = "Security-Gutachten"; /* No comment provided by engineer. */ "Security code" = "Sicherheitscode"; @@ -4831,9 +4846,6 @@ chat item action */ /* No comment provided by engineer. */ "Sent directly" = "Direkt gesendet"; -/* notification */ -"Sent file event" = "Datei-Ereignis wurde gesendet"; - /* message info title */ "Sent message" = "Gesendete Nachricht"; @@ -4880,10 +4892,10 @@ chat item action */ "server queue info: %@\n\nlast received msg: %@" = "Server-Warteschlangen-Information: %1$@\n\nZuletzt empfangene Nachricht: %2$@"; /* server test error */ -"Server requires authorization to create queues, check password" = "Um Warteschlangen zu erzeugen benötigt der Server eine Authentifizierung. Bitte überprüfen Sie das Passwort"; +"Server requires authorization to create queues, check password." = "Der Server erfordert zum Erstellen von Warteschlangen eine Autorisierung. Bitte überprüfen Sie das Passwort."; /* server test error */ -"Server requires authorization to upload, check password" = "Bitte das Passwort überprüfen - für den Upload benötigt der Server eine Berechtigung"; +"Server requires authorization to upload, check password." = "Der Server erfordert zum Hochladen eine Autorisierung. Bitte überprüfen Sie das Passwort."; /* No comment provided by engineer. */ "Server test failed!" = "Server Test ist fehlgeschlagen!"; @@ -5577,11 +5589,8 @@ report reason */ /* No comment provided by engineer. */ "Transport sessions" = "Transport-Sitzungen"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Beim Versuch die Verbindung mit dem Server aufzunehmen, der für den Empfang von Nachrichten mit diesem Kontakt genutzt wird, ist ein Fehler aufgetreten (Fehler: %@)."; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "Versuche die Verbindung mit dem Server aufzunehmen, der für den Empfang von Nachrichten mit diesem Kontakt genutzt wird."; +/* subscription status explanation */ +"Trying to connect to the server used to receive messages from this connection." = "Versuche eine Verbindung mit dem Server aufzunehmen, der für den Empfang von Nachrichten dieser Verbindung genutzt wird."; /* No comment provided by engineer. */ "Turkish interface" = "Türkische Bedienoberfläche"; @@ -5611,7 +5620,7 @@ report reason */ "Unblock member?" = "Mitglied freigeben?"; /* rcv group event chat item */ -"unblocked %@" = "%@ wurde freigegeben"; +"unblocked %@" = "hat %@ freigegeben"; /* No comment provided by engineer. */ "Undelivered messages" = "Nicht ausgelieferte Nachrichten"; @@ -6075,12 +6084,15 @@ report reason */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "Sie sind bereits Mitglied dieser Gruppe!\nVerbindungsanfrage wiederholen?"; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "Sie sind mit dem Server verbunden, der für den Empfang von Nachrichten mit diesem Kontakt genutzt wird."; +/* subscription status explanation */ +"You are connected to the server used to receive messages from this connection." = "Sie sind mit dem Server verbunden, der für den Empfang von Nachrichten dieser Verbindung genutzt wird."; /* No comment provided by engineer. */ "You are invited to group" = "Sie sind zu der Gruppe eingeladen"; +/* subscription status explanation */ +"You are not connected to the server used to receive messages from this connection (no subscription)." = "Sie sind nicht mit dem Server verbunden, der für den Empfang von Nachrichten dieser Verbindung genutzt wird (kein Abonnement)."; + /* No comment provided by engineer. */ "You are not connected to these servers. Private routing is used to deliver messages to them." = "Sie sind nicht mit diesen Servern verbunden. Zur Auslieferung von Nachrichten an diese Server wird privates Routing genutzt."; diff --git a/apps/ios/es.lproj/Localizable.strings b/apps/ios/es.lproj/Localizable.strings index 1158380c34..5ce7ab6843 100644 --- a/apps/ios/es.lproj/Localizable.strings +++ b/apps/ios/es.lproj/Localizable.strings @@ -1191,7 +1191,7 @@ set passcode view */ /* No comment provided by engineer. */ "Conditions are already accepted for these operator(s): **%@**." = "Las condiciones ya se han aceptado para el/los siguiente(s) operador(s): **%@**."; -/* No comment provided by engineer. */ +/* alert button */ "Conditions of use" = "Condiciones de uso"; /* No comment provided by engineer. */ @@ -1674,7 +1674,7 @@ swipe action */ "Delete chat profile?" = "¿Eliminar perfil?"; /* alert title */ -"Delete chat with member?" = "¿Eliminar chat con el miembro?"; +"Delete chat with member?" = "¿Eliminar chat con miembro?"; /* No comment provided by engineer. */ "Delete chat?" = "¿Eliminar chat?"; @@ -2041,7 +2041,7 @@ chat item action */ "Enable in direct chats (BETA)!" = "¡Activar en chats directos (BETA)!"; /* No comment provided by engineer. */ -"Enable instant notifications?" = "¿Activar notificación instantánea?"; +"Enable instant notifications?" = "¿Activar notificaciones instantáneas?"; /* No comment provided by engineer. */ "Enable lock" = "Activar bloqueo"; @@ -2238,6 +2238,9 @@ chat item action */ /* alert message */ "Error connecting to forwarding server %@. Please try later." = "Error al conectar con el servidor de reenvío %@. Por favor, inténtalo más tarde."; +/* subscription status explanation */ +"Error connecting to the server used to receive messages from this connection: %@" = "Error al conectar con el servidor usado para recibir mensajes de esta conexión: %@"; + /* No comment provided by engineer. */ "Error creating address" = "Error al crear dirección"; @@ -2266,10 +2269,10 @@ chat item action */ "Error decrypting file" = "Error al descifrar el archivo"; /* alert title */ -"Error deleting chat database" = "Error al eliminar base de datos"; +"Error deleting chat" = "Error al eliminar el chat"; /* alert title */ -"Error deleting chat with member" = "Error al eliminar el chat con el miembro"; +"Error deleting chat database" = "Error al eliminar base de datos"; /* alert title */ "Error deleting chat!" = "¡Error al eliminar chat!"; @@ -2438,6 +2441,9 @@ file error text snd error text */ "Error: %@" = "Error: %@"; +/* server test error */ +"Error: %@." = "Error: %@."; + /* No comment provided by engineer. */ "Error: no database file" = "Error: sin archivo de base de datos"; @@ -2573,6 +2579,18 @@ snd error text */ /* No comment provided by engineer. */ "Find chats faster" = "Encuentra chats mas rápido"; +/* No comment provided by engineer. */ +"Fingerprint in destination server address does not match certificate: %@." = "La huella en la dirección del servidor de destino no coincide con el certificado: %@."; + +/* No comment provided by engineer. */ +"Fingerprint in forwarding server address does not match certificate: %@." = "La huella en la dirección del servidor de reenvío no coincide con el certificado: %@."; + +/* No comment provided by engineer. */ +"Fingerprint in server address does not match certificate: %@." = "La huella en la dirección del servidor no coincide con el certificado: %@."; + +/* server test error */ +"Fingerprint in server address does not match certificate." = "La huella en la dirección del servidor no coincide con el certificado."; + /* No comment provided by engineer. */ "Fix" = "Reparar"; @@ -3257,6 +3275,9 @@ snd error text */ /* No comment provided by engineer. */ "Member" = "Miembro"; +/* past/unknown group member */ +"Member %@" = "Miembro %@"; + /* profile update event chat item */ "member %@ changed to %@" = "el miembro %1$@ ha cambiado a %2$@"; @@ -3533,7 +3554,7 @@ snd error text */ /* No comment provided by engineer. */ "Network settings" = "Configuración de red"; -/* No comment provided by engineer. */ +/* alert title */ "Network status" = "Estado de la red"; /* delete after time */ @@ -3692,6 +3713,9 @@ snd error text */ /* servers error */ "No servers to send files." = "Sin servidores para enviar archivos."; +/* No comment provided by engineer. */ +"no subscription" = "sin suscripciones"; + /* copied message info in history */ "no text" = "sin texto"; @@ -3961,9 +3985,6 @@ new chat action */ /* No comment provided by engineer. */ "Password to show" = "Contraseña para hacerlo visible"; -/* past/unknown group member */ -"Past member %@" = "Miembro pasado %@"; - /* No comment provided by engineer. */ "Paste desktop address" = "Pegar dirección de ordenador"; @@ -4075,9 +4096,6 @@ new chat action */ /* No comment provided by engineer. */ "Port" = "Puerto"; -/* server test error */ -"Possibly, certificate fingerprint in server address is incorrect" = "Posiblemente la huella del certificado en la dirección del servidor es incorrecta"; - /* No comment provided by engineer. */ "Preserve the last message draft, with attachments." = "Conserva el último borrador del mensaje con los datos adjuntos."; @@ -4267,9 +4285,6 @@ new chat action */ /* No comment provided by engineer. */ "received confirmation…" = "confirmación recibida…"; -/* notification */ -"Received file event" = "Evento de archivo recibido"; - /* message info title */ "Received message" = "Mensaje entrante"; @@ -4831,9 +4846,6 @@ chat item action */ /* No comment provided by engineer. */ "Sent directly" = "Directamente"; -/* notification */ -"Sent file event" = "Evento de archivo enviado"; - /* message info title */ "Sent message" = "Mensaje saliente"; @@ -4880,10 +4892,10 @@ chat item action */ "server queue info: %@\n\nlast received msg: %@" = "información cola del servidor: %1$@\n\núltimo mensaje recibido: %2$@"; /* server test error */ -"Server requires authorization to create queues, check password" = "El servidor requiere autorización para crear colas, comprueba la contraseña"; +"Server requires authorization to create queues, check password." = "El servidor requiere autorización para crear colas, comprueba la contraseña."; /* server test error */ -"Server requires authorization to upload, check password" = "El servidor requiere autorización para subir, comprueba la contraseña"; +"Server requires authorization to upload, check password." = "El servidor requiere autorización para subir, comprueba la contraseña."; /* No comment provided by engineer. */ "Server test failed!" = "¡Prueba no superada!"; @@ -5260,7 +5272,7 @@ report reason */ "Tail" = "Cola"; /* No comment provided by engineer. */ -"Take picture" = "Tomar foto"; +"Take picture" = "Hacer foto"; /* No comment provided by engineer. */ "Tap button " = "Pulsa el botón "; @@ -5577,11 +5589,8 @@ report reason */ /* No comment provided by engineer. */ "Transport sessions" = "Sesiones de transporte"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Intentando conectar con el servidor usado para recibir mensajes de este contacto (error: %@)."; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "Intentando conectar con el servidor usado para recibir mensajes de este contacto."; +/* subscription status explanation */ +"Trying to connect to the server used to receive messages from this connection." = "Intentando conectar con el servidor usado para recibir mensajes de esta conexión."; /* No comment provided by engineer. */ "Turkish interface" = "Interfaz en turco"; @@ -6075,12 +6084,15 @@ report reason */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "¡En proceso de unirte al grupo!\n¿Repetir solicitud de admisión?"; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "Estás conectado al servidor usado para recibir mensajes de este contacto."; +/* subscription status explanation */ +"You are connected to the server used to receive messages from this connection." = "Estás conectado al servidor usado para recibir mensajes de esta conexión."; /* No comment provided by engineer. */ "You are invited to group" = "Has sido invitado a un grupo"; +/* subscription status explanation */ +"You are not connected to the server used to receive messages from this connection (no subscription)." = "No estás conectado al servidor usado para recibir mensajes de esta conexión (no suscrito)."; + /* No comment provided by engineer. */ "You are not connected to these servers. Private routing is used to deliver messages to them." = "No tienes conexión directa a estos servidores. Los mensajes destinados a estos usan enrutamiento privado."; @@ -6334,7 +6346,7 @@ report reason */ "Your profile is stored on your device and only shared with your contacts." = "El perfil sólo se comparte con tus contactos."; /* No comment provided by engineer. */ -"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Tu perfil se almacena en tu dispositivo y sólo se comparte con tus contactos. Los servidores SimpleX no pueden ver tu perfil."; +"Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "Tu perfil se almacena en tu dispositivo y se comparte sólo con tus contactos. Los servidores SimpleX no pueden ver tu perfil."; /* alert message */ "Your profile was changed. If you save it, the updated profile will be sent to all your contacts." = "Tu perfil ha sido modificado. Si lo guardas la actualización será enviada a todos tus contactos."; diff --git a/apps/ios/fi.lproj/Localizable.strings b/apps/ios/fi.lproj/Localizable.strings index 670607daa9..884be40cc1 100644 --- a/apps/ios/fi.lproj/Localizable.strings +++ b/apps/ios/fi.lproj/Localizable.strings @@ -1428,6 +1428,9 @@ snd error text */ /* No comment provided by engineer. */ "Find chats faster" = "Löydä keskustelut nopeammin"; +/* server test error */ +"Fingerprint in server address does not match certificate." = "Palvelimen osoitteen varmenteen sormenjälki on mahdollisesti virheellinen"; + /* No comment provided by engineer. */ "Fix" = "Korjaa"; @@ -1980,7 +1983,7 @@ snd error text */ /* No comment provided by engineer. */ "Network settings" = "Verkkoasetukset"; -/* No comment provided by engineer. */ +/* alert title */ "Network status" = "Verkon tila"; /* delete after time */ @@ -2240,9 +2243,6 @@ new chat action */ /* No comment provided by engineer. */ "Polish interface" = "Puolalainen käyttöliittymä"; -/* server test error */ -"Possibly, certificate fingerprint in server address is incorrect" = "Palvelimen osoitteen varmenteen sormenjälki on mahdollisesti virheellinen"; - /* No comment provided by engineer. */ "Preserve the last message draft, with attachments." = "Säilytä viimeinen viestiluonnos liitteineen."; @@ -2348,9 +2348,6 @@ new chat action */ /* No comment provided by engineer. */ "received confirmation…" = "vahvistus saatu…"; -/* notification */ -"Received file event" = "Tiedoston vastaanottotapahtuma"; - /* message info title */ "Received message" = "Vastaanotettu viesti"; @@ -2642,9 +2639,6 @@ chat item action */ /* copied message info */ "Sent at: %@" = "Lähetetty klo: %@"; -/* notification */ -"Sent file event" = "Lähetetty tiedosto tapahtuma"; - /* message info title */ "Sent message" = "Lähetetty viesti"; @@ -2652,10 +2646,10 @@ chat item action */ "Sent messages will be deleted after set time." = "Lähetetyt viestit poistetaan asetetun ajan kuluttua."; /* server test error */ -"Server requires authorization to create queues, check password" = "Palvelin vaatii valtuutuksen jonojen luomiseen, tarkista salasana"; +"Server requires authorization to create queues, check password." = "Palvelin vaatii valtuutuksen jonojen luomiseen, tarkista salasana"; /* server test error */ -"Server requires authorization to upload, check password" = "Palvelin vaatii valtuutuksen tiedoston lataamiseksi, tarkista salasana"; +"Server requires authorization to upload, check password." = "Palvelin vaatii valtuutuksen tiedoston lataamiseksi, tarkista salasana"; /* No comment provided by engineer. */ "Server test failed!" = "Palvelintesti epäonnistui!"; @@ -2979,12 +2973,6 @@ chat item action */ /* No comment provided by engineer. */ "Transport isolation" = "Kuljetuksen eristäminen"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Yritetään muodostaa yhteyttä palvelimeen, jota käytetään tämän kontaktin viestien vastaanottamiseen (virhe: %@)."; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "Yritetään muodostaa yhteys palvelimeen, jota käytetään viestien vastaanottamiseen tältä kontaktilta."; - /* No comment provided by engineer. */ "Turn off" = "Sammuta"; @@ -3213,9 +3201,6 @@ chat item action */ /* No comment provided by engineer. */ "You are already connected to %@." = "Olet jo muodostanut yhteyden %@:n kanssa."; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "Olet yhteydessä palvelimeen, jota käytetään vastaanottamaan viestejä tältä kontaktilta."; - /* No comment provided by engineer. */ "You are invited to group" = "Sinut on kutsuttu ryhmään"; diff --git a/apps/ios/fr.lproj/Localizable.strings b/apps/ios/fr.lproj/Localizable.strings index 8f9be38180..dbfac375d1 100644 --- a/apps/ios/fr.lproj/Localizable.strings +++ b/apps/ios/fr.lproj/Localizable.strings @@ -1143,7 +1143,7 @@ set passcode view */ /* No comment provided by engineer. */ "Conditions are already accepted for these operator(s): **%@**." = "Les conditions sont déjà acceptées pour ces opérateurs : **%@**."; -/* No comment provided by engineer. */ +/* alert button */ "Conditions of use" = "Conditions d'utilisation"; /* No comment provided by engineer. */ @@ -2465,6 +2465,9 @@ snd error text */ /* No comment provided by engineer. */ "Find chats faster" = "Recherche de message plus rapide"; +/* server test error */ +"Fingerprint in server address does not match certificate." = "Il est possible que l'empreinte du certificat dans l'adresse du serveur soit incorrecte"; + /* No comment provided by engineer. */ "Fix" = "Réparer"; @@ -3320,7 +3323,7 @@ snd error text */ /* No comment provided by engineer. */ "Network settings" = "Paramètres réseau"; -/* No comment provided by engineer. */ +/* alert title */ "Network status" = "État du réseau"; /* delete after time */ @@ -3661,9 +3664,6 @@ new chat action */ /* No comment provided by engineer. */ "Password to show" = "Mot de passe à entrer"; -/* past/unknown group member */ -"Past member %@" = "Ancien membre %@"; - /* No comment provided by engineer. */ "Paste desktop address" = "Coller l'adresse du bureau"; @@ -3754,9 +3754,6 @@ new chat action */ /* No comment provided by engineer. */ "Port" = "Port"; -/* server test error */ -"Possibly, certificate fingerprint in server address is incorrect" = "Il est possible que l'empreinte du certificat dans l'adresse du serveur soit incorrecte"; - /* No comment provided by engineer. */ "Preserve the last message draft, with attachments." = "Conserver le brouillon du dernier message, avec les pièces jointes."; @@ -3928,9 +3925,6 @@ new chat action */ /* No comment provided by engineer. */ "received confirmation…" = "confimation reçu…"; -/* notification */ -"Received file event" = "Événement de fichier reçu"; - /* message info title */ "Received message" = "Message reçu"; @@ -4381,9 +4375,6 @@ chat item action */ /* No comment provided by engineer. */ "Sent directly" = "Envoyé directement"; -/* notification */ -"Sent file event" = "Événement de fichier envoyé"; - /* message info title */ "Sent message" = "Message envoyé"; @@ -4430,10 +4421,10 @@ chat item action */ "server queue info: %@\n\nlast received msg: %@" = "info sur la file d'attente du serveur : %1$@\n\ndernier message reçu : %2$@"; /* server test error */ -"Server requires authorization to create queues, check password" = "Le serveur requiert une autorisation pour créer des files d'attente, vérifiez le mot de passe"; +"Server requires authorization to create queues, check password." = "Le serveur requiert une autorisation pour créer des files d'attente, vérifiez le mot de passe"; /* server test error */ -"Server requires authorization to upload, check password" = "Le serveur requiert une autorisation pour téléverser, vérifiez le mot de passe"; +"Server requires authorization to upload, check password." = "Le serveur requiert une autorisation pour téléverser, vérifiez le mot de passe"; /* No comment provided by engineer. */ "Server test failed!" = "Échec du test du serveur !"; @@ -5033,12 +5024,6 @@ chat item action */ /* No comment provided by engineer. */ "Transport sessions" = "Sessions de transport"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Tentative de connexion au serveur utilisé pour recevoir les messages de ce contact (erreur : %@)."; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "Tentative de connexion au serveur utilisé pour recevoir les messages de ce contact."; - /* No comment provided by engineer. */ "Turkish interface" = "Interface en turc"; @@ -5489,9 +5474,6 @@ chat item action */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "Vous êtes déjà membre de ce groupe !\nRépéter la demande d'adhésion ?"; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "Vous êtes connecté·e au serveur utilisé pour recevoir les messages de ce contact."; - /* No comment provided by engineer. */ "You are invited to group" = "Vous êtes invité·e au groupe"; diff --git a/apps/ios/hu.lproj/Localizable.strings b/apps/ios/hu.lproj/Localizable.strings index f87f19c586..451bdfc699 100644 --- a/apps/ios/hu.lproj/Localizable.strings +++ b/apps/ios/hu.lproj/Localizable.strings @@ -227,7 +227,7 @@ "%lld seconds" = "%lld mp"; /* No comment provided by engineer. */ -"%lldd" = "%lldn"; +"%lldd" = "%lldnap"; /* No comment provided by engineer. */ "%lldh" = "%lldó"; @@ -239,7 +239,7 @@ "%lldm" = "%lldp"; /* No comment provided by engineer. */ -"%lldmth" = "%lldh"; +"%lldmth" = "%lldhónap"; /* No comment provided by engineer. */ "%llds" = "%lldmp"; @@ -651,7 +651,7 @@ swipe action */ "Anybody can host servers." = "Bárki üzemeltethet kiszolgálókat."; /* No comment provided by engineer. */ -"App build: %@" = "Az alkalmazás összeállítási száma: %@"; +"App build: %@" = "Alkalmazás összeállítási száma: %@"; /* No comment provided by engineer. */ "App data migration" = "Alkalmazásadatok átköltöztetése"; @@ -675,10 +675,10 @@ swipe action */ "App session" = "Alkalmazás munkamenete"; /* No comment provided by engineer. */ -"App version" = "Az alkalmazás verziója"; +"App version" = "Alkalmazás verziója"; /* No comment provided by engineer. */ -"App version: v%@" = "Az alkalmazás verziója: v%@"; +"App version: v%@" = "Alkalmazás verziója: v%@"; /* No comment provided by engineer. */ "Appearance" = "Megjelenés"; @@ -774,19 +774,19 @@ swipe action */ "Background" = "Háttér"; /* No comment provided by engineer. */ -"Bad desktop address" = "Érvénytelen számítógépcím"; +"Bad desktop address" = "Hibás a számítógép címe"; /* integrity error chat item */ -"bad message hash" = "hibás az üzenet hasítóértéke"; +"bad message hash" = "hibás az üzenet kivonata"; /* No comment provided by engineer. */ -"Bad message hash" = "Érvénytelen az üzenet hasítóértéke"; +"Bad message hash" = "Hibás az üzenet kivonata"; /* integrity error chat item */ "bad message ID" = "hibás az üzenet azonosítója"; /* No comment provided by engineer. */ -"Bad message ID" = "Téves üzenet ID"; +"Bad message ID" = "Hibás az üzenet azonosítója"; /* No comment provided by engineer. */ "Better calls" = "Továbbfejlesztett hívásélmény"; @@ -804,7 +804,7 @@ swipe action */ "Better messages" = "Továbbfejlesztett üzenetek"; /* No comment provided by engineer. */ -"Better networking" = "Jobb hálózatkezelés"; +"Better networking" = "Továbbfejlesztett hálózatkezelés"; /* No comment provided by engineer. */ "Better notifications" = "Továbbfejlesztett értesítések"; @@ -819,10 +819,10 @@ swipe action */ "Better user experience" = "Továbbfejlesztett felhasználói élmény"; /* No comment provided by engineer. */ -"Bio" = "Bemutatkozás"; +"Bio" = "Névjegy"; /* alert title */ -"Bio too large" = "A bemutatkozás túl hosszú"; +"Bio too large" = "A névjegy túl hosszú"; /* No comment provided by engineer. */ "Black" = "Fekete"; @@ -1191,7 +1191,7 @@ set passcode view */ /* No comment provided by engineer. */ "Conditions are already accepted for these operator(s): **%@**." = "A feltételek már el lettek fogadva a következő üzemeltető(k) számára: **%@**."; -/* No comment provided by engineer. */ +/* alert button */ "Conditions of use" = "Használati feltételek"; /* No comment provided by engineer. */ @@ -1492,7 +1492,7 @@ set passcode view */ "Create profile" = "Profil létrehozása"; /* server test step */ -"Create queue" = "Sorba állítás létrehozása"; +"Create queue" = "Várólista létrehozása"; /* No comment provided by engineer. */ "Create SimpleX address" = "SimpleX-cím létrehozása"; @@ -1522,7 +1522,7 @@ set passcode view */ "creator" = "készítő"; /* No comment provided by engineer. */ -"Current conditions text couldn't be loaded, you can review conditions via this link:" = "A jelenlegi feltételek szövegét nem lehetett betölteni, a feltételeket a következő hivatkozáson keresztül vizsgálhatja felül:"; +"Current conditions text couldn't be loaded, you can review conditions via this link:" = "A jelenlegi feltételek szövegét nem sikerült betölteni, a feltételeket a következő hivatkozáson keresztül vizsgálhatja felül:"; /* No comment provided by engineer. */ "Current Passcode" = "Jelenlegi jelkód"; @@ -1755,7 +1755,7 @@ swipe action */ "Delete profile" = "Profil törlése"; /* server test step */ -"Delete queue" = "Sorba állítás törlése"; +"Delete queue" = "Várólista törlése"; /* No comment provided by engineer. */ "Delete report" = "Jelentés törlése"; @@ -1857,7 +1857,7 @@ swipe action */ "different migration in the app/database: %@ / %@" = "különböző átköltöztetés az alkalmazásban/adatbázisban: %@ / %@"; /* No comment provided by engineer. */ -"Different names, avatars and transport isolation." = "Különböző nevek, profilképek és átvitelizoláció."; +"Different names, avatars and transport isolation." = "Különböző nevek, profilképek és átvitelelkülönítés."; /* connection level description */ "direct" = "közvetlen"; @@ -2238,6 +2238,9 @@ chat item action */ /* alert message */ "Error connecting to forwarding server %@. Please try later." = "Hiba történt a(z) %@ továbbítókiszolgálóhoz való kapcsolódáskor. Próbálja meg később."; +/* subscription status explanation */ +"Error connecting to the server used to receive messages from this connection: %@" = "Hiba történt a kapcsolódáskor ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál: %@"; + /* No comment provided by engineer. */ "Error creating address" = "Hiba történt a cím létrehozásakor"; @@ -2266,10 +2269,10 @@ chat item action */ "Error decrypting file" = "Hiba történt a fájl visszafejtésekor"; /* alert title */ -"Error deleting chat database" = "Hiba történt a csevegési adatbázis törlésekor"; +"Error deleting chat" = "Hiba a csevegés törlésekor"; /* alert title */ -"Error deleting chat with member" = "Hiba a taggal való csevegés törlésekor"; +"Error deleting chat database" = "Hiba történt a csevegési adatbázis törlésekor"; /* alert title */ "Error deleting chat!" = "Hiba történt a csevegés törlésekor!"; @@ -2323,7 +2326,7 @@ chat item action */ "Error opening chat" = "Hiba történt a csevegés megnyitásakor"; /* No comment provided by engineer. */ -"Error opening group" = "Hiba a csoport előkészítésekor"; +"Error opening group" = "Hiba történt a csoport megnyitásakor"; /* alert title */ "Error receiving file" = "Hiba történt a fájl fogadásakor"; @@ -2438,6 +2441,9 @@ file error text snd error text */ "Error: %@" = "Hiba: %@"; +/* server test error */ +"Error: %@." = "Hiba: %@."; + /* No comment provided by engineer. */ "Error: no database file" = "Hiba: nincs adatbázisfájl"; @@ -2553,7 +2559,7 @@ snd error text */ "Files and media are prohibited." = "A fájlok és a médiatartalmak küldése le van tiltva."; /* No comment provided by engineer. */ -"Files and media not allowed" = "A fájlok és a médiatartalmak nincsenek engedélyezve"; +"Files and media not allowed" = "A fájlok és a médiatartalmak küldése nincs engedélyezve"; /* No comment provided by engineer. */ "Files and media prohibited!" = "A fájlok és a médiatartalmak küldése le van tiltva!"; @@ -2573,6 +2579,18 @@ snd error text */ /* No comment provided by engineer. */ "Find chats faster" = "Csevegési üzenetek gyorsabb megtalálása"; +/* No comment provided by engineer. */ +"Fingerprint in destination server address does not match certificate: %@." = "A célkiszolgáló címében szereplő ujjlenyomat nem egyezik a tanúsítvánnyal: %@."; + +/* No comment provided by engineer. */ +"Fingerprint in forwarding server address does not match certificate: %@." = "A továbbítókiszolgáló címében szereplő ujjlenyomat nem egyezik a tanúsítvánnyal: %@."; + +/* No comment provided by engineer. */ +"Fingerprint in server address does not match certificate: %@." = "A kiszolgáló címében szereplő ujjlenyomat nem egyezik a tanúsítvánnyal: %@."; + +/* server test error */ +"Fingerprint in server address does not match certificate." = "A kiszolgáló címében szereplő ujjlenyomat nem egyezik a tanúsítvánnyal."; + /* No comment provided by engineer. */ "Fix" = "Javítás"; @@ -2865,7 +2883,7 @@ snd error text */ "Import database" = "Adatbázis importálása"; /* No comment provided by engineer. */ -"Import failed" = "Sikertelen importálás"; +"Import failed" = "Nem sikerült az importálás"; /* No comment provided by engineer. */ "Import theme" = "Téma importálása"; @@ -2880,10 +2898,10 @@ snd error text */ "Improved message delivery" = "Továbbfejlesztett üzenetkézbesítés"; /* No comment provided by engineer. */ -"Improved privacy and security" = "Fejlesztett adatvédelem és biztonság"; +"Improved privacy and security" = "Továbbfejlesztett adatvédelem és biztonság"; /* No comment provided by engineer. */ -"Improved server configuration" = "Javított kiszolgáló konfiguráció"; +"Improved server configuration" = "Továbbfejlesztett kiszolgálókonfiguráció"; /* No comment provided by engineer. */ "In order to continue, chat should be stopped." = "A folytatáshoz a csevegést meg kell szakítani."; @@ -3099,13 +3117,13 @@ snd error text */ "Join" = "Csatlakozás"; /* No comment provided by engineer. */ -"Join as %@" = "csatlakozás mint %@"; +"Join as %@" = "Csatlakozás mint: %@"; /* new chat sheet title */ -"Join group" = "Csatlakozás csoporthoz"; +"Join group" = "Csatlakozás a csoporthoz"; /* No comment provided by engineer. */ -"Join group conversations" = "Csatlakozás csoportos beszélgetésekhez"; +"Join group conversations" = "Csatlakozás a csoportbeszélgetésekhez"; /* No comment provided by engineer. */ "Join incognito" = "Csatlakozás inkognitóban"; @@ -3257,6 +3275,9 @@ snd error text */ /* No comment provided by engineer. */ "Member" = "Tag"; +/* past/unknown group member */ +"Member %@" = "%@ ismeretlen vagy már nem tag"; + /* profile update event chat item */ "member %@ changed to %@" = "%1$@ a következőre módosította a nevét: %2$@"; @@ -3351,7 +3372,7 @@ snd error text */ "Message may be delivered later if member becomes active." = "Az üzenet később is kézbesíthető, ha a tag aktívvá válik."; /* No comment provided by engineer. */ -"Message queue info" = "Üzenetsorbaállítási információ"; +"Message queue info" = "Üzenet várólista információi"; /* chat feature */ "Message reactions" = "Üzenetreakciók"; @@ -3533,7 +3554,7 @@ snd error text */ /* No comment provided by engineer. */ "Network settings" = "Hálózati beállítások"; -/* No comment provided by engineer. */ +/* alert title */ "Network status" = "Hálózat állapota"; /* delete after time */ @@ -3690,7 +3711,10 @@ snd error text */ "No servers to receive messages." = "Nincsenek üzenetfogadási kiszolgálók."; /* servers error */ -"No servers to send files." = "Nincsenek fájlküldő-kiszolgálók."; +"No servers to send files." = "Nincsenek fájlküldési kiszolgálók."; + +/* No comment provided by engineer. */ +"no subscription" = "nincs előfizetés"; /* copied message info in history */ "no text" = "nincs szöveg"; @@ -3836,7 +3860,7 @@ new chat action */ "Only your contact can send disappearing messages." = "Csak a partnere tud eltűnő üzeneteket küldeni."; /* No comment provided by engineer. */ -"Only your contact can send files and media." = "Csak a partnere küldhet fájlokat és a médiatartalmakat."; +"Only your contact can send files and media." = "Csak a partnere küldhet fájlokat és médiatartalmakat."; /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Csak a partnere tud hangüzeneteket küldeni."; @@ -3961,9 +3985,6 @@ new chat action */ /* No comment provided by engineer. */ "Password to show" = "Jelszó a megjelenítéshez"; -/* past/unknown group member */ -"Past member %@" = "(Már nem tag) %@"; - /* No comment provided by engineer. */ "Paste desktop address" = "Számítógép címének beillesztése"; @@ -4075,9 +4096,6 @@ new chat action */ /* No comment provided by engineer. */ "Port" = "Port"; -/* server test error */ -"Possibly, certificate fingerprint in server address is incorrect" = "Lehetséges, hogy a kiszolgáló címében szereplő tanúsítvány-ujjlenyomat helytelen"; - /* No comment provided by engineer. */ "Preserve the last message draft, with attachments." = "Az utolsó üzenet tervezetének megőrzése a mellékletekkel együtt."; @@ -4267,9 +4285,6 @@ new chat action */ /* No comment provided by engineer. */ "received confirmation…" = "visszaigazolás fogadása…"; -/* notification */ -"Received file event" = "Fogadott fájlesemény"; - /* message info title */ "Received message" = "Fogadott üzenetbuborék színe"; @@ -4691,7 +4706,7 @@ chat item action */ "secret" = "titok"; /* server test step */ -"Secure queue" = "Biztonságos sorba állítás"; +"Secure queue" = "Biztonságos várólista"; /* No comment provided by engineer. */ "Secured" = "Biztosítva"; @@ -4703,7 +4718,7 @@ chat item action */ "Security code" = "Biztonsági kód"; /* chat item text */ -"security code changed" = "a biztonsági kód módosult"; +"security code changed" = "biztonsági kódja módosult"; /* chat item action */ "Select" = "Kijelölés"; @@ -4751,7 +4766,7 @@ chat item action */ "Send errors" = "Üzenetküldési hibák"; /* No comment provided by engineer. */ -"Send link previews" = "Hivatkozás előnézete"; +"Send link previews" = "Hivatkozások előnézetének megjelenítése"; /* No comment provided by engineer. */ "Send live message" = "Élő üzenet küldése"; @@ -4831,9 +4846,6 @@ chat item action */ /* No comment provided by engineer. */ "Sent directly" = "Közvetlenül küldött"; -/* notification */ -"Sent file event" = "Elküldött fájlesemény"; - /* message info title */ "Sent message" = "Üzenetbuborék színe"; @@ -4877,13 +4889,13 @@ chat item action */ "Server protocol changed." = "A kiszolgálóprotokoll módosult."; /* queue info */ -"server queue info: %@\n\nlast received msg: %@" = "a kiszolgáló sorbaállítási információi: %1$@\n\nutoljára fogadott üzenet: %2$@"; +"server queue info: %@\n\nlast received msg: %@" = "a kiszolgáló várólista információi: %1$@\n\nutoljára fogadott üzenet: %2$@"; /* server test error */ -"Server requires authorization to create queues, check password" = "A kiszolgálónak engedélyre van szüksége a sorba állítás létrehozásához, ellenőrizze a jelszavát"; +"Server requires authorization to create queues, check password." = "A kiszolgálónak engedélyre van szüksége a várólisták létrehozásához, ellenőrizze a jelszavát."; /* server test error */ -"Server requires authorization to upload, check password" = "A kiszolgálónak hitelesítésre van szüksége a feltöltéshez, ellenőrizze jelszavát"; +"Server requires authorization to upload, check password." = "A kiszolgálónak hitelesítésre van szüksége a feltöltéshez, ellenőrizze a jelszavát."; /* No comment provided by engineer. */ "Server test failed!" = "Sikertelen kiszolgáló teszt!"; @@ -4949,7 +4961,7 @@ chat item action */ "Set passphrase to export" = "Jelmondat beállítása az exportáláshoz"; /* No comment provided by engineer. */ -"Set profile bio and welcome message." = "Bemutatkozószöveg és üdvözlőüzenet beállítása a profilokhoz."; +"Set profile bio and welcome message." = "Névjegy és üdvözlőüzenet beállítása a profilokhoz."; /* No comment provided by engineer. */ "Set the message shown to new members!" = "Megjelenítendő üzenet beállítása az új tagok számára!"; @@ -4995,7 +5007,7 @@ chat item action */ "Share old address" = "Régi cím megosztása"; /* alert button */ -"Share old link" = "Teljes hivatkozás megosztása"; +"Share old link" = "Régi (hosszú) hivatkozás megosztása"; /* No comment provided by engineer. */ "Share profile" = "Profil megosztása"; @@ -5034,7 +5046,7 @@ chat item action */ "Show developer options" = "Fejlesztői beállítások megjelenítése"; /* No comment provided by engineer. */ -"Show last messages" = "Legutóbbi üzenet előnézetének megjelenítése"; +"Show last messages" = "Legutóbbi üzenetek előnézetének megjelenítése"; /* No comment provided by engineer. */ "Show message status" = "Üzenet állapotának megjelenítése"; @@ -5043,7 +5055,7 @@ chat item action */ "Show percentage" = "Százalék megjelenítése"; /* No comment provided by engineer. */ -"Show preview" = "Értesítés előnézete"; +"Show preview" = "Értesítésekben megjelenő információk"; /* No comment provided by engineer. */ "Show QR code" = "QR-kód megjelenítése"; @@ -5383,7 +5395,7 @@ report reason */ "The future of messaging" = "Az üzenetváltás jövője"; /* No comment provided by engineer. */ -"The hash of the previous message is different." = "Az előző üzenet hasítóértéke különbözik."; +"The hash of the previous message is different." = "Az előző üzenet kivonata különbözik."; /* No comment provided by engineer. */ "The ID of the next message is incorrect (less or equal to the previous).\nIt can happen because of some bug or when the connection is compromised." = "A következő üzenet azonosítója érvénytelen (kisebb vagy egyenlő az előzővel).\nEz valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő."; @@ -5577,11 +5589,8 @@ report reason */ /* No comment provided by engineer. */ "Transport sessions" = "Munkamenetek átvitele"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál (hiba: %@)."; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál."; +/* subscription status explanation */ +"Trying to connect to the server used to receive messages from this connection." = "Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál."; /* No comment provided by engineer. */ "Turkish interface" = "Török kezelőfelület"; @@ -5647,7 +5656,7 @@ report reason */ "Unknown error" = "Ismeretlen hiba"; /* No comment provided by engineer. */ -"unknown servers" = "ismeretlen átjátszók"; +"unknown servers" = "ismeretlen kiszolgálók"; /* alert title */ "Unknown servers!" = "Ismeretlen kiszolgálók!"; @@ -5662,7 +5671,7 @@ report reason */ "Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection." = "Hacsak a partnere nem törölte a kapcsolatot, vagy ez a hivatkozás már használatban volt egyszer, lehet hogy ez egy hiba – jelentse a problémát.\nA kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcsolattartási hivatkozást, és ellenőrizze, hogy a hálózati kapcsolat stabil-e."; /* No comment provided by engineer. */ -"Unlink" = "Szétkapcsolás"; +"Unlink" = "Leválasztás"; /* No comment provided by engineer. */ "Unlink desktop?" = "Leválasztja a számítógépet?"; @@ -5791,7 +5800,7 @@ report reason */ "Use private routing with unknown servers when IP address is not protected." = "Privát útválasztás használata az ismeretlen kiszolgálókkal, ha az IP-cím nem védett."; /* No comment provided by engineer. */ -"Use private routing with unknown servers." = "Privát útválasztás használata ismeretlen kiszolgálókkal."; +"Use private routing with unknown servers." = "Privát útválasztás használata az ismeretlen kiszolgálókhoz."; /* No comment provided by engineer. */ "Use server" = "Kiszolgáló használata"; @@ -5809,7 +5818,7 @@ report reason */ "Use TCP port %@ when no port is specified." = "A következő TCP-port használata, amikor nincs port megadva: %@."; /* No comment provided by engineer. */ -"Use TCP port 443 for preset servers only." = "A 443-as TCP-port használata kizárólag az előre beállított kiszolgálokhoz."; +"Use TCP port 443 for preset servers only." = "A 443-as TCP-port használata kizárólag az előre beállított kiszolgálókhoz."; /* No comment provided by engineer. */ "Use the app while in the call." = "Alkalmazás használata hívás közben."; @@ -6075,12 +6084,15 @@ report reason */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "A csatlakozás már folyamatban van a csoporthoz!\nMegismétli a csatlakozási kérést?"; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "Ön már kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál."; +/* subscription status explanation */ +"You are connected to the server used to receive messages from this connection." = "Ön kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál."; /* No comment provided by engineer. */ "You are invited to group" = "Ön meghívást kapott a csoportba"; +/* subscription status explanation */ +"You are not connected to the server used to receive messages from this connection (no subscription)." = "Ön nem kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál (nincs előfizetés)."; + /* No comment provided by engineer. */ "You are not connected to these servers. Private routing is used to deliver messages to them." = "Ön nem kapcsolódik ezekhez a kiszolgálókhoz. A privát útválasztás az üzenetek kézbesítésére szolgál."; @@ -6154,7 +6166,7 @@ report reason */ "You can view your reports in Chat with admins." = "A jelentéseket megtekintheti a „Csevegés az adminisztrátorokkal” menüben."; /* alert title */ -"You can't send messages!" = "Nem lehet üzeneteket küldeni!"; +"You can't send messages!" = "Ön nem tud üzeneteket küldeni!"; /* chat item text */ "you changed address" = "Ön módosította a címet"; @@ -6304,7 +6316,7 @@ report reason */ "Your contacts will remain connected." = "A partnerei továbbra is kapcsolódva maradnak."; /* No comment provided by engineer. */ -"Your credentials may be sent unencrypted." = "A hitelesítési adati titkosítatlanul is elküldhetők."; +"Your credentials may be sent unencrypted." = "A hitelesítési adatai titkosítatlanul is elküldhetők."; /* No comment provided by engineer. */ "Your current chat database will be DELETED and REPLACED with the imported one." = "A jelenlegi csevegési adatbázis TÖRÖLVE és CSERÉLVE lesz az importáltra."; @@ -6325,7 +6337,7 @@ report reason */ "Your privacy" = "Adatvédelem"; /* No comment provided by engineer. */ -"Your profile" = "Profil"; +"Your profile" = "Saját profil"; /* No comment provided by engineer. */ "Your profile **%@** will be shared." = "A(z) **%@** nevű profilja meg lesz osztva."; diff --git a/apps/ios/it.lproj/Localizable.strings b/apps/ios/it.lproj/Localizable.strings index dc39cf9270..511a6835e5 100644 --- a/apps/ios/it.lproj/Localizable.strings +++ b/apps/ios/it.lproj/Localizable.strings @@ -1191,7 +1191,7 @@ set passcode view */ /* No comment provided by engineer. */ "Conditions are already accepted for these operator(s): **%@**." = "Le condizioni sono già state accettate per i seguenti operatori: **%@**."; -/* No comment provided by engineer. */ +/* alert button */ "Conditions of use" = "Condizioni d'uso"; /* No comment provided by engineer. */ @@ -2238,6 +2238,9 @@ chat item action */ /* alert message */ "Error connecting to forwarding server %@. Please try later." = "Errore di connessione al server di inoltro %@. Riprova più tardi."; +/* subscription status explanation */ +"Error connecting to the server used to receive messages from this connection: %@" = "Errore di connessione al server usato per ricevere messaggi da questa connessione: %@"; + /* No comment provided by engineer. */ "Error creating address" = "Errore nella creazione dell'indirizzo"; @@ -2266,10 +2269,10 @@ chat item action */ "Error decrypting file" = "Errore decifrando il file"; /* alert title */ -"Error deleting chat database" = "Errore nell'eliminazione del database della chat"; +"Error deleting chat" = "Errore di eliminazione della chat con il membro"; /* alert title */ -"Error deleting chat with member" = "Errore di eliminazione della chat con il membro"; +"Error deleting chat database" = "Errore nell'eliminazione del database della chat"; /* alert title */ "Error deleting chat!" = "Errore nell'eliminazione della chat!"; @@ -2438,6 +2441,9 @@ file error text snd error text */ "Error: %@" = "Errore: %@"; +/* server test error */ +"Error: %@." = "Errore: %@."; + /* No comment provided by engineer. */ "Error: no database file" = "Errore: nessun file di database"; @@ -2573,6 +2579,18 @@ snd error text */ /* No comment provided by engineer. */ "Find chats faster" = "Trova le chat più velocemente"; +/* No comment provided by engineer. */ +"Fingerprint in destination server address does not match certificate: %@." = "L'impronta digitale nell'indirizzo del server di destinazione non corrisponde al certificato: %@."; + +/* No comment provided by engineer. */ +"Fingerprint in forwarding server address does not match certificate: %@." = "L'impronta digitale nell'indirizzo del server di inoltro non corrisponde al certificato: %@."; + +/* No comment provided by engineer. */ +"Fingerprint in server address does not match certificate: %@." = "L'impronta digitale nell'indirizzo del server non corrisponde al certificato: %@."; + +/* server test error */ +"Fingerprint in server address does not match certificate." = "L'impronta digitale nell'indirizzo del server non corrisponde al certificato."; + /* No comment provided by engineer. */ "Fix" = "Correggi"; @@ -3257,6 +3275,9 @@ snd error text */ /* No comment provided by engineer. */ "Member" = "Membro"; +/* past/unknown group member */ +"Member %@" = "Membro %@"; + /* profile update event chat item */ "member %@ changed to %@" = "il membro %1$@ è diventato %2$@"; @@ -3533,7 +3554,7 @@ snd error text */ /* No comment provided by engineer. */ "Network settings" = "Impostazioni di rete"; -/* No comment provided by engineer. */ +/* alert title */ "Network status" = "Stato della rete"; /* delete after time */ @@ -3692,6 +3713,9 @@ snd error text */ /* servers error */ "No servers to send files." = "Nessun server per inviare file."; +/* No comment provided by engineer. */ +"no subscription" = "nessuna iscrizione"; + /* copied message info in history */ "no text" = "nessun testo"; @@ -3961,9 +3985,6 @@ new chat action */ /* No comment provided by engineer. */ "Password to show" = "Password per mostrare"; -/* past/unknown group member */ -"Past member %@" = "Membro passato %@"; - /* No comment provided by engineer. */ "Paste desktop address" = "Incolla l'indirizzo desktop"; @@ -4075,9 +4096,6 @@ new chat action */ /* No comment provided by engineer. */ "Port" = "Porta"; -/* server test error */ -"Possibly, certificate fingerprint in server address is incorrect" = "Probabilmente l'impronta del certificato nell'indirizzo del server è sbagliata"; - /* No comment provided by engineer. */ "Preserve the last message draft, with attachments." = "Conserva la bozza dell'ultimo messaggio, con gli allegati."; @@ -4267,9 +4285,6 @@ new chat action */ /* No comment provided by engineer. */ "received confirmation…" = "conferma ricevuta…"; -/* notification */ -"Received file event" = "Evento file ricevuto"; - /* message info title */ "Received message" = "Messaggio ricevuto"; @@ -4831,9 +4846,6 @@ chat item action */ /* No comment provided by engineer. */ "Sent directly" = "Inviato direttamente"; -/* notification */ -"Sent file event" = "Evento file inviato"; - /* message info title */ "Sent message" = "Messaggio inviato"; @@ -4880,10 +4892,10 @@ chat item action */ "server queue info: %@\n\nlast received msg: %@" = "info coda server: %1$@\n\nultimo msg ricevuto: %2$@"; /* server test error */ -"Server requires authorization to create queues, check password" = "Il server richiede l'autorizzazione di creare code, controlla la password"; +"Server requires authorization to create queues, check password." = "Il server richiede l'autorizzazione di creare code, controlla la password."; /* server test error */ -"Server requires authorization to upload, check password" = "Il server richiede l'autorizzazione per il caricamento, controllare la password"; +"Server requires authorization to upload, check password." = "Il server richiede l'autorizzazione per l'invio, controlla la password."; /* No comment provided by engineer. */ "Server test failed!" = "Test del server fallito!"; @@ -5577,11 +5589,8 @@ report reason */ /* No comment provided by engineer. */ "Transport sessions" = "Sessioni di trasporto"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Tentativo di connessione al server usato per ricevere messaggi da questo contatto (errore: %@)."; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "Tentativo di connessione al server usato per ricevere messaggi da questo contatto."; +/* subscription status explanation */ +"Trying to connect to the server used to receive messages from this connection." = "Tentativo di connessione al server usato per ricevere messaggi da questa connessione."; /* No comment provided by engineer. */ "Turkish interface" = "Interfaccia in turco"; @@ -6075,12 +6084,15 @@ report reason */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "Stai già entrando nel gruppo!\nRipetere la richiesta di ingresso?"; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "Sei connesso/a al server usato per ricevere messaggi da questo contatto."; +/* subscription status explanation */ +"You are connected to the server used to receive messages from this connection." = "Sei connesso/a al server usato per ricevere messaggi da questa connessione."; /* No comment provided by engineer. */ "You are invited to group" = "Sei stato/a invitato/a al gruppo"; +/* subscription status explanation */ +"You are not connected to the server used to receive messages from this connection (no subscription)." = "Non sei connesso/a al server usato per ricevere messaggi da questa connessione (nessuna iscrizione)."; + /* No comment provided by engineer. */ "You are not connected to these servers. Private routing is used to deliver messages to them." = "Non sei connesso/a a questi server. L'instradamento privato è usato per consegnare loro i messaggi."; diff --git a/apps/ios/ja.lproj/Localizable.strings b/apps/ios/ja.lproj/Localizable.strings index 818b2eb1ce..d4510af72f 100644 --- a/apps/ios/ja.lproj/Localizable.strings +++ b/apps/ios/ja.lproj/Localizable.strings @@ -371,6 +371,9 @@ swipe action */ /* No comment provided by engineer. */ "Accepted conditions" = "承諾された条件"; +/* No comment provided by engineer. */ +"Acknowledged" = "了承済み"; + /* No comment provided by engineer. */ "Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." = "プロフィールにアドレスを追加し、連絡先があなたのアドレスを他の人と共有できるようにします。プロフィールの更新は連絡先に送信されます。"; @@ -1659,6 +1662,9 @@ snd error text */ /* No comment provided by engineer. */ "Find chats faster" = "チャットを素早く検索"; +/* server test error */ +"Fingerprint in server address does not match certificate." = "サーバアドレスの証明証IDが正しくないかもしれません"; + /* No comment provided by engineer. */ "Fix" = "修正"; @@ -2217,7 +2223,7 @@ snd error text */ /* No comment provided by engineer. */ "Network settings" = "ネットワーク設定"; -/* No comment provided by engineer. */ +/* alert title */ "Network status" = "ネットワーク状況"; /* delete after time */ @@ -2483,9 +2489,6 @@ new chat action */ /* No comment provided by engineer. */ "Polish interface" = "ポーランド語UI"; -/* server test error */ -"Possibly, certificate fingerprint in server address is incorrect" = "サーバアドレスの証明証IDが正しくないかもしれません"; - /* No comment provided by engineer. */ "Preserve the last message draft, with attachments." = "添付を含めて、下書きを保存する。"; @@ -2591,9 +2594,6 @@ new chat action */ /* No comment provided by engineer. */ "received confirmation…" = "確認を受け取りました…"; -/* notification */ -"Received file event" = "ファイル受信イベント"; - /* message info title */ "Received message" = "受信したメッセージ"; @@ -2864,9 +2864,6 @@ chat item action */ /* copied message info */ "Sent at: %@" = "送信日時: %@"; -/* notification */ -"Sent file event" = "送信済みファイルイベント"; - /* message info title */ "Sent message" = "送信"; @@ -2874,10 +2871,10 @@ chat item action */ "Sent messages will be deleted after set time." = "一定時間が経ったら送信されたメッセージが削除されます。"; /* server test error */ -"Server requires authorization to create queues, check password" = "キューを作成するにはサーバーの認証が必要です。パスワードを確認してください"; +"Server requires authorization to create queues, check password." = "キューを作成するにはサーバーの認証が必要です。パスワードを確認してください"; /* server test error */ -"Server requires authorization to upload, check password" = "アップロードにはサーバーの認証が必要です。パスワードを確認してください"; +"Server requires authorization to upload, check password." = "アップロードにはサーバーの認証が必要です。パスワードを確認してください"; /* No comment provided by engineer. */ "Server test failed!" = "サーバテスト失敗!"; @@ -3201,12 +3198,6 @@ chat item action */ /* No comment provided by engineer. */ "Transport isolation" = "トランスポート隔離"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "この連絡先からのメッセージの受信に使用されるサーバーに接続しようとしています (エラー: %@)。"; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "このコンタクトから受信するメッセージのサーバに接続しようとしてます。"; - /* No comment provided by engineer. */ "Turn off" = "オフにする"; @@ -3435,9 +3426,6 @@ chat item action */ /* No comment provided by engineer. */ "You are already connected to %@." = "すでに %@ に接続されています。"; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "この連絡先から受信するメッセージのサーバに既に接続してます。"; - /* No comment provided by engineer. */ "You are invited to group" = "グループ招待が届きました"; diff --git a/apps/ios/nl.lproj/Localizable.strings b/apps/ios/nl.lproj/Localizable.strings index 86bd6d4dbf..79e3da3b01 100644 --- a/apps/ios/nl.lproj/Localizable.strings +++ b/apps/ios/nl.lproj/Localizable.strings @@ -1158,7 +1158,7 @@ set passcode view */ /* No comment provided by engineer. */ "Conditions are already accepted for these operator(s): **%@**." = "Voorwaarden zijn reeds geaccepteerd voor de volgende operator(s): **%@**."; -/* No comment provided by engineer. */ +/* alert button */ "Conditions of use" = "Gebruiksvoorwaarden"; /* No comment provided by engineer. */ @@ -2203,10 +2203,10 @@ chat item action */ "Error decrypting file" = "Fout bij het ontsleutelen van bestand"; /* alert title */ -"Error deleting chat database" = "Fout bij het verwijderen van de chat database"; +"Error deleting chat" = "Fout bij het verwijderen van chat met lid"; /* alert title */ -"Error deleting chat with member" = "Fout bij het verwijderen van chat met lid"; +"Error deleting chat database" = "Fout bij het verwijderen van de chat database"; /* alert title */ "Error deleting chat!" = "Fout bij verwijderen gesprek!"; @@ -2498,6 +2498,9 @@ snd error text */ /* No comment provided by engineer. */ "Find chats faster" = "Vind chats sneller"; +/* server test error */ +"Fingerprint in server address does not match certificate." = "Mogelijk is de certificaat vingerafdruk in het server adres onjuist"; + /* No comment provided by engineer. */ "Fix" = "Herstel"; @@ -3434,7 +3437,7 @@ snd error text */ /* No comment provided by engineer. */ "Network settings" = "Netwerk instellingen"; -/* No comment provided by engineer. */ +/* alert title */ "Network status" = "Netwerk status"; /* delete after time */ @@ -3826,9 +3829,6 @@ new chat action */ /* No comment provided by engineer. */ "Password to show" = "Wachtwoord om weer te geven"; -/* past/unknown group member */ -"Past member %@" = "Voormalig lid %@"; - /* No comment provided by engineer. */ "Paste desktop address" = "Desktopadres plakken"; @@ -3940,9 +3940,6 @@ new chat action */ /* No comment provided by engineer. */ "Port" = "Poort"; -/* server test error */ -"Possibly, certificate fingerprint in server address is incorrect" = "Mogelijk is de certificaat vingerafdruk in het server adres onjuist"; - /* No comment provided by engineer. */ "Preserve the last message draft, with attachments." = "Bewaar het laatste berichtconcept, met bijlagen."; @@ -4126,9 +4123,6 @@ new chat action */ /* No comment provided by engineer. */ "received confirmation…" = "bevestiging ontvangen…"; -/* notification */ -"Received file event" = "Ontvangen bestandsgebeurtenis"; - /* message info title */ "Received message" = "Ontvangen bericht"; @@ -4654,9 +4648,6 @@ chat item action */ /* No comment provided by engineer. */ "Sent directly" = "Direct verzonden"; -/* notification */ -"Sent file event" = "Verzonden bestandsgebeurtenis"; - /* message info title */ "Sent message" = "Verzonden bericht"; @@ -4703,10 +4694,10 @@ chat item action */ "server queue info: %@\n\nlast received msg: %@" = "informatie over serverwachtrij: %1$@\n\nlaatst ontvangen bericht: %2$@"; /* server test error */ -"Server requires authorization to create queues, check password" = "Server vereist autorisatie om wachtrijen te maken, controleer wachtwoord"; +"Server requires authorization to create queues, check password." = "Server vereist autorisatie om wachtrijen te maken, controleer wachtwoord"; /* server test error */ -"Server requires authorization to upload, check password" = "Server vereist autorisatie om te uploaden, wachtwoord controleren"; +"Server requires authorization to upload, check password." = "Server vereist autorisatie om te uploaden, wachtwoord controleren"; /* No comment provided by engineer. */ "Server test failed!" = "Servertest mislukt!"; @@ -5346,12 +5337,6 @@ report reason */ /* No comment provided by engineer. */ "Transport sessions" = "Transportsessies"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Proberen verbinding te maken met de server die wordt gebruikt om berichten van dit contact te ontvangen (fout: %@)."; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "Proberen verbinding te maken met de server die wordt gebruikt om berichten van dit contact te ontvangen."; - /* No comment provided by engineer. */ "Turkish interface" = "Turkse interface"; @@ -5820,9 +5805,6 @@ report reason */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "Je sluit je al aan bij de groep!\nDeelnameverzoek herhalen?"; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "U bent verbonden met de server die wordt gebruikt om berichten van dit contact te ontvangen."; - /* No comment provided by engineer. */ "You are invited to group" = "Je bent uitgenodigd voor de groep"; diff --git a/apps/ios/pl.lproj/Localizable.strings b/apps/ios/pl.lproj/Localizable.strings index a3847f802f..34c79eeef4 100644 --- a/apps/ios/pl.lproj/Localizable.strings +++ b/apps/ios/pl.lproj/Localizable.strings @@ -1098,7 +1098,7 @@ set passcode view */ /* No comment provided by engineer. */ "Conditions are already accepted for these operator(s): **%@**." = "Warunki zostały już zaakceptowane przez tego(-ych) operatora(-ów): **%@**."; -/* No comment provided by engineer. */ +/* alert button */ "Conditions of use" = "Warunki użytkowania"; /* No comment provided by engineer. */ @@ -2273,6 +2273,9 @@ snd error text */ /* No comment provided by engineer. */ "Find chats faster" = "Szybciej znajduj czaty"; +/* server test error */ +"Fingerprint in server address does not match certificate." = "Możliwe, że odcisk palca certyfikatu w adresie serwera jest nieprawidłowy"; + /* No comment provided by engineer. */ "Fix" = "Napraw"; @@ -3080,7 +3083,7 @@ snd error text */ /* No comment provided by engineer. */ "Network settings" = "Ustawienia sieci"; -/* No comment provided by engineer. */ +/* alert title */ "Network status" = "Status sieci"; /* delete after time */ @@ -3373,9 +3376,6 @@ new chat action */ /* No comment provided by engineer. */ "Password to show" = "Hasło do wyświetlenia"; -/* past/unknown group member */ -"Past member %@" = "Były członek %@"; - /* No comment provided by engineer. */ "Paste desktop address" = "Wklej adres komputera"; @@ -3466,9 +3466,6 @@ new chat action */ /* No comment provided by engineer. */ "Port" = "Port"; -/* server test error */ -"Possibly, certificate fingerprint in server address is incorrect" = "Możliwe, że odcisk palca certyfikatu w adresie serwera jest nieprawidłowy"; - /* No comment provided by engineer. */ "Preserve the last message draft, with attachments." = "Zachowaj ostatnią wersję roboczą wiadomości wraz z załącznikami."; @@ -3634,9 +3631,6 @@ new chat action */ /* No comment provided by engineer. */ "received confirmation…" = "otrzymano potwierdzenie…"; -/* notification */ -"Received file event" = "Otrzymano zdarzenie pliku"; - /* message info title */ "Received message" = "Otrzymano wiadomość"; @@ -4081,9 +4075,6 @@ chat item action */ /* No comment provided by engineer. */ "Sent directly" = "Wysłano bezpośrednio"; -/* notification */ -"Sent file event" = "Wyślij zdarzenie pliku"; - /* message info title */ "Sent message" = "Wyślij wiadomość"; @@ -4118,10 +4109,10 @@ chat item action */ "server queue info: %@\n\nlast received msg: %@" = "Informacje kolejki serwera: %1$@\n\nostatnia otrzymana wiadomość: %2$@"; /* server test error */ -"Server requires authorization to create queues, check password" = "Serwer wymaga autoryzacji do tworzenia kolejek, sprawdź hasło"; +"Server requires authorization to create queues, check password." = "Serwer wymaga autoryzacji do tworzenia kolejek, sprawdź hasło"; /* server test error */ -"Server requires authorization to upload, check password" = "Serwer wymaga autoryzacji do przesłania, sprawdź hasło"; +"Server requires authorization to upload, check password." = "Serwer wymaga autoryzacji do przesłania, sprawdź hasło"; /* No comment provided by engineer. */ "Server test failed!" = "Test serwera nie powiódł się!"; @@ -4658,12 +4649,6 @@ chat item action */ /* No comment provided by engineer. */ "Transport sessions" = "Sesje transportowe"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Próbowanie połączenia z serwerem używanym do odbierania wiadomości od tego kontaktu (błąd: %@)."; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "Próbowanie połączenia z serwerem używanym do odbierania wiadomości od tego kontaktu."; - /* No comment provided by engineer. */ "Turkish interface" = "Turecki interfejs"; @@ -5087,9 +5072,6 @@ chat item action */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "Już dołączasz do grupy!\nPowtórzyć prośbę dołączenia?"; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "Jesteś połączony z serwerem używanym do odbierania wiadomości od tego kontaktu."; - /* No comment provided by engineer. */ "You are invited to group" = "Jesteś zaproszony do grupy"; diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index f3b88969c9..0826bca4a3 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -1191,7 +1191,7 @@ set passcode view */ /* No comment provided by engineer. */ "Conditions are already accepted for these operator(s): **%@**." = "Условия уже приняты для следующих оператора(ов): **%@**."; -/* No comment provided by engineer. */ +/* alert button */ "Conditions of use" = "Условия использования"; /* No comment provided by engineer. */ @@ -2266,10 +2266,10 @@ chat item action */ "Error decrypting file" = "Ошибка расшифровки файла"; /* alert title */ -"Error deleting chat database" = "Ошибка при удалении данных чата"; +"Error deleting chat" = "Ошибка при удалении чата с членом группы"; /* alert title */ -"Error deleting chat with member" = "Ошибка при удалении чата с членом группы"; +"Error deleting chat database" = "Ошибка при удалении данных чата"; /* alert title */ "Error deleting chat!" = "Ошибка при удалении чата!"; @@ -2438,6 +2438,9 @@ file error text snd error text */ "Error: %@" = "Ошибка: %@"; +/* server test error */ +"Error: %@." = "Ошибка: %@."; + /* No comment provided by engineer. */ "Error: no database file" = "Ошибка: данные чата не найдены"; @@ -2573,6 +2576,18 @@ snd error text */ /* No comment provided by engineer. */ "Find chats faster" = "Быстро найти чаты"; +/* No comment provided by engineer. */ +"Fingerprint in destination server address does not match certificate: %@." = "Хэш в адресе сервера назначения не соответствует сертификату: %@."; + +/* No comment provided by engineer. */ +"Fingerprint in forwarding server address does not match certificate: %@." = "Хэш в адресе пересылающего сервера не соответствует сертификату: %@."; + +/* No comment provided by engineer. */ +"Fingerprint in server address does not match certificate: %@." = "Хэш в адресе сервера не соответствует сертификату: %@."; + +/* server test error */ +"Fingerprint in server address does not match certificate." = "Возможно, хэш сертификата в адресе сервера неверный."; + /* No comment provided by engineer. */ "Fix" = "Починить"; @@ -3257,6 +3272,9 @@ snd error text */ /* No comment provided by engineer. */ "Member" = "Член группы"; +/* past/unknown group member */ +"Member %@" = "Член группы %@"; + /* profile update event chat item */ "member %@ changed to %@" = "член %1$@ изменился на %2$@"; @@ -3513,7 +3531,7 @@ snd error text */ "Name" = "Имя"; /* No comment provided by engineer. */ -"Network & servers" = "Сеть & серверы"; +"Network & servers" = "Сеть и серверы"; /* No comment provided by engineer. */ "Network connection" = "Интернет-соединение"; @@ -3533,7 +3551,7 @@ snd error text */ /* No comment provided by engineer. */ "Network settings" = "Настройки сети"; -/* No comment provided by engineer. */ +/* alert title */ "Network status" = "Состояние сети"; /* delete after time */ @@ -3961,9 +3979,6 @@ new chat action */ /* No comment provided by engineer. */ "Password to show" = "Пароль чтобы раскрыть"; -/* past/unknown group member */ -"Past member %@" = "Бывший член %@"; - /* No comment provided by engineer. */ "Paste desktop address" = "Вставить адрес компьютера"; @@ -4075,9 +4090,6 @@ new chat action */ /* No comment provided by engineer. */ "Port" = "Порт"; -/* server test error */ -"Possibly, certificate fingerprint in server address is incorrect" = "Возможно, хэш сертификата в адресе сервера неверный"; - /* No comment provided by engineer. */ "Preserve the last message draft, with attachments." = "Сохранить последний черновик, вместе с вложениями."; @@ -4267,9 +4279,6 @@ new chat action */ /* No comment provided by engineer. */ "received confirmation…" = "получено подтверждение…"; -/* notification */ -"Received file event" = "Загрузка файла"; - /* message info title */ "Received message" = "Полученное сообщение"; @@ -4540,7 +4549,7 @@ swipe action */ "Review members" = "Одобрять членов"; /* admission stage description */ -"Review members before admitting (\"knocking\")." = "Одобрять членов для вступления в группу."; +"Review members before admitting (\"knocking\")." = "Вручную одобрять членов для вступления в группу."; /* No comment provided by engineer. */ "reviewed by admins" = "одобрен админами"; @@ -4831,9 +4840,6 @@ chat item action */ /* No comment provided by engineer. */ "Sent directly" = "Отправлено напрямую"; -/* notification */ -"Sent file event" = "Отправка файла"; - /* message info title */ "Sent message" = "Отправленное сообщение"; @@ -4880,10 +4886,10 @@ chat item action */ "server queue info: %@\n\nlast received msg: %@" = "информация сервера об очереди: %1$@\n\nпоследнее полученное сообщение: %2$@"; /* server test error */ -"Server requires authorization to create queues, check password" = "Сервер требует авторизации для создания очередей, проверьте пароль"; +"Server requires authorization to create queues, check password." = "Сервер требует авторизации для создания очередей, проверьте пароль."; /* server test error */ -"Server requires authorization to upload, check password" = "Сервер требует авторизации для загрузки, проверьте пароль"; +"Server requires authorization to upload, check password." = "Сервер требует авторизации для загрузки, проверьте пароль."; /* No comment provided by engineer. */ "Server test failed!" = "Ошибка теста сервера!"; @@ -5114,6 +5120,9 @@ chat item action */ /* No comment provided by engineer. */ "SimpleX protocols reviewed by Trail of Bits." = "Аудит SimpleX протоколов от Trail of Bits."; +/* simplex link type */ +"SimpleX relay link" = "Ссылка SimpleX relay"; + /* No comment provided by engineer. */ "Simplified incognito mode" = "Упрощенный режим Инкогнито"; @@ -5269,7 +5278,7 @@ report reason */ "Tap Connect to send request" = "Нажмите Соединиться, чтобы отправить запрос"; /* No comment provided by engineer. */ -"Tap Connect to use bot" = "Нажмите Соединиться, чтобы использовать бот."; +"Tap Connect to use bot" = "Нажмите Соединиться, чтобы использовать бот"; /* No comment provided by engineer. */ "Tap Create SimpleX address in the menu to create it later." = "Нажмите Создать адрес SimpleX в меню, чтобы создать его позже."; @@ -5574,12 +5583,6 @@ report reason */ /* No comment provided by engineer. */ "Transport sessions" = "Транспортные сессии"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Устанавливается соединение с сервером, через который Вы получаете сообщения от этого контакта (ошибка: %@)."; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "Устанавливается соединение с сервером, через который Вы получаете сообщения от этого контакта."; - /* No comment provided by engineer. */ "Turkish interface" = "Турецкий интерфейс"; @@ -6072,9 +6075,6 @@ report reason */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "Вы уже вступаете в группу!\nПовторить запрос на вступление?"; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "Установлено соединение с сервером, через который Вы получаете сообщения от этого контакта."; - /* No comment provided by engineer. */ "You are invited to group" = "Вы приглашены в группу"; diff --git a/apps/ios/th.lproj/Localizable.strings b/apps/ios/th.lproj/Localizable.strings index ef5d68b0fb..2700711773 100644 --- a/apps/ios/th.lproj/Localizable.strings +++ b/apps/ios/th.lproj/Localizable.strings @@ -1380,6 +1380,9 @@ snd error text */ /* No comment provided by engineer. */ "Find chats faster" = "ค้นหาแชทได้เร็วขึ้น"; +/* server test error */ +"Fingerprint in server address does not match certificate." = "อาจเป็นไปได้ว่าลายนิ้วมือของ certificate ในที่อยู่เซิร์ฟเวอร์ไม่ถูกต้อง"; + /* No comment provided by engineer. */ "Fix" = "แก้ไข"; @@ -1923,7 +1926,7 @@ snd error text */ /* No comment provided by engineer. */ "Network settings" = "การตั้งค่าเครือข่าย"; -/* No comment provided by engineer. */ +/* alert title */ "Network status" = "สถานะเครือข่าย"; /* delete after time */ @@ -2180,9 +2183,6 @@ new chat action */ /* No comment provided by engineer. */ "Polish interface" = "อินเตอร์เฟซภาษาโปแลนด์"; -/* server test error */ -"Possibly, certificate fingerprint in server address is incorrect" = "อาจเป็นไปได้ว่าลายนิ้วมือของ certificate ในที่อยู่เซิร์ฟเวอร์ไม่ถูกต้อง"; - /* No comment provided by engineer. */ "Preserve the last message draft, with attachments." = "เก็บข้อความที่ร่างไว้ล่าสุดพร้อมไฟล์แนบ"; @@ -2285,9 +2285,6 @@ new chat action */ /* No comment provided by engineer. */ "received confirmation…" = "ได้รับการยืนยัน…"; -/* notification */ -"Received file event" = "ได้รับไฟล์"; - /* message info title */ "Received message" = "ได้รับข้อความ"; @@ -2570,9 +2567,6 @@ chat item action */ /* copied message info */ "Sent at: %@" = "ส่งเมื่อ: %@"; -/* notification */ -"Sent file event" = "เหตุการณ์ไฟล์ที่ส่ง"; - /* message info title */ "Sent message" = "ข้อความที่ส่งแล้ว"; @@ -2580,10 +2574,10 @@ chat item action */ "Sent messages will be deleted after set time." = "ข้อความที่ส่งจะถูกลบหลังเกินเวลาที่กําหนด"; /* server test error */ -"Server requires authorization to create queues, check password" = "เซิร์ฟเวอร์ต้องการการอนุญาตในการสร้างคิว โปรดตรวจสอบรหัสผ่าน"; +"Server requires authorization to create queues, check password." = "เซิร์ฟเวอร์ต้องการการอนุญาตในการสร้างคิว โปรดตรวจสอบรหัสผ่าน"; /* server test error */ -"Server requires authorization to upload, check password" = "เซิร์ฟเวอร์ต้องการการอนุญาตในการอัปโหลด โปรดตรวจสอบรหัสผ่าน"; +"Server requires authorization to upload, check password." = "เซิร์ฟเวอร์ต้องการการอนุญาตในการอัปโหลด โปรดตรวจสอบรหัสผ่าน"; /* No comment provided by engineer. */ "Server test failed!" = "การทดสอบเซิร์ฟเวอร์ล้มเหลว!"; @@ -2895,12 +2889,6 @@ chat item action */ /* No comment provided by engineer. */ "Transport isolation" = "การแยกการขนส่ง"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "กำลังพยายามเชื่อมต่อกับเซิร์ฟเวอร์ที่ใช้รับข้อความจากผู้ติดต่อนี้ (ข้อผิดพลาด: %@)"; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "พยายามเชื่อมต่อกับเซิร์ฟเวอร์ที่ใช้รับข้อความจากผู้ติดต่อนี้"; - /* No comment provided by engineer. */ "Turn off" = "ปิด"; @@ -3123,9 +3111,6 @@ chat item action */ /* No comment provided by engineer. */ "You are already connected to %@." = "คุณได้เชื่อมต่อกับ %@ แล้ว"; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "คุณเชื่อมต่อกับเซิร์ฟเวอร์ที่ใช้รับข้อความจากผู้ติดต่อนี้"; - /* No comment provided by engineer. */ "You are invited to group" = "คุณได้รับเชิญให้เข้าร่วมกลุ่ม"; diff --git a/apps/ios/tr.lproj/Localizable.strings b/apps/ios/tr.lproj/Localizable.strings index e4285f12ba..9acf2cc425 100644 --- a/apps/ios/tr.lproj/Localizable.strings +++ b/apps/ios/tr.lproj/Localizable.strings @@ -557,6 +557,9 @@ swipe action */ /* No comment provided by engineer. */ "Allow downgrade" = "Sürüm düşürmeye izin ver"; +/* No comment provided by engineer. */ +"Allow files and media only if your contact allows them." = "Dosyalara ve medyaya yalnızca iletişiminiz izin verdiğinde izin verin."; + /* No comment provided by engineer. */ "Allow irreversible message deletion only if your contact allows it to you. (24 hours)" = "Konuştuğun kişi, kalıcı olarak silinebilen mesajlara izin veriyorsa sen de ver. (24 saat içinde)"; @@ -608,6 +611,9 @@ swipe action */ /* No comment provided by engineer. */ "Allow your contacts to send disappearing messages." = "Kişilerinizin kaybolan mesajlar göndermesine izin verin."; +/* No comment provided by engineer. */ +"Allow your contacts to send files and media." = "Kişilerinizin dosya ve medya göndermesine izin verin."; + /* No comment provided by engineer. */ "Allow your contacts to send voice messages." = "Kişilerinizin sesli mesajlar göndermesine izin verin."; @@ -861,6 +867,9 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "bold" = "kalın"; +/* No comment provided by engineer. */ +"Bot" = "Bot"; + /* No comment provided by engineer. */ "Both you and your contact can add message reactions." = "Sen ve konuştuğun kişi mesaj tepkileri ekleyebilir."; @@ -873,6 +882,9 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "Both you and your contact can send disappearing messages." = "Sen ve konuştuğun kişi kaybolan mesajlar gönderebilir."; +/* No comment provided by engineer. */ +"Both you and your contact can send files and media." = "Sen de kişilerin de medya ve dosya gönderebilir."; + /* No comment provided by engineer. */ "Both you and your contact can send voice messages." = "Sen ve konuştuğun kişi sesli mesaj gönderebilir."; @@ -1179,7 +1191,7 @@ set passcode view */ /* No comment provided by engineer. */ "Conditions are already accepted for these operator(s): **%@**." = "Koşullar çoktan operatör(ler) tarafından kabul edildi: **%@**."; -/* No comment provided by engineer. */ +/* alert button */ "Conditions of use" = "Kullanım koşulları"; /* No comment provided by engineer. */ @@ -1790,6 +1802,9 @@ swipe action */ /* No comment provided by engineer. */ "Delivery receipts!" = "Mesaj gönderildi bilgisi!"; +/* No comment provided by engineer. */ +"Deprecated options" = "Kullanımdan kaldırılan seçenekler"; + /* No comment provided by engineer. */ "Description" = "Açıklama"; @@ -2251,10 +2266,10 @@ chat item action */ "Error decrypting file" = "Dosya şifresi çözülürken hata oluştu"; /* alert title */ -"Error deleting chat database" = "Sohbet veritabanı silinirken sorun oluştu"; +"Error deleting chat" = "Üye ile sohbet silme hatası"; /* alert title */ -"Error deleting chat with member" = "Üye ile sohbet silme hatası"; +"Error deleting chat database" = "Sohbet veritabanı silinirken sorun oluştu"; /* alert title */ "Error deleting chat!" = "Sohbet silinirken hata oluştu!"; @@ -2370,6 +2385,9 @@ chat item action */ /* No comment provided by engineer. */ "Error sending message" = "Mesaj gönderilirken hata oluştu"; +/* No comment provided by engineer. */ +"Error setting auto-accept" = "Otomatik kabul ayarında hata"; + /* No comment provided by engineer. */ "Error setting delivery receipts!" = "Görüldü ayarlanırken hata oluştu!"; @@ -2528,6 +2546,9 @@ snd error text */ /* chat feature */ "Files and media" = "Dosyalar ve medya"; +/* No comment provided by engineer. */ +"Files and media are prohibited in this chat." = "Bu sohbette dosyalar ve medya yasaktır."; + /* No comment provided by engineer. */ "Files and media are prohibited." = "Dosyalar ve medya bu grupta yasaklandı."; @@ -2552,6 +2573,9 @@ snd error text */ /* No comment provided by engineer. */ "Find chats faster" = "Sohbetleri daha hızlı bul"; +/* server test error */ +"Fingerprint in server address does not match certificate." = "Muhtemelen, sunucu adresindeki parmakizi sertifikası doğru değil"; + /* No comment provided by engineer. */ "Fix" = "Düzelt"; @@ -3107,6 +3131,9 @@ snd error text */ /* alert title */ "Keep unused invitation?" = "Kullanılmamış davet tutulsun mu?"; +/* No comment provided by engineer. */ +"Keep your chats clean" = "Sohbetlerinizi temiz tutun"; + /* No comment provided by engineer. */ "Keep your connections" = "Bağlantılarınızı koruyun"; @@ -3233,6 +3260,9 @@ snd error text */ /* No comment provided by engineer. */ "Member" = "Kişi"; +/* past/unknown group member */ +"Member %@" = "Üye%@"; + /* profile update event chat item */ "member %@ changed to %@" = "kişi %1$@ , %2$@ olarak değişti"; @@ -3248,6 +3278,9 @@ snd error text */ /* item status text */ "Member inactive" = "Üye inaktif"; +/* No comment provided by engineer. */ +"Member is deleted - can't accept request" = "Üye silinmiş - istek kabul edilemez"; + /* chat feature */ "Member reports" = "Üye raporları"; @@ -3506,7 +3539,7 @@ snd error text */ /* No comment provided by engineer. */ "Network settings" = "Ağ ayarları"; -/* No comment provided by engineer. */ +/* alert title */ "Network status" = "Ağ durumu"; /* delete after time */ @@ -3790,6 +3823,9 @@ new chat action */ /* No comment provided by engineer. */ "Only you can send disappearing messages." = "Sadece sen kaybolan mesajlar gönderebilirsin."; +/* No comment provided by engineer. */ +"Only you can send files and media." = "Sadece sen dosya ve medya gönderebilirsin."; + /* No comment provided by engineer. */ "Only you can send voice messages." = "Sadece sen sesli mesajlar gönderebilirsin."; @@ -3805,6 +3841,9 @@ new chat action */ /* No comment provided by engineer. */ "Only your contact can send disappearing messages." = "Sadece karşıdaki kişi kaybolan mesajlar gönderebilir."; +/* No comment provided by engineer. */ +"Only your contact can send files and media." = "Sadece kişilerin dosya ve medya gönderebilir."; + /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Sadece karşıdaki kişi sesli mesajlar gönderebilir."; @@ -3820,9 +3859,15 @@ new chat action */ /* authentication reason */ "Open chat console" = "Sohbet konsolunu aç"; +/* alert action */ +"Open clean link" = "Temiz linki aç"; + /* No comment provided by engineer. */ "Open conditions" = "Açık koşullar"; +/* alert action */ +"Open full link" = "Tam linki aç"; + /* new chat action */ "Open group" = "Grubu aç"; @@ -3850,6 +3895,9 @@ new chat action */ /* No comment provided by engineer. */ "Open to join" = "Katılmak için aç"; +/* No comment provided by engineer. */ +"Open to use bot" = "Botu kullanmak için aç"; + /* No comment provided by engineer. */ "Opening app…" = "Uygulama açılıyor…"; @@ -3919,9 +3967,6 @@ new chat action */ /* No comment provided by engineer. */ "Password to show" = "Gösterilecek şifre"; -/* past/unknown group member */ -"Past member %@" = "Geçmiş üye %@"; - /* No comment provided by engineer. */ "Paste desktop address" = "Bilgisayar adresini yapıştır"; @@ -4033,9 +4078,6 @@ new chat action */ /* No comment provided by engineer. */ "Port" = "Port"; -/* server test error */ -"Possibly, certificate fingerprint in server address is incorrect" = "Muhtemelen, sunucu adresindeki parmakizi sertifikası doğru değil"; - /* No comment provided by engineer. */ "Preserve the last message draft, with attachments." = "Son mesaj taslağını ekleriyle birlikte koru."; @@ -4225,9 +4267,6 @@ new chat action */ /* No comment provided by engineer. */ "received confirmation…" = "onaylama alındı…"; -/* notification */ -"Received file event" = "Dosya etkinliği alındı"; - /* message info title */ "Received message" = "Mesaj alındı"; @@ -4332,6 +4371,9 @@ swipe action */ /* No comment provided by engineer. */ "Remove image" = "Resmi kaldır"; +/* No comment provided by engineer. */ +"Remove link tracking" = "Bağlantı izlemeyi kaldır"; + /* No comment provided by engineer. */ "Remove member" = "Kişiyi sil"; @@ -4422,6 +4464,12 @@ swipe action */ /* No comment provided by engineer. */ "request to join rejected" = "katılma isteği reddedildi"; +/* rcv group event chat item */ +"requested connection" = "istenilen bağlantı"; + +/* rcv direct event chat item */ +"requested connection from group %@" = "%@ grubundan bağlantı isteği"; + /* chat list item title */ "requested to connect" = "bağlanma isteği gönderildi"; @@ -4780,9 +4828,6 @@ chat item action */ /* No comment provided by engineer. */ "Sent directly" = "Direkt gönderildi"; -/* notification */ -"Sent file event" = "Dosya etkinliği gönderildi"; - /* message info title */ "Sent message" = "Mesaj gönderildi"; @@ -4829,10 +4874,10 @@ chat item action */ "server queue info: %@\n\nlast received msg: %@" = "sunucu kuyruk bilgisi: %1$@\n\nson alınan msj: %2$@"; /* server test error */ -"Server requires authorization to create queues, check password" = "Sunucunun sıra oluşturması için yetki gereklidir, şifreyi kontrol edin"; +"Server requires authorization to create queues, check password." = "Sunucunun sıra oluşturması için yetki gereklidir, şifreyi kontrol edin"; /* server test error */ -"Server requires authorization to upload, check password" = "Sunucunun yükleme yapması için yetki gereklidir, şifreyi kontrol edin"; +"Server requires authorization to upload, check password." = "Sunucunun yükleme yapması için yetki gereklidir, şifreyi kontrol edin"; /* No comment provided by engineer. */ "Server test failed!" = "Sunucu testinde hata oluştu!"; @@ -4897,6 +4942,9 @@ chat item action */ /* No comment provided by engineer. */ "Set passphrase to export" = "Dışa aktarmak için parola ayarla"; +/* No comment provided by engineer. */ +"Set profile bio and welcome message." = "Profil biyografisi ve hoşgeldiniz mesajı düzenle."; + /* No comment provided by engineer. */ "Set the message shown to new members!" = "Yeni üyeler için gösterilen bir mesaj ayarla!"; @@ -4937,6 +4985,12 @@ chat item action */ /* No comment provided by engineer. */ "Share link" = "Bağlantıyı paylaş"; +/* alert button */ +"Share old address" = "Eski adresi paylaş"; + +/* alert button */ +"Share old link" = "Eski bağlantıyı paylaş"; + /* No comment provided by engineer. */ "Share profile" = "Profil paylaş"; @@ -4952,12 +5006,18 @@ chat item action */ /* No comment provided by engineer. */ "Share with contacts" = "Kişilerle paylaş"; +/* No comment provided by engineer. */ +"Share your address" = "Adresini paylaş"; + /* No comment provided by engineer. */ "Short description" = "Kısa açıklama"; /* No comment provided by engineer. */ "Short link" = "Kısa bağlantı"; +/* No comment provided by engineer. */ +"Short SimpleX address" = "Kısa SimpleX adresi"; + /* No comment provided by engineer. */ "Show → on messages sent via private routing." = "Gizli yönlendirme yoluyla gönderilen mesajlarda → işaretini göster."; @@ -5048,6 +5108,9 @@ chat item action */ /* No comment provided by engineer. */ "SimpleX protocols reviewed by Trail of Bits." = "SimpleX protokolleri Trail of Bits tarafından incelenmiştir."; +/* simplex link type */ +"SimpleX relay link" = "SimpleX aktarıcı bağlantısı"; + /* No comment provided by engineer. */ "Simplified incognito mode" = "Basitleştirilmiş gizli mod"; @@ -5202,6 +5265,9 @@ report reason */ /* No comment provided by engineer. */ "Tap Connect to send request" = "Bağlan'a dokunarak isteği gönderin"; +/* No comment provided by engineer. */ +"Tap Connect to use bot" = "Botu kullanmak için Bağlan tuşuna bas"; + /* No comment provided by engineer. */ "Tap Create SimpleX address in the menu to create it later." = "Daha sonra oluşturmak için menüden BasitX adresi oluştur'a dokunun."; @@ -5274,6 +5340,9 @@ report reason */ /* No comment provided by engineer. */ "Thanks to the users – contribute via Weblate!" = "Kullanıcılar için teşekkürler - Weblate aracılığıyla katkıda bulun!"; +/* alert message */ +"The address will be short, and your profile will be shared via the address." = "Adres kısa olacak ve profiliniz bu adres üzerinden paylaşılacaktır."; + /* No comment provided by engineer. */ "The app can notify you when you receive messages or contact requests - please open settings to enable." = "Uygulama, mesaj veya iletişim isteği aldığınızda sizi bilgilendirebilir - etkinleştirmek için lütfen ayarları açın."; @@ -5313,6 +5382,9 @@ report reason */ /* No comment provided by engineer. */ "The ID of the next message is incorrect (less or equal to the previous).\nIt can happen because of some bug or when the connection is compromised." = "Bir sonraki mesajın kimliği yanlış (bir öncekinden az veya aynı).\nBazı hatalar nedeniyle veya bağlantı tehlikeye girdiğinde meydana gelebilir."; +/* alert message */ +"The link will be short, and group profile will be shared via the link." = "Bağlantı kısa olacak ve grup profili bağlantı üzerinden paylaşılacaktır."; + /* No comment provided by engineer. */ "The message will be deleted for all members." = "Mesaj tüm üyeler için silinecektir."; @@ -5409,6 +5481,12 @@ report reason */ /* No comment provided by engineer. */ "This setting applies to messages in your current chat profile **%@**." = "Bu ayar, geçerli sohbet profiliniz **%@** deki mesajlara uygulanır."; +/* No comment provided by engineer. */ +"This setting is for your current profile **%@**." = "Bu ayar, mevcut profiliniz içindir."; + +/* No comment provided by engineer. */ +"Time to disappear is set only for new contacts." = "Kaybolma süresi yalnızca yeni kişiler için ayarlanır."; + /* No comment provided by engineer. */ "Title" = "Başlık"; @@ -5457,6 +5535,9 @@ report reason */ /* No comment provided by engineer. */ "To send" = "Göndermek için"; +/* alert message */ +"To send commands you must be connected." = "Komut göndermek için bağlı olmanız gerekir."; + /* No comment provided by engineer. */ "To support instant push notifications the chat database has to be migrated." = "Anlık anlık bildirimleri desteklemek için sohbet veritabanının taşınması gerekir."; @@ -5490,12 +5571,6 @@ report reason */ /* No comment provided by engineer. */ "Transport sessions" = "Taşıma oturumları"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Bu kişiden mesaj almak için kullanılan sunucuya bağlanılmaya çalışılıyor (hata: %@)."; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "Bu kişiden mesaj almak için kullanılan sunucuya bağlanılmaya çalışılıyor."; - /* No comment provided by engineer. */ "Turkish interface" = "Türkçe arayüz"; @@ -5625,9 +5700,27 @@ report reason */ /* No comment provided by engineer. */ "Updating settings will re-connect the client to all servers." = "Ayarların güncellenmesi, istemciyi tüm sunuculara yeniden bağlayacaktır."; +/* alert button */ +"Upgrade" = "Yükseltme"; + +/* No comment provided by engineer. */ +"Upgrade address" = "Adres güncelleme"; + +/* alert message */ +"Upgrade address?" = "Adres güncellensin mi?"; + /* No comment provided by engineer. */ "Upgrade and open chat" = "Yükselt ve sohbeti aç"; +/* alert message */ +"Upgrade group link?" = "Grub linki güncellensin mi?"; + +/* No comment provided by engineer. */ +"Upgrade link" = "Linki güncelle"; + +/* No comment provided by engineer. */ +"Upgrade your address" = "Adresini yükselt"; + /* No comment provided by engineer. */ "Upload errors" = "Yükleme hataları"; @@ -5670,6 +5763,9 @@ report reason */ /* No comment provided by engineer. */ "Use from desktop" = "Bilgisayardan kullan"; +/* No comment provided by engineer. */ +"Use incognito profile" = "Gizli profil kullan"; + /* No comment provided by engineer. */ "Use iOS call interface" = "iOS arama arayüzünden kullan"; @@ -5862,6 +5958,9 @@ report reason */ /* No comment provided by engineer. */ "Welcome message is too long" = "Hoş geldiniz mesajı çok uzun"; +/* No comment provided by engineer. */ +"Welcome your contacts 👋" = "Kişilerine hoş geldin👋"; + /* No comment provided by engineer. */ "What's new" = "Neler yeni"; @@ -5964,9 +6063,6 @@ report reason */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "Gruba zaten katılıyorsunuz!\nKatılma isteği tekrarlansın mı?"; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "Bu kişiden mesaj almak için kullanılan sunucuya bağlısınız."; - /* No comment provided by engineer. */ "You are invited to group" = "Gruba davet edildiniz"; diff --git a/apps/ios/uk.lproj/Localizable.strings b/apps/ios/uk.lproj/Localizable.strings index c4efd9513a..fe8cfe22a0 100644 --- a/apps/ios/uk.lproj/Localizable.strings +++ b/apps/ios/uk.lproj/Localizable.strings @@ -1179,7 +1179,7 @@ set passcode view */ /* No comment provided by engineer. */ "Conditions are already accepted for these operator(s): **%@**." = "Умови вже прийняті для наступних операторів: **%@**."; -/* No comment provided by engineer. */ +/* alert button */ "Conditions of use" = "Умови використання"; /* No comment provided by engineer. */ @@ -2248,10 +2248,10 @@ chat item action */ "Error decrypting file" = "Помилка розшифрування файлу"; /* alert title */ -"Error deleting chat database" = "Помилка видалення бази даних чату"; +"Error deleting chat" = "Помилка при видаленні чату з учасником"; /* alert title */ -"Error deleting chat with member" = "Помилка при видаленні чату з учасником"; +"Error deleting chat database" = "Помилка видалення бази даних чату"; /* alert title */ "Error deleting chat!" = "Помилка видалення чату!"; @@ -2549,6 +2549,9 @@ snd error text */ /* No comment provided by engineer. */ "Find chats faster" = "Швидше знаходьте чати"; +/* server test error */ +"Fingerprint in server address does not match certificate." = "Відбиток в адресі сервера не співпадає з сертифікатом."; + /* No comment provided by engineer. */ "Fix" = "Виправити"; @@ -3506,7 +3509,7 @@ snd error text */ /* No comment provided by engineer. */ "Network settings" = "Налаштування мережі"; -/* No comment provided by engineer. */ +/* alert title */ "Network status" = "Стан мережі"; /* delete after time */ @@ -3919,9 +3922,6 @@ new chat action */ /* No comment provided by engineer. */ "Password to show" = "Показати пароль"; -/* past/unknown group member */ -"Past member %@" = "Колишній учасник %@"; - /* No comment provided by engineer. */ "Paste desktop address" = "Вставте адресу робочого столу"; @@ -4033,9 +4033,6 @@ new chat action */ /* No comment provided by engineer. */ "Port" = "Порт"; -/* server test error */ -"Possibly, certificate fingerprint in server address is incorrect" = "Можливо, в адресі сервера неправильно вказано відбиток сертифіката"; - /* No comment provided by engineer. */ "Preserve the last message draft, with attachments." = "Зберегти чернетку останнього повідомлення з вкладеннями."; @@ -4225,9 +4222,6 @@ new chat action */ /* No comment provided by engineer. */ "received confirmation…" = "отримали підтвердження…"; -/* notification */ -"Received file event" = "Подія отримання файлу"; - /* message info title */ "Received message" = "Отримано повідомлення"; @@ -4780,9 +4774,6 @@ chat item action */ /* No comment provided by engineer. */ "Sent directly" = "Відправлено напряму"; -/* notification */ -"Sent file event" = "Подія надісланого файлу"; - /* message info title */ "Sent message" = "Надіслано повідомлення"; @@ -4829,10 +4820,10 @@ chat item action */ "server queue info: %@\n\nlast received msg: %@" = "інформація про чергу на сервері: %1$@\n\nостаннє отримане повідомлення: %2$@"; /* server test error */ -"Server requires authorization to create queues, check password" = "Сервер вимагає авторизації для створення черг, перевірте пароль"; +"Server requires authorization to create queues, check password." = "Сервер вимагає авторизації для створення черг, перевірте пароль."; /* server test error */ -"Server requires authorization to upload, check password" = "Сервер вимагає авторизації для завантаження, перевірте пароль"; +"Server requires authorization to upload, check password." = "Сервер вимагає авторизації для завантаження, перевірте пароль."; /* No comment provided by engineer. */ "Server test failed!" = "Тест сервера завершився невдало!"; @@ -5514,12 +5505,6 @@ report reason */ /* No comment provided by engineer. */ "Transport sessions" = "Транспортні сесії"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "Спроба з'єднатися з сервером, який використовується для отримання повідомлень від цього контакту (помилка: %@)."; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "Спроба з'єднатися з сервером, який використовується для отримання повідомлень від цього контакту."; - /* No comment provided by engineer. */ "Turkish interface" = "Турецький інтерфейс"; @@ -6012,9 +5997,6 @@ report reason */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "Ви вже приєдналися до групи!\nПовторити запит на приєднання?"; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "Ви підключені до сервера, який використовується для отримання повідомлень від цього контакту."; - /* No comment provided by engineer. */ "You are invited to group" = "Запрошуємо вас до групи"; diff --git a/apps/ios/zh-Hans.lproj/Localizable.strings b/apps/ios/zh-Hans.lproj/Localizable.strings index 6ba3c08be2..24d153afd5 100644 --- a/apps/ios/zh-Hans.lproj/Localizable.strings +++ b/apps/ios/zh-Hans.lproj/Localizable.strings @@ -1122,7 +1122,7 @@ set passcode view */ /* No comment provided by engineer. */ "Conditions are already accepted for these operator(s): **%@**." = "已经接受下列运营方的条款:**%@**。"; -/* No comment provided by engineer. */ +/* alert button */ "Conditions of use" = "使用条款"; /* No comment provided by engineer. */ @@ -2441,6 +2441,9 @@ snd error text */ /* No comment provided by engineer. */ "Find chats faster" = "更快地查找聊天记录"; +/* server test error */ +"Fingerprint in server address does not match certificate." = "服务器地址中的证书指纹可能不正确"; + /* No comment provided by engineer. */ "Fix" = "修复"; @@ -3362,7 +3365,7 @@ snd error text */ /* No comment provided by engineer. */ "Network settings" = "网络设置"; -/* No comment provided by engineer. */ +/* alert title */ "Network status" = "网络状态"; /* delete after time */ @@ -3739,9 +3742,6 @@ new chat action */ /* No comment provided by engineer. */ "Password to show" = "显示密码"; -/* past/unknown group member */ -"Past member %@" = "前任成员 %@"; - /* No comment provided by engineer. */ "Paste desktop address" = "粘贴桌面地址"; @@ -3829,9 +3829,6 @@ new chat action */ /* No comment provided by engineer. */ "Polish interface" = "波兰语界面"; -/* server test error */ -"Possibly, certificate fingerprint in server address is incorrect" = "服务器地址中的证书指纹可能不正确"; - /* No comment provided by engineer. */ "Preserve the last message draft, with attachments." = "保留最后的消息草稿及其附件。"; @@ -4000,9 +3997,6 @@ new chat action */ /* No comment provided by engineer. */ "received confirmation…" = "已受到确认……"; -/* notification */ -"Received file event" = "收到文件项目"; - /* message info title */ "Received message" = "收到的信息"; @@ -4447,9 +4441,6 @@ chat item action */ /* No comment provided by engineer. */ "Sent directly" = "直接发送"; -/* notification */ -"Sent file event" = "已发送文件项目"; - /* message info title */ "Sent message" = "已发信息"; @@ -4481,10 +4472,10 @@ chat item action */ "server queue info: %@\n\nlast received msg: %@" = "服务器队列信息: %1$@\n\n上次收到的消息: %2$@"; /* server test error */ -"Server requires authorization to create queues, check password" = "服务器需要授权才能创建队列,检查密码"; +"Server requires authorization to create queues, check password." = "服务器需要授权才能创建队列,检查密码"; /* server test error */ -"Server requires authorization to upload, check password" = "服务器需要授权来上传,检查密码"; +"Server requires authorization to upload, check password." = "服务器需要授权来上传,检查密码"; /* No comment provided by engineer. */ "Server test failed!" = "服务器测试失败!"; @@ -5015,12 +5006,6 @@ chat item action */ /* No comment provided by engineer. */ "Transport sessions" = "传输会话"; -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact (error: %@)." = "正在尝试连接到用于从该联系人接收消息的服务器(错误:%@)。"; - -/* No comment provided by engineer. */ -"Trying to connect to the server used to receive messages from this contact." = "正在尝试连接到用于从该联系人接收消息的服务器。"; - /* No comment provided by engineer. */ "Turkish interface" = "土耳其语界面"; @@ -5438,9 +5423,6 @@ chat item action */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "您已经加入了这个群组!\n重复加入请求?"; -/* No comment provided by engineer. */ -"You are connected to the server used to receive messages from this contact." = "您已连接到用于接收该联系人消息的服务器。"; - /* No comment provided by engineer. */ "You are invited to group" = "您被邀请加入群组"; diff --git a/apps/multiplatform/android/build.gradle.kts b/apps/multiplatform/android/build.gradle.kts index 8168c92bf8..9b4e7ad58c 100644 --- a/apps/multiplatform/android/build.gradle.kts +++ b/apps/multiplatform/android/build.gradle.kts @@ -15,8 +15,7 @@ android { applicationId = "chat.simplex.app" namespace = "chat.simplex.app" minSdk = 26 - //noinspection OldTargetApi - targetSdk = 34 + targetSdk = 35 // !!! // skip version code after release to F-Droid, as it uses two version codes versionCode = (extra["android.version_code"] as String).toInt() diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index 8b4dd0e4d7..602b8f2b90 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -95,7 +95,8 @@ kotlin { implementation("com.jakewharton:process-phoenix:3.0.0") - val cameraXVersion = "1.3.4" + // https://issuetracker.google.com/issues/351313880 + val cameraXVersion = "1.5.1" implementation("androidx.camera:camera-core:${cameraXVersion}") implementation("androidx.camera:camera-camera2:${cameraXVersion}") implementation("androidx.camera:camera-lifecycle:${cameraXVersion}") @@ -113,7 +114,7 @@ kotlin { } // For jSystemThemeDetector only implementation("net.java.dev.jna:jna-platform:5.14.0") - implementation("com.sshtools:two-slices:0.9.0-SNAPSHOT") + implementation("com.sshtools:two-slices:0.9.1") implementation("org.slf4j:slf4j-simple:2.0.12") implementation("uk.co.caprica:vlcj:4.8.3") implementation("net.java.dev.jna:jna:5.14.0") @@ -254,8 +255,11 @@ afterEvaluate { val fileRegex = Regex("MR/../strings.xml$|MR/..-.../strings.xml$|MR/..-../strings.xml$|MR/base/strings.xml$") val tree = kotlin.sourceSets["commonMain"].resources.filter { fileRegex.containsMatchIn(it.absolutePath.replace("\\", "/")) }.asFileTree val baseStringsFile = tree.firstOrNull { it.absolutePath.replace("\\", "/").endsWith("base/strings.xml") } ?: throw Exception("No base/strings.xml found") + val lvStringsFile = tree.firstOrNull { it.absolutePath.replace("\\", "/").endsWith("lv/strings.xml") } ?: throw Exception("No base/strings.xml found") val treeList = ArrayList(tree.toList()) treeList.remove(baseStringsFile) + // removed lv/strings.xml file with 100+ errors + treeList.remove(lvStringsFile) treeList.add(0, baseStringsFile) val baseFormatting = mutableMapOf>() treeList.forEachIndexed { index, file -> diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/SimplexService.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/SimplexService.android.kt index 14e4134010..3ccdcecab0 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/SimplexService.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/SimplexService.android.kt @@ -12,8 +12,11 @@ actual fun getWakeLock(timeout: Long): (() -> Unit) { } } return { - wakeLock?.release() - wakeLock = null + val lock = wakeLock + if (lock != null) { + if (lock.isHeld) lock.release() + wakeLock = null + } } } diff --git a/apps/multiplatform/common/src/commonMain/cpp/android/CMakeLists.txt b/apps/multiplatform/common/src/commonMain/cpp/android/CMakeLists.txt index 44cb31d424..49794a8ab5 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/android/CMakeLists.txt +++ b/apps/multiplatform/common/src/commonMain/cpp/android/CMakeLists.txt @@ -57,6 +57,9 @@ set_target_properties( support PROPERTIES IMPORTED_LOCATION # can link multiple libraries, such as libraries you define in this # build script, prebuilt third-party libraries, or system libraries. +# https://developer.android.com/guide/practices/page-sizes#cmake +target_link_options(app-lib PRIVATE "-Wl,-z,max-page-size=16384") + target_link_libraries( # Specifies the target library. app-lib diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt index d88a450fd1..70e0067260 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt @@ -243,9 +243,9 @@ fun MainScreen() { ModalManager.fullscreen.showOneTimePasscodeInView() AlertManager.privacySensitive.showInView() if (onboarding == OnboardingStage.OnboardingComplete) { - LaunchedEffect(chatModel.currentUser.value, chatModel.appOpenUrl.value) { + LaunchedEffect(chatModel.chatRunning.value, chatModel.currentUser.value, chatModel.appOpenUrl.value) { val (rhId, url) = chatModel.appOpenUrl.value ?: (null to null) - if (url != null) { + if (url != null && chatModel.chatRunning.value == true) { chatModel.appOpenUrl.value = null connectIfOpenedViaUri(rhId, url, chatModel) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 047d14d9f1..8db2cc1a76 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -96,11 +96,12 @@ object ChatModel { val dbMigrationInProgress = mutableStateOf(false) val incompleteInitializedDbRemoved = mutableStateOf(false) // map of connections network statuses, key is agent connection id - val networkStatuses = mutableStateMapOf() val switchingUsersAndHosts = mutableStateOf(false) // current chat val chatId = mutableStateOf(null) + val chatAgentConnId = mutableStateOf(null) + val chatSubStatus = mutableStateOf(null) val openAroundItemId: MutableState = mutableStateOf(null) val chatsContext = ChatsContext(null) val secondaryChatsContext = mutableStateOf(null) @@ -133,6 +134,7 @@ object ChatModel { // set when app is opened via contact or invitation URI (rhId, uri) val appOpenUrl = mutableStateOf?>(null) + val appOpenUrlConnecting = mutableStateOf(false) // Needed to check for bottom nav bar and to apply or not navigation bar color on Android val newChatSheetVisible = mutableStateOf(false) @@ -681,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) { @@ -1146,21 +1143,6 @@ object ChatModel { showingInvitation.value = showingInvitation.value?.copy(connChatUsed = true) } - fun setContactNetworkStatus(contact: Contact, status: NetworkStatus) { - val conn = contact.activeConn - if (conn != null) { - networkStatuses[conn.agentConnId] = status - } - } - - fun contactNetworkStatus(contact: Contact): NetworkStatus { - val conn = contact.activeConn - return if (conn != null) - networkStatuses[conn.agentConnId] ?: NetworkStatus.Unknown() - else - NetworkStatus.Unknown() - } - fun addTerminalItem(item: TerminalItem) { val maxItems = if (appPreferences.developerTools.get()) 500 else 200 if (terminalsVisible.isNotEmpty()) { @@ -1720,30 +1702,6 @@ sealed class ChatInfo: SomeChat, NamedChat { } } -@Serializable -sealed class NetworkStatus { - val statusString: String get() = - when (this) { - is Connected -> generalGetString(MR.strings.server_connected) - is Error -> generalGetString(MR.strings.server_error) - else -> generalGetString(MR.strings.server_connecting) - } - val statusExplanation: String get() = - when (this) { - is Connected -> generalGetString(MR.strings.connected_to_server_to_receive_messages_from_contact) - is Error -> String.format(generalGetString(MR.strings.trying_to_connect_to_server_to_receive_messages_with_error), connectionError) - else -> generalGetString(MR.strings.trying_to_connect_to_server_to_receive_messages) - } - - @Serializable @SerialName("unknown") class Unknown: NetworkStatus() - @Serializable @SerialName("connected") class Connected: NetworkStatus() - @Serializable @SerialName("disconnected") class Disconnected: NetworkStatus() - @Serializable @SerialName("error") class Error(val connectionError: String): NetworkStatus() -} - -@Serializable -data class ConnNetworkStatus(val agentConnId: String, val networkStatus: NetworkStatus) - @Serializable data class Contact( val contactId: Long, @@ -1915,12 +1873,6 @@ class ContactRef( val id: ChatId get() = "@$contactId" } -@Serializable -class ContactSubStatus( - val contact: Contact, - val contactError: ChatError? = null -) - @Serializable data class Connection( val connId: Long, @@ -2338,22 +2290,28 @@ data class GroupMember ( fun canBeRemoved(groupInfo: GroupInfo): Boolean { val userRole = groupInfo.membership.memberRole - return memberStatus != GroupMemberStatus.MemRemoved && memberStatus != GroupMemberStatus.MemLeft - && userRole >= GroupMemberRole.Admin && userRole >= memberRole && groupInfo.membership.memberActive + return userRole >= GroupMemberRole.Admin && userRole >= memberRole && groupInfo.membership.memberActive } fun canChangeRoleTo(groupInfo: GroupInfo): List? = - if (!canBeRemoved(groupInfo)) 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 } + val supportChatNotRead: Boolean get() = + if (supportChat != null) + supportChat.memberAttention > 0 || supportChat.mentions > 0 || supportChat.unread > 0 + else + false + val versionRange: VersionRange = activeConn?.peerChatVRange ?: memberChatVRange val memberIncognito = memberProfile.profileId != memberContactProfileId @@ -2513,12 +2471,6 @@ class LinkPreview ( } } -@Serializable -class MemberSubError ( - val member: GroupMemberIds, - val memberError: ChatError -) - @Serializable class NoteFolder( val noteFolderId: Long, @@ -2838,30 +2790,29 @@ data class ChatItem ( } val mergeCategory: CIMergeCategory? - get() = when (content) { - is CIContent.RcvChatFeature, - is CIContent.SndChatFeature, - is CIContent.RcvGroupFeature, - is CIContent.SndGroupFeature -> CIMergeCategory.ChatFeature - is CIContent.RcvGroupEventContent -> when (content.rcvGroupEvent) { - is RcvGroupEvent.UserRole, - is RcvGroupEvent.UserDeleted, - is RcvGroupEvent.GroupDeleted, - is RcvGroupEvent.MemberCreatedContact, - is RcvGroupEvent.NewMemberPendingReview -> - null - else -> CIMergeCategory.RcvGroupEvent - } - is CIContent.SndGroupEventContent -> when (content.sndGroupEvent) { - is SndGroupEvent.UserRole, is SndGroupEvent.UserLeft, is SndGroupEvent.MemberAccepted, is SndGroupEvent.UserPendingReview -> null - else -> CIMergeCategory.SndGroupEvent - } - else -> { - if (meta.itemDeleted == null) { - null - } else { - if (chatDir.sent) CIMergeCategory.SndItemDeleted else CIMergeCategory.RcvItemDeleted + get() = if (meta.itemDeleted != null) { + if (chatDir.sent) CIMergeCategory.SndItemDeleted else CIMergeCategory.RcvItemDeleted + } else { + when (content) { + is CIContent.RcvChatFeature, + is CIContent.SndChatFeature, + is CIContent.RcvGroupFeature, + is CIContent.SndGroupFeature -> CIMergeCategory.ChatFeature + is CIContent.RcvGroupEventContent -> when (content.rcvGroupEvent) { + is RcvGroupEvent.UserRole, + is RcvGroupEvent.UserDeleted, + is RcvGroupEvent.GroupDeleted, + is RcvGroupEvent.MemberCreatedContact, + is RcvGroupEvent.NewMemberPendingReview -> + null + else -> CIMergeCategory.RcvGroupEvent } + is CIContent.SndGroupEventContent -> when (content.sndGroupEvent) { + is SndGroupEvent.UserRole, is SndGroupEvent.UserLeft, is SndGroupEvent.MemberAccepted, is SndGroupEvent.UserPendingReview -> null + else -> CIMergeCategory.SndGroupEvent + } + else -> + null } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 1b0714dd9d..22da2ce649 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -27,12 +28,14 @@ import dev.icerock.moko.resources.compose.painterResource import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* +import chat.simplex.common.views.chat.item.contentModerationPostLink import chat.simplex.common.views.chat.item.showContentBlockedAlert import chat.simplex.common.views.chat.item.showQuotedItemDoesNotExistAlert import chat.simplex.common.views.chatlist.openGroupChat import chat.simplex.common.views.migration.MigrationFileLinkData import chat.simplex.common.views.onboarding.OnboardingStage import chat.simplex.common.views.usersettings.* +import chat.simplex.common.views.usersettings.networkAndServers.defaultConditionsLink import chat.simplex.common.views.usersettings.networkAndServers.serverHostname import com.charleskorn.kaml.Yaml import com.charleskorn.kaml.YamlConfiguration @@ -46,12 +49,17 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.sync.withLock import kotlinx.datetime.Clock import kotlinx.datetime.Instant +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toJavaLocalDateTime +import kotlinx.datetime.toLocalDateTime import kotlinx.serialization.* import kotlinx.serialization.builtins.* import kotlinx.serialization.descriptors.* import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.* +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle import java.util.Date typealias ChatCtrl = Long @@ -632,7 +640,7 @@ object ChatController { val chats = apiGetChats(rhId) chatModel.chatsContext.updateChats(chats, keepingChatId = keepingChatId) } - chatModel.userTags.value = apiGetChatTags(rhId).takeIf { hasUser } ?: emptyList() + chatModel.userTags.value = if (hasUser) apiGetChatTags(rhId) ?: emptyList() else emptyList() chatModel.activeChatTagFilter.value = null chatModel.updateChatTags(rhId) } @@ -653,7 +661,14 @@ object ChatController { break } try { - releaseLock() + val release = releaseLock + // delaying the release of wake lock in order to: + // 1. avoid race condition with the incoming call activity that fails to show if called after wake lock release (primary reason), + // 2. allow any other necessary processing for a bit of time with wakelock held. + launch { + delay(30000) + release() + } val msg = recvMsg(ctrl) releaseLock = getWakeLock(timeout = 60000) if (msg != null) { @@ -733,7 +748,7 @@ object ChatController { val message = { String.format(generalGetString(MR.strings.network_error_desc), serverHostname(e.brokerAddress)) } return when (e.brokerErr) { is BrokerErrorType.TIMEOUT -> MR.strings.connection_timeout to message() - is BrokerErrorType.NETWORK -> MR.strings.connection_error to message() + is BrokerErrorType.NETWORK -> if (e.brokerErr.networkError is NetworkError.UnknownCAError) null else MR.strings.connection_error to message() else -> null } } @@ -742,7 +757,7 @@ object ChatController { val message = { String.format(generalGetString(MR.strings.smp_proxy_error_connecting), serverHostname(e.serverAddress)) } return when (e.smpErr.proxyErr.brokerErr) { is BrokerErrorType.TIMEOUT -> MR.strings.private_routing_timeout to message() - is BrokerErrorType.NETWORK -> MR.strings.private_routing_error to message() + is BrokerErrorType.NETWORK -> if (e.smpErr.proxyErr.brokerErr.networkError is NetworkError.UnknownCAError) null else MR.strings.private_routing_error to message() else -> null } } @@ -753,7 +768,7 @@ object ChatController { is ProxyError.BROKER -> when (e.proxyErr.protocolErr.proxyErr.brokerErr) { is BrokerErrorType.TIMEOUT -> MR.strings.private_routing_timeout to message() - is BrokerErrorType.NETWORK -> MR.strings.private_routing_error to message() + is BrokerErrorType.NETWORK -> if (e.proxyErr.protocolErr.proxyErr.brokerErr.networkError is NetworkError.UnknownCAError) null else MR.strings.private_routing_error to message() else -> null } is ProxyError.NO_SESSION -> MR.strings.private_routing_no_session to message() @@ -1702,6 +1717,11 @@ object ChatController { val userId = kotlin.runCatching { currentUserId("apiCreateUserAddress") }.getOrElse { return null } val r = sendCmdWithRetry(rh, CC.ApiCreateMyAddress(userId)) if (r is API.Result && r.res is CR.UserContactLinkCreated) return r.res.connLinkContact + if (r is API.Error && r.err is ChatError.ChatErrorAgent && r.err.agentError is AgentErrorType.NOTICE) { + val e = r.err.agentError + showClientNoticeAlert(e.server, e.preset, e.expiresAt) + return null + } if (r == null) return null if (!(networkErrorAlert(r))) { apiErrorAlert("apiCreateUserAddress", generalGetString(MR.strings.error_creating_address), r) @@ -1835,20 +1855,20 @@ object ChatController { return r.result is CR.CmdOk } - suspend fun apiGetNetworkStatuses(rh: Long?): List? { - val r = sendCmd(rh, CC.ApiGetNetworkStatuses()) - if (r is API.Result && r.res is CR.NetworkStatuses) return r.res.networkStatuses - Log.e(TAG, "apiGetNetworkStatuses bad response: ${r.responseType} ${r.details}") - return null - } - - suspend fun apiChatRead(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?): Boolean { - val r = sendCmd(rh, CC.ApiChatRead(type, id, scope)) + suspend fun apiChatRead(rh: Long?, type: ChatType, id: Long): Boolean { + val r = sendCmd(rh, CC.ApiChatRead(type, id, scope = null)) if (r.result is CR.CmdOk) return true Log.e(TAG, "apiChatRead bad response: ${r.responseType} ${r.details}") return false } + suspend fun apiSupportChatRead(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope): Pair? { + val r = sendCmd(rh, CC.ApiChatRead(type, id, scope)) + if (r is API.Result && r.res is CR.MemberSupportChatRead) return r.res.groupInfo to r.res.member + apiErrorAlert("apiSupportChatRead", generalGetString(MR.strings.error_marking_member_support_chat_read), r) + return null + } + suspend fun apiChatItemsRead(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, itemIds: List): ChatInfo? { val r = sendCmd(rh, CC.ApiChatItemsRead(type, id, scope, itemIds)) if (r is API.Result && r.res is CR.ItemsReadForChat) return r.res.chatInfo @@ -2098,7 +2118,7 @@ object ChatController { return null } - suspend fun apiRemoveMembers(rh: Long?, groupId: Long, memberIds: List, withMessages: Boolean = false): Pair>? { + suspend fun apiRemoveMembers(rh: Long?, groupId: Long, memberIds: List, withMessages: Boolean): Pair>? { val r = sendCmd(rh, CC.ApiRemoveMembers(groupId, memberIds, withMessages)) if (r is API.Result && r.res is CR.UserDeletedMembers) return r.res.groupInfo to r.res.members if (!(networkErrorAlert(r))) { @@ -2161,6 +2181,11 @@ object ChatController { suspend fun apiCreateGroupLink(rh: Long?, groupId: Long, memberRole: GroupMemberRole = GroupMemberRole.Member): GroupLink? { val r = sendCmdWithRetry(rh, CC.APICreateGroupLink(groupId, memberRole)) if (r is API.Result && r.res is CR.GroupLinkCreated) return r.res.groupLink + if (r is API.Error && r.err is ChatError.ChatErrorAgent && r.err.agentError is AgentErrorType.NOTICE) { + val e = r.err.agentError + showClientNoticeAlert(e.server, e.preset, e.expiresAt) + return null + } if (r == null) return null if (!(networkErrorAlert(r))) { apiErrorAlert("apiCreateGroupLink", generalGetString(MR.strings.error_creating_link_for_group), r) @@ -2371,7 +2396,10 @@ object ChatController { && e.agentError.brokerErr is BrokerErrorType.NETWORK -> { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.connection_error), - String.format(generalGetString(MR.strings.network_error_desc), serverHostname(e.agentError.brokerAddress)) + String.format( + generalGetString(if (e.agentError.brokerErr.networkError is NetworkError.UnknownCAError) MR.strings.network_error_unknown_ca else MR.strings.network_error_desc), + serverHostname(e.agentError.brokerAddress) + ) ) true } @@ -2425,7 +2453,10 @@ object ChatController { && pe.brokerErr is BrokerErrorType.NETWORK -> { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.private_routing_error), - String.format(generalGetString(MR.strings.smp_proxy_error_connecting), serverHostname(srvAddr)) + String.format( + generalGetString(if (pe.brokerErr.networkError is NetworkError.UnknownCAError) MR.strings.smp_proxy_error_unknown_ca else MR.strings.smp_proxy_error_connecting), + serverHostname(srvAddr) + ) ) true } @@ -2464,7 +2495,8 @@ object ChatController { && pe.brokerErr is BrokerErrorType.NETWORK -> { AlertManager.shared.showAlertMsg( generalGetString(MR.strings.private_routing_error), - String.format(generalGetString(MR.strings.proxy_destination_error_failed_to_connect), serverHostname(proxyServer), serverHostname(relayServer)) + if (pe.brokerErr.networkError is NetworkError.UnknownCAError) String.format(generalGetString(MR.strings.proxy_destination_error_unknown_ca), serverHostname(relayServer)) + else String.format(generalGetString(MR.strings.proxy_destination_error_failed_to_connect), serverHostname(proxyServer), serverHostname(relayServer)) ) true } @@ -2525,12 +2557,15 @@ object ChatController { chatModel.replaceConnReqView(conn.id, "@${r.contact.contactId}") chatModel.chatsContext.removeChat(rhId, conn.id) } + if (r.contact.id == chatModel.chatId.value && conn != null) { + chatModel.chatAgentConnId.value = conn.agentConnId + chatModel.chatSubStatus.value = SubscriptionStatus.Active + } } } if (r.contact.directOrUsed) { ntfManager.notifyContactConnected(r.user, r.contact) } - chatModel.setContactNetworkStatus(r.contact, NetworkStatus.Connected()) } is CR.ContactConnecting -> { if (active(r.user) && r.contact.directOrUsed) { @@ -2555,7 +2590,6 @@ object ChatController { } } } - chatModel.setContactNetworkStatus(r.contact, NetworkStatus.Connected()) } is CR.ReceivedContactRequest -> { val contactRequest = r.contactRequest @@ -2597,45 +2631,14 @@ object ChatController { } } } - is CR.ContactsMerged -> { - if (active(r.user) && chatModel.chatsContext.hasChat(rhId, r.mergedContact.id)) { - if (chatModel.chatId.value == r.mergedContact.id) { - chatModel.chatId.value = r.intoContact.id - } + is CR.SubscriptionStatusEvt -> { + val chatAgentConnId = chatModel.chatAgentConnId.value + if (chatAgentConnId != null && r.connections.contains(chatAgentConnId)) { withContext(Dispatchers.Main) { - chatModel.chatsContext.removeChat(rhId, r.mergedContact.id) + chatModel.chatSubStatus.value = r.subscriptionStatus } } } - // ContactsSubscribed, ContactsDisconnected and ContactSubSummary are only used in CLI, - // They have to be used here for remote desktop to process these status updates. - is CR.ContactsSubscribed -> updateContactsStatus(r.contactRefs, NetworkStatus.Connected()) - is CR.ContactsDisconnected -> updateContactsStatus(r.contactRefs, NetworkStatus.Disconnected()) - is CR.ContactSubSummary -> { - for (sub in r.contactSubscriptions) { - if (active(r.user)) { - withContext(Dispatchers.Main) { - chatModel.chatsContext.updateContact(rhId, sub.contact) - } - } - val err = sub.contactError - if (err == null) { - chatModel.setContactNetworkStatus(sub.contact, NetworkStatus.Connected()) - } else { - processContactSubError(sub.contact, sub.contactError) - } - } - } - is CR.NetworkStatusResp -> { - for (cId in r.connections) { - chatModel.networkStatuses[cId] = r.networkStatus - } - } - is CR.NetworkStatuses -> { - for (s in r.networkStatuses) { - chatModel.networkStatuses[s.agentConnId] = s.networkStatus - } - } is CR.ChatInfoUpdated -> if (active(r.user)) { withContext(Dispatchers.Main) { @@ -2899,10 +2902,13 @@ object ChatController { && ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT) && chatModel.secondaryChatsContext.value?.secondaryContextFilter is SecondaryContextFilter.GroupChatScopeContext ) { - withContext(Dispatchers.Main) { - chatModel.secondaryChatsContext.value = null + CoroutineScope(Dispatchers.Default).launch { + delay(1000L) + withContext(Dispatchers.Main) { + chatModel.secondaryChatsContext.value = null + } + ModalManager.end.closeModals() } - ModalManager.end.closeModals() } } is CR.JoinedGroupMember -> @@ -2917,9 +2923,6 @@ object ChatController { chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.member) } } - if (r.memberContact != null) { - chatModel.setContactNetworkStatus(r.memberContact, NetworkStatus.Connected()) - } } is CR.GroupUpdated -> if (active(r.user)) { @@ -3232,12 +3235,6 @@ object ChatController { m.users.clear() m.users.addAll(users) getUserChatData(null) - val statuses = apiGetNetworkStatuses(null) - if (statuses != null) { - chatModel.networkStatuses.clear() - val ss = statuses.associate { it.agentConnId to it.networkStatus }.toMap() - chatModel.networkStatuses.putAll(ss) - } } private fun activeUser(rhId: Long?, user: UserLike): Boolean = @@ -3350,27 +3347,6 @@ object ChatController { } } - private fun updateContactsStatus(contactRefs: List, status: NetworkStatus) { - for (c in contactRefs) { - chatModel.networkStatuses[c.agentConnId] = status - } - } - - private fun processContactSubError(contact: Contact, chatError: ChatError) { - val e = chatError - val err: String = - if (e is ChatError.ChatErrorAgent) { - val a = e.agentError - when { - a is AgentErrorType.BROKER && a.brokerErr is BrokerErrorType.NETWORK -> "network" - a is AgentErrorType.SMP && a.smpErr is SMPErrorType.AUTH -> "contact deleted" - else -> e.string - } - } - else e.string - chatModel.setContactNetworkStatus(contact, NetworkStatus.Error(err)) - } - suspend fun switchUIRemoteHost(rhId: Long?) = showProgressIfNeeded { // TODO lock the switch so that two switches can't run concurrently? chatModel.chatId.value = null @@ -3397,12 +3373,6 @@ object ChatController { chatModel.secondaryChatsContext.value?.popChatCollector?.clear() } } - val statuses = apiGetNetworkStatuses(rhId) - if (statuses != null) { - chatModel.networkStatuses.clear() - val ss = statuses.associate { it.agentConnId to it.networkStatus }.toMap() - chatModel.networkStatuses.putAll(ss) - } getUserChatData(rhId) } @@ -3661,7 +3631,6 @@ sealed class CC { class ApiSendCallExtraInfo(val contact: Contact, val extraInfo: WebRTCExtraInfo): CC() class ApiEndCall(val contact: Contact): CC() class ApiCallStatus(val contact: Contact, val callStatus: WebRTCCallStatus): CC() - class ApiGetNetworkStatuses(): CC() class ApiAcceptContact(val incognito: Boolean, val contactReqId: Long): CC() class ApiRejectContact(val contactReqId: Long): CC() class ApiChatRead(val type: ChatType, val id: Long, val scope: GroupChatScope?): CC() @@ -3860,7 +3829,6 @@ sealed class CC { is ApiSendCallExtraInfo -> "/_call extra @${contact.apiId} ${json.encodeToString(extraInfo)}" is ApiEndCall -> "/_call end @${contact.apiId}" is ApiCallStatus -> "/_call status @${contact.apiId} ${callStatus.value}" - is ApiGetNetworkStatuses -> "/_network_statuses" is ApiChatRead -> "/_read chat ${chatRef(type, id, scope)}" is ApiChatItemsRead -> "/_read chat items ${chatRef(type, id, scope)} ${itemIds.joinToString(",")}" is ApiChatUnread -> "/_unread chat ${chatRef(type, id, scope = null)} ${onOff(unreadChat)}" @@ -4035,7 +4003,6 @@ sealed class CC { is ApiSendCallExtraInfo -> "apiSendCallExtraInfo" is ApiEndCall -> "apiEndCall" is ApiCallStatus -> "apiCallStatus" - is ApiGetNetworkStatuses -> "apiGetNetworkStatuses" is ApiChatRead -> "apiChatRead" is ApiChatItemsRead -> "apiChatItemsRead" is ApiChatUnread -> "apiChatUnread" @@ -4584,9 +4551,10 @@ data class ProtocolTestFailure( err + " " + generalGetString(MR.strings.error_smp_test_server_auth) testError is AgentErrorType.XFTP && testError.xftpErr is XFTPErrorType.AUTH -> err + " " + generalGetString(MR.strings.error_xftp_test_server_auth) - testError is AgentErrorType.BROKER && testError.brokerErr is BrokerErrorType.NETWORK -> + testError is AgentErrorType.BROKER && testError.brokerErr is BrokerErrorType.NETWORK && testError.brokerErr.networkError is NetworkError.UnknownCAError -> err + " " + generalGetString(MR.strings.error_smp_test_certificate) - else -> err + else -> + err + " " + String.format(generalGetString(MR.strings.error_with_info), testError.toString()) } } } @@ -6174,13 +6142,7 @@ sealed class CR { @Serializable @SerialName("contactRequestRejected") class ContactRequestRejected(val user: UserRef, val contactRequest: UserContactRequest, val contact_: Contact?): CR() @Serializable @SerialName("contactUpdated") class ContactUpdated(val user: UserRef, val toContact: Contact): CR() @Serializable @SerialName("groupMemberUpdated") class GroupMemberUpdated(val user: UserRef, val groupInfo: GroupInfo, val fromMember: GroupMember, val toMember: GroupMember): CR() - // TODO remove below - @Serializable @SerialName("contactsSubscribed") class ContactsSubscribed(val server: String, val contactRefs: List): CR() - @Serializable @SerialName("contactsDisconnected") class ContactsDisconnected(val server: String, val contactRefs: List): CR() - @Serializable @SerialName("contactSubSummary") class ContactSubSummary(val user: UserRef, val contactSubscriptions: List): CR() - // TODO remove above - @Serializable @SerialName("networkStatus") class NetworkStatusResp(val networkStatus: NetworkStatus, val connections: List): CR() - @Serializable @SerialName("networkStatuses") class NetworkStatuses(val user_: UserRef?, val networkStatuses: List): CR() + @Serializable @SerialName("subscriptionStatus") class SubscriptionStatusEvt(val subscriptionStatus: SubscriptionStatus, val connections: List): CR() @Serializable @SerialName("chatInfoUpdated") class ChatInfoUpdated(val user: UserRef, val chatInfo: ChatInfo): CR() @Serializable @SerialName("newChatItems") class NewChatItems(val user: UserRef, val chatItems: List): CR() @Serializable @SerialName("chatItemsStatusesUpdated") class ChatItemsStatusesUpdated(val user: UserRef, val chatItems: List): CR() @@ -6204,6 +6166,7 @@ sealed class CR { @Serializable @SerialName("groupDeletedUser") class GroupDeletedUser(val user: UserRef, val groupInfo: GroupInfo): CR() @Serializable @SerialName("joinedGroupMemberConnecting") class JoinedGroupMemberConnecting(val user: UserRef, val groupInfo: GroupInfo, val hostMember: GroupMember, val member: GroupMember): CR() @Serializable @SerialName("memberAccepted") class MemberAccepted(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() + @Serializable @SerialName("memberSupportChatRead") class MemberSupportChatRead(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("memberSupportChatDeleted") class MemberSupportChatDeleted(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("memberAcceptedByOther") class MemberAcceptedByOther(val user: UserRef, val groupInfo: GroupInfo, val acceptingMember: GroupMember, val member: GroupMember): CR() @Serializable @SerialName("memberRole") class MemberRole(val user: UserRef, val groupInfo: GroupInfo, val byMember: GroupMember, val member: GroupMember, val fromRole: GroupMemberRole, val toRole: GroupMemberRole): CR() @@ -6214,7 +6177,6 @@ sealed class CR { @Serializable @SerialName("deletedMember") class DeletedMember(val user: UserRef, val groupInfo: GroupInfo, val byMember: GroupMember, val deletedMember: GroupMember, val withMessages: Boolean): CR() @Serializable @SerialName("leftMember") class LeftMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("groupDeleted") class GroupDeleted(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() - @Serializable @SerialName("contactsMerged") class ContactsMerged(val user: UserRef, val intoContact: Contact, val mergedContact: Contact): CR() @Serializable @SerialName("userJoinedGroup") class UserJoinedGroup(val user: UserRef, val groupInfo: GroupInfo): CR() @Serializable @SerialName("joinedGroupMember") class JoinedGroupMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("connectedToGroupMember") class ConnectedToGroupMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val memberContact: Contact? = null): CR() @@ -6361,11 +6323,7 @@ sealed class CR { is ContactRequestRejected -> "contactRequestRejected" is ContactUpdated -> "contactUpdated" is GroupMemberUpdated -> "groupMemberUpdated" - is ContactsSubscribed -> "contactsSubscribed" - is ContactsDisconnected -> "contactsDisconnected" - is ContactSubSummary -> "contactSubSummary" - is NetworkStatusResp -> "networkStatus" - is NetworkStatuses -> "networkStatuses" + is SubscriptionStatusEvt -> "subscriptionStatus" is ChatInfoUpdated -> "chatInfoUpdated" is NewChatItems -> "newChatItems" is ChatItemsStatusesUpdated -> "chatItemsStatusesUpdated" @@ -6388,6 +6346,7 @@ sealed class CR { is GroupDeletedUser -> "groupDeletedUser" is JoinedGroupMemberConnecting -> "joinedGroupMemberConnecting" is MemberAccepted -> "memberAccepted" + is MemberSupportChatRead -> "memberSupportChatRead" is MemberSupportChatDeleted -> "memberSupportChatDeleted" is MemberAcceptedByOther -> "memberAcceptedByOther" is MemberRole -> "memberRole" @@ -6398,7 +6357,6 @@ sealed class CR { is DeletedMember -> "deletedMember" is LeftMember -> "leftMember" is GroupDeleted -> "groupDeleted" - is ContactsMerged -> "contactsMerged" is UserJoinedGroup -> "userJoinedGroup" is JoinedGroupMember -> "joinedGroupMember" is ConnectedToGroupMember -> "connectedToGroupMember" @@ -6538,11 +6496,7 @@ sealed class CR { is ContactRequestRejected -> withUser(user, "contactRequest: ${json.encodeToString(contactRequest)}\ncontact_: ${json.encodeToString(contact_)}") is ContactUpdated -> withUser(user, json.encodeToString(toContact)) is GroupMemberUpdated -> withUser(user, "groupInfo: $groupInfo\nfromMember: $fromMember\ntoMember: $toMember") - is ContactsSubscribed -> "server: $server\ncontacts:\n${json.encodeToString(contactRefs)}" - is ContactsDisconnected -> "server: $server\ncontacts:\n${json.encodeToString(contactRefs)}" - is ContactSubSummary -> withUser(user, json.encodeToString(contactSubscriptions)) - is NetworkStatusResp -> "networkStatus $networkStatus\nconnections: $connections" - is NetworkStatuses -> withUser(user_, json.encodeToString(networkStatuses)) + is SubscriptionStatusEvt -> "subscriptionStatus $subscriptionStatus\nconnections: $connections" is ChatInfoUpdated -> withUser(user, json.encodeToString(chatInfo)) is NewChatItems -> withUser(user, chatItems.joinToString("\n") { json.encodeToString(it) }) is ChatItemsStatusesUpdated -> withUser(user, chatItems.joinToString("\n") { json.encodeToString(it) }) @@ -6565,6 +6519,7 @@ sealed class CR { is GroupDeletedUser -> withUser(user, json.encodeToString(groupInfo)) is JoinedGroupMemberConnecting -> withUser(user, "groupInfo: $groupInfo\nhostMember: $hostMember\nmember: $member") is MemberAccepted -> withUser(user, "groupInfo: $groupInfo\nmember: $member") + is MemberSupportChatRead -> withUser(user, "groupInfo: $groupInfo\nmember: $member") is MemberSupportChatDeleted -> withUser(user, "groupInfo: $groupInfo\nmember: $member") is MemberAcceptedByOther -> withUser(user, "groupInfo: $groupInfo\nacceptingMember: $acceptingMember\nmember: $member") is MemberRole -> withUser(user, "groupInfo: $groupInfo\nbyMember: $byMember\nmember: $member\nfromRole: $fromRole\ntoRole: $toRole") @@ -6575,7 +6530,6 @@ sealed class CR { is DeletedMember -> withUser(user, "groupInfo: $groupInfo\nbyMember: $byMember\ndeletedMember: $deletedMember\nwithMessages: ${withMessages}") is LeftMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member") is GroupDeleted -> withUser(user, "groupInfo: $groupInfo\nmember: $member") - is ContactsMerged -> withUser(user, "intoContact: $intoContact\nmergedContact: $mergedContact") is UserJoinedGroup -> withUser(user, json.encodeToString(groupInfo)) is JoinedGroupMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member") is ConnectedToGroupMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member\nmemberContact: $memberContact") @@ -6765,7 +6719,8 @@ class ConnectionStats( val rcvQueuesInfo: List, val sndQueuesInfo: List, val ratchetSyncState: RatchetSyncState, - val ratchetSyncSupported: Boolean + val ratchetSyncSupported: Boolean, + var subStatus: SubscriptionStatus? ) { val ratchetSyncAllowed: Boolean get() = ratchetSyncSupported && listOf(RatchetSyncState.Allowed, RatchetSyncState.Required).contains(ratchetSyncState) @@ -6780,8 +6735,10 @@ class ConnectionStats( @Serializable class RcvQueueInfo( val rcvServer: String, + var status: QueueStatus, val rcvSwitchStatus: RcvSwitchStatus?, - var canAbortSwitch: Boolean + var canAbortSwitch: Boolean, + var subStatus: SubscriptionStatus ) @Serializable @@ -6795,6 +6752,7 @@ enum class RcvSwitchStatus { @Serializable class SndQueueInfo( val sndServer: String, + var status: QueueStatus, val sndSwitchStatus: SndSwitchStatus? ) @@ -6832,6 +6790,39 @@ enum class RatchetSyncState { @SerialName("agreed") Agreed } +@Serializable +enum class QueueStatus { + @SerialName("new") New, + @SerialName("confirmed") Confirmed, + @SerialName("secured") Secured, + @SerialName("active") Active, + @SerialName("disabled") Disabled +} + +@Serializable +sealed class SubscriptionStatus { + @Serializable @SerialName("active") object Active: SubscriptionStatus() + @Serializable @SerialName("pending") object Pending: SubscriptionStatus() + @Serializable @SerialName("removed") class Removed(val subError: String): SubscriptionStatus() + @Serializable @SerialName("noSub") object NoSub: SubscriptionStatus() + + val statusString: String get() = + when (this) { + is Active -> generalGetString(MR.strings.server_connected) + is Pending -> generalGetString(MR.strings.server_connecting) + is Removed -> generalGetString(MR.strings.server_error) + is NoSub -> generalGetString(MR.strings.server_no_sub) + } + + val statusExplanation: String get() = + when (this) { + is Active -> generalGetString(MR.strings.connected_to_server_to_receive_messages_from_contact) + is Pending -> generalGetString(MR.strings.trying_to_connect_to_server_to_receive_messages) + is Removed -> String.format(generalGetString(MR.strings.error_connecting_to_server_to_receive_messages), subError) + is NoSub -> generalGetString(MR.strings.not_connected_to_server_to_receive_messages_no_sub) + } +} + interface SimplexAddress { val connLinkContact: CreatedConnLink val shortLinkDataSet: Boolean @@ -7018,7 +7009,6 @@ sealed class ChatErrorType { is FileCancelled -> "fileCancelled" is FileCancel -> "fileCancel" is FileAlreadyExists -> "fileAlreadyExists" - is FileRead -> "fileRead" is FileWrite -> "fileWrite $message" is FileSend -> "fileSend" is FileRcvChunk -> "fileRcvChunk" @@ -7099,7 +7089,6 @@ sealed class ChatErrorType { @Serializable @SerialName("fileCancelled") class FileCancelled(val message: String): ChatErrorType() @Serializable @SerialName("fileCancel") class FileCancel(val fileId: Long, val message: String): ChatErrorType() @Serializable @SerialName("fileAlreadyExists") class FileAlreadyExists(val filePath: String): ChatErrorType() - @Serializable @SerialName("fileRead") class FileRead(val filePath: String, val message: String): ChatErrorType() @Serializable @SerialName("fileWrite") class FileWrite(val filePath: String, val message: String): ChatErrorType() @Serializable @SerialName("fileSend") class FileSend(val fileId: Long, val agentError: String): ChatErrorType() @Serializable @SerialName("fileRcvChunk") class FileRcvChunk(val message: String): ChatErrorType() @@ -7326,6 +7315,7 @@ sealed class AgentErrorType { is RCP -> "RCP ${rcpErr.string}" is BROKER -> "BROKER ${brokerErr.string}" is AGENT -> "AGENT ${agentErr.string}" + is NOTICE -> "NOTICE $server $expiresAt" is INTERNAL -> "INTERNAL $internalErr" is CRITICAL -> "CRITICAL $offerRestart $criticalErr" is INACTIVE -> "INACTIVE" @@ -7339,6 +7329,7 @@ sealed class AgentErrorType { @Serializable @SerialName("RCP") class RCP(val rcpErr: RCErrorType): AgentErrorType() @Serializable @SerialName("BROKER") class BROKER(val brokerAddress: String, val brokerErr: BrokerErrorType): AgentErrorType() @Serializable @SerialName("AGENT") class AGENT(val agentErr: SMPAgentError): AgentErrorType() + @Serializable @SerialName("NOTICE") class NOTICE(val server: String, val preset: Boolean, val expiresAt: Instant?): AgentErrorType() @Serializable @SerialName("INTERNAL") class INTERNAL(val internalErr: String): AgentErrorType() @Serializable @SerialName("CRITICAL") data class CRITICAL(val offerRestart: Boolean, val criticalErr: String): AgentErrorType() @Serializable @SerialName("INACTIVE") object INACTIVE: AgentErrorType() @@ -7388,7 +7379,7 @@ sealed class BrokerErrorType { } @Serializable @SerialName("RESPONSE") class RESPONSE(val smpErr: String): BrokerErrorType() @Serializable @SerialName("UNEXPECTED") object UNEXPECTED: BrokerErrorType() - @Serializable @SerialName("NETWORK") object NETWORK: BrokerErrorType() + @Serializable @SerialName("NETWORK") class NETWORK(val networkError: NetworkError): BrokerErrorType() @Serializable @SerialName("HOST") object HOST: BrokerErrorType() @Serializable @SerialName("TRANSPORT") class TRANSPORT(val transportErr: SMPTransportError): BrokerErrorType() @Serializable @SerialName("TIMEOUT") object TIMEOUT: BrokerErrorType() @@ -7475,6 +7466,16 @@ sealed class ProtocolCommandError { @Serializable @SerialName("NO_QUEUE") object NO_QUEUE: ProtocolCommandError() } +@Serializable +sealed class NetworkError { + @Serializable @SerialName("connectError") class ConnectError(val connectError: String): NetworkError() + @Serializable @SerialName("tLSError") class TLSError(val tlsError: String): NetworkError() + @Serializable @SerialName("unknownCAError") object UnknownCAError: NetworkError() + @Serializable @SerialName("failedError") object FailedError: NetworkError() + @Serializable @SerialName("timeoutError") object TimeoutError: NetworkError() + @Serializable @SerialName("subscribeError") class SubscribeError(val subscribeError: String): NetworkError() +} + @Serializable sealed class SMPTransportError { val string: String get() = when (this) { @@ -8056,3 +8057,34 @@ enum class MsgType { @SerialName("quota") QUOTA } + +fun showClientNoticeAlert(server: String, preset: Boolean, expiresAt: Instant?) { + var message = "Server: $server.\nConditions of use violation notice received from ${if (preset) "preset" else "this"} server.\nNo ID shared, see How it works." + if (expiresAt != null) { + val tz = TimeZone.currentSystemDefault() + val formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT) + message += "\n\nNew addresses can be created after ${expiresAt.toLocalDateTime(tz).toJavaLocalDateTime().format(formatter)}." + } + AlertManager.shared.showAlertDialogButtonsColumn(title = "Not allowed", text = AnnotatedString(message)) { + val uriHandler = LocalUriHandler.current + Column { + SectionItemView({ AlertManager.shared.hideAlert() }) { + Text(generalGetString(MR.strings.ok), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + if (preset) { + SectionItemView({ + AlertManager.shared.hideAlert() + uriHandler.openUriCatching(defaultConditionsLink) + }) { + Text(generalGetString(MR.strings.operator_conditions_of_use), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + SectionItemView({ + AlertManager.shared.hideAlert() + uriHandler.openUriCatching(contentModerationPostLink) + }) { + Text(generalGetString(MR.strings.how_it_works), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt index 19e40ab0a2..d7a241d5ee 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt @@ -1,7 +1,15 @@ package chat.simplex.common.platform +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.layout.ContentScale import boofcv.struct.image.GrayU8 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import java.io.ByteArrayOutputStream import java.io.InputStream import java.net.URI @@ -23,3 +31,23 @@ expect fun isImage(uri: URI): Boolean expect fun isAnimImage(uri: URI, drawable: Any?): Boolean expect fun loadImageBitmap(inputStream: InputStream): ImageBitmap + +@Composable +fun Base64AsyncImage( + base64ImageString: String, + contentDescription: String?, + contentScale: ContentScale, + modifier: Modifier = Modifier +) { + val imageBitmap by produceState(initialValue = null, base64ImageString) { + value = withContext(Dispatchers.IO) { base64ToBitmap(base64ImageString) } + } + imageBitmap?.let { + Image( + bitmap = it, + contentDescription = contentDescription, + contentScale = contentScale, + modifier = modifier + ) + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index c7b8cb2c81..061ea71016 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -72,9 +72,6 @@ fun ChatInfoView( val connStats = remember(contact.id, connectionStats) { mutableStateOf(connectionStats) } val developerTools = chatModel.controller.appPrefs.developerTools.get() if (chat != null && currentUser != null) { - val contactNetworkStatus = remember(chatModel.networkStatuses.toMap(), contact) { - mutableStateOf(chatModel.contactNetworkStatus(contact)) - } val chatRh = chat.remoteHostId val sendReceipts = remember(contact.id) { mutableStateOf(SendReceipts.fromBool(contact.chatSettings.sendRcpts, currentUser.sendRcptsContacts)) } val chatItemTTL = remember(contact.id) { mutableStateOf(if (contact.chatItemTTL != null) ChatItemTTL.fromSeconds(contact.chatItemTTL) else null) } @@ -101,7 +98,6 @@ fun ChatInfoView( setChatTTLAlert(chatsCtx, chat.remoteHostId, chat.chatInfo, chatItemTTL, previousChatTTL, deletingItems) }, connStats = connStats, - contactNetworkStatus.value, customUserProfile, localAlias, connectionCode, @@ -524,7 +520,6 @@ fun ChatInfoLayout( chatItemTTL: MutableState, setChatItemTTL: (ChatItemTTL?) -> Unit, connStats: MutableState, - contactNetworkStatus: NetworkStatus, customUserProfile: Profile?, localAlias: String, connectionCode: String?, @@ -643,13 +638,16 @@ fun ChatInfoLayout( if (contact.ready && contact.active) { SectionView(title = stringResource(MR.strings.conn_stats_section_title_servers)) { - SectionItemView({ - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.network_status), - contactNetworkStatus.statusExplanation - ) - }) { - NetworkStatusRow(contactNetworkStatus) + val chatSubStatus = chatModel.chatSubStatus.value + if (chatSubStatus != null) { + SectionItemView({ + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.network_status), + chatSubStatus.statusExplanation + ) + }) { + SubStatusRow(chatSubStatus) + } } if (cStats != null) { SwitchAddressButton( @@ -1063,7 +1061,7 @@ fun InfoViewActionButton( } @Composable -private fun NetworkStatusRow(networkStatus: NetworkStatus) { +fun SubStatusRow(subStatus: SubscriptionStatus) { Row( Modifier.fillMaxSize(), horizontalArrangement = Arrangement.SpaceBetween, @@ -1086,25 +1084,24 @@ private fun NetworkStatusRow(networkStatus: NetworkStatus) { horizontalArrangement = Arrangement.spacedBy(4.dp) ) { Text( - networkStatus.statusString, + subStatus.statusString, color = MaterialTheme.colors.secondary ) - ServerImage(networkStatus) + ServerImage(subStatus) } } } @Composable -private fun ServerImage(networkStatus: NetworkStatus) { +private fun ServerImage(subStatus: SubscriptionStatus) { Box(Modifier.size(18.dp)) { - when (networkStatus) { - is NetworkStatus.Connected -> + when (subStatus) { + is SubscriptionStatus.Active -> Icon(painterResource(MR.images.ic_circle_filled), stringResource(MR.strings.icon_descr_server_status_connected), tint = Color.Green) - is NetworkStatus.Disconnected -> + is SubscriptionStatus.Pending -> Icon(painterResource(MR.images.ic_pending_filled), stringResource(MR.strings.icon_descr_server_status_disconnected), tint = MaterialTheme.colors.secondary) - is NetworkStatus.Error -> + is SubscriptionStatus.Removed, SubscriptionStatus.NoSub -> Icon(painterResource(MR.images.ic_error_filled), stringResource(MR.strings.icon_descr_server_status_error), tint = MaterialTheme.colors.secondary) - else -> Icon(painterResource(MR.images.ic_circle), stringResource(MR.strings.icon_descr_server_status_pending), tint = MaterialTheme.colors.secondary) } } } @@ -1455,7 +1452,6 @@ fun PreviewChatInfoLayout() { connectionCode = "123", developerTools = false, connStats = remember { mutableStateOf(null) }, - contactNetworkStatus = NetworkStatus.Connected(), onLocalAliasChanged = {}, customUserProfile = null, openPreferences = {}, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index de48a67988..26daee363f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -102,6 +102,7 @@ fun ChatView( val chat = chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value } // They have their own iterator inside for a reason to prevent crash "Reading a state that was created after the snapshot..." val remoteHostId = remember { derivedStateOf { chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value }?.remoteHostId } } + val chatRh = remoteHostId.value val activeChat = remember { derivedStateOf { var chat = chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId.value } val chatInfo = chat?.chatInfo @@ -120,6 +121,8 @@ fun ChatView( if (chat == null || chatInfo == null || user == null) { LaunchedEffect(Unit) { chatModel.chatId.value = null + chatModel.chatAgentConnId.value = null + chatModel.chatSubStatus.value = null ModalManager.end.closeModals() } } else { @@ -161,10 +164,32 @@ fun ChatView( } if (chatsCtx.secondaryContextFilter == null) { markUnreadChatAsRead(chatId) + chatModel.groupMembers.value = emptyList() + chatModel.groupMembersIndexes.value = emptyMap() + chatModel.membersLoaded.value = false } showSearch.value = false searchText.value = "" selectedChatItems.value = null + if (chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.activeConn != null) { + withBGApi { + val r = chatModel.controller.apiContactInfo(chatRh, chatInfo.apiId) + if (r != null) { + val contactStats = r.first + if (contactStats != null) + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateContactConnectionStats(chatRh, chat.chatInfo.contact, contactStats) + chatModel.chatAgentConnId.value = chat.chatInfo.contact.activeConn.agentConnId + chatModel.chatSubStatus.value = contactStats.subStatus + } + } + } + } else { + withContext(Dispatchers.Main) { + chatModel.chatAgentConnId.value = null + chatModel.chatSubStatus.value = null + } + } } } } @@ -181,7 +206,6 @@ fun ChatView( } } val view = LocalMultiplatformView() - val chatRh = remoteHostId.value // We need to have real unreadCount value for displaying it inside top right button // Having activeChat reloaded on every change in it is inefficient (UI lags) val unreadCount = remember { @@ -353,7 +377,7 @@ fun ChatView( if (chatInfo is ChatInfo.Direct) { var contactInfo: Pair? by remember { mutableStateOf(preloadedContactInfo) } var code: String? by remember { mutableStateOf(preloadedCode) } - KeyChangeEffect(chatInfo.id, ChatModel.networkStatuses.toMap()) { + KeyChangeEffect(chatInfo.id) { contactInfo = chatModel.controller.apiContactInfo(chatRh, chatInfo.apiId) preloadedContactInfo = contactInfo code = chatModel.controller.apiGetContactCode(chatRh, chatInfo.apiId)?.second @@ -453,7 +477,7 @@ fun ChatView( } ModalManager.end.showModalCloseable(true) { close -> remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> - GroupMemberInfoView(chatRh, groupInfo, mem, scrollToItemId, stats, code, chatModel, close, close) + GroupMemberInfoView(chatRh, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = false, close, close) } } } @@ -707,8 +731,7 @@ fun ChatView( chatModel.controller.apiChatRead( chatRh, chatInfo.chatType, - chatInfo.apiId, - chatInfo.groupChatScope() + chatInfo.apiId ) } withContext(Dispatchers.Main) { @@ -1005,7 +1028,9 @@ fun ChatLayout( Column(if (oneHandUI.value && chatBottomBar.value) Modifier.align(Alignment.BottomStart).imePadding() else Modifier) { Box { if (selectedChatItems.value == null) { - MemberSupportChatAppBar(chatsCtx, chatsCtx.secondaryContextFilter.groupScopeInfo.groupMember_, { ModalManager.end.closeModal() }, onSearchValueChanged) + if (chat != null) { + MemberSupportChatAppBar(chatsCtx, remoteHostId, chat, chatsCtx.secondaryContextFilter.groupScopeInfo.groupMember_, scrollToItemId, { ModalManager.end.closeModal() }, onSearchValueChanged) + } } else { SelectedItemsCounterToolbar(selectedChatItems, !oneHandUI.value) } @@ -1277,9 +1302,51 @@ fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Colo ) } } + val chatSubStatus = chatModel.chatSubStatus.value + if ( + cInfo is ChatInfo.Direct && + cInfo.contact.ready && + cInfo.contact.active && + chatSubStatus != null && + chatSubStatus != SubscriptionStatus.Active + ) { + Box( + Modifier.padding(start = 10.dp) + ) { + SubStatusView(chatSubStatus) + } + } } } +@Composable +fun SubStatusView(status: SubscriptionStatus) { + when (status) { + SubscriptionStatus.Active -> + Box {} + SubscriptionStatus.Pending -> + SubProgressView() + is SubscriptionStatus.Removed, SubscriptionStatus.NoSub -> + Icon( + painterResource(MR.images.ic_error), + contentDescription = null, + tint = MaterialTheme.colors.secondary, + modifier = Modifier + .size(19.sp.toDp()) + ) + } +} + +@Composable +private fun SubProgressView() { + CircularProgressIndicator( + Modifier + .size(15.sp.toDp()), + color = MaterialTheme.colors.secondary, + strokeWidth = 1.5.dp + ) +} + @Composable private fun SupportChatsCountToolbar( chatInfo: ChatInfo, @@ -2941,12 +3008,6 @@ private fun archiveReports(chatRh: Long?, chatInfo: ChatInfo, itemIds: List Unit)? = null) { +fun rejectMemberDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) { AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.reject_pending_member_alert_title), confirmText = generalGetString(MR.strings.reject_pending_member_button), onConfirm = { - removeMember(rhId, member, chatModel, close) + removeMember(rhId, groupInfo, member, withMessages = false, chatModel, close) }, destructive = true, ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index b01d07d9b8..af4baad90f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -533,7 +533,6 @@ fun ComposeView( withContext(Dispatchers.Main) { chatsCtx.updateContact(chat.remoteHostId, contact) clearState() - chatModel.setContactNetworkStatus(contact, NetworkStatus.Connected()) } } else { composeState.value = composeState.value.copy(inProgress = false) @@ -554,7 +553,6 @@ fun ComposeView( withContext(Dispatchers.Main) { chatsCtx.updateContact(chat.remoteHostId, contact) clearState() - chatModel.setContactNetworkStatus(contact, NetworkStatus.Connected()) } } else { composeState.value = composeState.value.copy(inProgress = false) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index 5a749766c4..3f80361249 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -126,7 +126,7 @@ fun ModalData.GroupChatInfoView( } ModalManager.end.showModalCloseable(true) { closeCurrent -> remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> - GroupMemberInfoView(rhId, groupInfo, mem, scrollToItemId, stats, code, chatModel, closeCurrent) { + GroupMemberInfoView(rhId, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = false, closeCurrent) { closeCurrent() close() } @@ -233,15 +233,30 @@ private fun removeMemberAlert(rhId: Long?, groupInfo: GroupInfo, mem: GroupMembe MR.strings.member_will_be_removed_from_group_cannot_be_undone else MR.strings.member_will_be_removed_from_chat_cannot_be_undone - AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.button_remove_member_question), - text = generalGetString(messageId), - confirmText = generalGetString(MR.strings.remove_member_confirmation), - onConfirm = { - removeMembers(rhId, groupInfo, listOf(mem.groupMemberId)) - }, - destructive = true, - ) + AlertManager.shared.showAlertDialogButtonsColumn( + generalGetString(MR.strings.button_remove_member_question), + generalGetString(messageId), + buttons = { + Column { + SectionItemView({ + AlertManager.shared.hideAlert() + removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = false) + }) { + Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) + } + SectionItemView({ + AlertManager.shared.hideAlert() + removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = true) + }) { + Text(generalGetString(MR.strings.remove_member_delete_messages_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) + } + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + }) } private fun removeMembersAlert(rhId: Long?, groupInfo: GroupInfo, memberIds: List, onSuccess: () -> Unit = {}) { @@ -249,15 +264,30 @@ private fun removeMembersAlert(rhId: Long?, groupInfo: GroupInfo, memberIds: Lis MR.strings.members_will_be_removed_from_group_cannot_be_undone else MR.strings.members_will_be_removed_from_chat_cannot_be_undone - AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.button_remove_members_question), - text = generalGetString(messageId), - confirmText = generalGetString(MR.strings.remove_member_confirmation), - onConfirm = { - removeMembers(rhId, groupInfo, memberIds, onSuccess) - }, - destructive = true, - ) + AlertManager.shared.showAlertDialogButtonsColumn( + generalGetString(MR.strings.button_remove_members_question), + generalGetString(messageId), + buttons = { + Column { + SectionItemView({ + AlertManager.shared.hideAlert() + removeMembers(rhId, groupInfo, memberIds, withMessages = false, onSuccess = onSuccess) + }) { + Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) + } + SectionItemView({ + AlertManager.shared.hideAlert() + removeMembers(rhId, groupInfo, memberIds, withMessages = true, onSuccess = onSuccess) + }) { + Text(generalGetString(MR.strings.remove_member_delete_messages_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red) + } + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + }) } @Composable @@ -867,7 +897,7 @@ fun MemberRow(member: GroupMember, user: Boolean = false, infoPage: Boolean = tr verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) ) { - MemberProfileImage(size = MEMBER_ROW_AVATAR_SIZE, member) + MemberProfileImage(size = MEMBER_ROW_AVATAR_SIZE, member, async = true) Spacer(Modifier.width(DEFAULT_PADDING_HALF)) Column { Row(verticalAlignment = Alignment.CenterVertically) { @@ -1052,20 +1082,26 @@ private fun setGroupAlias(chat: Chat, localAlias: String, chatModel: ChatModel) } } -fun removeMembers(rhId: Long?, groupInfo: GroupInfo, memberIds: List, onSuccess: () -> Unit = {}) { +fun removeMembers(rhId: Long?, groupInfo: GroupInfo, memberIds: List, withMessages: Boolean, onSuccess: () -> Unit = {}) { withBGApi { - val r = chatModel.controller.apiRemoveMembers(rhId, groupInfo.groupId, memberIds) + val r = chatModel.controller.apiRemoveMembers(rhId, groupInfo.groupId, memberIds, withMessages = withMessages) if (r != null) { val (updatedGroupInfo, updatedMembers) = r withContext(Dispatchers.Main) { chatModel.chatsContext.updateGroup(rhId, updatedGroupInfo) updatedMembers.forEach { updatedMember -> chatModel.chatsContext.upsertGroupMember(rhId, updatedGroupInfo, updatedMember) + if (withMessages) { + chatModel.chatsContext.removeMemberItems(rhId, updatedMember, byMember = groupInfo.membership, groupInfo) + } } } withContext(Dispatchers.Main) { updatedMembers.forEach { updatedMember -> chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, updatedGroupInfo, updatedMember) + if (withMessages) { + chatModel.chatsContext.removeMemberItems(rhId, updatedMember, byMember = groupInfo.membership, groupInfo) + } } } onSuccess() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index e5f0b708e7..f09d2f44bb 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -49,9 +49,13 @@ fun GroupMemberInfoView( connectionStats: ConnectionStats?, connectionCode: String?, chatModel: ChatModel, + openedFromSupportChat: Boolean, close: () -> Unit, closeAll: () -> Unit, // Close all open windows up to ChatView ) { + KeyChangeEffect(chat.simplex.common.platform.chatModel.chatId.value) { + ModalManager.end.closeModals() + } BackHandler(onBack = close) val chat = chatModel.chats.value.firstOrNull { ch -> ch.id == chatModel.chatId.value && ch.remoteHostId == rhId } val connStats = remember { mutableStateOf(connectionStats) } @@ -105,7 +109,6 @@ fun GroupMemberInfoView( } openDirectChat(rhId, memberContact.contactId) closeAll() - chatModel.setContactNetworkStatus(memberContact, NetworkStatus.Connected()) } progressIndicator = false } @@ -133,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 @@ -225,7 +229,8 @@ fun GroupMemberInfoView( ) } } - } + }, + openedFromSupportChat = openedFromSupportChat ) if (progressIndicator) { @@ -239,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) + } } } } @@ -285,12 +320,14 @@ fun GroupMemberInfoLayout( blockForAll: () -> Unit, unblockForAll: () -> Unit, removeMember: () -> Unit, + deleteMemberMessages: () -> Unit, onRoleSelected: (GroupMemberRole) -> Unit, switchMemberAddress: () -> Unit, abortSwitchMemberAddress: () -> Unit, syncMemberConnection: () -> Unit, syncMemberConnectionForce: () -> Unit, verifyClicked: () -> Unit, + openedFromSupportChat: Boolean ) { val cStats = connStats.value fun knownDirectChat(contactId: Long): Pair? { @@ -340,7 +377,11 @@ fun GroupMemberInfoLayout( } } if (canRemove) { - RemoveMemberButton(removeMember) + if (member.memberStatus == GroupMemberStatus.MemRemoved || member.memberStatus == GroupMemberStatus.MemLeft) { + DeleteMemberMessagesButton(deleteMemberMessages) + } else { + RemoveMemberButton(removeMember) + } } } } @@ -440,6 +481,7 @@ fun GroupMemberInfoLayout( if (member.memberActive) { SectionView { if ( + !openedFromSupportChat && groupInfo.membership.memberRole >= GroupMemberRole.Moderator && (member.memberRole < GroupMemberRole.Moderator || member.supportChat != null) ) { @@ -488,6 +530,17 @@ fun GroupMemberInfoLayout( if (cStats != null) { SectionDividerSpaced() SectionView(title = stringResource(MR.strings.conn_stats_section_title_servers)) { + val subStatus = cStats.subStatus + if (subStatus != null) { + SectionItemView({ + AlertManager.shared.showAlertMsg( + generalGetString(MR.strings.network_status), + subStatus.statusExplanation + ) + }) { + SubStatusRow(subStatus) + } + } SwitchAddressButton( disabled = cStats.rcvQueuesInfo.any { it.rcvSwitchStatus != null } || !member.sendMsgEnabled, switchAddress = switchMemberAddress @@ -652,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, @@ -707,14 +771,16 @@ fun MemberProfileImage( size: Dp, mem: GroupMember, color: Color = MaterialTheme.colors.secondaryVariant, - backgroundColor: Color? = null + backgroundColor: Color? = null, + async: Boolean = false ) { ProfileImage( size = size, image = mem.image, color = color, backgroundColor = backgroundColor, - blurred = mem.blocked + blurred = mem.blocked, + async = async ) } @@ -918,12 +984,14 @@ fun PreviewGroupMemberInfoLayout() { blockForAll = {}, unblockForAll = {}, removeMember = {}, + deleteMemberMessages = {}, onRoleSelected = {}, switchMemberAddress = {}, abortSwitchMemberAddress = {}, syncMemberConnection = {}, syncMemberConnectionForce = {}, verifyClicked = {}, + openedFromSupportChat = false, ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt index 99565618f9..6680ef99bc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt @@ -51,7 +51,10 @@ private fun MemberSupportChatView( @Composable fun MemberSupportChatAppBar( chatsCtx: ChatModel.ChatsContext, + rhId: Long?, + chat: Chat, scopeMember_: GroupMember?, + scrollToItemId: MutableState, close: () -> Unit, onSearchValueChanged: (String) -> Unit ) { @@ -67,11 +70,31 @@ fun MemberSupportChatAppBar( } } BackHandler(onBack = onBackClicked) - if (scopeMember_ != null) { + if (chat.chatInfo is ChatInfo.Group && scopeMember_ != null) { + val groupInfo = chat.chatInfo.groupInfo DefaultAppBar( navigationButton = { NavigationButtonBack(onBackClicked) }, title = { MemberSupportChatToolbarTitle(scopeMember_) }, - onTitleClick = null, + onTitleClick = { + withBGApi { + val r = chatModel.controller.apiGroupMemberInfo(rhId, groupInfo.groupId, scopeMember_.groupMemberId) + val stats = r?.second + val code = if (scopeMember_.memberActive) { + val memCode = chatModel.controller.apiGetGroupMemberCode(rhId, groupInfo.apiId, scopeMember_.groupMemberId) + memCode?.second + } else { + null + } + ModalManager.end.showModalCloseable(true) { closeCurrent -> + remember { derivedStateOf { chatModel.getGroupMember(scopeMember_.groupMemberId) } }.value?.let { mem -> + GroupMemberInfoView(rhId, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = true, close = closeCurrent) { + closeCurrent() + close() + } + } + } + } + }, onTop = !oneHandUI.value || !chatBottomBar.value, showSearch = showSearch.value, onSearchValueChanged = onSearchValueChanged, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt index 298a545c8c..e696128288 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt @@ -270,6 +270,12 @@ private fun DropDownMenuForSupportChat(rhId: Long?, member: GroupMember, groupIn showMenu.value = false }) } else { + if (member.supportChatNotRead) { + ItemAction(stringResource(MR.strings.mark_read), painterResource(MR.images.ic_check), color = MaterialTheme.colors.primary, onClick = { + markSupportChatRead(rhId, groupInfo, member) + showMenu.value = false + }) + } ItemAction(stringResource(MR.strings.delete_member_support_chat_button), painterResource(MR.images.ic_delete), color = MaterialTheme.colors.error, onClick = { deleteMemberSupportChatDialog(rhId, groupInfo, member) showMenu.value = false @@ -300,3 +306,22 @@ private fun deleteMemberSupportChat(rhId: Long?, groupInfo: GroupInfo, member: G } } } + +private fun markSupportChatRead(rhId: Long?, groupInfo: GroupInfo, member: GroupMember) { + withBGApi { + if (member.supportChatNotRead) { + val r = chatModel.controller.apiSupportChatRead( + rh = rhId, + type = ChatType.Group, + id = groupInfo.apiId, + scope = GroupChatScope.MemberSupport(member.groupMemberId) + ) + if (r != null) { + withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertGroupMember(rhId, r.first, r.second) + chatModel.chatsContext.updateGroup(rhId, r.first) + } + } + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index 6f873035f1..758980059d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -563,23 +563,18 @@ fun ChatItemView( @Composable fun ContentItem() { val mc = cItem.content.msgContent - if (cItem.meta.itemDeleted != null && (!revealed.value || cItem.isDeletedContent)) { - MarkedDeletedItemView(chatsCtx, cItem, cInfo, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp) - MarkedDeletedItemDropdownMenu() - } else { - if (cItem.quotedItem == null && cItem.meta.itemForwarded == null && cItem.meta.itemDeleted == null && !cItem.meta.isLive) { - if (mc is MsgContent.MCText && isShortEmoji(cItem.content.text)) { - EmojiItemView(cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) - } else if (mc is MsgContent.MCVoice && cItem.content.text.isEmpty()) { - CIVoiceView(mc.duration, cItem.file, cItem.meta.itemEdited, cItem.chatDir.sent, hasText = false, cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp, longClick = { onLinkLongClick("") }, receiveFile = receiveFile) - } else { - framedItemView() - } + if (cItem.quotedItem == null && cItem.meta.itemForwarded == null && cItem.meta.itemDeleted == null && !cItem.meta.isLive) { + if (mc is MsgContent.MCText && isShortEmoji(cItem.content.text)) { + EmojiItemView(cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + } else if (mc is MsgContent.MCVoice && cItem.content.text.isEmpty()) { + CIVoiceView(mc.duration, cItem.file, cItem.meta.itemEdited, cItem.chatDir.sent, hasText = false, cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp, longClick = { onLinkLongClick("") }, receiveFile = receiveFile) } else { framedItemView() } - MsgContentItemDropdownMenu() + } else { + framedItemView() } + MsgContentItemDropdownMenu() } @Composable fun LegacyDeletedItem() { @@ -696,102 +691,107 @@ fun ChatItemView( } } - when (val c = cItem.content) { - is CIContent.SndMsgContent -> ContentItem() - is CIContent.RcvMsgContent -> ContentItem() - is CIContent.SndDeleted -> LegacyDeletedItem() - is CIContent.RcvDeleted -> LegacyDeletedItem() - is CIContent.SndCall -> CallItem(c.status, c.duration) - is CIContent.RcvCall -> CallItem(c.status, c.duration) - is CIContent.RcvIntegrityError -> if (developerTools) { - IntegrityErrorItemView(c.msgError, cItem, showTimestamp, cInfo.timedMessagesTTL) - DeleteItemMenu() - } else { - Box(Modifier.size(0.dp)) {} - } - is CIContent.RcvDecryptionError -> { - CIRcvDecryptionError(c.msgDecryptError, c.msgCount, cInfo, cItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember) - DeleteItemMenu() - } - is CIContent.RcvGroupInvitation -> { - CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito, showTimestamp = showTimestamp, timedMessagesTTL = cInfo.timedMessagesTTL) - DeleteItemMenu() - } - is CIContent.SndGroupInvitation -> { - CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito, showTimestamp = showTimestamp, timedMessagesTTL = cInfo.timedMessagesTTL) - DeleteItemMenu() - } - is CIContent.RcvDirectEventContent -> { - EventItemView() - MsgContentItemDropdownMenu() - } - is CIContent.RcvGroupEventContent -> { - when (c.rcvGroupEvent) { - is RcvGroupEvent.MemberCreatedContact -> CIMemberCreatedContactView(cItem, openDirectChat) - is RcvGroupEvent.NewMemberPendingReview -> PendingReviewEventItemView() - else -> EventItemView() + if (cItem.meta.itemDeleted != null && (!revealed.value || cItem.isDeletedContent)) { + MarkedDeletedItemView(chatsCtx, cItem, cInfo, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + MarkedDeletedItemDropdownMenu() + } else { + when (val c = cItem.content) { + is CIContent.SndMsgContent -> ContentItem() + is CIContent.RcvMsgContent -> ContentItem() + is CIContent.SndDeleted -> LegacyDeletedItem() + is CIContent.RcvDeleted -> LegacyDeletedItem() + is CIContent.SndCall -> CallItem(c.status, c.duration) + is CIContent.RcvCall -> CallItem(c.status, c.duration) + is CIContent.RcvIntegrityError -> if (developerTools) { + IntegrityErrorItemView(c.msgError, cItem, showTimestamp, cInfo.timedMessagesTTL) + DeleteItemMenu() + } else { + Box(Modifier.size(0.dp)) {} } - MsgContentItemDropdownMenu() - } - is CIContent.SndGroupEventContent -> { - when (c.sndGroupEvent) { - is SndGroupEvent.UserPendingReview -> PendingReviewEventItemView() - else -> EventItemView() + is CIContent.RcvDecryptionError -> { + CIRcvDecryptionError(c.msgDecryptError, c.msgCount, cInfo, cItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember) + DeleteItemMenu() + } + is CIContent.RcvGroupInvitation -> { + CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito, showTimestamp = showTimestamp, timedMessagesTTL = cInfo.timedMessagesTTL) + DeleteItemMenu() + } + is CIContent.SndGroupInvitation -> { + CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito, showTimestamp = showTimestamp, timedMessagesTTL = cInfo.timedMessagesTTL) + DeleteItemMenu() + } + is CIContent.RcvDirectEventContent -> { + EventItemView() + MsgContentItemDropdownMenu() + } + is CIContent.RcvGroupEventContent -> { + when (c.rcvGroupEvent) { + is RcvGroupEvent.MemberCreatedContact -> CIMemberCreatedContactView(cItem, openDirectChat) + is RcvGroupEvent.NewMemberPendingReview -> PendingReviewEventItemView() + else -> EventItemView() + } + MsgContentItemDropdownMenu() + } + is CIContent.SndGroupEventContent -> { + when (c.sndGroupEvent) { + is SndGroupEvent.UserPendingReview -> PendingReviewEventItemView() + else -> EventItemView() + } + MsgContentItemDropdownMenu() + } + is CIContent.RcvConnEventContent -> { + EventItemView() + MsgContentItemDropdownMenu() + } + is CIContent.SndConnEventContent -> { + EventItemView() + MsgContentItemDropdownMenu() + } + is CIContent.RcvChatFeature -> { + CIChatFeatureView(chatsCtx, cInfo, cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.SndChatFeature -> { + CIChatFeatureView(chatsCtx, cInfo, cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.RcvChatPreference -> { + val ct = if (cInfo is ChatInfo.Direct) cInfo.contact else null + CIFeaturePreferenceView(cItem, ct, c.feature, c.allowed, acceptFeature) + DeleteItemMenu() + } + is CIContent.SndChatPreference -> { + CIChatFeatureView(chatsCtx, cInfo, cItem, c.feature, MaterialTheme.colors.secondary, icon = c.feature.icon, revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.RcvGroupFeature -> { + CIChatFeatureView(chatsCtx, cInfo, cItem, c.groupFeature, c.preference.enabled(c.memberRole_, (cInfo as? ChatInfo.Group)?.groupInfo?.membership).iconColor, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.SndGroupFeature -> { + CIChatFeatureView(chatsCtx, cInfo, cItem, c.groupFeature, c.preference.enabled(c.memberRole_, (cInfo as? ChatInfo.Group)?.groupInfo?.membership).iconColor, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.RcvChatFeatureRejected -> { + CIChatFeatureView(chatsCtx, cInfo, cItem, c.feature, Color.Red, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.RcvGroupFeatureRejected -> { + CIChatFeatureView(chatsCtx, cInfo, cItem, c.groupFeature, Color.Red, revealed = revealed, showMenu = showMenu) + MsgContentItemDropdownMenu() + } + is CIContent.SndModerated -> DeletedItem() + is CIContent.RcvModerated -> DeletedItem() + is CIContent.RcvBlocked -> DeletedItem() + is CIContent.SndDirectE2EEInfo -> DirectE2EEInfoText(c.e2eeInfo) + is CIContent.RcvDirectE2EEInfo -> DirectE2EEInfoText(c.e2eeInfo) + is CIContent.SndGroupE2EEInfo -> E2EEInfoNoPQText() + is CIContent.RcvGroupE2EEInfo -> E2EEInfoNoPQText() + is CIContent.ChatBanner -> Spacer(modifier = Modifier.size(0.dp)) + is CIContent.InvalidJSON -> { + CIInvalidJSONView(c.json) + DeleteItemMenu() } - MsgContentItemDropdownMenu() - } - is CIContent.RcvConnEventContent -> { - EventItemView() - MsgContentItemDropdownMenu() - } - is CIContent.SndConnEventContent -> { - EventItemView() - MsgContentItemDropdownMenu() - } - is CIContent.RcvChatFeature -> { - CIChatFeatureView(chatsCtx, cInfo, cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu) - MsgContentItemDropdownMenu() - } - is CIContent.SndChatFeature -> { - CIChatFeatureView(chatsCtx, cInfo, cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu) - MsgContentItemDropdownMenu() - } - is CIContent.RcvChatPreference -> { - val ct = if (cInfo is ChatInfo.Direct) cInfo.contact else null - CIFeaturePreferenceView(cItem, ct, c.feature, c.allowed, acceptFeature) - DeleteItemMenu() - } - is CIContent.SndChatPreference -> { - CIChatFeatureView(chatsCtx, cInfo, cItem, c.feature, MaterialTheme.colors.secondary, icon = c.feature.icon, revealed, showMenu = showMenu) - MsgContentItemDropdownMenu() - } - is CIContent.RcvGroupFeature -> { - CIChatFeatureView(chatsCtx, cInfo, cItem, c.groupFeature, c.preference.enabled(c.memberRole_, (cInfo as? ChatInfo.Group)?.groupInfo?.membership).iconColor, revealed = revealed, showMenu = showMenu) - MsgContentItemDropdownMenu() - } - is CIContent.SndGroupFeature -> { - CIChatFeatureView(chatsCtx, cInfo, cItem, c.groupFeature, c.preference.enabled(c.memberRole_, (cInfo as? ChatInfo.Group)?.groupInfo?.membership).iconColor, revealed = revealed, showMenu = showMenu) - MsgContentItemDropdownMenu() - } - is CIContent.RcvChatFeatureRejected -> { - CIChatFeatureView(chatsCtx, cInfo, cItem, c.feature, Color.Red, revealed = revealed, showMenu = showMenu) - MsgContentItemDropdownMenu() - } - is CIContent.RcvGroupFeatureRejected -> { - CIChatFeatureView(chatsCtx, cInfo, cItem, c.groupFeature, Color.Red, revealed = revealed, showMenu = showMenu) - MsgContentItemDropdownMenu() - } - is CIContent.SndModerated -> DeletedItem() - is CIContent.RcvModerated -> DeletedItem() - is CIContent.RcvBlocked -> DeletedItem() - is CIContent.SndDirectE2EEInfo -> DirectE2EEInfoText(c.e2eeInfo) - is CIContent.RcvDirectE2EEInfo -> DirectE2EEInfoText(c.e2eeInfo) - is CIContent.SndGroupE2EEInfo -> E2EEInfoNoPQText() - is CIContent.RcvGroupE2EEInfo -> E2EEInfoNoPQText() - is CIContent.ChatBanner -> Spacer(modifier = Modifier.size(0.dp)) - is CIContent.InvalidJSON -> { - CIInvalidJSONView(c.json) - DeleteItemMenu() } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 2128d1991b..77ab62dbf1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -62,12 +62,11 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { when (chat.chatInfo) { is ChatInfo.Direct -> { - val contactNetworkStatus = chatModel.contactNetworkStatus(chat.chatInfo.contact) val defaultClickAction = { if (chatModel.chatId.value != chat.id) scope.launch { directChatAction(chat.remoteHostId, chat.chatInfo.contact, chatModel) } } ChatListNavLinkLayout( chatLinkPreview = { tryOrShowError("${chat.id}ChatListNavLink", error = { ErrorChatListItem() }) { - ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, disabled, linkMode, inProgress = false, progressByTimeout = false, defaultClickAction) + ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, disabled, linkMode, inProgress = false, progressByTimeout = false, defaultClickAction) } }, click = defaultClickAction, @@ -87,7 +86,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { ChatListNavLinkLayout( chatLinkPreview = { tryOrShowError("${chat.id}ChatListNavLink", error = { ErrorChatListItem() }) { - ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, disabled, linkMode, inProgress.value, progressByTimeout, defaultClickAction) + ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, disabled, linkMode, inProgress.value, progressByTimeout, defaultClickAction) } }, click = defaultClickAction, @@ -107,7 +106,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { ChatListNavLinkLayout( chatLinkPreview = { tryOrShowError("${chat.id}ChatListNavLink", error = { ErrorChatListItem() }) { - ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, disabled, linkMode, inProgress = false, progressByTimeout = false, defaultClickAction) + ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, disabled, linkMode, inProgress = false, progressByTimeout = false, defaultClickAction) } }, click = defaultClickAction, @@ -648,8 +647,7 @@ fun markChatRead(c: Chat) { chatModel.controller.apiChatRead( chat.remoteHostId, chat.chatInfo.chatType, - chat.chatInfo.apiId, - chat.chatInfo.groupChatScope() + chat.chatInfo.apiId ) chat = chatModel.getChat(chat.id) ?: return@withApi } @@ -745,7 +743,6 @@ fun acceptContactRequest( } inProgress?.value = false } - chatModel.setContactNetworkStatus(contact, NetworkStatus.Connected()) close?.invoke(chat) } else { inProgress?.value = false @@ -1058,7 +1055,6 @@ fun PreviewChatListNavLinkDirect() { null, null, null, - null, disabled = false, linkMode = SimplexLinkMode.DESCRIPTION, inProgress = false, @@ -1104,7 +1100,6 @@ fun PreviewChatListNavLinkGroup() { null, null, null, - null, disabled = false, linkMode = SimplexLinkMode.DESCRIPTION, inProgress = false, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 7b5be0c323..2109e21bfe 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -601,7 +601,8 @@ fun connectIfOpenedViaUri(rhId: Long?, uri: String, chatModel: ChatModel) { chatModel.appOpenUrl.value = rhId to uri } else { withBGApi { - planAndConnect(rhId, uri, close = null) + chatModel.appOpenUrlConnecting.value = true + planAndConnect(rhId, uri, close = null, cleanup = { chatModel.appOpenUrlConnecting.value = false }) } } } @@ -670,7 +671,9 @@ private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState // if some other text is pasted, enter search mode focusRequester.requestFocus() } else { - connectProgressManager.cancelConnectProgress() + if (!chatModel.appOpenUrlConnecting.value) { + connectProgressManager.cancelConnectProgress() + } if (listState.layoutInfo.totalItemsCount > 0) { listState.scrollToItem(0) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt index d5d6facafe..4280845867 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt @@ -42,7 +42,6 @@ fun ChatPreviewView( chatModelDraft: ComposeState?, chatModelDraftChatId: ChatId?, currentUserProfileDisplayName: String?, - contactNetworkStatus: NetworkStatus?, disabled: Boolean, linkMode: SimplexLinkMode, inProgress: Boolean, @@ -349,33 +348,7 @@ fun ChatPreviewView( @Composable fun chatStatusImage() { - if (cInfo is ChatInfo.Direct) { - if ( - cInfo.contact.active && - (cInfo.contact.activeConn?.connStatus == ConnStatus.Ready || cInfo.contact.activeConn?.connStatus == ConnStatus.SndReady) - ) { - val descr = contactNetworkStatus?.statusString - when (contactNetworkStatus) { - is NetworkStatus.Connected -> - IncognitoIcon(chat.chatInfo.incognito) - - is NetworkStatus.Error -> - Icon( - painterResource(MR.images.ic_error), - contentDescription = descr, - tint = MaterialTheme.colors.secondary, - modifier = Modifier - .size(19.sp.toDp()) - .offset(x = 2.sp.toDp()) - ) - - else -> - progressView() - } - } else { - IncognitoIcon(chat.chatInfo.incognito) - } - } else if (cInfo is ChatInfo.Group) { + if (cInfo is ChatInfo.Group) { if (progressByTimeout) { progressView() } else if (chat.chatStats.reportsCount > 0) { @@ -636,6 +609,6 @@ private data class ActiveVoicePreview( @Composable fun PreviewChatPreviewView() { SimpleXTheme { - ChatPreviewView(Chat.sampleData, true, null, null, "", contactNetworkStatus = NetworkStatus.Connected(), disabled = false, linkMode = SimplexLinkMode.DESCRIPTION, inProgress = false, progressByTimeout = false, {}) + ChatPreviewView(Chat.sampleData, true, null, null, "", disabled = false, linkMode = SimplexLinkMode.DESCRIPTION, inProgress = false, progressByTimeout = false, {}) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt index 55ac9c8810..ed1c7116e6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt @@ -64,7 +64,7 @@ enum class SubscriptionColorType { ACTIVE, ACTIVE_SOCKS_PROXY, DISCONNECTED, ACTIVE_DISCONNECTED } -data class SubscriptionStatus( +data class AppSubscriptionStatus( val color: SubscriptionColorType, val variableValue: Float, val statusPercent: Float @@ -75,7 +75,7 @@ fun subscriptionStatusColorAndPercentage( socksProxy: String?, subs: SMPServerSubs, hasSess: Boolean -): SubscriptionStatus { +): AppSubscriptionStatus { fun roundedToQuarter(n: Float): Float = when { n >= 1 -> 1f n <= 0 -> 0f @@ -83,25 +83,25 @@ fun subscriptionStatusColorAndPercentage( } val activeColor: SubscriptionColorType = if (socksProxy != null) SubscriptionColorType.ACTIVE_SOCKS_PROXY else SubscriptionColorType.ACTIVE - val noConnColorAndPercent = SubscriptionStatus(SubscriptionColorType.DISCONNECTED, 1f, 0f) + val noConnColorAndPercent = AppSubscriptionStatus(SubscriptionColorType.DISCONNECTED, 1f, 0f) val activeSubsRounded = roundedToQuarter(subs.shareOfActive) return if (!online) noConnColorAndPercent else if (subs.total == 0 && !hasSess) // On freshly installed app (without chats) and on app start - SubscriptionStatus(activeColor, 0f, 0f) + AppSubscriptionStatus(activeColor, 0f, 0f) else if (subs.ssActive == 0) { if (hasSess) - SubscriptionStatus(activeColor, activeSubsRounded, subs.shareOfActive) + AppSubscriptionStatus(activeColor, activeSubsRounded, subs.shareOfActive) else noConnColorAndPercent } else { // ssActive > 0 if (hasSess) - SubscriptionStatus(activeColor, activeSubsRounded, subs.shareOfActive) + AppSubscriptionStatus(activeColor, activeSubsRounded, subs.shareOfActive) else // This would mean implementation error - SubscriptionStatus(SubscriptionColorType.ACTIVE_DISCONNECTED, activeSubsRounded, subs.shareOfActive) + AppSubscriptionStatus(SubscriptionColorType.ACTIVE_DISCONNECTED, activeSubsRounded, subs.shareOfActive) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt index 72ef8c623d..5f3a73e7ea 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt @@ -12,12 +12,9 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.* -import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.* import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.InspectableValue import androidx.compose.ui.unit.* -import chat.simplex.common.model.BusinessChatType import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import chat.simplex.common.model.ChatInfo @@ -57,7 +54,8 @@ fun ProfileImage( icon: ImageResource = MR.images.ic_account_circle_filled, color: Color = MaterialTheme.colors.secondaryVariant, backgroundColor: Color? = null, - blurred: Boolean = false + blurred: Boolean = false, + async: Boolean = false ) { Box(Modifier.size(size)) { if (image == null) { @@ -85,13 +83,22 @@ fun ProfileImage( ) } } else { - val imageBitmap = base64ToBitmap(image) - Image( - imageBitmap, - stringResource(MR.strings.image_descr_profile_image), - contentScale = ContentScale.Crop, - modifier = ProfileIconModifier(size, blurred = blurred) - ) + if (async) { + Base64AsyncImage( + base64ImageString = image, + contentDescription = stringResource(MR.strings.image_descr_profile_image), + contentScale = ContentScale.Crop, + modifier = ProfileIconModifier(size, blurred = blurred) + ) + } else { + val imageBitmap = base64ToBitmap(image) + Image( + bitmap = imageBitmap, + contentDescription = stringResource(MR.strings.image_descr_profile_image), + contentScale = ContentScale.Crop, + modifier = ProfileIconModifier(size, blurred = blurred) + ) + } } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt index a14f163a91..9c6c0fa635 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt @@ -15,8 +15,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign + import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* @@ -38,7 +37,6 @@ fun ModalData.OnboardingConditionsView() { ModalView({}, showClose = false) { val serverOperators = remember { derivedStateOf { chatModel.conditions.value.serverOperators } } val selectedOperatorIds = remember { stateGetOrPut("selectedOperatorIds") { serverOperators.value.filter { it.enabled }.map { it.operatorId }.toSet() } } - val selectedOperators = remember { derivedStateOf { serverOperators.value.filter { selectedOperatorIds.value.contains(it.operatorId) } } } ColumnWithScrollBar( Modifier @@ -84,7 +82,7 @@ fun ModalData.OnboardingConditionsView() { Spacer(Modifier.weight(1f)) Column(Modifier.widthIn(max = if (appPlatform.isAndroid) 450.dp else 1000.dp).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) { - AcceptConditionsButton(enabled = selectedOperatorIds.value.isNotEmpty(), selectedOperators, selectedOperatorIds) + AcceptConditionsButton(enabled = selectedOperatorIds.value.isNotEmpty(), selectedOperatorIds) TextButtonBelowOnboardingButton(stringResource(MR.strings.onboarding_conditions_configure_server_operators)) { ModalManager.fullscreen.showModalCloseable { close -> ChooseServerOperators(serverOperators, selectedOperatorIds, close) @@ -217,7 +215,6 @@ private fun SetOperatorsButton(enabled: Boolean, close: () -> Unit) { @Composable private fun AcceptConditionsButton( enabled: Boolean, - selectedOperators: State>, selectedOperatorIds: State> ) { fun continueOnAccept() { @@ -235,9 +232,7 @@ private fun AcceptConditionsButton( onclick = { withBGApi { val conditionsId = chatModel.conditions.value.currentConditions.conditionsId - val acceptForOperators = selectedOperators.value.filter { !it.conditionsAcceptance.conditionsAccepted } - val operatorIds = acceptForOperators.map { it.operatorId } - val r = chatController.acceptConditions(chatModel.remoteHostId(), conditionsId = conditionsId, operatorIds = operatorIds) + val r = chatController.acceptConditions(chatModel.remoteHostId(), conditionsId = conditionsId, operatorIds = selectedOperatorIds.value.toList()) if (r != null) { chatModel.conditions.value = r val enabledOperators = enabledOperators(r.serverOperators, selectedOperatorIds.value) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NewServerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NewServerView.kt index 1ec2534ab1..6a999aa89d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NewServerView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NewServerView.kt @@ -43,6 +43,13 @@ fun ModalData.NewServerView( if (isActive) { newServer.value = res.first testing.value = false + val failure = res.second + if (failure != null) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.smp_servers_test_failed), + text = failure.localizedDescription + ) + } } } }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt index cc72387875..c619ae6ebc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt @@ -612,13 +612,14 @@ private fun SingleOperatorUsageConditionsView( } } +val defaultConditionsLink = "https://github.com/simplex-chat/simplex-chat/blob/stable/PRIVACY.md" + @Composable fun ConditionsTextView( rhId: Long? ) { val conditionsData = remember { mutableStateOf?>(null) } val failedToLoad = remember { mutableStateOf(false) } - val defaultConditionsLink = "https://github.com/simplex-chat/simplex-chat/blob/stable/PRIVACY.md" val scope = rememberCoroutineScope() // can show conditions when animation between modals finishes to prevent glitches val canShowConditionsAt = remember { System.currentTimeMillis() + 300 } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt index 8626fd3143..ccad962313 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt @@ -93,6 +93,13 @@ fun ProtocolServerView( if (isActive) { draftServer.value = res.first testing.value = false + val failure = res.second + if (failure != null) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.smp_servers_test_failed), + text = failure.localizedDescription + ) + } } } }, diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml index e741d4a188..41f454b2dd 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml @@ -910,7 +910,7 @@ أُزيل يُرجى تذكرها أو تخزينها بأمان - لا توجد طريقة لاستعادة كلمة المرور المفقودة! معاينة - من المحتمل أن الملف المرجعي للشهادة في عنوان الخادم غير صحيح + البصمة في عنوان الخادم لا تتطابق مع الشهادة. يتم استلام الرسائل… يُرجى الاتصال بمُدير المجموعة. أعد التفاوض @@ -1050,8 +1050,8 @@ أظهر: أظهر خيارات المطور simplexmq: v%s (%2s) - يتطلب الخادم إذنًا لإنشاء قوائم انتظار، تحقق من كلمة المرور - يتطلب الخادم إذنًا للرفع، تحقق من كلمة المرور + يتطلب الخادم إذنًا لإنشاء قوائم انتظار، تحقق من كلمة المرور. + يتطلب الخادم إذنًا للرفع، تحقق من كلمة المرور. أظهر جهة الاتصال فقط مكالمات SimpleX Chat خدمة SimpleX Chat @@ -1128,16 +1128,16 @@ معرف الرسالة التالية غير صحيح (أقل أو يساوي السابق). \nيمكن أن يحدث ذلك بسبب بعض العلل أو عندما يُخترق الاتصال. أزل من المفضلة - محاولة الاتصال بالخادم المستخدم لاستلام الرسائل من جهة الاتصال هذه. + محاولة الاتصال بالخادم المستخدم لاستلام الرسائل من هذا الاتصال. اختيار ملف إرسال غير مصرح به - محاولة الاتصال بالخادم المستخدم لاستلام الرسائل من جهة الاتصال هذه (خطأ: %1$s). + محاولة الاتصال بالخادم المستخدم لاستلام الرسائل من جهة الاتصال هذه (خطأ: %1$s). تشغيل خوادم WebRTC ICE أنت تستخدم ملف تعريف متخفي لهذه المجموعة - لمنع مشاركة ملفك التعريفي الرئيسي الذي يدعو جهات الاتصال غير مسموح به غيّرتَ دور %s إلى %s نعم - أنت متصل بالخادم المستخدم لاستلام الرسائل من جهة الاتصال هذه. + أنت متصل بالخادم المستخدم لاستلام الرسائل من هذا الاتصال. أنت لقد شاركت رابط لمرة واحدة سيتم إرسال ملف التعريفك إلى جهة الاتصال التي استلمت منها هذا الرابط. @@ -1556,7 +1556,7 @@ يحتوي سطح المكتب على رمز دعوة خاطئ سطح المكتب مشغول يحتوي سطح المكتب على إصدار غير مدعوم. يُرجى التأكد من استخدام نفس الإصدار على كلا الجهازين - العضو السابق %1$s + العضو %1$s وظيفة بطيئة خيارات المطور تغيّر العضو %1$s إلى %2$s @@ -2396,7 +2396,7 @@ الكل دخول العضو راجع الأعضاء قبل القبول (الطرق). - حدث خطأ أثناء حذف الدردشة مع العضو. + خطأ في حذف الدردشة لا يمكن إرسال الرسائل رُفض طلب الانضمام غادرت @@ -2516,4 +2516,10 @@ افتح الرابط الكامل أزل تتبع الروابط رابط مُرحل SimpleX + خطأ في وضع علامة \"مقروءة\" + البصمة في عنوان الخادم الوجهة لا تتطابق مع الشهادة: %1$s. + البصمة في عنوان خادم التحويل لا تتطابق مع الشهادة: %1$s. + البصمة في عنوان الخادم لا تتطابق مع الشهادة: %1$s. + لا اشتراك + أنت غير متصل بالخادم المستخدم لاستقبال الرسائل من هذا الاتصال (لا يوجد اشتراك). diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 151a2019be..371f0e076f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -32,9 +32,11 @@ connected error connecting - You are connected to the server used to receive messages from this contact. - Trying to connect to the server used to receive messages from this contact (error: %1$s). - Trying to connect to the server used to receive messages from this contact. + no subscription + You are connected to the server used to receive messages from this connection. + Trying to connect to the server used to receive messages from this connection. + Error connecting to the server used to receive messages from this connection: %1$s. + You are not connected to the server used to receive messages from this connection (no subscription). deleted @@ -146,15 +148,18 @@ Connection timeout Connection error + Fingerprint in server address does not match certificate: %1$s. Please check your network connection with %1$s and try again. Server address is incompatible with network settings: %1$s. Server version is incompatible with your app: %1$s. Private routing timeout Private routing error No private routing session + Fingerprint in forwarding server address does not match certificate: %1$s. Error connecting to forwarding server %1$s. Please try later. Forwarding server address is incompatible with network settings: %1$s. Forwarding server version is incompatible with network settings: %1$s. + Fingerprint in destination server address does not match certificate: %1$s. Forwarding server %1$s failed to connect to destination server %2$s. Please try later. Destination server address of %1$s is incompatible with forwarding server %2$s settings. Destination server version of %1$s is incompatible with forwarding server %2$s. @@ -167,7 +172,8 @@ Error adding member(s) Error joining group Error accepting member - Error deleting chat with member + Error marking read + Error deleting chat Cannot receive file Sender cancelled file transfer. Unknown servers! @@ -200,9 +206,9 @@ Error aborting address change Error synchronizing connection Test failed at step %s. - Server requires authorization to create queues, check password - Server requires authorization to upload, check password - Possibly, certificate fingerprint in server address is incorrect + Server requires authorization to create queues, check password. + Server requires authorization to upload, check password. + Fingerprint in server address does not match certificate. Error setting address Error Connect @@ -1855,14 +1861,19 @@ Remove member? Remove members? + Delete member messages? Remove member + Delete member messages Chat with member Send direct message Member will be removed from group - this cannot be undone! Members will be removed from group - this cannot be undone! Member will be removed from chat - this cannot be undone! Members will be removed from chat - this cannot be undone! + Member messages will be deleted - this cannot be undone! Remove + Remove and delete messages + Delete messages Remove member Block member? Block member diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml index 384a885e3b..03a8bcfdc5 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml @@ -1182,7 +1182,7 @@ Тази настройка се прилага за съобщения в текущия ви профил За да се защити поверителността, SimpleX използва идентификатори за опашки от съобщения, отделни за всеки от вашите контакти. Опит за свързване със сървъра, използван за получаване на съобщения от този контакт. - Опит за свързване със сървъра, използван за получаване на съобщения от този контакт (грешка: %1$s). + Опит за свързване със сървъра, използван за получаване на съобщения от този контакт (грешка: %1$s). Тестът е неуспешен на стъпка %s. Базата данни не работи правилно. Докоснете, за да научите повече Изображението не може да бъде декодирано. Моля, опитайте с друго изображение или се свържете с разработчиците. @@ -1559,7 +1559,7 @@ Настолното устройство е заето Връзката с настолното устройство бе прекъсната Критична грешка - Бивш член %1$s + Член %1$s неизвестен статус неизвестен Вътрешна грешка @@ -1859,7 +1859,7 @@ Размер на шрифта Статус на съобщението: %s Задай тема по подразбиране - Потвърдете файловете от неизвестни сървъри. + Потвърди файлове от неизвестни сървъри. Изпратени съобщения Грешка при стартиране на WebView. Актуализирайте системата си до новата версия. Моля, свържете се с разработчиците.\nГрешка: %s Други XFTP сървъри @@ -1967,7 +1967,7 @@ Качени файлове Грешки при качване Размер - Парчета изтрити + Изтрити парчета Изтеглени парчета Изтеглени файлове Настройки @@ -2313,7 +2313,7 @@ Блокиране на членовете за всички? Бизнес връзка С използването на SimpleX Chat вие се съгласявате със:\n- изпращане само на легално съдържание в публични групи.\n- уважение към другите потребители – без спам. - Промената на профила е невъзможна + Промяната на профила е невъзможна съобщенията не могат да бъдат изпратени Каталонски, индонезийски, румънски и виетнамски - благодарение на нашите потребители! криптиране от край до край.]]> @@ -2508,4 +2508,21 @@ Членът е изтрит - не може да се приеме заявката заявка за връзка от група %1$s Тази настройка е за текущия профил + Разреши файлове и медия само ако вашият контакт ги разрешава. + Позволи на вашите контактите да изпращат файлове и медия. + Бот + И вие, и вашият контакт можете да изпращате файлове и медия. + Отпадащи опции + Грешка при маркиране на чата като прочетен + Файлове и медията са забранени в този чат. + Само вие можете да изпращате файлове и медия. + Само вашият контакт може да изпраща файлове и медия. + Отвори почистен линк + Отвори пълен линк + Отвори за използване на бот + Забрани изпращането на файлове и медия. + Премахни параметри за проследяване от линковете + SimpleX реле линк + Натисни Свързване, за използване на бота + За да изпращате команди, трябва да сте свързани. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml index 71e2e57576..a3b97f0be2 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml @@ -1556,7 +1556,7 @@ Comproveu que l\'enllaç SimpleX sigui correcte. l\'enviament de fitxers encara no està suportat Intentant connectar-se al servidor utilitzat per rebre missatges d\'aquest contacte. - S\'està provant de connectar-se al servidor utilitzat per rebre missatges d\'aquest contacte (error: %1$s). + S\'està provant de connectar-se al servidor utilitzat per rebre missatges d\'aquest contacte (error: %1$s). Usar perfil actual Usar nou perfil incògnit Error aplicació @@ -1613,9 +1613,9 @@ La connexió ha arribat al límit de missatges no lliurats, és possible que el vostre contacte estigui fora de línia. Missatges no lliurats A menys que el vostre contacte hagi suprimit la connexió o aquest enllaç ja s\'ha utilitzat, pot ser que sigui un error; si us plau, informeu-ho.\nPer connectar-vos, demaneu al vostre contacte que creï un altre enllaç de connexió i comproveu que teniu una connexió de xarxa estable. - Possiblement, l\'empremta digital del certificat a l\'adreça del servidor és incorrecta - El servidor requereix autorització per crear cues, comproveu la contrasenya - El servidor requereix autorització per carregar, comproveu la contrasenya + L\'empremta digital a l\'adreça del servidor no coincideix amb el certificat. + El servidor requereix autorització per crear cues, comproveu la contrasenya. + El servidor requereix autorització per carregar, comproveu la contrasenya. La prova ha fallat al pas %s. Cua segura Carrega fitxer @@ -1999,7 +1999,7 @@ Convidar a xatejar Nou rol de membre No hi ha contactes per afegir - Membre anterior %1$s + Membre %1$s Ometre convidar membres desconegut Convidar membres @@ -2381,7 +2381,7 @@ No hi ha xats amb membres Acceptar com a observador Desar la configuració d\'admissió? - Error en suprimir el xat amb membre + Error en suprimir el xat %d xat(s) el(la) membre té una versió antiga Un(a) nou(va) membre vol unir-se al grup. @@ -2496,4 +2496,9 @@ Obrir enllaç complet Eliminar el seguiment de l\'enllaç Opcions obsoletes + Enllaç de servidor SimpleX + Error marcant com a llegit + L\'empremta digital a l\'adreça del servidor de destinació no coincideix amb el certificat: %1$s. + L\'empremta digital a l\'adreça del servidor de reenviament no coincideix amb el certificat: %1$s. + L\'empremta digital a l\'adreça del servidor no coincideix amb el certificat: %1$s. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml index c369ab6b8d..423f0e135f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml @@ -254,7 +254,7 @@ Hovory na uzamčené obrazovce: Otevřete SimpleX Chat pro přijetí hovoru Povolte volání ze zamčené obrazovky prostřednictvím Nastavení. - Otevřený + Otevřít Zvuk zapnut Reproduktor vypnut Reproduktor zapnut @@ -356,7 +356,7 @@ Nastavit 1 den Chyba spojení (AUTH) Odesílatel možná smazal požadavek připojení - Server vyžaduje autorizaci pro vytváření front, zkontrolujte heslo + Server vyžaduje autorizaci pro vytvoření front, zkontrolujte heslo. Odstranit frontu Smazat Smazat čekající připojení\? @@ -493,7 +493,7 @@ Připojtíte se ke všem členům skupiny. Připojení Jste připojeni k serveru, který se používá k přijímání zpráv od tohoto kontaktu. - Pokoušíte se připojit k serveru používaném pro příjem zpráv od tohoto kontaktu (chyba: %1$s). + Pokoušíte se připojit k serveru používaném pro příjem zpráv od tohoto kontaktu (chyba: %1$s). označeno jako smazáno Odesílání souborů zatím není podporováno přijímání souborů zatím není podporováno @@ -519,7 +519,7 @@ Chyba mazání žádosti kontaktu Chyba mazání probíhajícího připojení kontaktu Test selhal v kroku %s. - Je možné, že otisk certifikátu v adrese serveru je nesprávný. + Otisk adresy serveru neodpovídá certifikátu. Připojit Odpojit Chyba mazání uživatelského profilu @@ -548,8 +548,8 @@ Smazat pro mě upraveno neautorizované odeslání - jste pozváni do skupiny - připojit jako %s + Jste pozváni do skupiny + Připojit jako %s připojuji… Začněte nový chat Chat s vývojáři @@ -1016,7 +1016,7 @@ Chyba načítání serverů XFTP Chyba ukládání XFTP serverů Ujistěte se, že adresy XFTP serverů jsou ve správném formátu s oddělenými řádky a nejsou duplicitní. - Server vyžaduje autorizaci pro nahrávání, zkontrolujte heslo + Server vyžaduje autorizaci pro nahrávání, zkontrolujte heslo. Porovnat soubor Vytvořit soubor Smazat soubor @@ -1172,7 +1172,7 @@ Sdílet adresu s kontakty\? Přestat sdílet adresu\? Vytvořit adresu, aby se s vámi lidé mohli spojit. - Uložit nastavení automatického přijímání + Uložit nastavení SimpleX adresy Přestat sdílet Automaticky přijmout Můžete vytvořit později @@ -1363,7 +1363,7 @@ 6 nových jazyků pro rozhraní Aplikace šifruje nové místní soubory (kromě videí) Arabština, bulharština, finština, hebrejština, thajština a ukrajinština - díky uživatelům a Weblate. - propojeno napřímo + požádáno o připojení Otevřít Šifrování uložených souborů a médií Chyba vytváření kontaktu @@ -1374,7 +1374,7 @@ Vytvořit nový profil v desktopové aplikaci. 💻 Změnit inkognito při připojování. - připojení k adresáři skupin (BETA)!\n- doručenky (až 20 členů).\n- rychlejší a stabilnější. - odeslat přímou zprávu + odeslat pro připojení smazaný kontakt Chyba Vytvořit profil @@ -1573,7 +1573,7 @@ Vyhledávání přijímá pozvánky. Zobrazit pomalé API volání odstraněna kontaktní adresa - Vložte člena %1$s + Člen %1$s Vložte odkaz na připojení! Nejnovější historie a vylepšený directory bot. Soukromé poznámky @@ -1592,7 +1592,7 @@ Chyba otevření prohlížeče odstraněn profilový obrázek nastavit novou kontaktní adresu - nastavit nový profilový obrázek + nastavil nový profilový obrázek Uložené zprávy Pomalá funkce Soukromé poznámky @@ -2282,7 +2282,7 @@ Report: %s Nahlásit další: uvidí pouze skupinový moderátoři. Můžete nastavit název připojení, pro pamatování, s kým byl odkaz sdílen. - Vaše připojení bylo přesunuto na %s, ale došlo k neočekávané chybě při přesměrování na profil. + Vaše připojení bylo přesunuto na %s, ale nastala chyba při přepínání profilu. Pro každý profil použijte různé přihlašovací údaje Neznámé servery Pro ochranu vaší IP adresy, soukromé směrování používá vaše servery SMP k doručování zpráv. @@ -2395,8 +2395,8 @@ Chat s adminy Přijmout Přijmout člena - schválen adminy - Přidat krátký odkaz + čeká na schválení adminy + Povýšit adresu přijat %1$s Vás přijal schválení @@ -2427,7 +2427,7 @@ Přijmout jako člena Přijmout jako pozorovatele Odmítnout člena? - Chyba odstranění chatu se členem + Chyba odstranění chatu Použït profil inkognito Otevřít chat Otevřít nový chat @@ -2450,4 +2450,82 @@ Načítání profilu… Člen je smazán - nemůže přijmout žádost Nová skupinová role: Moderátor + Zastaralé možnosti + Soubory a média jsou zakázány v tomto chatu. + Pouze vy můžete odesílat soubory a média. + Pouze vaše kontakty mohou posílat soubory a média. + Otevřít čistý odkaz + Otevřít celý odkaz + Otevřít pro přijetí + Otevřít pro připojení + Otevřít pro připojení + Otevřít pro použítí bota + Vypršel čas soukromého směrování + Odesílání souborů a médií zakázáno. + Vypršel čas protokolu na pozadí + Odmítnout žádost o kontakt + Odebrat sledování odkazů + Odstranit zprávy a blokovat členy. + koncovým šifrováním.]]> + SimpleX relé odkaz + Sezení bez soukromého směrování + 4 nové jazyky rozhraní + Přijmout žádost o kontakt + Přijmout žádost o kontakt + Přidat zprávu + Povolit soubory a média pouze pokud, je váš kontakt povolí. + Povolit vašim kontaktům odesílání souborů a médii. + Bio: + Bio příliš velké + Bot + Vy i vaše kontakty můžete posílat soubory a média. + Obchodní spojení + Nelze změnit profil + Katalánština, Indonéština, Rumunština a Vietnamština - díky našim uživatelům! + až bude váš požadavek přijat.]]> + Chat se správci + Chat se členy než se připojí. + Chyba při označení jako přečteno + Otisk adresy cílového serveru neodpovídá certifikátu: %1$s. + Otisk adresy přeposílacího serveru neodpovídá certifikátu: %1$s. + Otisk adresy serveru neodpovídá certifikátu: %1$s. + Chatujte okamžitě po připojení. + požadováno spojení ze skupiny %1$s + požadavek odeslán + Zkontrolovat členy skupiny + Odeslat žádost o kontakt? + Odeslat žádost + Odeslat žádost bez zprávy + Posílání soukromé zpětné vazby do skupin. + Pošlete kontaktu po připojení. + Nastavení bio profilu a uvítací zprávy. + Sdílení staré adresy + Sdílení starého odkazu + Sdílet vaši adresu + Stručný popis: + Krátké SimpleX adresy + Klepněte na Připojte se k chatu + Klepněte na Připojit k odeslání požadavku + Klepněte na Připojit k použití bota + Klepněte na Připojit skupinu + Vypršelo TCP připojení na pozadí + Adresa bude krátká a Váš profil bude sdílen prostřednictvím adresy. + Odkaz bude krátký a profil skupiny bude sdílen prostřednictvím odkazu. + Odesílatel NEBUDE informován. + Toto nastavení je pro váš aktuální profil + Čas mizení, je nastaven pouze pro nové kontakty. + Pro odeslání příkazů musíte být připojen. + Pro použití jiného profilu po pokusu o připojení, smažte chat a znovu použijte odkaz. + Aktualizovat vaši adresu + Povýšení + Povýšit adresu? + Povýšit odkaz skupiny + Povýšit odkaz skupiny? + Uvítací zpráva + Přivítejte vaše kontakty 👋 + Vaše bio: + Váš obchodní kontakt + Váš kontakt + Vaše skupina + Váš profil diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml index c4f84d4397..30e557a4e3 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml @@ -178,7 +178,7 @@ fejl forbinder Du har forbindelse til den server, der bruges til at modtage beskeder fra denne kontakt. - Forsøger at oprette forbindelse til den server, der bruges til at modtage beskeder fra denne kontakt (fejl: %1$s). + Forsøger at oprette forbindelse til den server, der bruges til at modtage beskeder fra denne kontakt (fejl: %1$s). Forsøger at oprette forbindelse til den server, der bruges til at modtage beskeder fra denne kontakt. slettet markeret som slettet @@ -715,4 +715,148 @@ Verificier sikkerhedsnummer Brug inkognito -profil Send besked + 4 nye interface -sprog + Tillad kun filer og medier, hvis din kontakt tillader dem. + Lad dine kontakter sende filer og medier. + Udseende + App krypterer nye lokale filer (undtagen videoer). + Appikon + Anvende + Ansøg på + App adgangskode + App adgangskode + App-adgangskode erstattes med selvdestruktionsskode. + App -session + App -tema + App værktøjslinjer + Appopdatering er downloadet + App version + App version: v%s + Arabisk, bulgarsk, finsk, hebraisk, thailandsk og ukrainsk - takket være brugerne og Weblate. + Arkiv og upload + Arkivkontakter for at chatte senere. + Arkiverede kontakter + Arkiveringsdatabase + Spørge + forsøg + Lyd- og videoopkald + lydopkald + lydopkald (ikke E2E krypteret) + Lyd fra + Lyd på + Audio & videoopkald + Audio/videoopkald + Audio/videoopkald er forbudt. + Autentificering annulleret + forfatter + Auto-accept + Auto-accept-kontaktanmodninger + Auto-accept-billeder + \nFås i v5.1 + Tilbage + Baggrund + Dårlig skrivebordsadresse + Dårlig besked hash + Dårlig besked hash + Dårligt besked -id + Dårligt besked -id + Beta + Bedre opkald + Bedre grupper + Bedre grupper ydeevne + Bedre meddelelsesdatoer. + Bedre beskeder + Bedre privatlivets fred og sikkerhed + Bedre sikkerhed ✅ + Bedre brugeroplevelse + Bio: + Bio for stor + Sort + Blok + blokeret + Blokeret af admin + blokeret %s + Blok for alle + Blokergruppemedlemmer + Blok medlem + Blok medlem? + Blokermedlem for alle? + Blokermedlem for alle? + Bluetooth + Sløret + Slør for bedre privatliv. + Slør medier + fed + Bot + Både dig og din kontakt kan tilføje meddelelsesreaktioner. + Både dig og din kontakt kan irreversibelt slette sendte beskeder. (24 timer) + Både dig og din kontakt kan foretage opkald. + Både dig og din kontakt kan sende forsvindende beskeder. + Både dig og din kontakt kan sende filer og medier. + Både dig og din kontakt kan sende stemmemeddelelser. + Forretningsadresse + Forretningschats + Med chatprofil (standard) eller ved forbindelse (beta). + Ved at bruge simplex chat accepterer du:\n- Send kun lovligt indhold i offentlige grupper.\n- Respekter andre brugere - ingen spam. + Opkald allerede afsluttet! + Opkald sluttede + Opkald sluttede %1$s + Opkaldsfejl + Ringer … + ring i gang + Ring i gang + Opkald + Opkald på låseskærmen: + Kalder forbudt! + Kamera + Kamera + Kamera og mikrofon + Kamera ikke tilgængeligt + Annuller + Annulleret %s + Annuller linkeksempel + Annuller live -meddelelsen + Annuller migration + Kan ikke få adgang til Keystore for at gemme databaseadgangskode + Kan ikke ringe til kontakten + Kan ikke ringe til gruppemedlem + Kan ikke ændre profil + Kan ikke invitere kontakt! + Kan ikke invitere kontakter! + Kan ikke sende besked til gruppemedlem + Katalansk, indonesisk, rumænsk og vietnamesisk - takket være vores brugere! + med kun en kontakt - Del personligt eller via enhver messenger.]]> + ende-til-ende krypteret med sikkerhed efter kvantet i direkte meddelelser.]]> + for hver chatprofil, du har i appen.]]> + til hvert kontakt- og gruppemedlem.\n Bemærk : Hvis du har mange forbindelser, kan dit batteri og trafikforbrug være væsentligt højere, og nogle forbindelser kan mislykkes.]]> + Tilføj kontakt: Sådan opretter du et nyt invitationslink eller opretter forbindelse via et link, du har modtaget.]]> + Bedst til batteri. Du modtager kun meddelelser, når appen løber (ingen baggrundstjeneste).]]> + Opret gruppe: At oprette en ny gruppe.]]> + godt til batteri . App kontrollerer meddelelser hvert 10. minut. Du kan gå glip af opkald eller presserende beskeder.]]> + Bemærk: Meddelelse og filrelæer er tilsluttet via SOCKS -proxy. Opkald og afsendelse af link -forhåndsvisninger Brug direkte forbindelse.]]> + Bemærk : Brug af den samme database på to enheder vil bryde dekryptering af meddelelser fra dine forbindelser som en sikkerhedsbeskyttelse.]]> + Bemærk : Du vil ikke være i stand til at gendanne eller ændre adgangskode, hvis du mister den.]]> + bruger mere batteri ! App løber altid i baggrunden - underretninger vises øjeblikkeligt.]]> + ADVARSEL : Arkivet slettes.]]> + Forbinde + Forbind automatisk + Opret forbindelse direkte? + tilsluttet + tilsluttet + tilsluttet + Tilsluttet + Forbundet desktop + Forbundet mobil + Forbundet servere + Forbundet til desktop + Forbundet til mobil + Forbind hurtigere! 🚀 + Tilslutning + Forbindelse… + Forbindelse + Forbindelse (accepteret) + Forbindelse (annonceret) + Tilslutning af opkald … + Tilslutning af opkald + Tilslutning (introduceret) diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml index 795441b079..e038207801 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml @@ -13,9 +13,9 @@ Verbunden Fehler Verbinde - Sie sind mit dem Server verbunden, der für den Empfang von Nachrichten mit diesem Kontakt genutzt wird. - Beim Versuch, die Verbindung mit dem Server aufzunehmen, der für den Empfang von Nachrichten mit diesem Kontakt genutzt wird, ist ein Fehler aufgetreten (Fehler: %1$s). - Versuche die Verbindung mit dem Server aufzunehmen, der für den Empfang von Nachrichten mit diesem Kontakt genutzt wird. + Sie sind mit dem Server verbunden, der für den Empfang von Nachrichten dieser Verbindung genutzt wird. + Beim Versuch, die Verbindung mit dem Server aufzunehmen, der für den Empfang von Nachrichten mit diesem Kontakt genutzt wird, ist ein Fehler aufgetreten (Fehler: %1$s). + Versuche eine Verbindung mit dem Server aufzunehmen, der für den Empfang von Nachrichten dieser Verbindung genutzt wird. Gelöscht als gelöscht markiert @@ -77,8 +77,8 @@ Fehler beim Löschen der ausstehenden Kontaktaufnahme Fehler beim Wechseln der Empfängeradresse Der Test ist beim Schritt %s fehlgeschlagen. - Um Warteschlangen zu erzeugen, benötigt der Server eine Authentifizierung. Bitte überprüfen Sie das Passwort. - Der Fingerabdruck des Zertifikats in der Serveradresse ist wahrscheinlich ungültig. + Der Server erfordert zum Erstellen von Warteschlangen eine Autorisierung. Bitte überprüfen Sie das Passwort. + Fingerabdruck in der Serveradresse stimmt nicht mit dem Zertifikat überein. Verbinde Erzeuge Warteschlange Sichere Warteschlange @@ -1097,7 +1097,7 @@ Host Fehler beim Speichern der XFTP-Server Fehler beim Laden der SMP-Server - Bitte das Passwort überprüfen – für den Upload benötigt der Server eine Berechtigung + Der Server erfordert zum Hochladen eine Autorisierung. Bitte überprüfen Sie das Passwort. Datei herunterladen Datei vergleichen Datei löschen @@ -1641,7 +1641,7 @@ Interner Fehler %s wird eine nicht unterstützte Version verwendet. Bitte stellen Sie sicher, dass beide Geräte die selbe Version nutzen]]> %s ist besetzt]]> - Ehemaliges Mitglied %1$s + Mitglied %1$s Die Ausführung dieser Funktion dauert zu lange: %1$d Sekunden: %2$s Langsame Funktion Langsame API-Aufrufe anzeigen @@ -1652,8 +1652,8 @@ Private Notizen Es werden alle Nachrichten gelöscht. Dies kann nicht rückgängig gemacht werden! Private Notizen entfernen? - es wurde %s blockiert - Es wurden %s freigegeben + hat %s blockiert + hat %s freigegeben Sie haben %s blockiert Sie haben %s freigegeben Mitglied für Alle blockieren? @@ -1665,7 +1665,7 @@ Für Alle freigeben Mitglied für Alle freigeben? wurde blockiert - ist vom Administrator blockiert worden + wurde vom Administrator blockiert wurde vom Administrator blockiert Für Alle blockiert Erstellt um @@ -2500,7 +2500,7 @@ Sie haben die Gruppe verlassen Kontakt deaktiviert Nicht synchronisiert - Fehler beim Löschen des Chats mit dem Mitglied + Fehler beim Löschen des Chats Kontakt nicht bereit Sie können keine Nachrichten senden! Chat löschen @@ -2606,4 +2606,10 @@ Verbinden tippen, um den Bot zu nutzen. Um Befehle senden zu können, müssen Sie verbunden sein. SimpleX Relais-Link + Fehler beim Markieren als gelesen + Fingerabdruck in der Zielserveradresse stimmt nicht mit dem Zertifikat überein: %1$s. + Fingerabdruck in der Weiterleitungsserveradresse stimmt nicht mit dem Zertifikat überein: %1$s. + Fingerabdruck in der Serveradresse stimmt nicht mit dem Zertifikat überein: %1$s. + Kein Abonnement + Sie sind nicht mit dem Server verbunden, der für den Empfang von Nachrichten dieser Verbindung genutzt wird (kein Abonnement). diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml index 2d27bb3592..9e38019c8b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml @@ -29,7 +29,7 @@ πάντα Αποδοχή Επιτρέψτε τα μηνύματα που εξαφανίζονται μόνο εάν το επιτρέπει η επαφή σας. - Επιτρέψτε στις επαφές σας να διαγράφουν μη αναστρέψιμα τα απεσταλμένα μηνύματα. + Επιτρέψτε στις επαφές σας να διαγράφουν μη αναστρέψιμα τα απεσταλμένα μηνύματα. (24 ώρες) Επιτρέψτε στις επαφές σας να στέλνουν μηνύματα που εξαφανίζονται. Επιτρέπονται τα φωνητικά μηνύματα μόνο εάν τα επιτρέπει η επαφή σας. Επιτρέψτε στις επαφές σας να σας καλέσουν. @@ -48,9 +48,9 @@ Οι διαχειριστές μπορούν να δημιουργήσουν τους συνδέσμους συμμετοχής σε ομάδες. Όλες οι συνομιλίες και τα μηνύματα θα διαγραφούν - αυτή η ενέργεια δεν μπορεί να αντιστραφεί! Όλα τα μηνύματα θα διαγραφούν - αυτή η ενέργεια δεν μπορεί να αντιστραφεί! Τα μηνύματα θα διαγραφούν ΜΟΝΟ για εσάς. - Επιτρέψτε τη μη αναστρέψιμη διαγραφή μηνυμάτων μόνο εάν σας το επιτρέπει η επαφή σας. + Επιτρέψτε τη μη αναστρέψιμη διαγραφή μηνυμάτων μόνο εάν το επιτρέπει η επαφή σας. (24 ώρες) Επιτρέπονται οι κλήσεις μόνο εάν η επαφή σας τις επιτρέπει. - Επιτρέψτε τη μη αναστρέψιμη διαγραφή των απεσταλμένων μηνυμάτων. + Επιτρέψτε τη μη αναστρέψιμη διαγραφή των απεσταλμένων μηνυμάτων. (24 ώρες) Να επιτρέπονται τα φωνητικά μηνύματα; Πάντα ενεργό Να χρησιμοποιείται πάντα αναμεταδότη @@ -230,7 +230,7 @@ Η αλλαγή διεύθυνσης θα ακυρωθεί. Θα χρησιμοποιηθεί η παλιά διεύθυνση παραλαβής. Ενεργές συνδέσεις Προχωρημένες ρυθμίσεις - Πρόσθετη προφορά + Πρόσθετος τόνος Προσθήκη επαφής Διακοπή αλλαγής διεύθυνσης Προχωρημένες ρυθμίσεις @@ -395,4 +395,46 @@ Καλύτερο για τη ζωή της μπαταρίας . Θα λαμβάνετε ειδοποιήσεις μόνο όταν εκτελείται η εφαρμογή (ΧΩΡΙΣ υπηρεσία παρασκηνίου).]]> Beta Καλύτερες κλήσεις + %1$d σφάλμα/τα αρχείου/ων:\n%2$s + 1 συζήτηση με ένα μέλος + 1 αναφορά + 1 χρόνος + Σχετικά με χειρηστές + Αποδοχή + Αποδοχή + Αποδοχή ως μέλος + Αποδοχή ως παρατηρητής + Αποδοχή όρων + Αποδοχή αιτήματος επαφής + Αποδοχή αιτήματος επαφής + αποδέχτηκε %1$s + Αποδεχούμενοι όροι + αποδέχτηκε τη πρόσκληση + σε αποδέχτηκε + Αποδοχή μέλους + Προστέθηκαν διακομιστές πολυμέσων και αρχείων + Προστέθηκε διακομιστής μυνημάτων + Προσθήκη φίλων + Πρόσθετος τόνος 2 + Προσθήκη λίστας + Προσθήκη μυνήματος + Διεύθυνση ή σύνδεσμος μιας χρήσης; + Ρυθμίσεις διεύθυνσης + Προσθήκη μέλη ομάδας + Προσθήκη στην λίστα + Πρόσθεσε τα μέλη της ομάδας σου στις συνομιλίες. + όλα + Όλα + Όλες οι συζητήσεις θα διαγραφτούν απο την λίστα %s, και η λίστα θα διαγραφτεί + Όλα τα καινούργια μυνήματα από αυτά τα μέλη θα είναι κρυμμένα! + Επιτρέψτε τα αρχεία και πολυμέσα μόνο αν η επαφή σου το επιτρέπει. + Επιτρέψτε την αναφορά μυνημάτων στους διαχειριστές. + Επιτρέψτε τις επαφές σας να σας στέλνουν αρχεία και πολυμέσα. + Όλες η αναφορές θα αρχειοθετηθούν για εσένα. + Όλοι οι διακομιστές + Άλλος λόγος + Η εφαρμογή πάντα να τρέχει στο παρασκήνιο + Αρχειοθέτηση + Αρχειοθέτηση όλων των αναφορών; + αρχειοθετημένη αναφορά diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml index 35452354a3..b5e756aaad 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml @@ -23,7 +23,7 @@ ICONO DE LA APLICACIÓN La optimización de la batería está activa, desactivando el servicio en segundo plano y las solicitudes periódicas de nuevos mensajes. Puedes volver a activarlos en Configuración. El servicio está siempre en funcionamiento en segundo plano. Las notificaciones se muestran en cuanto haya mensajes nuevos. - Se puede desactivar en Configuración – las notificaciones se seguirán mostrando mientras la app esté en funcionamiento.]]> + Se puede desactivar en la configuración. En ese caso las notificaciones se seguirán mostrando mientras la aplicación esté en funcionamiento.]]> Siempre activo Permitir y después: @@ -329,7 +329,7 @@ Archivo: %s ¡Error al cambiar perfil! Añadir manualmente - Cómo usar los servidores + Cómo usar tus servidores Error al parar SimpleX Introduce la contraseña correcta. Introduce la contraseña… @@ -438,7 +438,7 @@ Para verificar el cifrado de extremo a extremo con tu contacto, compara (o escanea) el código en ambos dispositivos. La base de datos no está cifrada. Escribe una contraseña para protegerla. Asegúrate de que las direcciones del servidor SMP tienen el formato correcto, están separadas por líneas y no están duplicadas. - Notificación instantánea + Notificaciones instantáneas Configuración avanzada Sólo los dispositivos cliente almacenan perfiles de usuario, contactos, grupos y mensajes. Cómo afecta a la batería @@ -463,7 +463,7 @@ ¡Invitación caducada! ha salido Mensajes en vivo - Notificación instantánea + Notificaciones instantáneas Servicio El mensaje se marcará para eliminar. El destinatario o destinatarios podrán revelar este mensaje. ¡Mensaje en vivo! @@ -571,7 +571,7 @@ Establecer una conexión privada Comprueba tu conexión de red con %1$s e inténtalo de nuevo. El remitente puede haber eliminado la solicitud de conexión. - Posiblemente la huella del certificado en la dirección del servidor es incorrecta + La huella en la dirección del servidor no coincide con el certificado. Responder Guardar contraseña en Keystore Error al restaurar base de datos @@ -738,7 +738,7 @@ Esta acción es irreversible. Los mensajes enviados y recibidos anteriores a la selección serán eliminados. Podría tardar varios minutos. Esta configuración se aplica a los mensajes del perfil actual ¡Esta cadena no es un enlace de conexión! - SimpleX se ejecuta en segundo plano en lugar de usar notificaciones push.]]> + SimpleX se ejecuta en segundo plano.]]> Configuración Altavoz desactivado Inciar chat nuevo @@ -748,11 +748,11 @@ Para proteger tu información, activa el Bloqueo SimpleX. \nSe te pedirá que completes la autenticación antes de activar esta función. Para actualizar la configuración el cliente se reconectará a todos los servidores. - ¿Usar servidores SimpleX Chat\? + ¿Usar servidores de SimpleX Chat? Enlace de grupo SimpleX Invitación SimpleX de un uso Enlaces SimpleX - El servidor requiere autorización para crear colas, comprueba la contraseña + El servidor requiere autorización para crear colas, comprueba la contraseña. Para recibir notificaciones, introduce la contraseña de la base de datos Llamadas de chat SimpleX Cíclico @@ -804,9 +804,9 @@ El rol cambiará a %s y el miembro recibirá una invitación nueva. Actualizar ¿Actualizar la configuración de red\? - Intentando conectar con el servidor para recibir mensajes de este contacto. + Intentando conectar con el servidor usado para recibir mensajes de esta conexión. formato de mensaje desconocido - Intentando conectar con el servidor para recibir mensajes de este contacto (error: %1$s). + Error al conectar con el servidor usado para recibir mensajes de esta conexión: (error: %1$s). Prueba no superada en el paso %s. Pulsa para iniciar chat nuevo Compartir mensaje… @@ -916,7 +916,7 @@ Tu perfil se enviará al contacto del que has recibido este enlace. Conectarás con todos los miembros del grupo. tu - Estás conectado al servidor usado para recibir mensajes de este contacto. + Estás conectado al servidor usado para recibir mensajes de esta conexión. mediante dirección de contacto mediante enlace de grupo enlace de un solo uso @@ -937,7 +937,7 @@ Tu servidor Dirección de tu servidor Tu perfil actual - Tu perfil se almacena en tu dispositivo y sólo se comparte con tus contactos. Los servidores SimpleX no pueden ver tu perfil. + Tu perfil se almacena en tu dispositivo y se comparte sólo con tus contactos. Los servidores SimpleX no pueden ver tu perfil. Sistema Añadir mensaje de bienvenida Llamadas y videollamadas @@ -1016,7 +1016,7 @@ Error al cargar servidores XFTP Error al cargar servidores SMP Asegúrate de que las direcciones del servidor XFTP tienen el formato correcto, están separadas por líneas y no están duplicadas. - El servidor requiere autorización para subir, comprueba la contraseña + El servidor requiere autorización para subir, comprueba la contraseña. Comparar archivo Crear archivo Eliminar archivo @@ -1229,10 +1229,10 @@ Interfaz en japonés y portugués sin texto Han ocurrido algunos errores no críticos durante la importación: - ¿Cerrar\? + ¿Salir de SimpleX? APLICACIÓN Reiniciar - Cerrar + Salir Las notificaciones dejarán de funcionar hasta que vuelvas a iniciar la aplicación Desactivado Error al cancelar cambio de dirección @@ -1609,7 +1609,7 @@ Por favor, informa a los desarrolladores: \n%s Reiniciar chat - Miembro pasado %1$s + Miembro %1$s el miembro %1$s ha cambiado a %2$s dirección de contacto eliminada ha eliminado la imagen del perfil @@ -2132,7 +2132,7 @@ Operador Servidores predefinidos Revisar condiciones - %s servidores + Servidores %s Las condiciones serán aceptadas el: %s. Condiciones de uso Para enrutamiento privado @@ -2166,7 +2166,7 @@ Sin servidores para recibir mensajes. Servidor del operador O para compartir en privado - Selecciona los operadores de red a utilizar + Selecciona operadores a usar. Compartir dirección públicamente Compartir enlaces de un solo uso y direcciones SimpleX es seguro a través de cualquier medio. Actualizar @@ -2402,7 +2402,7 @@ Admisión de miembros el miembro usa una versión antigua Sin chats - Error al eliminar el chat con el miembro + Error al eliminar el chat %d chats con miembros %d mensajes un chat con miembro @@ -2425,7 +2425,7 @@ Chat con miembros Chat con administradores Eliminar chat - ¿Eliminar chat con el miembro? + ¿Eliminar chat con miembro? Rechazar ¿Rechazar al miembro? Revisar miembros @@ -2531,4 +2531,10 @@ Limpiar enlaces de seguimiento Abrir enlace completo Enlace de servidor SimpleX + Error al marcar como leído + La huella en la dirección del servidor no coincide con el certificado: %1$s. + La huella en la dirección del servidor de destino no coincide con el certificado: %1$s. + La huella en la dirección del servidor de reenvío no coincide con el certificado: %1$s. + Sin suscripciones + No estás conectado al servidor usado para recibir mensajes de esta conexión (no suscrito). diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml index fef8f7f12e..eba31ba788 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml @@ -35,7 +35,7 @@ متصل شدن مسیر نامعتبر فایل برنامه از کار افتاد - در حال تلاش برای اتصال به سرور مورد استفاده برای دریافت پیام‌ها از این مخاطب (خطا: %1$s). + در حال تلاش برای اتصال به سرور مورد استفاده برای دریافت پیام‌ها از این مخاطب (خطا: %1$s). حذف شد علامت گذاشته شده به عنوان حذف شده توسط %s حذف شد @@ -119,8 +119,8 @@ خطا در لغو تغییر نشانی خطا در انطباق زمانی اتصال آزمایش در گام %s ناموفق بود. - سرور برای بارگذازی به اجازه نیاز دارد، گذرواژه را بررسی کنید - احتمال دارد اثر انگشت گواهینامه در نشانی سرور نادرست باشد + سرور برای بارگذاری نیاز به مجوز دارد، گذرواژه را بررسی کنید. + اثر انگشت در نشانی سرور با گواهی مطابقت ندارد. ایجاد صف بارگذاری فایل بارگیری فایل @@ -140,7 +140,7 @@ صف امن حذف صف ایجاد فایل - سرور برای ایجاد صف داده‌ها به اجازه نیاز دارد، گذرواژه را بررسی کنید + سرور برای ایجاد صف‌ها نیاز به مجوز دارد، گذرواژه را بررسی کنید. مگر اینکه مخاطبتان اتصال را حذف کرده یا این لینک قبلا استفاده شده باشد، ممکن است این یک اشکال باشد - لطفا آن را گزارش دهید. \nبرای متصل شدن، لطفا از مخاطبتان بخواهید لینک اتصال دیگری ایجاد کند و بررسی کنید که اتصال شبکه باثباتی دارید. عملکرد کند @@ -998,7 +998,7 @@ شما را حذف کرد رمزنگاری مورد توافق قرار گرفت ناظر - عضو پیشین %1$s + اعضا %1$s بسط دادن انتخاب نقش عبارت عبور رمزنگاری پایگاه داده به‌روز خواهد شد. درخواست اتصال کرد @@ -1938,7 +1938,7 @@ خطا در فوروارد کردن پیام‌ها خطا در ایجاد گزارش خطا در پذیرفتن عضو - خطا در پاک کردن چت با عضو + خطا در حذف کردن چت پیوند اتصال پشتیبانی‌نشده این پیوند به نسخه جدیدتری از برنامه نیاز دارد. لطفاً برنامه را به‌روزرسانی کنید یا از مخاطب خود بخواهید پیوند سازگاری بفرستد. اتصال مسدود شد @@ -2527,4 +2527,9 @@ فقط شما می‌توانید فایل و رسانه ارسال کنید. فقط مخاطب شما می‌تواند فایل‌ها و رسانه‌ها را ارسال کند. ارسال فایل‌ها و رسانه‌ها در این چت ممنوع است. + پل ارتباطی سیمپلکس + خطا در علامت گذاری به عنوان خوانده شده + اثر انگشت در نشانی سرور مقصد با گواهی مطابقت ندارد: ‎%1$s. + اثر انگشت در نشانی سرور انتقال با گواهی مطابقت ندارد: ‎%1$s. + اثر انگشت در نشانی سرور با گواهی مطابقت ندارد: ‎%1$s. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml index 1dd6598ef3..c6b094ab56 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml @@ -1036,7 +1036,7 @@ Profiilisi lähetetään kontaktille, jolta sait tämän linkin. Liityt ryhmään, johon tämä linkki viittaa, ja muodostat yhteyden sen ryhmän jäseniin. Olet yhteydessä palvelimeen, jota käytetään vastaanottamaan viestejä tältä kontaktilta. - Yritetään muodostaa yhteys palvelimeen, jota käytetään viestien vastaanottamiseen tältä kontaktilta (virhe: %1$s). + Yritetään muodostaa yhteys palvelimeen, jota käytetään viestien vastaanottamiseen tältä kontaktilta (virhe: %1$s). sinä kertalinkillä %1$s:n kautta diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml index 46b6e6e2fd..563b1a02c7 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml @@ -52,7 +52,7 @@ Veuillez vérifier que vous avez utilisé le bon lien ou demandez à votre contact de vous en envoyer un autre. Erreur de connexion Erreur lors de l\'ajout de membre·s - Tentative de connexion au serveur utilisé pour recevoir les messages de ce contact (erreur : %1$s). + Tentative de connexion au serveur utilisé pour recevoir les messages de ce contact (erreur : %1$s). format de message invalide Lien entier Erreur lors de la sauvegarde des serveurs SMP diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml index 57a91d2c42..12b578edbf 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml @@ -43,7 +43,7 @@ Az összes csevegési profiljához az alkalmazásban külön TCP-kapcsolat (és SOCKS-hitelesítési adat) lesz használva.]]> Mindkét fél küldhet eltűnő üzeneteket. Az Android Keystore-t a jelmondat biztonságos tárolására használják – lehetővé teszi az értesítési szolgáltatás működését. - Hibás az üzenet hasítóértéke + Hibás az üzenet kivonata Háttér Megjegyzés: az üzenet- és fájltovábbító kiszolgálók SOCKS proxyn keresztül kapcsolódnak. A hívások és a hivatkozások előnézetének küldése közvetlen kapcsolatot használ.]]> Alkalmazásadatok biztonsági mentése @@ -52,7 +52,7 @@ A csevegési profillal (alapértelmezett), vagy a kapcsolattal (BÉTA). Egy új, véletlenszerű profil lesz megosztva. A hangüzenetek küldése csak abban az esetben van engedélyezve, ha a partnere is engedélyezi. - Az alkalmazás összeállítási száma: %s + Alkalmazás összeállítási száma: %s Hang- és videóhívások Speciális hálózati beállítások A hangüzenetek küldése engedélyezve van a partnerei számára. @@ -63,7 +63,7 @@ Kapcsolódás folyamatban! Nem lehet fogadni a fájlt Hitelesítés elérhetetlen - Az alkalmazás verziója + Alkalmazás verziója Üdvözlőüzenet hozzáadása titkosítás elfogadása %s számára… \nElérhető az v5.1-es kiadásban @@ -122,7 +122,7 @@ Élő csevegési üzenet visszavonása Az üzenetek végleges törlése csak abban az esetben van engedélyezve, ha a partnere is engedélyezi. (24 óra) Hang- és videóhívások - hibás az üzenet hasítóértéke + hibás az üzenet kivonata Mindig fut Az Android Keystore biztonságosan fogja tárolni a jelmondatot az alkalmazás újraindítása, vagy a jelmondat módosítása után – lehetővé teszi az értesítések fogadását. Az összes alkalmazásadat törölve. @@ -161,7 +161,7 @@ Mindkét fél tud hívásokat kezdeményezni. Sikertelen hitelesítés %s összes új üzenete el lesz rejtve! - Az alkalmazás verziója: v%s + Alkalmazás verziója: v%s A hívások kezdeményezése csak abban az esetben van engedélyezve, ha a partnere is engedélyezi. Kiszolgáló hozzáadása Hang bekapcsolva @@ -302,7 +302,7 @@ befejezett CSEVEGÉSI ADATBÁZIS Önmegsemmisítő jelkód módosítása - Sorba állítás létrehozása + Várólista létrehozása színezett kapcsolódás… Sötét téma @@ -322,7 +322,7 @@ Csoporthivatkozás létrehozása Csevegési konzol Fájlok törlése az összes csevegési profilból - Sorba állítás törlése + Várólista törlése Partner törlése cím módosítása… Társítva a hordozható eszközhöz @@ -429,7 +429,7 @@ Leírás %d óra %dp - Szétkapcsolás + Kapcsolat bontása Szerkesztés Letiltás (csoport egyéni beállításainak megtartása) %d csoportesemény @@ -532,7 +532,7 @@ Fájlok és médiatartalmak KONZOLHOZ Nem sikerült a titkosítást újraegyeztetni. - Hiba történt a felhasználóprofil törlésekor + Hiba történt a felhasználói profil törlésekor Csoporttag általi javítás nem támogatott Adja meg az üdvözlőüzenetet… Titkosított adatbázis @@ -634,7 +634,7 @@ Hogyan Összecsukás Kép - Fejlesztett adatvédelem és biztonság + Továbbfejlesztett adatvédelem és biztonság Mellőzés Kép elküldve Se név, se üzenet @@ -645,7 +645,7 @@ Inkognitó Használati útmutató Alkalmazás képernyőjének elrejtése a gyakran használt alkalmazások között. - Javított kiszolgáló konfiguráció + Továbbfejlesztett kiszolgálókonfiguráció Előzmények Rejtett profiljelszó Adatbázis importálása @@ -710,7 +710,7 @@ Nagy fájl! Helyi név Hálózat és kiszolgálók - Értesítés előnézete + Értesítésekben megjelenő információk Társítsa össze a hordozható eszköz- és számítógépes alkalmazásokat! 🔗 közvetett (%1$s) Hamarosan további fejlesztések érkeznek! @@ -751,7 +751,7 @@ bekapcsolva Japán és portugál kezelőfelület Az üzenetek végleges törlése le van tiltva. - %s nevű hordozható eszközzel]]> + %s nevű hordozható eszköz le lett választva]]> hónap Üzenetvázlat Egy üzenet eltüntetése @@ -817,7 +817,7 @@ Barátok meghívása Menük és figyelmeztetések Tagok meghívása - Csatlakozás mint %s + Csatlakozás mint: %s Nincs csevegés kijelölve Csak helyi profiladatok inkognitó egy egyszer használható meghívón keresztül @@ -882,7 +882,7 @@ Megnyitás Protokoll időtúllépése titok - Értesítés előnézete + Értesítésekben megjelenő információk várakozás a visszaigazolásra… Fájl megállítása a csoporthivatkozáson keresztül @@ -904,7 +904,7 @@ Frissítés Kijelölés Csak Ön tud hívásokat indítani. - Biztonságos sorba állítás + Biztonságos várólista Értékelje az alkalmazást Egyszer használható meghívó megosztása Hiba történt az adatbázis visszaállításakor @@ -931,8 +931,8 @@ Új inkognitóprofil használata Frissítse az alkalmazást, és lépjen kapcsolatba a fejlesztőkkel. SimpleX - Hivatkozás előnézete - a biztonsági kód módosult + Hivatkozások előnézetének megjelenítése + biztonsági kódja módosult Csak a partner nevének megjelenítése Hangszóró bekapcsolva Importált csevegési adatbázis használatához indítsa újra az alkalmazást. @@ -1134,7 +1134,7 @@ Csoport megnyitása Elküldve A hangüzenetek küldése le van tiltva. - Legutóbbi üzenet előnézetének megjelenítése + Legutóbbi üzenetek előnézetének megjelenítése Az előre beállított kiszolgáló címe Időszakos értesítések letiltva! A jelkód módosult! @@ -1142,7 +1142,7 @@ Ez a QR-kód nem egy hivatkozás! Várakozás a fájlra simplexmq: v%s (%2s) - Szétkapcsolás + Leválasztás Véletlenszerű profil Érvénytelen jelmondat! A reakciók hozzáadása az üzenetekhez le van tiltva. @@ -1269,7 +1269,7 @@ Ötletek és javaslatok Figyelmeztetés: néhány adat elveszhet! Koppintson ide az új csevegés indításához - Várakozás a számítógépre… + Várakozás a számítógép-alkalmazásra… Az üzenetváltás jövője Módosítja a hálózati beállításokat? Várakozás a hordozható eszköz társítására: @@ -1289,7 +1289,7 @@ A partnereinek online kell lennie ahhoz, hogy a kapcsolat létrejöjjön.\nVisszavonhatja ezt a kapcsolatot és eltávolíthatja a partnert (ezt később ismét megpróbálhatja egy új hivatkozással). A jelmondat nem található a Keystore-ban, ezért kézzel szükséges megadni. Ez akkor történhetett meg, ha visszaállította az alkalmazás adatait egy biztonsági mentési eszközzel. Ha nem így történt, akkor lépjen kapcsolatba a fejlesztőkkel. A partnerei továbbra is kapcsolódva maradnak. - A kiszolgálónak hitelesítésre van szüksége a feltöltéshez, ellenőrizze jelszavát + A kiszolgálónak hitelesítésre van szüksége a feltöltéshez, ellenőrizze a jelszavát. Az adatbázis nem működik megfelelően. Koppintson ide a további információkért A fájl küldése le fog állni. Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál. @@ -1305,9 +1305,9 @@ Az alkalmazás 1 perc után bezárható a háttérben. Ön meghívást kapott a csoportba Engedélyezze a következő párbeszédpanelen az azonnali értesítések fogadásához.]]> - A kiszolgálónak engedélyre van szüksége a sorba állítás létrehozásához, ellenőrizze a jelszavát + A kiszolgálónak engedélyre van szüksége a várólisták létrehozásához, ellenőrizze a jelszavát. Kapcsolódni fog a csoport összes tagjához. - Lehetséges, hogy a kiszolgáló címében szereplő tanúsítvány-ujjlenyomat helytelen + A kiszolgáló címében szereplő ujjlenyomat nem egyezik a tanúsítvánnyal. A biztonsága érdekében kapcsolja be a SimpleX-zár funkciót.\nA funkció bekapcsolása előtt a rendszer felszólítja a képernyőzár beállítására az eszközén. A videó akkor érkezik meg, amikor a küldője elérhető lesz, várjon, vagy ellenőrizze később! Ellenőrizze a hálózati kapcsolatát vele: %1$s, és próbálja újra. @@ -1317,12 +1317,12 @@ A kép nem dekódolható. Próbálja meg egy másik képpel, vagy lépjen kapcsolatba a fejlesztőkkel. Érvénytelen fájlelérési útvonalat osztott meg. Jelentse a problémát az alkalmazás fejlesztőinek. Már van egy csevegési profil ugyanezzel a megjelenítendő névvel. Válasszon egy másik nevet. - Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál (hiba: %1$s). + Hiba történt a kapcsolódáskor ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál: %1$s. A fájl fogadása le fog állni. Ne felejtse el, vagy tárolja biztonságosan – az elveszett jelszót nem lehet visszaállítani! A videó akkor érkezik meg, amikor a küldője befejezte annak feltöltését. Ön egy egyszer használható meghívót osztott meg inkognitóban - Ön már kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál. + Ön kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál. Később engedélyezheti a beállításokban Akkor lesz kapcsolódva a csoporthoz, amikor a csoport tulajdonosának eszköze online lesz, várjon, vagy ellenőrizze később! különböző átköltöztetés az alkalmazásban/adatbázisban: %s / %s @@ -1475,7 +1475,7 @@ Használati útmutatóban olvasható.]]> A jelmondat a beállításokban egyszerű szövegként van tárolva. Konzol megjelenítése új ablakban - Az előző üzenet hasítóértéke különbözik. + Az előző üzenet kivonata különbözik. Ezek a beállítások csak a jelenlegi csevegési profiljára vonatkoznak Várjon, amíg a fájl betöltődik a társított hordozható eszközről GitHub-tárolónkban.]]> @@ -1520,7 +1520,7 @@ Számítógép inaktív Csevegés újraindítása Időtúllépés a számítógéphez való csatlakozáskor - Kapcsolat bontva a számítógéppel + A számítógép le lett választva A kapcsolat megszakadt A kapcsolat megszakadt A kapcsolat a számítógéppel rossz állapotban van @@ -1528,10 +1528,10 @@ Jelentse a fejlesztőknek: \n%s %s hordozható eszköz által használt alkalmazás verziója nem támogatott. Győződjön meg arról, hogy mindkét eszközön ugyanazt a verziót használja]]> - %s nevű hordozható eszközzel]]> + %s nevű hordozható eszköz le lett választva]]> Érvénytelen megjelenítendő név! Ez a megjelenítendő név érvénytelen. Válasszon egy másik nevet. - %s nevű hordozható eszközzel, a következő okból: %s]]> + %s nevű hordozható eszköz le lett választva, a következő okból: %s]]> Kapcsolat bontva a következő okból: %s %s hordozható eszköz nem található]]> %s hordozható eszközzel rossz állapotban van]]> @@ -1543,7 +1543,7 @@ Fejlesztői beállítások A funkció végrehajtása túl sokáig tart: %1$d másodperc: %2$s %s hordozható eszköz elfoglalt]]> - (Már nem tag) %1$s + %1$s ismeretlen vagy már nem tag ismeretlen állapot %1$s a következőre módosította a nevét: %2$s eltávolította a kapcsolattartási címet @@ -1561,7 +1561,7 @@ Létrehozva: %s Az összes üzenet törölve lesz – ez a művelet nem vonható vissza! Továbbfejlesztett üzenetkézbesítés - Csatlakozás csoportos beszélgetésekhez + Csatlakozás a csoportbeszélgetésekhez Hivatkozás beillesztése a kapcsolódáshoz! Privát jegyzetek A keresősáv elfogadja a meghívási hivatkozásokat. @@ -1741,7 +1741,7 @@ Igen NE használjon privát útválasztást. Privát útválasztás - Privát útválasztás használata az ismeretlen kiszolgálókkal. + Privát útválasztás használata az ismeretlen kiszolgálókhoz. Mindig használjon privát útválasztást. Üzenet-útválasztási mód Közvetlen üzenetküldés, ha az IP-cím védett és a saját kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. @@ -1790,7 +1790,7 @@ Perzsa kezelőfelület Védje az IP-címét a partnerei által kiválasztott üzenetváltási továbbítókiszolgálókkal szemben.\nEngedélyezze a *Hálózat és kiszolgálók* menüben. Ismeretlen kiszolgálókról származó fájlok megerősítése. - Javított üzenetkézbesítés + Továbbfejlesztett üzenetkézbesítés Alkalmazás témájának visszaállítása Tegye egyedivé a csevegéseit! Új csevegési témák @@ -1799,10 +1799,10 @@ Csökkentett akkumulátor-használattal. Hiba történt a WebView előkészítésekor. Frissítse rendszerét az új verzióra. Lépjen kapcsolatba a fejlesztőkkel.\nHiba: %s Felhasználó által létrehozott téma visszaállítása - Üzenetsorbaállítási információ + Üzenet várólista információi nincs Kézbesítési hibák felderítése - a kiszolgáló sorbaállítási információi: %1$s\n\nutoljára fogadott üzenet: %2$s + a kiszolgáló várólista információi: %1$s\n\nutoljára fogadott üzenet: %2$s Érvénytelen kulcs vagy ismeretlen fájltöredékcím – valószínűleg a fájl törlődött. Ideiglenes fájlhiba Üzenet állapota @@ -2057,7 +2057,7 @@ Üzenetbuborék alakja Farok Kiszolgáló - Minden alkalommal, amikor elindítja az alkalmazást, új SOCKS-hitelesítési adatokat fog használni. + Minden alkalommal, amikor elindítja az alkalmazást, új SOCKS-hitelesítési adatok lesznek használva. Alkalmazás munkamenete Az összes kiszolgálóhoz új, SOCKS-hitelesítési adatok lesznek használva. Kattintson a címmező melletti info gombra a mikrofon használatának engedélyezéséhez. @@ -2127,7 +2127,7 @@ Hálózatüzemeltető Weboldal Feltételek elfogadásának ideje: %s. - A feltételek el lesznek elfogadva a következő időpontban: %s. + A feltételek el lesznek fogadva a következő időpontban: %s. Kiszolgálók használata %s használata A jelenlegi feltételek szövegét nem sikerült betölteni, a feltételeket a következő hivatkozáson keresztül vizsgálhatja felül: @@ -2162,7 +2162,7 @@ A Flux kiszolgálókat engedélyezheti a beállításokban, a „Hálózat és kiszolgálók” menüben, a metaadatok jobb védelme érdekében. Eszköztárak a metaadatok jobb védelme érdekében. - Javított csevegési navigáció + Továbbfejlesztett csevegési navigáció - Csevegés megnyitása az első olvasatlan üzenetnél.\n- Ugrás az idézett üzenetekre. Frissített feltételek megtekintése A jelenlegi csevegési profiljához tartozó új fájlok kiszolgálói @@ -2352,7 +2352,7 @@ Összes kiszolgáló Kikapcsolva Előre beállított kiszolgálók - A 443-as TCP-port használata kizárólag az előre beállított kiszolgálokhoz. + A 443-as TCP-port használata kizárólag az előre beállított kiszolgálókhoz. Hiba a tag befogadásakor %d csevegés a tagokkal %d üzenet @@ -2393,12 +2393,12 @@ csatlakozási kérés elutasítva Ön elhagyta a csoportot a tag régi verziót használ - Hiba a taggal való csevegés törlésekor + Hiba a csevegés törlésekor Ön nem tud üzeneteket küldeni! a partner nem áll készen nincs szinkronizálva Törli a taggal való csevegést? - a partnere elhagyta a csevegést + partner törölve Csevegés törlése Elutasítás Elutasítja a tagot? @@ -2408,8 +2408,8 @@ Új csoport megnyitása végpontok közötti titkosítással vannak védve.]]> Hiba történt a partneri kapcsolatkérés elutasításakor - Hiba a csevegés megnyitásakor - Hiba a csoport megnyitásakor + Hiba történt a csevegés megnyitásakor + Hiba történt a csoport megnyitásakor Hiba a profil módosításakor Megnyitás a csatlakozáshoz Megnyitás a kapcsolódáshoz @@ -2450,9 +2450,9 @@ TCP-kapcsolat időtúllépése a háttérben Profil betöltése… Rövid leírás: - Bemutatkozás: - Bemutatkozás: - A bemutatkozás túl hosszú + Saját névjegy: + Névjegy: + A névjegy túl hosszú A leírás túl hosszú Partneri kapcsolatkérés elfogadása Üzleti kapcsolat @@ -2468,17 +2468,17 @@ Saját cím létrehozása Eltűnő üzenetek engedélyezése alapértelmezetten. Tartsa tisztán a csevegéseit - Bemutatkozószöveg és üdvözlőüzenet beállítása a profilokhoz. + Névjegy és üdvözlőüzenet beállítása a profilokhoz. Saját cím megosztása Rövid SimpleX-cím Cím frissítése Üdvözölje a partnereit 👋 4 új kezelőfelületi nyelv - Katalán, indonéz, román és vietnami – köszönjük felhasználóinknak! + Katalán, indonéz, román és vietnámi – köszönjük a felhasználóinknak! Csoporthivatkozás frissítése A hivatkozás rövid lesz és a csoportprofil meg lesz osztva a hivatkozáson keresztül. Régi cím megosztása - Régi hivatkozás megosztása + Régi (hosszú) hivatkozás megosztása PARTNERI KAPCSOLATKÉRÉSEK A CSOPORTOKBÓL A tag törölve lett – nem lehet elfogadni a kérést a(z) %1$s nevű csoportból partneri kapcsolatot kért @@ -2499,4 +2499,10 @@ Teljes hivatkozás megnyitása Nyomonkövetési paraméterek eltávolítása a hivatkozásokból SimpleX továbbítókiszolgáló-hivatkozás + Hiba a csevegés olvasottként való megjelölésekor + A célkiszolgáló címében szereplő ujjlenyomat nem egyezik a tanúsítvánnyal: %1$s. + A továbbítókiszolgáló címében szereplő ujjlenyomat nem egyezik a tanúsítvánnyal: %1$s. + A kiszolgáló címében szereplő ujjlenyomat nem egyezik a tanúsítvánnyal: %1$s. + nincs előfizetés + Ön nem kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál (nincs előfizetés). diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml index 95ad71a47d..909c6c7cfe 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml @@ -103,16 +103,16 @@ Menghubungkan Tautan tidak valid Periksa apakah tautan SimpleX sudah benar. - Anda terhubung ke server yang digunakan untuk menerima pesan dari kontak ini. - Mencoba menyambung ke server yang digunakan untuk menerima pesan dari kontak ini (error: %1$s). + Anda terhubung ke server yang digunakan untuk menerima pesan dari koneksi ini. + Mencoba menyambung ke server yang digunakan untuk menerima pesan dari kontak ini (error: %1$s). Migrasi basis data sedang berlangsung, \nmemerlukan waktu beberapa menit. menghubungkan Lokasi file tidak valid Tampilan macet Anda membagikan lokasi file yang tidak valid. Laporkan masalah ini ke pengembang aplikasi. - error - Tautan sekali + galat + Tautan 1-kali %1$d pesan yang terlewati %1$d pesan yang dilewati %s tidak didukung. Harap pastikan kamu menggunakan versi yang sama pada kedua perangkat.]]> @@ -307,7 +307,7 @@ Kontak %d pesan dihapus dihapus - Mencoba terhubung ke server untuk menerima pesan dari kontak ini. + Mencoba terhubung ke server untuk menerima pesan dari koneksi ini. disimpan diundang untuk terhubung Deskripsi @@ -341,7 +341,7 @@ %1$d berkas lainnya gagal. Kontak sudah ada menghubungkan… - kirim pesan langsung + kirim untuk terhubung Anda tidak memiliki obrolan Memuat obrolan… Ketuk untuk Hubungkan @@ -474,7 +474,7 @@ Izin Anda Izin kontak bawaan (%s) - Pesan sementara + Pesan menghilang Pesan pribadi Pesan suara Setel preferensi grup @@ -664,7 +664,7 @@ Kirim pesan suara tidak diizinkan. Hingga 100 pesan terakhir dikirim ke anggota baru. Pesan suara - Pesan sementara + Pesan menghilang Pesan langsung Profil obrolan tersembunyi Moderasi grup @@ -692,7 +692,7 @@ Kode QR tidak valid Kesalahan saat mengganti profil Ketuk untuk tempel tautan - Koneksi Anda dipindahkan ke %s tetapi terjadi kesalahan tak terduga saat mengarahkan Anda ke profil. + Koneksi Anda dipindahkan ke %s tetapi gagal saat mengarahkan Anda ke profil. Kirim kami email Kirim pertanyaan dan ide Kunci SimpleX @@ -768,19 +768,19 @@ koneksi %1$d koneksi terjalin menghubungkan… - Anda bagikan tautan sekali - Anda bagikan tautan sekali samaran + Anda bagikan tautan 1-kali + Anda bagikan tautan samaran 1-kali via tautan grup samaran via tautan grup via tautan alamat kontak samaran via tautan alamat kontak - via tautan sekali + via tautan 1-kali Alamat kontak SimpleX - samaran via tautan sekali + samaran via tautan 1-kali Tautan lengkap Tautan grup SimpleX Tautan SimpleX - Undangan sekali SimpleX + Undangan 1-kali SimpleX via %1$s Gagal simpan server XFTP Nama tampilan tidak valid! @@ -793,7 +793,7 @@ Teks ini tersedia di pengaturan Ketuk untuk memulai obrolan baru Anda diundang ke grup - gabung sebagai %s + Gabung sebagai %s menghubungkan… Tidak dapat kirim pesan Bagikan pesan… @@ -1151,7 +1151,7 @@ Harap periksa apakah tautan yang digunakan benar atau minta kontak Anda untuk kirim tautan lain. Gagal menerima permintaan kontak Galat - Mungkin sidik jari sertifikat di alamat server salah + Sidik jari pada alamat server tidak cocok dengan sertifikat. Gagal mengatur alamat Gagal hapus profil pengguna Hapus antrian @@ -1193,7 +1193,7 @@ Gagal memuat obrolan Nama tampilan ini tidak valid. Silakan pilih nama lain. Pengirim mungkin telah hapus permintaan koneksi. - Server perlu otorisasi untuk membuat antrian, periksa kata sandi + Server perlu otorisasi untuk membuat antrean, periksa kata sandi. Gagal menghapus permintaan kontak Gagal menghapus koneksi kontak tertunda Gagal mengubah alamat @@ -1262,7 +1262,7 @@ Server tak dikenal! Kecuali kontak Anda hapus koneksi atau tautan ini sudah digunakan, mungkin ini adalah bug - harap laporkan.\nUntuk terhubung, harap minta kontak Anda untuk buat tautan koneksi lain dan periksa apakah Anda memiliki koneksi jaringan stabil. Gagal sinkronkan koneksi - Server perlu otorisasi untuk mengunggah, periksa kata sandi + Server perlu otorisasi untuk mengunggah, periksa kata sandi. Buat antrian Dapat dimatikan melalui pengaturan – notifikasi akan tetap ditampilkan saat aplikasi berjalan.]]> SimpleX berjalan di latar belakang alih-alih gunakan notifikasi push.]]> @@ -1339,8 +1339,8 @@ Ketentuan diterima pada: %s. Ketentuan akan diterima pada: %s. Koneksi - Rol - Ganti rol + Peran + Ganti peran Profil obrolan Anda akan dikirim ke anggota grup Profil obrolan Anda akan dikirim ke anggota obrolan Profil grup disimpan di perangkat anggota, bukan di server. @@ -1359,7 +1359,7 @@ kode keamanan berubah %d kontak dipilih Tautan grup - Anggota lama %1$s + Anggota %1$s enkripsi end-to-end standar enkripsi e2e quantum resistant menghubungkan (undangan perkenalan) @@ -1393,7 +1393,7 @@ Ketentuan akan diterima untuk operator yang diaktifkan setelah 30 hari. Alat pengembang Warna obrolan - terhubung langsung + permintaan koneksi Koneksi ke desktop dalam kondisi buruk Konfirmasi hapus kontak? Waktu kustom @@ -1411,7 +1411,7 @@ Peningkatan basis data Konfirmasi peningkatan basis data Turunkan dan buka obrolan - mengubah hak %s menjadi %s + mengubah peran %s menjadi %s Dihapus pada: %s Hapus profil %1$s!]]> @@ -1491,7 +1491,7 @@ Anda dapat konfigurasi operator di pengaturan Jaringan dan server. Kesalahan basis data Frasa sandi basis data berbeda dengan yang disimpan di Keystore. - Ubah hak grup? + Ubah peran grup? %s.]]> Ketentuan Penggunaan %s.]]> @@ -1554,7 +1554,7 @@ Ketika lebih dari satu operator diaktifkan, tidak satupun dari mereka memiliki metadata untuk mengetahui siapa yang berkomunikasi. Obrolan sedang berjalan Basis data akan dienkripsi dan frasa sandi disimpan di Keystore. - mengubah hak Anda jadi %s + mengubah peran Anda jadi %s mengubah alamat… mengubah alamat untuk %s… ID basis data: %d @@ -1670,7 +1670,7 @@ Gagal kirim undangan Undang Status berkas - Gagal ganti hak + Gagal ganti peran Gagal hapus anggota Samaran Mode samaran melindungi privasi Anda dengan menggunakan profil acak baru untuk setiap kontak. @@ -1713,7 +1713,7 @@ Grup tidak aktif Undangan kedaluwarsa! profil grup diperbarui - Hak awal + Peran awal Gagal membuat tautan grup Gagal hapus tautan grup Gagal perbarui tautan grup @@ -1761,7 +1761,7 @@ Nama lengkap: Untuk melanjutkan, obrolan harus dihentikan. negosiasi ulang enkripsi diperlukan untuk %s - Perluas pemilihan hak + Perluas pemilihan peran Ditemukan desktop Gagal simpan pengaturan enkripsi ok untuk %s @@ -1881,7 +1881,7 @@ Alamat penerima akan diubah ke server lain. Perubahan alamat akan selesai setelah pengirim online. Minta kontak Anda untuk aktifkan kirim pesan suara. gambar profil - Simpan pengaturan terima otomatis + Simpan pengaturan alamat SimpleX Catatan diperbarui pada: %s dtk Alamat desktop salah @@ -2069,8 +2069,8 @@ Anda dapat ubah di pengaturan Tampilan. Grup ini tidak ada lagi. Anda menolak undangan grup - Anda mengubah hak Anda menjadi %s - Anda mengubah hak %s menjadi %s + Anda mengubah peran Anda menjadi %s + Anda mengubah peran %s menjadi %s Lihat ketentuan Server untuk berkas baru dari profil obrolan Anda saat ini Perbarui aplikasi secara otomatis @@ -2081,7 +2081,7 @@ Anda sudah terhubung melalui tautan 1-kali ini! Gunakan dari desktop Tindakan ini tidak dapat dibatalkan - semua berkas dan media yang diterima dan dikirim akan dihapus. Gambar beresolusi rendah akan tetap ada. - Hak akan diubah menjadi %s. Semua orang dalam grup akan diberitahu. + Peran akan diubah menjadi %s. Semua orang dalam grup akan diberitahu. Basis data obrolan Anda tidak dienkripsi - setel frasa sandi untuk melindunginya. Anda diundang ke grup Menunggu ponsel terhubung: @@ -2121,7 +2121,7 @@ Anda mengubah alamat untuk %s profil grup diperbarui %s, %s dan %s terhubung - Hak akan diubah menjadi %s. Semua orang dalam obrolan akan diberitahu. + Peran akan diubah menjadi %s. Semua orang dalam obrolan akan diberitahu. minggu Gagal unggah Mengunggah arsip @@ -2174,7 +2174,7 @@ Server SMP Profil obrolan Anda akan dikirim\nke kontak Anda Bagikan alamat - Hak akan diubah menjadi %s. Anggota akan menerima undangan baru. + Peran akan diubah menjadi %s. Anggota akan menerima undangan baru. Perbarui pengaturan jaringan? Ketuk untuk aktifkan profil. Mulai dari %s. @@ -2375,7 +2375,7 @@ grup telah dihapus Silakan tunggu pengurus grup untuk meninjau permintaanmu untuk bergabung ke grup ditinjau oleh pengurus - Tambah tautan singkat + Tingkatkan tautan menerimamu diterima %1$s Atur penerimaan anggota @@ -2388,7 +2388,7 @@ Tinjau anggota sebelum menerima (mengetuk) Mengobrol dengan pengurus semua - Ada kesalahan saat menghapus obrolan dengan anggota + Gagal hapus obrolan Laporan telah dikirim ke pengurus Anda tidak dapat mengirim pesan! kontak telah dihapus @@ -2423,4 +2423,90 @@ enkripsi end-to-end.]]> hanya setelah permintaan Anda diterima.]]> Obrolan dengan admin + Chat dengan anggota sebelum mereka bergabung. + Hubungkan + Terhubung lebih cepat! 🚀 + PERMINTAAN KONTAK DARI GRUP + kontak harus menerima… + Buat alamat Anda + Opsi tidak berlaku + Deskripsi terlalu panjang + Aktifkan pesan menghilang secara default. + Gagal mengubah profil + Gagal membuka obrolan + Gagal membuka grup + Gagal menolak permintaan kontak + Berkas dan media dilarang dalam obrolan ini. + Grup + Gabung grup + Membersihkan obrolan Anda + Lalu lintas jaringan seluler lebih sedikit. + Memuat profil… + Anggota dihapus - tidak dapat menerima permintaan + Kirim pesan instan setelah tekan Hubungkan. + Peran grup baru: Moderator + Tidak ada sesi routing pribadi + Hanya Anda yang dapat kirim berkas dan media. + Hanya kontak Anda yang dapat kirim berkas dan media. + Buka obrolan + Buka tautan bersih + Buka tautan + Buka obrolan baru + Buka grup baru + Buka untuk terima + Buka untuk hubungkan + Buka untuk gabung + Buka untuk gunakan bot + Routing pribadi terlalu lama + Larang mengirim berkas dan media. + Latar belakang protokol terlalu lama + Tolak permintaan kontak + Hapus tautan pelacak + Menghapus pesan dan memblokir anggota. + permintaan koneksi dari grup %1$s + permintaan dikirim + Tinjau anggota grup + Kirim permintaan kontak? + Kirim permintaan + Kirim permintaan tanpa pesan + Kirimkan masukan pribadi Anda ke grup. + Dikirim ke kontak Anda setelah terhubung. + Pasang bio profil dan pesan sambutan. + Bagikan alamat lama + Bagikan tautan lama + Bagikan alamat Anda + Deskripsi singkat: + Alamat singkat SimpleX + Tautan relay SimpleX + Sambut kontak Anda 👋 + Bio Anda: + Kontak bisnis Anda + Kontak Anda + Grup Anda + Profil Anda + Ketuk Hubungkan untuk chat + Tekan Hubungkan untuk kirim permintaan + Tekan Hubungkan untuk gunakan bot + Tekan Gabung grup + Batas waktu koneksi TCP bg + Alamat akan singkat, dan profil Anda akan dibagikan melalui alamat tersebut. + Tautan akan singkat, dan profil grup akan dibagikan melalui tautan tersebut. + Pengirim TIDAK akan diberi tahu. + Pengaturan ini untuk profil Anda saat ini + Waktu menghilang hanya diatur untuk kontak baru. + Untuk mengirim perintah, Anda harus terhubung. + Untuk menggunakan profil lain setelah mencoba hubungkan, hapus chat dan gunakan tautan lagi. + Perbarui alamat Anda + Tingkatkan + Tingkatkan alamat? + Tingkatkan tautan grup + Tingkatkan tautan grup? + Gunakan profil samaran + Pesan sambutan + Gagal menandai dibaca + Sidik jari di alamat server tujuan tidak cocok dengan sertifikat: %1$s. + Sidik jari pada alamat server penerusan tidak cocok dengan sertifikat: %1$s. + Sidik jari di alamat server tidak cocok dengan sertifikat: %1$s. + tidak berlangganan + Anda tidak terhubung ke server yang digunakan untuk menerima pesan dari koneksi ini (tidak berlangganan). diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml index 8c2c4cfed1..1c7e39d51e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -18,8 +18,8 @@ connesso errore in connessione - Sei connesso al server usato per ricevere messaggi da questo contatto. - Tentativo di connessione al server usato per ricevere messaggi da questo contatto. + Sei connesso/a al server usato per ricevere messaggi da questa connessione. + Tentativo di connessione al server usato per ricevere messaggi da questa connessione. eliminato contrassegnato eliminato l\'invio di file non è ancora supportato @@ -73,7 +73,7 @@ Errore di eliminazione della connessione del contatto in attesa Errore cambiando l\'indirizzo Test fallito al passo %s. - Il server richiede l\'autorizzazione di creare code, controlla la password + Il server richiede l\'autorizzazione di creare code, controlla la password. Connetti Crea coda Coda sicura @@ -201,7 +201,7 @@ Ripristina OK Connettere via indirizzo del contatto? - Tentativo di connessione al server usato per ricevere messaggi da questo contatto (errore: %1$s). + Tentativo di connessione al server usato per ricevere messaggi da questo contatto (errore: %1$s). Ti connetterai a tutti i membri del gruppo. connessione %1$d Descrizione @@ -210,7 +210,7 @@ Sei già connesso a %1$s. A meno che il tuo contatto non abbia eliminato la connessione o che questo link non sia già stato usato, potrebbe essere un errore; per favore segnalalo. \nPer connetterti, chiedi al tuo contatto di creare un altro link di connessione e controlla di avere una connessione di rete stabile. - Probabilmente l\'impronta del certificato nell\'indirizzo del server è sbagliata + L\'impronta digitale nell\'indirizzo del server non corrisponde al certificato. SimpleX funziona in secondo piano invece di usare le notifiche push.]]> Consentilo nella prossima schermata per ricevere le notifiche immediatamente.]]> Servizio SimpleX Chat @@ -1015,7 +1015,7 @@ In attesa del video Errore nel caricamento dei server XFTP Errore nel salvataggio dei server XFTP - Il server richiede l\'autorizzazione per l\'invio, controlla la password + Il server richiede l\'autorizzazione per l\'invio, controlla la password. Confronta file Crea file Scarica file @@ -1569,7 +1569,7 @@ Il desktop è inattivo Il desktop è stato disconnesso Tempo scaduto durante la connessione al desktop - Membro passato %1$s + Membro %1$s L\'esecuzione della funzione impiega troppo tempo: %1$d secondi: %2$s Funzione lenta Mostra chiamate API lente @@ -2014,7 +2014,7 @@ connetti Contatto eliminato! Confermare l\'eliminazione del contatto? - Messaggio + Chatta Nessuna selezione Seleziona Selezionato %d @@ -2437,7 +2437,7 @@ Eliminare la chat con il membro? Rifiutare il membro? Elimina chat - Errore di eliminazione della chat con il membro + Errore di eliminazione della chat Aggiorna l\'indirizzo Accetta la richiesta di contatto Aggiungi un messaggio @@ -2535,4 +2535,10 @@ Apri link completo Rimuovi il tracciamento del link Link del relay SimpleX + Errore nel segnare la lettura + L\'impronta digitale nell\'indirizzo del server di destinazione non corrisponde al certificato: %1$s. + L\'impronta digitale nell\'indirizzo del server di inoltro non corrisponde al certificato: %1$s. + L\'impronta digitale nell\'indirizzo del server non corrisponde al certificato: %1$s. + nessuna iscrizione + Non sei connesso/a al server usato per ricevere messaggi da questa connessione (nessuna iscrizione). diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml index b71831c9e0..6b413c9bfa 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml @@ -1063,7 +1063,7 @@ כדי לאמת הצפנה מקצה־לקצה עם איש הקשר שלכם, יש להשוות (או לסרוק) את הקוד במכשירים שלכם. פרופילי צ׳אט לעדכן מצב בידוד תעבורה\? - מנסה להתחבר לשרת המשמש לקבלת הודעות מאיש קשר זה (שגיאה: %1$s). + מנסה להתחבר לשרת המשמש לקבלת הודעות מאיש קשר זה (שגיאה: %1$s). פורמט הודעה לא ידוע דרך הדפדפן בטל השתקה diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml index efc1810bac..cc85e49a8c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml @@ -887,7 +887,7 @@ あなたのプライバシーを守るために、他のアプリと違って、ユーザーIDの変わりに SimpleX メッセージ束毎にIDを配布し、各連絡先が別々と扱います。 あなたのチャットプロフィールが他のグループメンバーに公開されます。 エンドツーエンド暗号化を確認するには、ご自分の端末と連絡先の端末のコードを比べます (スキャンします)。 - このコンタクトから受信するメッセージのサーバに接続しようとしてます。(エラー: %1$s)。 + このコンタクトから受信するメッセージのサーバに接続しようとしてます。(エラー: %1$s)。 使用済みリンク、または連絡先による接続の削除ではなければ、バッグの可能性があります。開発者にお伝えください。 \n繋がるには、連絡先に新しくリンクを発行してもらって、電波が安定かどうかご確認ください。 接続を完了するには、連絡相手がオンラインになる必要があります。 diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml index adf66650f1..1e71459df9 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml @@ -1404,7 +1404,7 @@ Jūsų profilis bus išsiųstas kontaktui iš kurio gavote šią nuorodą. Prisijungsite prie visų grupės narių. Esate prisijungę prie serverio skirto gauti žinutes iš šio kontakto. - Bandoma prisijungti prie serverio skirto žinučių gavimui iš šio kontakto (klaida: %1$s). + Bandoma prisijungti prie serverio skirto žinučių gavimui iš šio kontakto (klaida: %1$s). Bandoma prisijungti prie serverio skirto žinučių gavimui iš šio kontakto. nėra detalių SimpleX fono tarnybą - ji naudoja kelis procentus akumuliatoriaus per dieną.]]> diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml new file mode 100644 index 0000000000..2843b2cf8d --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml @@ -0,0 +1,2503 @@ + + + 1 minūte + 1 mēnesis + tūkst. + Vai izveidot savienojumu, izmantojot vienreizēju saiti? + Pievienoties grupai? + Izmantot pašreizējo profilu + Izmantot jaunu inkognito profilu + Izmantot inkognito profilu + Jūsu profils tiks nosūtīts kontaktpersonai, no kuras saņēmāt šo saiti. + Jūs pievienosities visiem grupas dalībniekiem. + Pievienoties + Atvērt tērzēšanu + Atvērt jaunu tērzēšanu + Atvērt grupu + Atvērt jaunu grupu + Nederīga saite + Lūdzu, pārbaudiet, vai SimpleX saite ir pareiza. + Tiek atvērta datubāze… + Notiek datubāzes migrācija.\nTas var aizņemt dažas minūtes. + Nederīgs faila ceļš + Jūs kopīgojāt nederīgu faila ceļu. Ziņojiet par problēmu lietotnes izstrādātājiem. + Skats pārstāja darboties + savienots + kļūda + notiek savienošana + Jūs esat izveidojis savienojumu ar serveri, kas tiek izmantots ziņojumu saņemšanai no šīs kontaktpersonas. + Mēģina izveidot savienojumu ar serveri, kas tiek izmantots ziņojumu saņemšanai no šīs kontaktpersonas. + izdzēsts + atzīmēts kā izdzēsts + %d ziņojumi atzīmēti kā izdzēsti + moderē %s + %1$d ziņojumus moderē %2$s + To redzat tikai Jūs un moderatori + 1 diena + 1 nedēļa + 1 gads + 30 sekundes + 4 jaunas saskarnes valodas + 5 minūtes + 6 jaunas saskarnes valodas + a + b + Pārtraukt + Atcelt adreses maiņu + Vai atcelt adreses maiņu? + bloķēts + bloķējis administrators + %d ziņojumi bloķēti + Administrators bloķējis %d ziņojumus + failu sūtīšana vēl netiek atbalstīta + failu saņemšana vēl netiek atbalstīta + Jūs + pārsūtīts + saglabāts + saglabāts no %s + nederīga tērzēšana + nederīgi dati + kļūda, rādot ziņojumu + kļūda, rādot saturu + Atšifrēšanas kļūda + Šifrēšanas atkārtotas saskaņošanas kļūda + Šī tērzēšana ir aizsargāta ar pilnīgu šifrēšanu. + Šo tērzēšanu aizsargā kvantu izturīga pilnīga šifrēšana. + Privātās piezīmes + pieprasīts savienojums + pieņemts ielūgums + savienojas… + Jūs kopīgojāt vienreizēju saiti + Pilnā saite + Saites atvēršana pārlūkprogrammā var samazināt savienojuma privātumu un drošību. Neuzticamas SimpleX saites būs sarkanas. + uzaicināts pievienoties + Savienoties, izmantojot kontaktpersonas saiti + Savienoties, izmantojot saiti inkognito režīmā + Ziņot par vienuma redzamību moderatoriem + Ziņot par vienuma arhivēšanu + Ziņot par vienuma arhivēšanu, ko veica + Nezināms ziņojuma formāts + Nederīgs ziņojuma formāts + Tiešraide + Moderēts apraksts + E2ee Info E2ee + E2ee Info No Pq + E2ee Info Pq + Savienojuma lokālais displeja vārds + Displeja vārds Savienojums izveidots + Apraksts Jūs kopīgojāt vienreizējo saiti inkognito režīmā + Apraksts Izmantojot grupas saiti + Apraksts Izmantojot grupas saiti inkognito režīmā + Apraksts Izmantojot kontaktadreses saiti + Apraksts Izmantojot kontaktadreses saiti inkognito režīmā + Apraksts, izmantojot vienreizēju saiti + Apraksts, izmantojot vienreizēju saiti inkognito režīmā + Simplex saites kontakts + Simplex saites uzaicinājums + Simplex saites grupa + Simplex saites kanāls + Simplex saites relejs + Simplex saites savienojums + Simplex saites režīms + Simplex saites režīma apraksts + Simplex Link Mode Pārlūkprogramma + Ziņošanas iemesls: Spams + Ziņošanas iemesls: Nelegāls saturs + Ziņošanas iemesls: Kopienas noteikumu pārkāpums + Ziņošanas iemesls: Profils + Ziņošanas iemesls: Cits + Kļūda, saglabājot Smp serverus + Kļūda, saglabājot Xftp serverus + Pārliecinieties, vai Smp serveru adreses ir pareizā formātā un unikālas + Pārliecinieties, vai Xftp serveru adreses ir pareizā formātā un unikālas + Kļūda ielādējot Smp serverus + Kļūda ielādējot Xftp serverus + Kļūda iestatot tīkla konfigurāciju + Neizdevās parsēt čata nosaukumu + Neizdevās parsēt čatu nosaukumus + Sazinieties ar izstrādātājiem + Neizdevās izveidot lietotāju + Neizdevās izveidot lietotāju (dublikāts) + Neizdevās izveidot lietotāju (dublikāts) + Neizdevās izveidot lietotāju (nepareizs) + Neizdevās izveidot lietotāju, nederīgs apraksts + Neizdevās aktivizēt lietotāju + Neizdevās saglabāt serverus + Nav konfigurēti ziņojumu serveri + Nav konfigurēti ziņojumu serveri saņemšanai + Nav konfigurēti ziņojumu serveri privātai maršrutēšanai + Nav konfigurēti mediju serveri + Nav konfigurēti mediju serveri sūtīšanai + Nav konfigurēti mediju serveri privātai maršrutēšanai + Čata profilam + Kļūdas serveru konfigurācijā + Kļūda, pieņemot operatora nosacījumus + Bloķēšanas iemesls: spams + Bloķēšanas iemesls: saturs + Savienojuma taimauts + Savienojuma kļūda + Tīkla kļūda: nezināms CA + Tīkla kļūdas apraksts + Tīkla kļūda: brokera resursdatora apraksts + Tīkla kļūda: brokera versijas apraksts + Privātās maršrutēšanas taimauts + Privātās maršrutēšanas kļūda + Privātās maršrutēšanas sesijas nav + Smp Proxy kļūda, nezināms CA + Smp Proxy kļūda, savienojuma izveide + Smp Proxy kļūda, brokera resursdators + Smp Proxy kļūda, brokera versija + Proxy galamērķa kļūda, nezināms CA + Proxy galamērķa kļūda, neizdevās izveidot savienojumu + Proxy galamērķa kļūda, brokera resursdators + Proxy Destination Error Broker Version + Lūdzu, mēģiniet vēlāk + Kļūda, sūtot ziņojumu + Kļūda, pārsūtot ziņojumus + Kļūda, veidojot ziņojumu + Kļūda, veidojot atskaiti + Kļūda, ielādējot detaļas + Kļūda, pievienojot dalībniekus + Kļūda, pievienojoties grupai + Kļūda, apstiprinot dalībnieku + Kļūda, atzīmējot dalībnieka atbalsta čatu kā lasītu + Kļūda, dzēšot dalībnieka atbalsta čatu + Nevar saņemt failu + Sūtītājs atcēla faila pārsūtīšanu + Fails Nav Apstiprināts (Nosaukums) + Fails Nav Apstiprināts (Apraksts) + %d Citas Failu Kļūdas + Kļūda, saņemot failu + %d Failu Kļūdas + Kļūda, veidojot adresi + Kontakts jau eksistē + Jūs jau esat savienots ar %s caur šo saiti + Nederīga savienojuma saite + Lūdzu, pārbaudiet pareizu saiti un, iespējams, pieprasiet jaunu + Neatbalstīta savienojuma saite + Saite prasa jaunāku lietotnes versiju, lūdzu, atjauniniet + Savienojuma kļūda Auth + Savienojuma kļūda Auth apraksts + Savienojuma kļūda Bloķēts + Savienojuma kļūda Bloķēts apraksts + Auth Open Migration uz citu ierīci + Bloķēšana nav iespējota + Jūs varat ieslēgt bloķēšanu + Ziņojuma piegādes kļūdas virsraksts + Ziņojuma piegādes brīdinājuma virsraksts + Ziņojuma piegādes kļūdas apraksts + Ziņojums ir dzēsts vai nav saņemts, kļūdas virsraksts + Ziņojums ir dzēsts vai nav saņemts, kļūdas apraksts + Ziņošanas iemesla brīdinājuma virsraksts + Ziņošanas arhīva brīdinājuma virsraksts + Pārsūtīt Brīdinājumu Pārsūtīt Ziņas Bez Failiem + Pārsūtīt Failus Ziņas Tiek Dzēstas Pēc Izvēles Apraksta + Pārsūtīt Failus Nav Akceptēts Apraksts + Pārsūtīt Failus Notiek Apraksts + Pārsūtīt Failus Neizdevās Saņemt Apraksts + Pārsūtīt Failus Trūkst Apraksts + Pārsūtīt Failus Nav Akceptēts Saņemt Failus + Pārsūtīt Failus Ziņas Tiek Dzēstas Pēc Izvēles Nosaukuma + Čata Saraksts Izlase + Čata Saraksts Kontakti + Čatu saraksta grupas + Čatu saraksta uzņēmumi + Čatu saraksta piezīmes + Čatu saraksta grupu atskaites + Paziņojumu grupas atskaite + Čatu saraksts Visi + Čatu saraksts Pievienot sarakstu + Grupu atskaites Aktīva viena + Grupu atskaites Aktīvas + Grupu atskaites Dalībnieku atskaites + Jauni atbalsta grupas ziņojumi + Jaunas atbalsta grupas čata sarunas + Jauna atbalsta grupas čata saruna + Jaunas atbalsta grupas čata sar. + Pievienoties čata sarunai + Nosūtīt pieprasījumu, lai pievienotos + Pievienoties, lai izmantotu botu + Apstiprināt kontaktpersonas pieprasījumu + Jūsu kontaktpersona + Bots + Čata baneris - pievienoties grupai + Čata baneris - tava grupa + Čata baneris - grupa + Čata baneris - biznesa savienojums + Čata baneris - tavs biznesa kontakts + Dalīties ar ziņu + Dalīties ar attēlu + Dalīties ar failu + Pārsūtīt ziņu + Pārsūtīt vairākas + Nevar koplietot ziņas brīdinājuma virsraksts + Nevar koplietot ziņas brīdinājuma teksts + Pievienot + Ikonas apraksts kontekstam + Ikonas apraksts attēla priekšskatījuma atcelšanai + Ikonas apraksts faila priekšskatījuma atcelšanai + Attēlu limita virsraksts + Video limita virsraksts + Attēlu limita apraksts + Video limita apraksts + Attēla atkodēšanas izņēmums + Attēla atkodēšanas izņēmuma apraksts + Video atkodēšanas izņēmuma apraksts + Faili un multivide ir aizliegti + Tikai īpašnieki var atļaut failus un multividi + Rakstiet un sūtiet tiešo ziņojumu, lai izveidotu savienojumu + Pārsūtīt ziņojumus (%d) + Saglabāt ziņojumus (%d) + SimpleX saites nav atļautas + Faili un multivide nav atļauti + Balss ziņas nav atļautas + Ierakstiet ziņu + Maksimālais ziņas lielums + Ir sasniegts maksimālais ziņas lielums + Ir sasniegts maksimālais ziņas lielums (ne teksts) + Ir sasniegts maksimālais ziņas lielums (pārsūtīšana) + Ziņot par iemeslu: spams + Ziņot par iemeslu: profils + Ziņot par iemeslu: kopiena + Ziņot par iemeslu: nelikumīgs saturs + Ziņošanas iemesla virsraksts - cits + Ziņojums Nosūtīts - Brīdinājuma Virsraksts + Ziņojums Nosūtīts - Brīdinājuma Ziņa - Skatīt Atbalsta Čatā + Pievienoties grupai + Pievienot ziņu + Savienoties + Vai sūtīt kontakta pieprasījumu? + Sūtot kontakta pieprasījumu, jūs atklāsiet savu SimpleX lietotājvārdu šim kontaktam. Vai vēlaties turpināt? + Sūtīt pieprasījumu bez ziņas + Sūtīt pieprasījumu + Nevar nosūtīt ziņu + Nevar nosūtīt ziņu – kontaktpersona nav gatava + Nevar nosūtīt ziņu – pieprasījums ir nosūtīts + Nevar nosūtīt ziņu – kontaktpersona ir izdzēsta + Nevar nosūtīt ziņu – kontaktpersona nav sinhronizēta + Nevar nosūtīt ziņu – kontaktpersona ir atspējota + Novērotājs nevar nosūtīt ziņu + Novērotājs nevar nosūtīt ziņu + Nevar nosūtīt ziņu – noraidīts + Nevar nosūtīt ziņu – grupa ir izdzēsta + Nevar nosūtīt ziņu, dalībnieks ir noņemts + Nevar nosūtīt ziņu, jūs esat izgājis + Nevar nosūtīt ziņu + Jūs esat vērotājs + Pārbaudīts ar administratoriem + Nevar nosūtīt ziņu, dalībniekam ir veca versija + Nevar Nosūtīt Komandas Brīdinājuma Teksts + Attēla Apraksts + Ikonas Apraksts Gaidot Attēlu + Ikonas Apraksts Lūgts Saņemt + Ikona Apraksts Attēls Nosūtīts Pabeigts + Gaida Attēlu + Attēls Tiks Saņemts, Kad Kontaktpersona Pabeigs Augšupielādi + Attēls Tiks Saņemts, Kad Kontaktpersona Būs Tiešsaistē + Attēls Saglabāts + Video Apraksts + Ikona Apraksts Gaida Video + Ikona Apraksts Video Pieprasīts Saņemt + Ikona Apraksts Video Nosūtīts Pabeigts + Gaida Video + Izmantot kameras pogu + No galerijas + Izvēlēties failu + Izvēlēties faila nosaukumu + Galerijas attēla poga + Galerijas video poga + Paldies, ka instalējāt SimpleX + Jūs varat sazināties ar SimpleX čata dibinātāju + Lai sāktu jaunu čatu, palīdzības virsraksts + Čata palīdzības pieskāriena poga + Saglabāt neizmantoto uzaicinājuma jautājumu + Jūs varat vēlreiz apskatīt uzaicinājuma saiti + Saglabāt uzaicinājuma saiti + Izveido saiti + Mēģināt vēlreiz + Kopīgojiet šo vienreizējo saiti + Ielīmējiet saņemto saiti + Ielīmētais teksts nav saite + Pieskarieties, lai ielīmētu saiti + Ielādē profilu + Nederīgs Qr kods + Noskenētais kods nav SimpleX Link Qr kods + Dzēstās sarunas + Nav filtrētu kontaktu + Kontaktu saraksta galvenes nosaukums + Konteksta lietotāja atlasītājs - Tavs profils + Konteksta lietotāja atlasītājs - Nevar mainīt profila brīdinājuma virsrakstu + Konteksta lietotāja atlasītājs - Nevar mainīt profila brīdinājuma ziņojums + Skenēt kodu + Nepareizs kods + Skenēt kodu no kontaktu lietotnes + Drošības kods + Atzīmēt kodu kā verificētu + Notīrīt verifikāciju + Salīdzināt, lai verificētu + Ir verificēts + Nav verificēts + Tavi iestatījumi + Tava SimpleX kontaktadreses + Tavi čata profili + Izveidot čata profilu + Datubāzes parole un eksportēšana + Par SimpleX Chat + Kā lietot SimpleX Chat + Markdown palīdzība + Markdown ziņās + Čats ar dibinātāju + Sūtiet mums e-pastu + Čata slēdzene + Čata konsole + Ziņojumu serveri + Smp serveri + Konfigurēti Smp serveri + Citi Smp serveri + Smp serveru iepriekš iestatīta adrese + Pievienot iepriekš iestatītu Smp serveri + Pievienot Smp serveri + Smp serveru testa serveris + Smp serveru testēšana + Saglabāt Smp serverus + Smp serveru tests neizdevās + Dažu Smp serveru tests neizdevās + Smp serveru QR koda skenēšana + Smp serveru ievadīšana manuāli + Smp serveru jauns serveris + Smp serveru iepriekš iestatīts serveris + Smp serveru jūsu serveris + Smp serveru jūsu servera adrese + Smp serveru izmantot serveri + Smp serveru izmantot serveri jaunam savienojumam + Smp Serveru pievienošana citai ierīcei + Smp Serveru nederīga adrese + Smp Serveru adreses pārbaude + Smp Servera dzēšana + Smp Serveri katram lietotājam + Vai saglabāt Smp Serverus? + Multivides un failu serveri + Xftp Serveri + Konfigurēti Xftp Serveri + Citi Xftp Serveri + Abonēšanas Procentuālais Daudzums + Instalēt SimpleX Chat Terminālim + Atiestatīt Visus Padomus + Atzīmēt Ar Zvaigzni Github + Ziedot + Novērtēt Aplikāciju + Izmantot SimpleX Chat Serverus? + Jūsu SMP Serveri + Jūsu XFTP Serveri + Izmantojot SimpleX Chat Serverus + Kā to darīt + Kā lietot savus serverus + Saglabātie ICE serveri tiks noņemti + Jūsu ICE serveri + Konfigurēt ICE serverus + Ievadiet vienu ICE serveri katrā rindā + Kļūda, saglabājot ICE serverus + Pārliecinieties, vai ICE serveru adreses ir pareizā formātā un unikālas + Poga \"Saglabāt serverus + Tīkls un serveri + Tīkla iestatījumi + Tīkla iestatījumu nosaukums + Tīkla Socks Proxy + Tīkla Socks Proxy iestatījumi + Tīkla Socks ieslēdzējs izmantot Socks Proxy + Tīkla Proxy autentifikācija + Tīkla Proxy nejauši akreditācijas dati + Tīkla Proxy autentifikācijas režīms izolēt pēc autentifikācijas lietotāja + Tīkla Proxy autentifikācijas režīms izolēt pēc autentifikācijas entītijas + Tīkla Proxy autentifikācijas režīms bez autentifikācijas + Tīkla starpniekservera autentifikācijas režīms - lietotājvārds un parole + Tīkla starpniekservera lietotājvārds + Tīkla starpniekservera parole + Tīkla starpniekservera ports + Nepareiza tīkla starpniekservera konfigurācija + Nepareiza tīkla starpniekservera konfigurācija + Resursdatora darbība + Porta darbība + Iespējot SOCKS tīklu + SOCKS tīkla informācija + Slīpsvītra Teksts + Pārsvītrots Teksts + Krāsains Teksts + Slepenais Teksts + Zvana Statuss Zvana + Zvana Statuss Garām Palaists + Zvana Statuss Noraidīts + Zvana Statuss Pieņemts + Zvana Statuss Savienojas + Zvana Statuss Notiek + Kontakts vēlas savienoties, izmantojot zvanu + Videozvans bez šifrēšanas + Šifrēts videozvans + Audiozvans bez šifrēšanas + Šifrēts audiozvans + Pieņemt + Noraidīt + Ignorēt + Zvans jau ir beidzies + Ikonas apraksts videozvanam + Ikona Audio zvans + Zvana piekļuves atļauja darbvirsmai noraidīta + Zvana piekļuves atļauja darbvirsmai noraidīta Chrome pārlūkā + Zvana piekļuves atļauja darbvirsmai noraidīta Safari pārlūkā + Audio un video zvanu iestatījumi + Tavi zvani + Vienmēr izmantot releju + Zvans bloķēšanas ekrānā + Pieņemt zvanu bloķēšanas ekrānā + Rādīt zvanu bloķēšanas ekrānā + Zvans nav atļauts bloķēšanas ekrānā + Jūsu Ice Serveri + Webrtc Ice Serveri + Relay Serveris Aizsargā Ip + Relay Serveris Ja Nepieciešams + Atveriet SimpleX Chat, lai pieņemtu zvanu + Atļaut zvanu pieņemšanu no bloķēšanas ekrāna + Atvērt + Statuss E2E Šifrēts + Statuss Bez E2E Šifrēšanas + Statusa kontaktam ir E2E šifrēšana + Statusa kontaktam nav E2E šifrēšanas + Zvana savienojums starp lietotājiem (Peer To Peer) + Zvana savienojums caur releju + Ikonas apraksts - pārtraukt zvanu + Ikonas apraksts - izslēgt video + Ikonas apraksts - ieslēgt video + Ikonas apraksts - izslēgt audio + Ikonas apraksts - ieslēgt audio + Ikonas apraksts - izslēgt skaļruni + Ikona Apraksts Skaļrunis Ieslēgts + Ikona Apraksts Skaņa Izslēgta + Ikona Apraksts Apgriezt Kameru + Ikona Apraksts Zvans Gaida Nosūtīšanu + Ikona Apraksts Neatbildēts Zvans + Ikona Apraksts Zvans Noraidīts + Ikona Apraksts Zvans Savienojas + Ikona Apraksts Zvans Notiek + Ikona Apraksts Zvans Beidzās + Atbildēt uz Zvanu + Izlaista integritātes ziņa + Bojāts integritātes ziņas jaucējkods (hash) + Bojāts integritātes ziņas ID + Dublēta integritātes ziņa + Brīdinājums par izlaistām ziņām + Brīdinājums par izlaistām ziņām. Tas var notikt, kad + Brīdinājums par ziņu ar bojātu jaucējkodu (hash) + Brīdinājums par ziņu ar bojātu jaucējkodu (hash) + Brīdinājums par ziņu ar bojātu ID + Brīdinājums par ziņu ar bojātu ID + Brīdinājums: Atšifrēšanas kļūda, neizdevās atšifrēt %d ziņas + Brīdinājums: Atšifrēšanas kļūda, pārāk daudz izlaistu ziņu + Brīdinājums: Fragmentu šifrēšana nav sinhronizēta, vecā datubāze + Brīdinājums: Neizdevās šifrēšanas atkārtota vienošanās + Brīdinājums: Lūdzu, ziņojiet par šo kļūdu izstrādātājiem + Privātums un drošība + Tavs privātums + Aizsargāt lietotnes ekrānu + Šifrēt lokālās datnes + Automātiski pieņemt attēlus + Aizsargāt IP adresi + Lietotne lūgs apstiprināt nezināmus failu serverus + Bez Tor vai VPN IP adrese būs redzama failu serveriem + Sūtīt saišu priekšskatījumus + Notīrīt saišu pārslēgu + Privātums Rādīt pēdējās ziņas + Privātums Ziņas melnraksts + Pilna rezerves kopija + Ieslēgt bloķēšanu + Bloķēšanas režīms + Bloķēt pēc + Iesniegt piekļuves kodu + Apstiprināt piekļuves kodu + Nepareizs piekļuves kods + Jauns piekļuves kods + Autentifikācija atcelta + La Mode System + La Mode Passcode + La App Passcode + La Mode Off + Parole uzstādīta + Parole mainīta + Parole nav mainīta + Mainīt bloķēšanas režīmu + Pašiznīcināšanās + Aktivizēta pašiznīcināšanās parole + Mainīt pašiznīcināšanās režīmu + Mainīt pašiznīcināšanās paroli + Pašiznīcināšanās parole aktivizēta + Pašiznīcināšanās parole mainīta + Pašiznīcināšanās parole + Ieslēgt pašiznīcināšanos + Pašiznīcināšanās Jauns Parādāmais Vārds + Ja ievadīsiet pašiznīcināšanās kodu + Visi lietotnes dati tiks dzēsti + Lietotnes parole aizstāta ar pašiznīcināšanos + Tiek izveidots tukšs čata profils + Ja ievadīsiet paroli, dati tiks izdzēsti + Uzstādīt paroli + Šis iestatījums ir paredzēts jūsu pašreizējam profilam + Saņemts grupas notikums: 1 dalībnieks pievienojies + Saņemts grupas notikums: 2 dalībnieki pievienojušies + Saņemts grupas notikums: 3 dalībnieki pievienojušies + Saņemts grupas notikums: N dalībnieki pievienojušies + Saņemto grupas notikumu skaits + Saņemti grupas un citi notikumi + Grupas dalībnieki 2 + Grupas dalībnieki N + Saņemts grupas notikums: Atvērt čatu + Profila atjaunināšanas notikums: Kontakta vārds ir mainīts + Snd Conn Event Switch Queue Phase Completed + Snd Conn Event Switch Queue Phase Changing + Conn Event Ratchet Sync Ok + Conn Event Ratchet Sync Allowed + Conn Event Ratchet Sync Required + Conn Event Ratchet Sync Started + Conn Event Ratchet Sync Agreed + Snd Conn Event Ratchet Sync Ok + Snd Conn Event Ratchet Sync Allowed + Snd Conn Event Ratchet Sync Required + Snd Conn Event Ratchet Sinhronizācija Sākta + Snd Conn Event Ratchet Sinhronizācija Apstiprināta + Rcv Conn Event Verifikācijas Kods Atiestatīts + Conn Event Iespējots Pq + Conn Event Atspējots Pq + Grupas Dalībnieka Loma Vērotājs + Grupas Dalībnieka Loma Autors + Grupas Dalībnieka Loma Dalībnieks + Grupas Dalībnieka Loma Moderators + Grupas Dalībnieka Loma Administrators + Grupas dalībnieka loma - īpašnieks + Grupas dalībnieka statuss - noraidīts + Grupas dalībnieka statuss - izņemts + Grupas dalībnieka statuss - atstājis + Grupas dalībnieka statuss - grupa izdzēsta + Grupas dalībnieka statuss - nezināms + Grupas dalībnieka statuss - uzaicināts + Grupas dalībnieka statuss - gaida apstiprinājumu + Grupas dalībnieka statuss - gaida apstiprinājumu (saīsināts) + Grupas dalībnieka statuss - gaida pārskatīšanu + Grupas dalībnieka statuss Gaida apstiprinājumu (īsais) + Grupas dalībnieka statuss Iepazīstināts + Grupas dalībnieka statuss Iepazīstināšanas uzaicinājums + Grupas dalībnieka statuss Pieņemts + Grupas dalībnieka statuss Paziņots + Grupas dalībnieka statuss Savienots + Grupas dalībnieka statuss Pabeigts + Grupas dalībnieka statuss Izveidotājs + Grupas dalībnieka statuss Savienojas + Grupas dalībnieka statuss Nezināms (īsais) + Iepriekšējais dalībnieks Vārds + Nav kontaktu, ko pievienot + Jaunā dalībnieka loma + Sākotnējā dalībnieka loma + Ikonas apraksts - izvērst lomu + Uzaicināt uz grupu + Uzaicināt uz čatu + Izlaist uzaicināšanu + Izvēlieties kontaktus + Ikonas apraksts - kontakts atzīmēts + Notīrīt kontaktu atlases pogu + Atlasīto kontaktu skaits + Nav atlasītu kontaktu + Uzaicinājums aizliegts + Uzaicinājuma aizlieguma apraksts + Poga Pievienot dalībniekus + Poga Pievienot komandas dalībniekus + Poga Pievienot draugus + Grupas informācijas sadaļas virsraksts - dalībnieku skaits + Grupas informācija - Tu + Poga Dzēst grupu + Poga Dzēst čatu + Vai dzēst grupu? + Vai dzēst čatu? + Dzēšot grupu visiem dalībniekiem, šo darbību nevarēs atsaukt. + Dzēšot čatu visiem dalībniekiem, šo darbību nevarēs atsaukt. + Dzēšot grupu sev, šo darbību nevarēs atsaukt. + Dzēšot čatu sev, šo darbību nevarēs atsaukt. + Poga Atstāt grupu + Poga Atstāt čatu + Poga Rediģēt grupas profilu + Poga Pievienot sagaidīšanas ziņu + Poga Sagaidīšanas ziņa + Grupas saite + Izveidot grupas saiti + Poga Izveidot grupas saiti + Dzēst saites jautājumu + Dzēst saiti + Jūs varat dalīties ar grupas saiti, un jebkurš varēs pievienoties + Visi grupas dalībnieki paliks savienoti + Kļūda, veidojot saiti grupai + Kļūda, atjauninot saiti grupai + Kļūda, dzēšot saiti grupai + Kļūda, veidojot dalībnieka kontaktu + Kļūda, sūtot ziņu kontakta uzaicinājumu + Tikai grupas īpašnieki var mainīt iestatījumus + Tikai čata īpašnieki var mainīt iestatījumus + Adreses sadaļas nosaukums + Dalīties ar adresi + Jūs varat dalīties ar šo adresi ar saviem kontaktiem + Koplietojamā teksta atjaunināšanas laiks + Koplietojamā teksta ziņojuma statuss + Koplietojamā teksta faila statuss + Koplietojamā teksta nosūtīšanas laiks + Koplietojamā teksta izveides laiks + Koplietojamā teksta saņemšanas laiks + Koplietojamā teksta dzēšanas laiks + Koplietojamā teksta moderēšanas laiks + Koplietojamā teksta pazušanas laiks + Pašreizējā vienuma informācija + Sūtītājs Ts laikā + Pašreizējās versijas laika zīmogs + Vienuma informācija bez teksta + Saņēmējs: Piegādes statuss + Saglabātās ziņas nosaukums + Poga \"Noņemt dalībnieku? + Poga \"Noņemt dalībniekus? + Poga \"Noņemt dalībnieku + Poga \"Atbalsta čata dalībnieks + Poga \"Sūtīt tiešo ziņu + Gaišā tēma + Tumšā tēma + SimpleX tēma + Melnā tēma + Sistēmas valoda + Tēma + Krāsu režīms + Tumšā tēma + Tumšā režīma krāsas + Importēt tēmu + Motīva importēšanas kļūda + Motīva importēšanas kļūdas apraksts + Eksportēt motīvu + Atiestatīt krāsu + Atiestatīt atsevišķu krāsu + Motīva galamērķa lietotnes motīvs + Primārā krāsa + Primārās krāsas variants + Sekundārā krāsa + Sekundārās krāsas variants + Fona krāsa + Virsmas krāsa + Virsraksta krāsa + Primary Variant2 krāsa + Nosūtītās ziņas krāsa + Nosūtītā citāta krāsa + Saņemtās ziņas krāsa + Saņemtā citāta krāsa + Fona tapetes krāsa + Fona tapetes tonis + Motīva Attēla Noņemšana + Izskata Fonta Izmērs + Izskata Tālummaiņa + Izskata Lietotnes Rīkjoslas + Izskata Lietotnē Iebūvēto Joslu Alfa + Izskata Joslu Izplūšanas Rādiuss + Sistēmas Režīma Uznirstošais Paziņojums + Fona Attēla Priekšskatījums - Sveika, Alise! + Fona Attēla Priekšskatījums - Sveiks, Bob! + Fona Attēla Mērogs + Fona Attēla Mērogošana Atkārtoti + Fona Attēla Mērogošana Aizpildīt + Fona Attēla Mērogošana Ietilpināt + Fona Attēla Paplašinātie Iestatījumi + Čata Tēmas Atiestatīšana uz Lietotnes Tēmu + Čata Tēmas Atiestatīšana uz Lietotāja Tēmu + Iestatīt Čata Tēmu uz Noklusējuma Tēmu + Lietot Čata Tēmu Režīmam + Lietot Čata Tēmu Visiem Režīmiem + Lietot Čata Tēmu Gaišajam Režīmam + Tērzēšanas tēma tumšajam režīmam + Tavas atļautās tērzēšanas preferences + Kontakta atļautās tērzēšanas preferences + Tērzēšanas preferenču noklusējums + Tērzēšanas preferences Jā + Tērzēšanas preferences Nē + Tērzēšanas preferences Vienmēr + Tērzēšanas preferences Ieslēgts + Tērzēšanas preferences Izslēgts` + Tērzēšanas preferences + Kontaktu preferences + Grupu preferences + Iestatīt grupu preferences + Iestatīt dalībnieku uzņemšanu + Tavas preferences + Ziņas ar taimeri + Tiešās ziņas + Pilnīga dzēšana + Ziņu reakcijas + Balss ziņas + Faili un Multivide + SimpleX Saites + Nesenā Vēsture + Audio Video Zvani + \nPieejams V51 versijā + Funkcija Ieslēgta + Funkcija Ieslēgta Jums + Funkcija Ieslēgta Kontaktam + Funkcija Izslēgta + Funkcijas Saņemšana Aizliegta + Pieņemt funkciju + Pieņemt funkciju kopumu 1 dienu + Atļaut saviem kontaktiem sūtīt pazūdošus ziņojumus + Atļaut pazūdošus ziņojumus tikai tad, ja + Pazūšanas laiks ir iestatīts tikai jauniem kontaktiem + Aizliegt sūtīt pazūdošus ziņojumus + Atļaut saviem kontaktiem neatgriezeniski dzēst + Atļaut neatgriezenisku ziņojumu dzēšanu tikai tad, ja + Kontakti var atzīmēt ziņojumus dzēšanai + Atļaut saviem kontaktiem sūtīt balss ziņojumus + Atļaut balss ziņas tikai tad, ja + Aizliegt balss ziņu sūtīšanu + Atļaut jūsu kontaktiem sūtīt failus un multivides saturu + Atļaut failus un multivides saturu tikai tad, ja + Aizliegt failu un multivides satura sūtīšanu + Atļaut jūsu kontaktiem pievienot ziņu reakcijas + Atļaut ziņu reakcijas tikai tad, ja + Aizliegt ziņu reakcijas + Atļaut jūsu kontaktiem zvanīt + Atļaut zvanus tikai tad, ja + Aizliegt zvanus + Gan jūs, gan jūsu kontaktpersona var sūtīt pazūdošus ziņojumus + Tikai jūs varat sūtīt pazūdošus ziņojumus + Tikai jūsu kontaktpersona var sūtīt pazūdošus ziņojumus + Pazūdoši ziņojumi šajā čatā ir aizliegti + Gan jūs, gan jūsu kontaktpersonas var dzēst + Tikai jūs varat dzēst ziņojumus + Tikai jūsu kontaktpersona var dzēst + Ziņojumu dzēšana ir aizliegta + Gan jūs, gan jūsu kontaktpersona var sūtīt balss ziņojumus + Aizliegt ziņu dzēšanu + Atļaut sūtīt balss ziņas + Aizliegt sūtīt balss ziņas + Atļaut ziņu reakcijas + Aizliegt ziņu reakcijas grupā + Atļaut sūtīt failus + Aizliegt sūtīt failus + Atļaut sūtīt SimpleX saites + Aizliegt sūtīt SimpleX saites + Iespējot nesenās vēstures sūtīšanu + V6 3 Reports Descr + V6 3 Organizēt čatu sarakstus + V6 3 Organizēt čatu sarakstu apraksts + V6 3 Labāka privātums un drošība + V6 3 Privāti multivides failu nosaukumi + V6 3 Iestatīt ziņojumu derīguma termiņu čatos + V6 3 Labāka grupu veiktspēja + V6 3 Ātrāka ziņojumu sūtīšana + V6 3 Ātrāka grupu dzēšana + V6 4 Savienoties ātrāk + V6 4 Ātrāks savienojums Descr + V6 4 Pārskatīt dalībniekus + V6 4 Pārskatīt dalībniekus Descr + V6 4 Atbalsta čats + V6 4 Atbalsta čats Descr + V6 4 Moderatora loma + V6 4 Moderatora loma Descr + V6 4 Ziņojumu piegāde Descr + V6 4 1 Sveicināti kontakti + V6 4 1 Sveicināti kontakti Descr + V6 4 1 Uzturiet tērzēšanas tīras + V6 4 1 Uzturiet tērzēšanas tīras Apraksts + V6 4 1 Īsā adrese + V6 4 1 Īsās adreses izveide + V6 4 1 Īsās adreses atjaunināšana + V6 4 1 Īsās adreses kopīgošana + V6 4 1 Jaunu saskarnes valodu apraksts + Skatīt atjauninātos nosacījumus + Pielāgota laika vienība sekundēs + Pielāgota laika vienība minūtēs + Pielāgots laika vienības stundas + Pielāgots laika vienības dienas + Pielāgots laika vienības nedēļas + Pielāgots laika vienības mēneši + Pielāgota laika izvēles atlasīšana + Pielāgota laika izvēles pielāgošana + Piegādes apstiprinājumu nosaukums + Ieslēgt apstiprinājumus visiem + Piegādes apstiprinājumu sūtīšana tiks ieslēgta visiem profiliem + Piegādes apstiprinājumu sūtīšana tiks ieslēgta + Neieslēgt kvītis + Jūs varat ieslēgt piegādes kvītis vēlāk + Piegādes kvītis ir atspējotas + Jūs varat ieslēgt piegādes kvītis vēlāk (brīdinājums) + Kļūda, ieslēdzot piegādes kvītis + Saistīt mobilo ierīci + Saistītās mobilās ierīces + Skenēt no mobilās ierīces + Pārbaudīt savienojumu + Pārbaudīt kodu mobilajā ierīcē + Šīs ierīces nosaukums + Šīs ierīces versija + Savienots mobilais tālrunis + Savienots ar mobilo tālruni + Ievadiet šīs ierīces nosaukumu + Šīs ierīces nosaukums, kas koplietots ar mobilo tālruni + Kļūda + Šī ierīce + Ierīces + Jauna mobilā ierīce + Atvienot darbvirsmas jautājums + Atvienot darbvirsmu + Atvienot attālo resursdatoru + Atvienot attālos resursdatorus + Attālais resursdators tika atvienots (paziņojums) + Attālais resursdators tika atvienots (virsraksts) + Attālā vadība tika atvienota (virsraksts) + Attālais resursdators atvienots no + Attālā vadība atvienota ar iemeslu + Attālās vadības savienojums apturēts (apraksts) + Remote Ctrl savienojums ir pārtraukts Identity Desc + Kopēšanas kļūda + Vai atvienoties no darbvirsmas? + Vienlaicīgi var darboties tikai viena ierīce + Atveriet mobilajā ierīcē un skenējiet QR kodu + Gaida, kad mobilā ierīce pieslēgsies + Nepareiza darbvirsmas adrese + Nesaderīga darbvirsmas versija + Darbvirsmas lietotnes versija nav saderīga + Darbvirsmas savienojums ir pārtraukts + Sesijas kods + Savienojamies ar darbvirsmu + Gaidām darbvirsmu + Atrasta darbvirsma + Savienoties ar darbvirsmu + Savienots ar darbvirsmu + Savienota darbvirsma + Pārbaudiet kodu ar darbvirsmu + Jauna darbvirsma + Saistītās darbvirsmas + Datoru Ierīces + Saistīto Datoru Iestatījumi + Skenēt QR Kodu No Datora + Datora Adrese + Pārbaudīt Savienojumus + Atklāt Tīklā + Multicast Atklājams Vietējā Tīklā + Multicast Automātiski Savienoties + Ielīmēt Datora Adresi + Datora Ierīce + Nav Saderīgs + Atjaunot QR Kodu + Nav Pievienots Mobilais Tālrunis + Nejaušs Ports + Atvērt Portu Ugunsmūrī + Atvērt Portu Ugunsmūrī Apraksts + Attālā Saimniekdatora Kļūda - Trūkst + Attālā Saimniekdatora Kļūda - Nav Aktīvs + Attālā Saimniekdatora Kļūda - Aizņemts + Attālā Saimniekdatora Kļūda - Noildze + Migrate To Device Imports Neizdevās + Migrate To Device Atkārtot Importu + Migrate To Device Ievadiet Paroli + Migrate To Device Faila Dzēšana Vai Saite Ir Nederīga + Migrate To Device Kļūda Arhīva Lejupielādē + Migrate To Device Čats Ir Pārcelts + Migrate To Device Pabeigt Migrāciju + Migrate To Device Apstipriniet Tīkla Iestatījumus + Migrate To Device Apstipriniet Tīkla Iestatījumu Kājeni + Migrate To Device Lietot Onion + Savienojuma kvotas kļūda + Savienojums neizdevās servera kvotas dēļ. + Kļūda, pieņemot kontaktu pieprasījumu + Kļūda, noraidot kontaktu pieprasījumu + Sūtītājs, iespējams, ir izdzēsis savienojuma pieprasījumu. + Kļūda, dzēšot kontaktu + Kļūda, dzēšot grupu + Kļūda, dzēšot piezīmju mapi + Kļūda, dzēšot kontaktu pieprasījumu + Kļūda, dzēšot gaidošo kontaktu savienojumu + Kļūda, mainot adresi + Kļūda, pārtraucot adreses maiņu + Kļūda, sinhronizējot savienojumu + Kļūda smp testa posmā + Kļūda smp testa servera autentifikācijā + Kļūda xftp testa servera autentifikācijā + Kļūda smp testa sertifikātā + Kļūda, iestādot adresi + Kļūda + Savienot + Atslēgt + Izveidot rindu + Droša rinda + Dzēst rindu + Izveidot failu + Augšupielādēt failu + Lejupielādēt failu + Salīdzināt failu + Dzēst failu + Kļūda, dzēšot lietotāju + Kļūda, atjauninot lietotāja privātumu + Iespējami lēni procesi + Iespējami lēni procesi + Kļūda, atjauninot čata tagus + Kļūda, izveidojot čata tagus + Kļūda, ielādējot čata tagus + Kļūda, sagatavojot kontaktu + Kļūda, sagatavojot grupu + Kļūda, mainot lietotāju + Tūlītējas paziņojumi + Pakalpojumu paziņojumi + Pakalpojumu paziņojumi atslēgti + Lai saglabātu privātumu, Simplex izmanto fona pakalpojumu, nevis uznirstošos paziņojumus, tas patērē mazāk datora akumulatora. + To var atslēgt caur iestatījumiem, paziņojumi joprojām tiek rādīti. + Izslēgt akumulatora optimizāciju + Izslēdzot pakalpojumu un periodiskos paziņojumus + Periodiskie paziņojumi + Periodiskie paziņojumi atslēgti + Periodiski paziņojumi + Izslēgt akumulatora optimizāciju + Izslēgt sistēmas ierobežojumu + Atslēgt paziņojumus + Sistēmas ierobežots fons + Brīdinājums par sistēmas ierobežotu fonu + Sistēmas ierobežots fons zvanā + Sistēmas ierobežots fons zvanā + Brīdinājums par sistēmas ierobežotu fonu zvanā + Ievadiet paroli + Ievadiet paroli + Datu bāzes inicializācijas kļūda + Neizdevās inicializēt datu bāzi. + Xiaomi ignorēt akumulatora optimizāciju + Simplex pakalpojumu paziņojums + Simplex pakalpojumu paziņojuma teksts + Zvana pakalpojumu paziņojums audio zvanam + Zvana pakalpojumu paziņojums video zvanam + Zvana pakalpojumu paziņojums par zvana beigām + Paslēpt paziņojumu + Paziņojumu kanāla ziņas + Paziņojumu kanāla zvanus + Iestatījumi par paziņojumu režīmu + Iestatījumi par paziņojumu priekšskatījuma režīmu + Iestatījumi par paziņojumu priekšskatījumu + Paziņojumu režīms izslēgts + Paziņojumu režīms periodisks + Paziņojumu režīms pakalpojums + Paziņojumu režīms izslēgts + Paziņojumu režīms periodisks + Paziņojumu režīms pakalpojums + Paziņojumu priekšskatījuma režīms + Paziņojuma priekšskatījuma režīms kontaktam + Paziņojuma priekšskatījuma režīms slēpts + Paziņojuma priekšskatījuma režīms + Paziņojuma priekšskatījuma režīms kontaktam + Paziņojuma attēlošanas režīms slēpts + Paziņojuma priekšskatījums kādam + Jauna ziņa + Jauns kontaktu pieprasījums + Kontakts savienots + Kļūda, rādot darbvirsmas paziņojumu + SimpleX Bloķēšana + Lai aizsargātu jūsu informāciju, ieslēdziet SimpleX bloķēšanu; jums tiks lūgts pabeigt autentifikāciju, pirms šī funkcija tiks aktivizēta. + Ieslēgt + Bloķēšanas režīms + Izmantot ierīces bloķēšanu + Izmantot lietotnes piekļuves kodu + Autentifikācija neizdevās + Nevarēja pārbaudīt + Nav lietotnes paroli + Ievadiet lietotnes piekļuves kodu + Pašreizējais lietotnes piekļuves kods + Mainīt lietotnes piekļuves kodu + Autentificēt + Uzreiz + sekundes + minūtes + Lūdzu, atcerieties saglabāt paroli + SimpleX bloķēšana ieslēgta + Jums būs jāveic autentifikācija, kad sākat vai atsākat + Atbloķēt + Pieslēgties, izmantojot akreditācijas datus + Ieslēgt SimpleX Bloķēšanu + Izslēgt SimpleX Bloķēšanu + Apstiprināt akreditācijas datus + Autentifikācija nav pieejama + Ierīces autentifikācija nav iespējota; to varat ieslēgt iestatījumos, kad tā būs iespējota. + Ierīces autentifikācija ir atspējota; izslēdzam. + Apturēt sarunu + Atvērt sarunu konsoli + Atvērt sarunu profilus + Ziņojumu arhīvs %dth + Ziņojumu arhīvs visi + Ziņojumu arhīvs + Ziņojumu arhīvs apraksts visi + Ziņojumu arhīvs man + Ziņojumu arhīvs visiem moderātoriem + Ci cita kļūda + Sūtīšanas autentifikācijas kļūda + Sūtīšanas kvotas kļūda + Sūtīšanas derīguma termiņš beidzies + Sūtīšanas pārsūtīšanas kļūda + Sūtīšanas starpniekservera kļūda + Sūtīšanas starpniekservera pārsūtīšanas kļūda + Servera hosta kļūda + Servera versijas kļūda + Faila autentifikācijas kļūda + Faila bloķēšanas kļūda + Faila nav + Faila pārsūtīšanas kļūda + Atbildēt + Kopīgot + Kopēt + Saglabāt + Rediģēt + Informācijas izvēlne + Meklēt + Arhivēt + Arhivēt ziņojumu + Arhivēt ziņojumus + Dzēst ziņojumu + Nosūtīta ziņa + Saņemta ziņa + Rediģēšanas vēsture + Nav vēstures + Atbilde uz + Saglabāts + Pārsūtīts + Saglabāts no + Pārsūtīts no + Saņēmēji nevar redzēt, no kura ir ziņa. + Piegāde + Nav piegādes informācijas + Dzēst + Atklāt + Paslēpt + Atļaut + Moderēt + Ziņot + Izvēlēties + Paplašināt + Dzēst ziņu? + Dzēst ziņas? + Šo darbību nevar atcelt. + Šo darbību nevar atcelt. + Ziņas dzēšanas atzīme dzēsta + Ziņu dzēšanas atzīme dzēsta + Dzēst? + Dzēst dalībniekus? + Moderētā ziņa tiks dzēsta + Moderētās ziņas tiks dzēstas + Moderētā ziņa tiks atzīmēta + Moderētās ziņas tiks atzīmētas + Tikai man + Visiem + Apturēt pārsūtīšanu + Apturēt faila sūtīšanu + Vai vēlaties apturēt šī faila sūtīšanu? + Apturēt faila saņemšanu + Vai vēlaties apturēt šī faila saņemšanu? + Apturēt + Atcelt failu + Atcelt failu + Vai vēlaties atcelt piekļuvi šim failam? + Atcelt + Pārsūtīt + Lejupielādēt failu + Saraksta izvēlne + Ziņa pārsūtīta + Šī ziņa tika pārsūtīta no citas sarunas. + Dalībnieks neaktīvs + Dalībnieks ir neaktīvs + Rediģēts + Ziņa nosūtīta + Ziņa nosūtīta bez atļaujas + Ziņas nosūtīšana neizdevās + Saņemtā ziņa nav izlasīta + Laipni lūdzam + Laipni lūdzam + Šis teksts ir pieejams iestatījumos + Jūsu sarunas + Rīkjoslas iestatījumi + Kontakta savienojums gaida apstiprinājumu + Dalībnieka kontakts nosūtīt tiešo ziņu + Grupas priekšskatījums ir atvērts pievienošanai + Grupas priekšskatījums, jūs esat aicināts + Grupas priekšskatījums, pievienojieties kā + Grupas priekšskatījums noraidīts + Grupas savienojums gaida apstiprinājumu + Noklikšķiniet, lai uzsāktu jaunu sarunu + Sarunājieties ar izstrādātājiem + Jums nav nevienas sarunas + Ielādēju sarunas… + Nav sarunu, kas atbilst filtram + Sarunu sarakstā nav + Nav neizlasītu sarunu + Nav sarunu + Nav atrastas sarunas + Kontakts, noklikšķiniet, lai savienotos + Atvērts savienojumam + Atvērts, lai izmantotu robotu + Atvērts, lai pieņemtu + Kontaktam jāpieņem + Savienoties ar kontaktu %s? + Meklējiet vai ielīmējiet simplex saiti + Adreses izveides instrukcija + Nav izvēlēta saruna + Izvēlēto sarunu vienības nav izvēlētas + Izvēlēto sarunu vienības izvēlētas %d + Pārsūtīt ziņas + Nav ko pārsūtīt + Video tiks saņemts, kad kontakts pabeigs augšupielādi. + Video tiks saņemts, kad kontakts būs tiešsaistē. + Fails + Liels fails + Kontakts nosūtīja lielu failu + Maksimālais atbalstītais faila izmērs + Gaidām failu + Fails tiks saņemts, kad kontakts pabeigs augšupielādi. + Fails tiks saņemts, kad kontakts būs tiešsaistē. + Fails saglabāts + Fails nav atrasts + Kļūda, saglabājot failu + Attālinātā faila ielāde + Attālinātā faila ielāde. + Faila kļūda + Pagaidu faila kļūda + Atvērt ar lietotni + Balss ziņa + Balss ziņa ar ilgumu + Nosūtīt + Paziņojumi + Atspējot automātisko dzēšanu? + Mainīt automātisko dzēšanu? + Atspējot automātisko dzēšanu + Mainīt automātisko sarunu dzēšanu + Atspējot automātisko dzēšanu + Sarunu laika ierobežojuma opciju apakšdaļa + Skatīt savienojumu + Skatīt atvērt + Skatīt + Skatīt zvanu + Skatīt meklēšanu + Skatīt video + Dzēst kontaktu? + Visas ziņas tiks dzēstas. To nevar atcelt. + Šo darbību nevar atcelt. + Saglabāt sarunu + Tikai dzēst sarunu + Apstiprināt kontakta dzēšanu? + Dzēst un paziņot kontaktam + Dzēst bez paziņojuma + Dzēst kontaktu + Saruna dzēsta + Jūs joprojām varat sūtīt ziņas kontaktam + Kontakts dzēsts + Jūs joprojām varat skatīt sarunu ar kontaktu + Ievadiet kontakta vārdu + Ievadiet čata nosaukumu + Serveris ir savienots + Serveris nav savienots + Servera kļūda + Serveris gaida + Vai vēlaties mainīt saņemšanas adresi? + Mainīt saņemšanas adresi + Pārtraukt saņemšanas adreses maiņu + Vai vēlaties piespiedu sinhronizāciju? + Piespiedu sinhronizācija + Apstiprināt piespiedu sinhronizāciju + Vai vēlaties sinhronizēt savienojumu? + Sinhronizēt savienojumu + Apstiprināt savienojuma sinhronizāciju + Šifrēšanas pārrunāšana notiek + Skatīt drošības kodu + Verificēt drošības kodu + Sūtīt ziņu + Ierakstīt balss ziņu + Vai vēlaties atļaut balss ziņas? + Jums jāatļauj balss ziņas, lai tās sūtītu. + Balss ziņas šajā čatā ir aizliegtas. + Lūdziet kontaktam iespējot balss ziņas + Tikai grupas īpašnieki var iespējot balss ziņas + Sūtīt tiešo ziņu + Izbeidzoša ziņa + Sūtīt izbeidzošu ziņu + Pielāgots laiks + Sūtīt + Tiešā ziņa + Sūtīt tiešo ziņu + Sūtīt + Atcelt tiešo ziņu + Atpakaļ + Atcelt + Apstiprināt + Atjaunot + Labi + Nav detaļu + Pievienot kontaktu + Kopēts + Pievienot kontaktu vai izveidot grupu + Kopīgot vienreizēju saiti + Savienot, izmantojot saiti vai QR + Nolasīt QR kodu + Izveidot grupu + Lai kopīgotu ar savu kontaktu + Savienot, izmantojot saiti vai QR no starpliktuves vai klātienē + Tikai saglabāts dalībnieku ierīcēs + Iespējot kameras piekļuvi + Noklikšķiniet, lai nolasītu + Kamera nav pieejama + Atļauja noraidīta + Augstāk minētais, tad prievārds turpinājums + Pievienot kontaktu, lai izveidotu saiti vai savienotu, izmantojot saiti + Izveidot grupu, lai izveidotu jaunu grupu + Lai savienotu, izmantojot saiti + Ja esat saņēmis simplex ielūguma saiti, varat to atvērt pārlūkā + Datorā nolasīt QR kodu no lietotnes, izmantojot QR koda nolasīšanu + Mobilajā ierīcē noklikšķiniet uz atvērt mobilajā lietotnē, tad noklikšķiniet uz savienot lietotnē + Pieņemt savienojuma pieprasījumu? + Ja izvēlēsieties noraidīt, sūtītājs netiks informēts + Pieņemt kontaktu + Pieņemt kontaktu inkognito + Noraidīt kontaktu + Pieņemt kontaktu pieprasījumu + Noraidīt kontaktu pieprasījumu + Sūtītājs netiks informēts + Dalībnieks ir izdzēsts, nevar pieņemt pieprasījumu + Notīrīt sarunu? + Notīrīt piezīmju mapi? + Notīrīt sarunu + Notīrīt piezīmju mapi + Notīrīt + Notīrīt sarunu + Notīrīt sarunas darbība + Dzēst kontaktu darbība + Dzēst grupu darbība + Atzīmēt kā izlasītu + Atzīmēt kā neizlasītu + Iestatīt kontakta vārdu + Atskaņot sarunu + Atskaņot visas sarunas + Atjaunot sarunu + Atzīmēt kā iecienītu + Noņemt no iecienītajiem + Neizlasītie minējumi + Izveidot sarakstu + Pievienot sarakstam + Mainīt sarakstu + Saglabāt sarakstu + Saraksta nosaukums + Saraksts ar šo nosaukumu jau pastāv + Dzēst sarakstu + Dzēst sarakstu? + Šo darbību nevar atcelt. + Rediģēt sarakstu + Mainīt secību + Jūs esat aicinājis kontaktu + Jūs esat pieņēmis savienojumu + Dzēst gaidošo? + Kontakts, ar kuru jūs dalījāties ar saiti, nevarēs izveidot savienojumu + Savienojums, ko jūs esat pieņēmis, tiks atcelts + Kontakta savienojums gaida + Savienojums gaida, viņiem jābūt tiešsaistē, varat dzēst un mēģināt vēlreiz + Kontakts vēlas izveidot savienojumu ar jums + Profila attēla vietturis + Profila attēls + Aizvērt + Saites priekšskatījums + Atcelt saites priekšskatījumu + Iestatījumi + QR kods + Adrese + Palīdzība + Simplex komanda + SimpleX logo + E-pasts + Vairāk + Rādīt QR kodu + Nederīgs QR kods + Šis QR kods nav saite + Nederīga kontaktu saite + Šī saite nav derīga savienojuma saite + Savienojuma pieprasījums nosūtīts + Jūs tiksiet savienots, kad grupas saimnieka ierīce būs tiešsaistē + Jūs tiksiet savienots, kad jūsu savienojuma pieprasījums tiks pieņemts + Jūs tiksiet savienots, kad jūsu kontaktu ierīce būs tiešsaistē + Ja jūs nevarat tikties klātienē, rādiet QR videozvanā vai caur citu kanālu + Jūsu čata profils tiks nosūtīts jūsu kontaktam + Ja jūs nevarat tikties klātienē, skenējiet QR videozvanā vai lūdziet ielūguma saiti + Kopīgot ielūguma saiti + Ielīmējiet saiti, ko saņēmāt, lai savienotos ar savu kontaktu + Uzzināt vairāk + Uzzināt vairāk par adresi + Savienojiet, tiks kopīgots jauns nejaušs profils + Savienojiet, jūsu profils tiks kopīgots + Skenējiet QR, lai savienotos ar kontaktu + Ja jūs nevarat tikties klātienē + Kopīgot adresi publiski + Kopīgot simplex adresi sociālajos tīklos + Jūs varat kopīgot savu adresi + Jūs nezaudēsiet savus kontaktus, ja izdzēsīsiet adresi + Kopīgot vienreizēju saiti ar draugu + Vienreizēju saiti var izmantot tikai ar vienu kontaktu + Jūs varat iestatīt savienojuma nosaukumu, lai atcerētos + Savienojuma drošība + Simplex adrese un vienreizējās saites ir drošas kopīgošanai + Lai pasargātu no jūsu saites aizvietošanas, salīdziniet kodus + Jūs varat pieņemt vai noraidīt savienojumu + Lasiet vairāk lietotāja rokasgrāmatā ar saiti + Adrese vai vienreizēja saite + Savienoties caur saiti + Savienoties + Ielīmēt + Šī virkne nav savienojuma saite + Jūs varat arī savienoties, noklikšķinot uz saites + Jauna saruna + Jauns + Pievienot kontaktu cilni + Skatīt ielīmēto saiti + Ielīmēt saiti + Vienreizēja saite + Vienreizēja saite īsi + Simplex adrese + Vai arī parādiet šo QR kodu + Pilna saite + Īsa saite + Jauna saruna dalīties profilā + Izvēlēties sarunas profilu + Profila maiņas kļūda + Profila maiņas kļūda + Vai arī skenējiet QR kodu + Tīkls atspējot SOCKS + Tīkls atspējot SOCKS + Tīkls izmantot sīpolu viesus + Tīkls izmantot sīpolu viesus priekšroka + Tīkls izmantot sīpolu viesus nē + Tīkls izmantot sīpolu viesus nepieciešams + Dodiet priekšroku sīpolu viesiem tīklā. + Sīpolu viesi netiks izmantoti tīklā. + Sīpolu viesi ir obligāti tīklā. + Tīkla sesijas režīms transporta izolācija + Tīkla sesijas režīms lietotājs + Tīkla sesijas režīms sesija + Tīkla sesijas režīms serveris + Tīkla sesijas režīms entitāte + Tīkla sesijas režīms lietotājs. + Tīkla sesijas režīms sesija. + Tīkla sesijas režīms serveris. + Tīkla sesijas režīms entitāte. + Atjaunināt tīkla sesijas režīmu? + Atspējot sīpolu viesus, ja nav atbalsta + Socks proxy iestatījumu ierobežojumi + Tīkla smp proxy režīms privātā maršrutēšana + Tīkla smp proxy režīms vienmēr + Tīkla smp proxy režīms nezināms + Tīkla smp proxy režīms neaizsargāts + Tīkla smp proxy režīms nekad + Tīkla smp proxy režīms vienmēr + Tīkla smp proxy režīms nezināms + Tīkla smp proxy režīms neaizsargāts + Tīkla smp proxy režīms nekad + Atjaunināt tīkla smp proxy režīmu? + Tīkla smp proxy rezerves atļaut samazināšanu + Tīkla smp proxy rezerves atļaut + Tīkla smp proxy rezerves atļaut aizsargātu + Tīkla smp proxy rezerves aizliegt + Tīkla smp proxy rezerves atļaut + Tīkla smp proxy rezerves atļaut aizsargātu + Tīkla smp proxy rezerves aizliegt + Atjaunināt tīkla smp proxy rezerves? + Privātā maršrutēšana rādīt + Privātā maršrutēšana skaidrojums + Tīkla smp tīmekļa ports + Tīkla smp tīmekļa porta slēdzis + Tīkla smp tīmekļa porta kājenes + Tīkla smp tīmekļa porta iepriekš iestatītā kājenes + Tīkla smp tīmekļa ports viss + Tīkla smp tīmekļa porta iepriekš iestatījums + Tīkla smp tīmekļa ports izslēgts + Izskata iestatījumi + Pielāgot tēmu + Tēmas krāsas + Lietotnes versija + Lietotnes versijas nosaukums + Lietotnes versijas kods + Pamatversija + Pamat simplexmq versija + Pārbaudīt atjauninājumus + Lietotnes atjauninājumu pārbaude atspējota + Lietotnes atjauninājumu pārbaude stabila + Lietotnes atjauninājumu pārbaude beta + Ir pieejams atjauninājums + Lejupielādēt + Izlaist + Lejupielāde uzsākta + Lejupielāde pabeigta + Atvērt + Instalēt + Veiksmīgi instalēts + Atjauninājums veiksmīgi instalēts. + Lejupielāde atcelta + Atgādināt vēlāk + Paziņojums par atjauninājumu + Saņemiet paziņojumus par pieejamiem atjauninājumiem. + Atspējot paziņojumus par atjauninājumiem + Rādīt izstrādātāja opcijas + Paslēpt izstrādātāja opcijas + Rādīt izstrādātāja opcijas + Kļūdu žurnāli + Izstrādātāja opcijas + Izstrādātāja opcijas + Novecojušas opcijas + Rādīt iekšējās kļūdas + Rādīt lēnus API izsaukumus + Izslēgt? + Izslēgšana + Kļūda saglabājot iestatījumus + Izveidot adresi + Dzēst adresi? + Jūsu kontakti paliks savienoti. + Visi jūsu kontakti paliks savienoti. + Visi jūsu kontakti paliks savienoti, atjauninājums nosūtīts. + Kopīgot saiti + Pievienot adresi savai profilam + Izveidot adresi un ļaut cilvēkiem savienoties + Izveidot simplex adresi + Kopīgot ar kontaktiem + Kopīgot adresi ar kontaktiem? + Profila atjauninājums tiks nosūtīts kontaktiem. + Pārtraukt adreses kopīgošanu + Pārtraukt kopīgošanu + Automātiski pieņemt kontaktus + Nosūtīts jūsu kontaktam pēc savienojuma izveides + Sveiciena ziņa + Ievadiet sveiciena ziņu (pēc izvēles) + Saglabāt iestatījumus? + Saglabāt automātiskā pieņemšanas iestatījumus + Dzēst adresi + Aicināt draugus + E-pasta aicinājuma temats + E-pasta aicinājuma saturs + Sociālajiem tīkliem + Vai arī, lai dalītos privāti + Simplex adrese vai vienreizējs saite + Izveidot vienreizēju saiti + Adreses iestatījumi + Uzņēmuma adrese + Pievienojiet savus komandas locekļus sarunām + Pievienot īsu saiti + Dalīties ar profilu, izmantojot saiti + Dalīties ar profilu, izmantojot saiti (teksts) + Dalīties + Uzlabot grupas saiti + Dalīties ar grupas profilu, izmantojot saiti + Dalīties ar grupas profilu, izmantojot saiti (teksts) + Dalīties ar veco adresi + Dalīties ar veco saiti + Turpināt uz nākamo soli + Nekādā gadījumā neveidot adresi + Jūs varat to izveidot vēlāk + Jūs varat padarīt adresi redzamu caur iestatījumiem + Aicināt draugus (īsi) + Rādāmais vārds + Pilns vārds + Īsa apraksts + Biogrāfija ir pārāk liela + Jūsu pašreizējais profils + Jūsu profils tiek glabāts ierīcē un tiek dalīts tikai ar kontaktiem, Simplex to neredz + Rediģēt attēlu + Dzēst attēlu + Saglabāt uzņemšanu? + Vai vēlaties saglabāt preferences? + Saglabāt un paziņot kontaktam + Saglabāt un paziņot kontaktiem + Saglabāt un paziņot grupas dalībniekiem + Iziet bez saglabāšanas + Paslēpt profilu + Parole, lai parādītu + Saglabāt profila paroli + Paslēptā profila parole + Apstiprināt paroli + Lai atklātu profilu, ievadiet paroli + Kļūda, saglabājot lietotāja paroli + Jūs kontrolējat savu sarunu + Ziņojumu un lietotņu platforma, kas aizsargā jūsu privātumu un drošību + Mēs nesaglabājam kontaktus vai ziņas serveros + Izveidot profilu + Jūsu profils tiek saglabāts jūsu ierīcē + Profils tiek koplietots tikai ar jūsu kontaktiem + Parādāmā vārda laukā nedrīkst būt atstarpes + Parādāmais vārds + Īss + Izveidot profilu + Izveidot citu profilu + Izveidot adresi + Nederīgs vārds + Labot vārdu uz + Par SimpleX + Kā izmantot Markdown + Jūs varat izmantot Markdown, lai formatētu ziņas + Trekns teksts + Zvana statuss beidzies + Zvana statuss kļūda + Zvana stāvoklis sākas + Zvana stāvoklis gaida atbildi + Zvana stāvoklis gaida apstiprinājumu + Zvana stāvoklis saņēmis atbildi + Zvana stāvoklis saņēmis apstiprinājumu + Zvana stāvoklis savienojas + Zvana stāvoklis savienots + Zvana stāvoklis beidzies + Nevar atvērt pārlūkprogrammu + Nevar atvērt pārlūkprogrammu + Nepieciešamas atļaujas + Atļaujas audio ierakstīšanai + Atļaujas kamerai + Atļaujas kamerai un audio ierakstīšanai + Piešķirt atļaujas + Piešķirt atļaujas iestatījumos + Atrast iestatījumos un piešķirt atļaujas + Atvērt iestatījumus + Audio ierīce - austiņas + Audio ierīce - skaļrunis + Audio ierīce - vadu austiņas + Audio ierīce - Bluetooth + Kļūda, inicializējot tīmekļa skatu + WebView nav atbalstīts šajā ierīces arhitektūrā. + Nākamā paaudze privātajai ziņošanai + Privātums pārdefinēts + Pirmā platforma bez lietotāju ID + Imūna pret surogātpastu un ļaunprātīgu izmantošanu + Cilvēki var savienoties tikai caur saites, ko jūs kopīgojat + Decentralizēts + Atvērtā koda protokols un kods, ko ikviens var palaist serveros + Izveidot savu profilu + Izveidot privātu savienojumu + Migrēt no citas ierīces + Kā tas darbojas + Kā darbojas SimpleX + Lai aizsargātu privātumu, SimpleX izmanto ID rindām + Tikai klientu ierīces glabā kontaktu grupas un e2e šifrētas ziņas + Visas ziņas un faili ir e2e šifrēti + Lasiet vairāk GitHub krātuvē. + Izmantot čatu + Ievada paziņojumu režīms + Ievada paziņojumu režīma apakšvirsraksts + Ievada paziņojumu režīms izslēgts + Ievada paziņojumu režīms periodisks + Ievada paziņojumu režīms pakalpojums + Ievada paziņojumu režīms izslēgts + Ievada paziņojumu režīms izslēgts, īss apraksts + Ievada paziņojumu režīms periodisks + Ievada paziņojumu režīms periodisks apraksts īsi + Ievada paziņojumu režīms pakalpojums + Ievada paziņojumu režīms pakalpojuma apraksts īsi + Ievada paziņojumu režīms akumulators + Iestatīt datu bāzes paroli + Jūs varat to mainīt vēlāk + Izmantot nejaušu paroli + Ievada nosacījumi privātās sarunas nav pieejamas + Ievada nosacījumi, izmantojot jūs piekrītat + Ievada nosacījumi privātuma politika un lietošanas noteikumi + Ievada nosacījumi pieņemt + Ievada nosacījumi konfigurēt servera operatorus + Ievada izvēlēties servera operatorus + Ievada tīkla operatori + Ievada tīkla operatori simplex flux vienošanās + Ievada tīkla operatori lietotne izmantos citus operatorus + Ievada tīkla operatori nevar redzēt, kas ar ko runā + Ievada tīkla operatori lietotne izmantos maršrutēšanai + Ievada tīkls par operatoriem + Ievada izvēlēties tīkla operatorus, ko izmantot + Kā tas palīdz privātumam + Ievada tīkla operatori konfigurēt caur iestatījumiem + Ievada tīkla operatori nosacījumi tiks pieņemti + Ievada tīkla operatori nosacījumi, ko jūs varat konfigurēt + Ievada tīkla operatori pārskatīt vēlāk + Ievada tīkla operatori atjaunināt + Ievada tīkla operatori turpināt + Ienākošais video zvans + Ienākošais audio zvans + Čeki + Čeku apraksts 1 + Čeku kontakti + Čeku kontakti iespējot + Čeku kontakti atspējot + Čeku kontakti pārsniegšana iespējota + Čeku kontakti pārsniegšana atspējota + Čeku kontakti iespējot saglabāt pārsniegumus + Čeku kontakti atspējot saglabāt pārsniegumus + Čeku kontakti iespējot visiem + Rēķinu kontaktu atslēgšana visiem + Rēķinu grupas + Rēķinu grupu aktivizēšana + Rēķinu grupu atslēgšana + Rēķinu grupu pārsniegšana aktivizēta + Rēķinu grupu pārsniegšana atslēgta + Rēķinu grupu aktivizēšana ar saglabātām pārsniegšanām + Rēķinu grupu atslēgšana ar saglabātām pārsniegšanām + Rēķinu grupu aktivizēšana visiem + Rēķinu grupu atslēgšana visiem + Privātuma mediju izplūšanas rādiuss + Privātuma mediju izplūšanas rādiuss izslēgts + Privātuma mediju izplūšanas rādiuss mīksts + Privātuma mediju izplūšanas rādiuss vidējs + Privātuma mediju izplūšanas rādiuss spēcīgs + Privātuma čata saraksta atvērtie saites + Privātuma čata saraksta atvērtie saites jā + Privātuma čata saraksta atvērtie saites nē + Privātuma čata saraksta atvērtie saites jautāt + Vai vēlaties atvērt tīmekļa saiti? + Privātuma čata saraksta atvērt tīmekļa saiti + Privātuma čata saraksta atvērt pilnu tīmekļa saiti + Privātuma čata saraksta atvērt tīru tīmekļa saiti + Iestatījumi jūs + Iestatījumi + Iestatījumi čata datubāze + Iestatījumi palīdzība + Iestatījumi atbalsts + Iestatījumi lietotne + Iestatījumi ierīce + Iestatījumi čati + Iestatījumi faili + Iestatījumi piegādes rēķini + Iestatījumi kontaktu pieprasījumi no grupām + Iestatījumi restartēt lietotni + Iestatījumi izslēgt + Iestatījumi izstrādātāja rīki + Iestatījumi eksperimentālās funkcijas + Iestatījumi zeķes + Iestatījumi + Tēmas + Profila attēli + Ziņu forma + Ziņu formas stūris + Ziņu formas aste + Sarunu tēmas iestatījumi + Lietotāja tēmas iestatījumi + Sarunu krāsu iestatījumi + Ziņu iestatījumi + Privāto ziņu maršrutēšanas iestatījumi + Zvanīšanas iestatījumi + Tīkla savienojuma iestatījumi + Incognito iestatījumi + Eksperimentālie iestatījumi + Izmantot no darbvirsmas + Jūsu sarunu datubāze + Palaist sarunu + Attālie hosti + Saruna notiek + Saruna ir apstājusies + Sarunu datubāze + Datubāzes parolfrāze + Eksportēt datubāzi + Importēt datubāzi + Jauns datubāzes arhīvs + Vecs datubāzes arhīvs + Atvērt datubāzes mapi + Dzēst datubāzi + Kļūda, sākot sarunu + Apstāt sarunu? + Apstāt sarunu, lai eksportētu, importētu vai dzēstu sarunu datubāzi + Apstiprināt sarunas apstāšanu + Iestatīt paroli eksportēšanai + Iestatiet paroli, lai eksportētu + Kļūda, apstādot sarunu + Kļūda, eksportējot sarunu datubāzi + Importēt datubāzi? + Jūsu pašreizējā sarunu datubāze tiks dzēsta un aizvietota ar importēto + Apstiprināt datubāzes importēšanu + Kļūda, dzēšot datubāzi + Kļūda, importējot datu bāzi + Sarunu datu bāze importēta + Restartējiet lietotni, lai izmantotu importēto sarunu datu bāzi + Notikušas nenozīmīgas kļūdas importēšanas laikā + Vai vēlaties dzēst sarunu? + Sarunu dzēšanas darbību nevar atcelt + Sarunu datu bāze dzēsta + Restartējiet lietotni, lai izveidotu jaunu sarunu profilu + Jums jāizmanto jaunākā datu bāzes versija + Faili un mediji + Dzēst failus un medijus visiem lietotājiem + Dzēst visus failus un medijus + Vai vēlaties dzēst failus un medijus? + Dzēst failus un medijus + Nav saņemtu lietotnes failu + Kopējais failu skaits un izmērs + Sarunu vienības derīguma termiņš nav + Sarunu vienības derīguma termiņš sekundēs + Sarunu vienības derīguma termiņš pēc noklusējuma + Ziņas + Ziņas + Dzēst ziņas pēc + Vai vēlaties iespējot automātisko dzēšanu? + Iespējot automātisko dzēšanu + Dzēst ziņas + Kļūda, mainot dzēšanu + Sarunu datu bāze eksportēta + Sarunu datu bāze eksportēta un saglabāta + Sarunu datu bāze eksportēta un migrēta + Sarunu datu bāze eksportēta, ne visi faili + Sarunu datu bāze eksportēta, turpināt + Kļūda, saglabājot datu bāzi + Saglabāt paroli atslēgu glabātājā + Saglabāt paroli iestatījumos + Datu bāze ir šifrēta + Kļūda, šifrējot datu bāzi + Noņemt paroli no atslēgu glabātāja + Noņemt paroli no iestatījumiem + Paziņojumi tiks slēpti + Noņemt paroli + Šifrēt datubāzi + Atjaunot datubāzi + Pašreizējā frāze + Jaunā frāze + Apstiprināt jauno frāzi + Atjaunot datubāzes frāzi + Iestatīt datubāzes frāzi + Iestatīt frāzi + Ievadiet pareizo pašreizējo frāzi + Datubāze nav šifrēta + Atslēgu glabātuve tiek droši glabāta + Iestatījumi tiek glabāti parastā tekstā + Šifrēts ar nejaušu frāzi + Nav iespējams atgūt frāzi + Atslēgu glabātuve ļauj saņemt ntfs + Frāze tiks saglabāta iestatījumos + Jums katru reizi jāievada frāze + Šifrēt datubāzi? + Mainīt datubāzes frāzi? + Datubāze tiks šifrēta + Datubāze tiks šifrēta un frāze tiks saglabāta + Datubāze tiks šifrēta un frāze tiks saglabāta iestatījumos + Datubāzes šifrēšana tiks atjaunināta + Datubāzes šifrēšana tiks atjaunināta iestatījumos + Datubāzes frāze tiks atjaunināta + Droši glabāt frāzi + Droši glabāt frāzi bez atgūšanas + Nepareiza frāze + Kļūda, lasot frāzi + Šifrēta datubāze + Datubāzes kļūda + Atslēgu glabātuves kļūda + Frāze ir atšķirīga + Fails ar ceļu + Nepieciešama datubāzes frāze + Kļūda ar + Nav iespējams piekļūt atslēgu glabātuvei + Nezināma datubāzes kļūda ar + Nepareiza frāze + Ievadiet pareizo frāzi + Nezināma kļūda + Ievadiet atslēgvārdu + Saglabāt atslēgvārdu un atvērt sarunu + Atvērt sarunu + Datu bāzes dublējumu var atjaunot. + Atjaunot datu bāzi + Atjaunot datu bāzi + Vai tiešām vēlaties atjaunot datu bāzi? + Apstiprināt datu bāzes atjaunošanu + Datu bāzes atjaunošanas kļūda + Atslēgvārds nav atrasts + Atslēgvārdu nevar nolasīt + Atslēgvārdu nevar nolasīt, lūdzu, ievadiet to manuāli + Datu bāzes jaunināšana + Datu bāzes samazināšana + Nesaderīga datu bāzes versija + Apstiprināt datu bāzes jauninājumus + Vienas rokas saskarne + Sarunu apakšējā josla + Vienas rokas saskarnes karte + Vienas rokas saskarnes maiņas instrukcija + Terminālis vienmēr redzams + Sarunu saraksts vienmēr redzams + Nederīga migrācijas apstiprināšana + Jaunināt un atvērt sarunu + Samazināt un atvērt sarunu + Mtr kļūda: nav lejupvērstas migrācijas + Mtr kļūda: atšķirīgs + Datu bāzes migrācijas + Datu bāzes samazināšana + Saruna ir apturēta + Jūs varat uzsākt sarunu, izmantojot iestatījumus vai restartējot lietotni + Sākt sarunu? + Saruna ir apturēta, jums jāveic datu bāzes pārsūtīšana + Grupas ielūguma vienums + Pievienoties grupai? + Jūs esat aicināts pievienoties grupai, lai sazinātos ar grupas dalībniekiem + Pievienoties grupai + Pievienoties grupai incognito + Pievienojos grupai + Jūs esat pieņēmuši grupas ielūgumu, savienojoties ar grupas dalībnieku, kurš jūs aicināja. + Atstāt grupu + Vai vēlaties atstāt grupu? + Vai vēlaties atstāt čatu? + Jūs pārtrauksiet saņemt ziņas no šīs grupas, bet sarunu vēsture tiks saglabāta. + Jūs pārtrauksiet saņemt ziņas no šī čata, bet sarunu vēsture tiks saglabāta. + Pievienot dalībniekus + Grupa ir neaktīva + Grupas ielūgums ir beidzies + Grupas ielūgums ir beidzies + Nav grupas + Nav grupas + Nav iespējams aicināt kontaktus + Nav iespējams aicināt kontaktus, jo nav pieejamu kontaktu. + Jūs nosūtījāt grupas ielūgumu + Jūs esat aicināts uz grupu + Grupas ielūgums - pieskarieties, lai pievienotos + Grupas ielūgums - pieskarieties, lai pievienotos incognito + Jūs pievienojāties šai grupai + Jūs noraidījāt grupas ielūgumu + Grupas ielūgums ir beidzies + Saņemts tiešais notikums: kontakts izdzēsts + Saņemts tiešais notikums: grupas ielūguma saite saņemta + Saņemts grupas notikums: dalībnieks pievienots + Saņemts grupas notikums: dalībnieks savienots + Saņemts grupas notikums: dalībnieks pieņēmis + Saņemts grupas notikums: lietotājs pieņēmis + Saņemts grupas notikums: dalībnieks atstājis + Saņemts grupas notikums: dalībnieka loma mainīta + Saņemts grupas notikums: dalībnieks bloķēts + Saņemts grupas notikums: dalībnieks atbloķēts + Saņemts grupas notikums: jūsu loma mainīta + Saņemts grupas notikums: dalībnieks izdzēsts + Saņemts grupas notikums: lietotājs izdzēsts + Saņemts grupas notikums: grupa izdzēsta + Saņemts grupas notikums: grupas profils atjaunināts + Saņemts grupas notikums: aicināts caur jūsu grupas saiti + Saņemts grupas notikums: dalībnieks izveidojis kontaktu + Saņemts grupas notikums: jauns dalībnieks gaida apstiprinājumu + Nosūtīts grupas notikums: dalībnieka loma mainīta + Grupas notikums mainījis lomu jums + Grupas notikums: dalībnieks bloķēts + Grupas notikums: dalībnieks atbloķēts + Grupas notikums: dalībnieks dzēsts + Grupas notikums: lietotājs pametis + Grupas notikums: grupas profils atjaunināts + Grupas notikums: dalībnieks pieņemts + Grupas notikums: lietotājs gaida pārskatīšanu + Profila atjauninājuma notikums: attēls noņemts + Profila atjauninājuma notikums: jauns attēls iestatīts + Profila atjauninājuma notikums: adrese noņemta + Profila atjauninājuma notikums: jauna adrese iestatīta + Profila atjauninājuma notikums: profils atjaunināts + Profila atjauninājuma notikums: dalībnieka vārds mainīts + Saņemšanas savienojuma notikums: maiņas rinda fāze pabeigta + Saņemšanas savienojuma notikums: maiņas rinda fāze mainās + Sūtīšanas savienojuma notikums: maiņas rinda fāze pabeigta dalībniekam + Sūtīšanas savienojuma notikums: maiņas rinda fāze mainās dalībniekam + Sūtīt kvītis + Kvīšu sūtīšana atspējota + Kvīšu sūtīšana atspējota + Kvīšu sūtīšana ir atspējota + Pievienot dalībniekus + Atbalsta čats + Konsolei + Rindas vietējais nosaukums + Rindas datu bāzes ID + Rindas debug piegāde + Rindas atjaunināts + Rindas ziņas statuss + Rindas faila statuss + Rindas nosūtīts + Rindas izveidots + Rindas saņemts + Rindas dzēsts + Rindas moderēts + Rindas pazūd + Kopīgojiet teksta datu bāzes ID + Dalībnieks tiks noņemts no grupas, šo darbību nevar atcelt + Dalībnieki tiks noņemti no grupas, šo darbību nevar atcelt + Dalībnieks tiks noņemts no sarunas, šo darbību nevar atcelt. + Dalībnieki tiks noņemti no sarunas, šo darbību nevar atcelt. + Apstiprināt dalībnieka noņemšanu + Noņemt dalībnieku + Bloķēt dalībnieku? + Bloķēt dalībnieku + Apstiprināt dalībnieka bloķēšanu + Bloķēt visiem? + Bloķēt dalībniekus visiem? + Bloķēt visiem + Bloķēt dalībnieku + Bloķēt dalībniekus + Atbloķēt dalībnieku? + Atbloķēt dalībnieku + Apstiprināt dalībnieka atbloķēšanu + Atbloķēt visiem? + Atbloķēt dalībniekus visiem? + Atbloķēt visiem + Atbloķēt dalībnieku + Atbloķēt dalībniekus + Dalībnieks ir bloķēts no administratora puses + Dalībnieks ir bloķēts + Dalībnieks ir atspējots + Dalībnieks ir neaktīvs + Dalībnieks + Loma grupā + Mainīt lomu + Mainīt + Pārslēgt + Mainīt dalībnieka lomu? + Dalībnieka loma tiks mainīta ar paziņojumu + Dalībnieka loma tiks mainīta ar paziņojumu sarunā + Dalībnieka loma tiks mainīta ar ielūgumu + Savienot caur dalībnieka adresi + Savienot caur dalībnieka adresi + Kļūda, noņemot dalībnieku + Kļūda, mainot lomu + Kļūda, bloķējot dalībnieku visiem + Rinda grupā + Rinda sarunā + Savienojuma rinda + Tiešais savienojuma līmenis + Netiešais savienojuma līmenis + Rinda + Nav rindas + Rindas serveris + Nevar zvanīt kontaktam + Nevar zvanīt kontaktam, lūdzu, gaidiet savienojumu. + Nevar zvanīt kontaktam, jo tas ir dzēsts. + Atļaut zvanus? + Jums jāatļauj zvanīt. + Zvanīšana aizliegta + Zvanīšana ir aizliegta, lūdzu, atļaujiet zvanus. + Nevar zvanīt dalībniekam + Nevar zvanīt dalībniekam, lūdzu, nosūtiet ziņu. + Nevar nosūtīt dalībniekam + Savienojums nav gatavs + Laipni lūdzam ziņa + Saglabāt laipni lūdzam? + Laipni lūdzam ziņa ir pārāk gara + Saglabāt un atjaunināt grupas profilu + Grupas laipni lūdzam priekšskatījums + Ievadiet laipni lūdzam ziņu + Pārāk liels + Savienojuma statistika serveriem + Saņemšana caur + Sūtīšana caur + Tīkla statuss + Mainīt saņemšanas adresi + Labot savienojumu + Labot savienojumu? + Apstiprināt savienojuma labošanu + Savienojuma labošana nav atbalstīta no kontakta puses + Savienojuma labošana nav atbalstīta no grupas dalībnieka puses + Pārrunāt šifrēšanu + Izveidot slepenu grupu + Grupa ir decentralizēta + Grupas redzamā nosaukuma lauks + Grupas pilnā nosaukuma lauks + Grupas īsā apraksta lauks + Grupa ir pārāk liela + Grupas galvenais profils nosūtīts + Sarunas galvenais profils nosūtīts + Izveidot grupu + Grupas profils tiek glabāts dalībnieku ierīcēs. + Saglabāt grupas profilu + Kļūda, saglabājot grupas profilu + Tīkla iepriekš iestatītie serveri + Operatora pārskata nosacījumi + Operatora nosacījumi pieņemti + Operatora nosacījumi pieņemti aktivizētiem operatoriem + Jūsu serveri + Operatoru nosacījumi pieņemti + Operatoru nosacījumi tiks pieņemti + Operators + Operatora serveri + Operators + Operatora mājaslapa + Operatora nosacījumi pieņemti + Operatora nosacījumi tiks pieņemti + Operatora izmantošanas slēdzis + Izmantot operatora x serverus + Operatora nosacījumi neizdevās ielādēt + Operatora nosacījumi pieņemti dažiem + Operatora tie paši nosacījumi tiks piemēroti + Operatora tie paši nosacījumi tiks piemēroti operatoriem + Operatora nosacījumi tiks piemēroti + Operatora nosacījumi tiks pieņemti dažiem + Operatoru nosacījumi arī tiks piemēroti + Skatīt nosacījumus + Pieņemt nosacījumus + Operatora lietošanas nosacījumi + Operatora atjaunotie nosacījumi + Operatora, lai izmantotu, pieņemiet nosacījumus + Operatora izmantošana ziņām + Operatora izmantošana ziņu saņemšanai + Operatora izmantošana ziņu privātai maršrutēšanai + Operatora pievienotie serveri + Operatora izmantošana failiem + Operatora izmantošana nosūtīšanai + Xftp serveri uz lietotāju + Operatora pievienotie xftp serveri + Operatora atvērtie nosacījumi + Operatora atvērtās izmaiņas + Kļūda servera atjaunināšanā + Kļūda servera protokola maiņā + Kļūda servera operatora maiņā + Operatora serveris + Serveris pievienots operatora nosaukumam + Kļūda servera pievienošanā + Tīkla opcija TCP savienojums + Tīkla opcijas atiestatītas uz noklusējumu + Tīkla opcija sekundes + Tīkla opcija TCP savienojuma laika limits + Tīkla opcija TCP savienojuma laika limits fons + Tīkla opcija protokola laika limits + Tīkla opcija protokola laika limits fons + Tīkla opcija protokola laika limits uz kb + Tīkla opcija RCV vienlaicība + Tīkla opcija ping intervāls + Tīkla opcija ping skaits + Tīkla opcija iespējot TCP keep alive + Tīkla opcijas saglabāt + Tīkla opcijas saglabāt un atkārtoti savienot + Atjaunināt tīkla iestatījumus? + Iestatījumu atjaunināšana atkārtoti savienos klientu ar visiem serveriem + Atjaunināt iestatījumus? + Pievienot lietotājus + Dzēst lietotājus? + Lietotāji dzēš visas sarunas + Lietotāji dzēš profilu priekš + Lietotāji dzēš ar savienojumiem + Lietotāji dzēš tikai datus + Paslēpt lietotāju + Atklāt lietotāju + Noklusināt lietotāju + Atcelt lietotāja noklusināšanu + Ievadiet paroli, lai parādītu + Noklikšķiniet, lai aktivizētu profilu + Padarīt profilu privātu + Jūs varat paslēpt vai izslēgt lietotāja profilu + Nerādīt atkal + Izslēgts, kad neaktīvs + Jūs joprojām saņemsiet zvanus un paziņojumus + Dzēst profilu + Dzēst sarunas profilu + Atvērt profilu + Atvērt sarunas profilu + Profila parole + Incognito + Incognito nejaušs profils + Incognito aizsargā + Incognito ļauj + Incognito kopīgo + Krāsu režīms: sistēmas + Krāsu režīms: gaišs + Krāsu režīms: tumšs + Tēmas režīms: sistēmas + Tikai jūs varat sūtīt balsi + Tikai jūsu kontakts var sūtīt balsi + Balsi aizliegts šajā sarunā + Gan jūs, gan jūsu kontakts var sūtīt failus + Tikai jūs varat sūtīt failus + Tikai jūsu kontakts var sūtīt failus + Faili aizliegti šajā sarunā + Gan jūs, gan jūsu kontakts var pievienot reakcijas + Tikai jūs varat pievienot reakcijas + Tikai jūsu kontakts var pievienot reakcijas + Reakcijas aizliegtas šajā sarunā + Gan jūs, gan jūsu kontakts var veikt zvanus + Tikai jūs varat veikt zvanus + Tikai jūsu kontakts var veikt zvanus + Zvanus aizliegts ar šo kontaktu + Atļaut sūtīt iznīkstošas ziņas + Aizliegt sūtīt iznīkstošas ziņas + Atļaut tiešās ziņas + Aizliegt tiešās ziņas + Atļaut dzēst ziņas + Atspējot neseno vēstures sūtīšanu + Iespējot dalībnieku ziņojumu sūtīšanu + Aizliegt locekļu ziņojumu sūtīšanu + Grupas locekļi var sūtīt iznīkstošas ziņas + Iznīkstošas ziņas ir aizliegtas + Grupas locekļi var sūtīt tiešās ziņas + Tiešās ziņas ir aizliegtas + Tiešās ziņas grupā ir aizliegtas + Tiešās ziņas čatā ir aizliegtas + Grupas locekļi var dzēst + Dzēšana čatā ir aizliegta + Grupas locekļi var sūtīt balss ziņas + Balss ziņas ir aizliegtas + Grupas locekļi var pievienot reakcijas + Reakcijas ir aizliegtas + Grupas locekļi var sūtīt failus + Faili grupā ir aizliegti + Grupas locekļi var sūtīt simplex saites + Simplex saites grupā ir aizliegtas + Jauniem locekļiem tiek nosūtīta nesenā vēsture + Jauniem locekļiem netiek nosūtīta nesenā vēsture + Grupas locekļi var sūtīt ziņojumus + Dalībnieku ziņojumi ir aizliegti + Dzēst pēc + sekundes + s + minūtes + mēnesis + mēneši + m + mth + stunda + stundas + h + diena + dienas + d + nedēļa + nedēļas + w + Piedāvātā funkcija + Piedāvātā funkcija ar parametru + Funkcija atcelta + Funkcijas lomas visiem dalībniekiem + Funkcijas lomas moderatoriem + Funkcijas lomas administratoriem + Funkcijas lomas īpašniekiem + Funkcija iespējota + Dalībnieku uzņemšana + Uzņemšanas posma pārskats + Pārskats par uzņemšanas posmu. + Dalībnieku kritēriji izslēgti + Dalībnieku kritēriji visi + Dalībnieku atbalsts + Nav atbalsta sarunu + Dzēst dalībnieku atbalsta sarunu + Dzēst dalībnieku atbalsta sarunu + Atbalsta saruna + Noraidīt gaidošo dalībnieku + Noraidīt gaidošo dalībnieku + Pieņemt gaidošo dalībnieku + Pieņemt gaidošo dalībnieku + Vai pieņemt gaidošo dalībnieku? + Apstiprinājums par gaidošā dalībnieka pieņemšanu kā dalībniekam + Apstiprinājums par gaidošā dalībnieka pieņemšanu kā novērotājam + Kas jauns + Jaunumi šajā versijā + Lasiet vairāk par jaunumiem + Drošības novērtējums + Drošības novērtējums. + Grupu saites + Grupu saites. + Automātiski pieņemt kontaktu pieprasījumus + Automātiski pieņemt kontaktu pieprasījumus. + Balss ziņas + Balss ziņas. + Neatgriezeniska dzēšana + Neatgriezeniska ziņu dzēšana. + Uzlabota servera konfigurācija + Uzlabota servera konfigurācija. + Uzlabota privātums un drošība + Uzlabota privātums un drošība. + Izbeigušās ziņas + Izbeigušās ziņas, kas pazūd pēc noteikta laika. + Tiešraides ziņas + Ziņas, kas tiek nosūtītas un saņemtas reālajā laikā. + Pārbaudīt savienojuma drošību + Pārbaudiet, vai jūsu savienojums ir drošs. + Franču saskarne + Izvēlieties franču valodu kā saskarnes valodu. + Vairāki čata profili + Izveidojiet un pārvaldiet vairākus čata profilus. + Melnraksts + Saglabājiet ziņu kā melnrakstu, lai to vēlāk pabeigtu. + Transporta izolācija + Izolējiet transporta slāni, lai uzlabotu drošību. + Privāti failu nosaukumi + Izmantojiet privātus failu nosaukumus, lai aizsargātu jūsu datus. + Samazināta akumulatora lietošana + Uzlabota akumulatora efektivitāte, lai pagarinātu lietošanas laiku. + Itāļu saskarne + Izvēlieties itāļu valodu kā saskarnes valodu. + Slēptie čata profili + Pārvaldiet slēptos čata profilus, kas nav redzami citiem. + Audio un video zvanīšana + Veiciet audio un video zvanus ar citiem lietotājiem. + Grupas moderēšana + Moderējiet grupas sarunas un saturu. + Grupas sveiciena ziņa + Sveiciena ziņa, kas tiek nosūtīta jaunajiem grupas dalībniekiem. + Samazināta akumulatora lietošana + Uzlabota akumulatora efektivitāte, lai pagarinātu lietošanas laiku. + Ķīniešu un spāņu saskarne + Izvēlieties ķīniešu vai spāņu valodu kā saskarnes valodu. + Lielu failu atbalsts + Atbalstiet lielu failu nosūtīšanu un saņemšanu. + Lietotnes piekļuves kods + Iestatiet piekļuves kodu, lai aizsargātu lietotni. + Poļu saskarne + Izvēlieties poļu valodu kā saskarnes valodu. + Ziņu reakcijas + Izteikiet savas reakcijas uz ziņām. + Pašiznīcinošs piekļuves kods + Piekļuves kods, kas nodrošina pašiznīcināšanos. + Pielāgotas tēmas + Izvēlieties un pielāgojiet tēmas pēc savas gaumes. + Uzlabotas ziņas + Saņemiet labākas un skaidrākas ziņas. + Japāņu-portugāļu saskarne + Jaunumi, pateicoties lietotāju ieguldījumam Weblate. + Ziņu piegādes apstiprinājumi + Saņemiet apstiprinājumus par ziņu piegādi. + Iemīļoto filtrs + Filtrējiet savas iecienītākās ziņas. + Šifrēšanas labojums + Uzlabojiet šifrēšanas drošību. + Izzust viena ziņa + Ziņa izzudīs pēc noteikta laika. + Vairāk iespēju + Atklājiet vēl vairāk funkciju. + Jauna darbvirsmas lietotne + Izmantojiet jauno un uzlaboto darbvirsmas lietotni. + Šifrēt vietējās failus + Aizsargājiet savus failus ar šifrēšanu. + Atklājiet grupas + Pievienojieties interesantām grupām. + Vienkāršāks inkognito režīms + Izmantojiet inkognito režīmu vieglāk. + Jaunas saskarnes valodas + Saistīt mobilo un darbvirsmas lietotni + Savienojiet mobilo un darbvirsmas lietotni. + Uzlabotas grupas + Izveidojiet un pārvaldiet grupas efektīvāk. + Inkognito grupas + Izveidojiet grupas, kas ir privātas un anonīmas. + Bloķēt grupas dalībniekus + Bloķējiet nevēlamus grupas dalībniekus. + Atklājiet vēl vairāk iespēju. + Privātas piezīmes + Saglabājiet savas piezīmes drošībā. + Vienkāršāka savienojuma saskarne + Izmantojiet savienojuma saskarni vieglāk. + Pievienoties grupas sarunai + Pievienojieties grupas sarunai. + Piegāde + Ziņu piegāde. + Jaunas saskarnes valodas + Kvantumam izturīga šifrēšana + Kvantumam izturīga šifrēšana. + Lietotnes datu migrācija + Lietotnes datu migrācija. + Attēls attēlā zvani + Attēls attēlā zvani. + Drošākas grupas + Drošākas grupas. + Kvantumam izturīga šifrēšana. + Pārsūtīt + Pārsūtīt. + Zvana skaņas + Zvana skaņas. + Profila attēlu forma + Profila attēlu forma. + Tīkls + Tīkls. + Jaunas saskarnes valodas + Privāta maršrutēšana + Privāta maršrutēšana. + Sarunu tēmas + Sarunu tēmas. + Droši faili + Droši faili. + Piegāde + Ziņu piegāde. + Persiešu saskarne + Jauna sarunu pieredze + Jaunas mediju iespējas + Privāta maršrutēšana. + Jūsu kontakti. + Pieejamā sarunu rīkjosla + Pieejamā sarunu rīkjosla. + Savienojieties ātrāk. + Dzēst daudz ziņu. + Sarunu saraksta multivide + Privātuma izplūšana + Palielināt fonta izmēru + Atjaunināt lietotni + Atjauniniet lietotni, lai iegūtu jaunākās funkcijas un uzlabojumus. + Savienojuma serveri + Pārbaudiet savienojuma serveru statusu. + Labāka drošība + Uzlabota drošība jūsu datiem. + Labāki zvani + Uzlabota zvanu kvalitāte. + Labāka lietotāja pieredze + Mainiet sarunu profilu. + Pielāgojamas ziņas + Ziņu datumi + Pārsūtīt vairākas ziņas + Dzēst vairākas ziņas + Tīkla decentralizācija + Decentralizējiet tīklu, lai uzlabotu drošību. + Tīkla decentralizācija, iespējot plūsmu + Tīkla decentralizācija, iespējot plūsmu iemesls + Uzlabota sarunu navigācija + Vieglāka navigācija sarunās. + Uzņēmumu sarunas + Sarunas uzņēmumiem. + Atsauces + Atsauces uz jums sarunās. + Ziņojumi + Attālinātā hosta kļūda: slikts stāvoklis + Attālinātā hosta kļūda: slikta versija + Attālinātā hosta kļūda: atslēgts + Attālinātā kontrole: neaktīva + Attālinātā kontrole: slikts stāvoklis + Attālinātā kontrole: aizņemta + Attālinātā kontrole: laika ierobežojums + Attālinātā kontrole: atslēgta + Attālinātā kontrole: slikta ielūgums + Attālinātā kontrole: slikta versija + Izstrādē + Šī funkcija ir izstrādē. + Savienojiet plānu, lai savienotos ar sevi + Šis ir jūsu personīgais vienreizējais saite + Jūs jau savienojaties ar %1$s + Jūs jau savienojaties + Jūs jau savienojaties, izmantojot šo vienreizējo saiti + Šis ir jūsu personīgais simplex adrese + Atkārtot savienojuma pieprasījumu + Jūs jau esat pieprasījis savienojumu, izmantojot šo adresi + Pievienojieties savai grupai + Šis ir jūsu saite grupai %1$s + Atkārtot pievienošanās pieprasījumu + Grupa jau pastāv + Čats jau pastāv + Jūs jau pievienojaties grupai %1$s + Jūs jau pievienojaties grupai + Jūs jau pievienojaties grupai, izmantojot šo saiti + Jūs jau esat grupā %1$s + Jūs jau esat savienots ar %1$s + Savienojiet, izmantojot saiti + Aģenta kritiska kļūda + Notikusi kritiska kļūda aģentā. + Aģenta iekšēja kļūda + Notikusi iekšēja kļūda aģentā. + Restartēt čatu + Migrēt uz ierīci + Vai nu ielīmējiet arhīva saiti + Ielīmējiet arhīva saiti + Nederīga faila saite + Čata arhīvs + Migrē uz ierīci + Migrē uz ierīci, datu bāzes inicializācija + Migrē uz ierīci, lejupielādējot detaļas + Migrē uz ierīci, lejupielādējot arhīvu + Migrē uz ierīci, lejupielādēti %d baiti + Migrē uz ierīci, lejupielāde neizdevās + Atkārtot lejupielādi + Mēģiniet vēlreiz + Migrē uz ierīci, importējot arhīvu + Migrēt no ierīces + Migrēt no ierīces uz citu ierīci + Kļūda, migrējot no ierīces, saglabājot iestatījumus + Migrēt no ierīces, eksportētā faila nav + Kļūda, migrējot no ierīces, eksportējot arhīvu + Migrēt no ierīces, datu bāzes inicializācija + Kļūda, migrējot no ierīces, augšupielādējot arhīvu + Kļūda, migrējot no ierīces, dzēšot datu bāzi + Migrēt no ierīces, apturot sarunu + Migrēt no ierīces, sarunai jābūt apturētai + Migrēt no ierīces, arhivēt un augšupielādēt + Migrēt no ierīces, apstiprināt augšupielādi + Migrēt no ierīces, visi dati tiks augšupielādēti + Migrēt no ierīces, arhivējot datu bāzi + Migrēt no ierīces, augšupielādētie biti + Migrēt no ierīces, augšupielādējot arhīvu + Migrēt no ierīces, augšupielāde neizdevās + Migrēt no ierīces, atkārtot augšupielādi + Migrēt no ierīces, mēģiniet vēlreiz + Migrēt no ierīces, veidojot arhīva saiti + Migrēt no ierīces, atcelt migrāciju + Migrēt no ierīces, pabeigt migrāciju + Migrēt no ierīces, vai dzēst arhīvu? + Migrēt no ierīces, augšupielādētais arhīvs tiks dzēsts + Migrēt no ierīces, izvēlieties migrēt no citas ierīces + Migrēt no ierīces, vai kopīgot šo faila saiti + Migrēt no ierīces, dzēst datu bāzi no ierīces + Migrēt no ierīces, sarunas uzsākšana vairākās ierīcēs nav atbalstīta + Migrēt no ierīces, uzsākt sarunu + Migrēt no ierīces, migrācija pabeigta + Migrēt no ierīces, nedrīkstat uzsākt datu bāzi divās ierīcēs + Migrēt no ierīces, izmantošana divās ierīcēs pārtrauc šifrēšanu + Migrēt no ierīces, pārbaudīt datu bāzes paroli + Migrēt no ierīces, pārbaudīt paroli + Migrēt no ierīces, apstipriniet, ka atceraties paroli + Migrēt no ierīces, pārbaudiet savienojumu un mēģiniet vēlreiz + Migrēt no ierīces, arhīvs tiks dzēsts + Kļūda, migrējot no ierīces, pārbaudot paroli + Tīkla veids: nav tīkla savienojuma + Tīkla veids: mobilais + Tīkla veids: Wi-Fi + Tīkla veids: Ethernet + Cita tīkla veida + Serveri + Serveru failu cilne + Trūkstošie serveri + Serveru mērķis + Visi lietotāji + Pašreizējais lietotājs + Serveru transporta sesiju virsraksts + Savienotās serveru sesijas + Savienojamās serveru sesijas + Serveru sesiju kļūdas + Serveru statistikas virsraksts + Nosūtītās ziņas no serveriem + Saņemtās ziņas no serveriem + Serveru detaļas + Serveru privāto datu atruna + Serveru abonementu virsraksts + Abonētās serveru savienojumu + Gaidošie serveru abonementu savienojumi + Kopējais serveru abonementu skaits + Savienoto serveru virsraksts + Iepriekš savienoto serveru virsraksts + Proksēto serveru virsraksts + Proksēto serveru kājenes + Pārsavienot serverus + Pārsavienot serverus + Pārsavienot serveri + Pārsavienot serveri + Kļūda pārsavienojot serverus + Kļūda pārsavienojot serveri + Serveru modalitātes kļūda + Pārsavienot visus serverus + Atjaunot serveru statistiku + Atjaunot serveru statistiku + Atjaunot serveru statistiku + Apstiprināt serveru statistikas atjaunošanu + Kļūda atjaunojot serveru statistiku + Augšupielādēti serveri + Lejupielādēti serveri + Detalizēta serveru statistika + Serveru detalizētā statistika par nosūtītajām ziņām + Serveru detalizētā statistika par kopējo nosūtīto ziņu skaitu + Serveru detalizētā statistika par saņemtajām ziņām + Serveru detalizētā statistika par kopējo saņemto ziņu skaitu + Serveru detalizētā statistika par saņemšanas kļūdām + Serveri sākot no + Smp serveris + Xftp serveris + Pārlādēt + Mēģinājumi + Nosūtīts tieši + Nosūtīts caur proxy + Proxy + Nosūtīšanas kļūdas + Beidzies + Citi + Dublikāti + Atšifrēšanas kļūdas + Citas kļūdas + Apstiprināts + Apstiprināšanas kļūdas + Savienojumi + Izveidots + Aizsargāts + Pabeigts + Izdzēsts + Izdzēšanas kļūdas + Abonēts + Abonēšanas rezultāti ignorēti + Abonēšanas kļūdas + Augšupielādētie faili + Izmērs + Augšupielādītie fragmenti + Augšupielādes kļūdas + Izdzēstie fragmenti + Lejupielādētie fragmenti + Lejupielādētie faili + Lejupielādes kļūdas + Servera adrese + Atvērt servera iestatījumus + Sasniegts maksimālais grupas pieminējumu skaits ziņā. + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nb-rNO/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/nb-rNO/strings.xml new file mode 100644 index 0000000000..a6385a5ce0 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nb-rNO/strings.xml @@ -0,0 +1,220 @@ + + + 1 dag + 1 minutt + 1 måned + 1 rapport + Engangslenke + 1 uke + 1 år + 30 sekunder + 5 minutter + a + b + Avbryt + Avbryt adresseendring + Avbryt adresseendring? + Om operatører + Om SimpleX + Om SimpleX-adressen + Om SimpleX Chat + Aksentfarge + Aksepter + Aksepter + Aksepter + Aksepter + Aksepter + Aksepter + Aksepter som medlem + Aksepter som observatør + Aksepter vilkår + Aksepter tilkoblingsforespørsel? + Aksepter kontaktforespørsel + Aksepter kontaktforespørsel + Akseptert %1$s + Akseptert anrop + Akseptert vilkår + Akseptert invitasjon + Aksepterte deg + Aksepter inkognito + Aksepter medlem + Koble til servere via SOCKS proxy på port %d? Proxy må startes før du skrur på dette valget. + Bekreftelsesfeil + Aktive tilkoblinger + Legg til en adresse i profilen din slik at kontaktene dine kan dele denne med andre. Profiloppdateringen vil bli sendt til dine kontakter. + Legg til kontakt + Lagt til medie- og filservere + Meldingsservere er lagt til + Legg til venner + Legg til en liste + Legg til en melding + Legg til forhåndsvalgte servere + Legg til en profil + Adresse + Adresseendringen vil bli avbrutt. Den gamle mottakeradressen vil bli brukt. + Adresse eller engangslenke? + Adresseinnstillinger + Legg til en server + Legg til servere ved å skanne QR-koder. + Legg til teammedlemmer + Legg til en annen enhet + Legg til listen + Legg til velkomstmelding + Legg til dine teammedlemmer i samtalene. + administrator + administratorer + Administratorer kan blokkere ett medlem for alle. + Administratorer kan lage lenker for å bli med i grupper. + Avanserte nettverksinnstillinger + Avanserte innstillinger + Avanserte innstillinger + godkjenner krypteringen… + alle + Alle + All appdata er slettet. + Alle chatter og meldinger vil bli slettet - dette kan ikke angres! + Alle chatter vil bli fjernet fra listen %s, og listen vil bli slettet + Alle gruppemedlemmer vil forbli tilkoblet. + alle medlemmer + Alle meldinger vil bli slettet - dette kan ikke angres! + Alle meldinger vil bli slettet – dette kan ikke angres! Meldingene vil KUN bli slettet for deg. + Alle nye meldinger fra %s vil bli skjult! + Alle nye meldinger fra disse medlemmene vil bli skjult! + Tillat + Tillate + Tillat anrop? + Tillat samtaler kun vis kontakten din tillater dem. + Tillat forsvinnende meldinger kun vis kontakten din tillater dem. + Tillat nedgradering + Tillat filer og medier kun vis kontakten din tillater det. + Tillat irreversibel sletting av meldinger kun hvis kontakten din tillater det. (24 timer) + Tillat meldingsreaksjoner. + Tillat meldingsreaksjoner kun vis kontakten din tillater det. + Tillat direktemeldinger til medlemmer. + Tillat å slette sendte meldinger irreversibelt. (24 timer) + Tillat å rapportere meldinger til moderatorer. + Tillat å sende forsvinnende meldinger. + Tillat å sende filer og medier. + Tillat sending av SimpleX-lenker. + Tillat sending av talemeldinger. + Tillat talemeldinger? + Tillat talemeldinger kun vis kontakten din tillater det. + Tillat kontaktene dine å sende meldingsreaksjoner. + Tillat kontaktene dine å ringe deg. + Tillat kontaktene dine å irreversibelt slette sendte meldinger. (24 timer) + Tillat kontaktene dine å sende forsvinnende meldinger. + Tillat kontaktene dine å sende filer og medier. + Tillat kontaktene dine å sende talemeldinger. + Alle profiler + Alle rapporter vil bli arkivert for deg. + Alle servere + Alle dine kontakter, samtaler og filer vil bli kryptert og lastet opp i deler til konfigurerte XFTP-reléer. + Alle kontaktene dine vil forbli tilkoblet. + Alle kontaktene dine vil forbli tilkoblet. Profiloppdatering vil bli sendt til kontaktene dine. + Kobler allerede til! + alltid + Alltid + Alltid på + Bruk alltid privat ruting. + Bruk alltid relé + og %d andre hendelser + Android Keystore brukes til å lagre passord på en sikker måte – det gjør at varslingstjenesten fungerer. + Android Keystore brukes til å trygt lagre passordet ditt etter at du restarter appen eller bytter passord - det gjør at du kan motta varsler. + En tom chat-profil med navnet du har valgt vil bli laget, og appen åpnes som vanlig. + En ny tilfeldig profil vil bli delt. + En annen grunn + Svar anrop + Hvem som helst kan være vert for servere. + APP + Appen kjører alltid i bakgrunnen + App build: %s + Appen kan bare motta varsler når den er åpen, ingen bakgrunnstjeneste vil bli startet. + Backup av appdata + Migrering av appdata + Utseende + Appen krypterer nye lokale filer (unntatt videoer). + Appikon + Bruk + Bruk på + App-passord + App-passord + App-passordet byttes med det selvdestruerende passordet. + Appøkt + Apptema + App-verktøylinjer + Appoppdatering er lastet ned. + Appversjon + Appversjon: v%s + Arabisk, bulgarsk, finsk, hebraisk, thai og ukrainks - takk til brukerne og Weblate. + Arkiv + Arkiver alle rapporter? + Arkiver og last opp + Arkiver kontakter for å chatte senere. + Arkiverte kontakter + arkivert rapport + arkivert rapport av %s + Arkiver %d rapporter? + Arkiver rapport + Arkiver rapport? + Arkiver rapporter + Arkiverer database + Spør + Bedt om å motta bildet + Bedt om å motta videoen + Legg ved + forsøk + Lyd- og videosamtaler + lydanrop + Lydanrop + lydanrop (ikke E2E-kryptert) + Lyd av + Lyd på + Lyd- og videosamtaler + Lyd/videosamtaler + Lyd/videosamtaler er ikke tillatt. + Autentisering avbrutt + Autentisering mislyktes + Autentisering er ikke tilgjengelig + forfatter + Godta automatisk + Godta kontaktforespørsler automatisk + Godta bilder automatisk + \nTilgjengelig i v5.1 + Tilbake + Bakgrunn + Bakgrunnstjenesten kjører alltid - varsler vises så snart meldingene er tilgjengelige. + Batterioptimalisering er aktivert, og bakgrunnstjenesten og periodiske forespørsler om nye meldinger er slått av. Du kan aktivere det igjen i innstillingene. + Beta + Bedre anrop + Bedre grupper + Bedre gruppeytelse + Bedre meldingsdatoer. + Bedre meldinger + Bedre personvern og sikkerhet + Bedre sikkerhet ✅ + Bedre brukeropplevelse + Bio: + Bio er for stor + Svart + Blokker + blokkert + blokkert + blokkert av administrator + Blokkert av administrator + blokkert %s + Blokker for alle + Blokker gruppemedlemmer + Blokker medlem + Blokker medlem? + Blokker medlem for alle? + Blokker medlemmer for alle? + Bluetooth + fet + Bot + Både du og kontakten din kan legge til reaksjoner på meldinger. + Både du og kontakten din kan slette sendte meldinger irreversibelt. (24 timer) + Både du og kontakten din kan ringe. + Både du og kontakten din kan sende forsvinnende meldinger. + Både du og kontakten din kan sende filer og medier. + Både du og kontakten din kan sende talemeldinger. + Firmaadresse + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml index b5be8b9773..655b1cedbd 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml @@ -811,7 +811,7 @@ Dank aan de gebruikers – draag bij via Weblate! Transport isolation SimpleX - U bent verbonden met de server die wordt gebruikt om berichten van dit contact te ontvangen. + U bent verbonden met de server die wordt gebruikt om berichten van deze verbinding te ontvangen. Je profiel wordt verzonden naar het contact van wie je deze link hebt ontvangen. Je maakt verbinding met alle groepsleden. Uitvoeren bij geopende app @@ -912,8 +912,8 @@ SimpleX groep link SimpleX links Eenmalige SimpleX uitnodiging - Proberen verbinding te maken met de server die wordt gebruikt om berichten van dit contact te ontvangen. - Er wordt geprobeerd verbinding te maken met de server die wordt gebruikt om berichten van dit contact te ontvangen (fout: %1$s). + Er wordt geprobeerd verbinding te maken met de server die wordt gebruikt om berichten van deze verbinding te ontvangen. + Er wordt geprobeerd verbinding te maken met de server die wordt gebruikt om berichten van dit contact te ontvangen (fout: %1$s). onbekend berichtformaat Via browser via contact adres link @@ -2436,4 +2436,11 @@ Accepteer Lid accepteren Lid zal toetreden tot de groep, lid accepteren? + Incognitoprofiel gebruiken + Open chat + Open een nieuwe chat + Nieuwe groep openen + geen abonnement + U bent niet verbonden met de server die u gebruikt om berichten van deze verbinding te ontvangen (geen abonnement). + end-to-end-encryptie.]]> diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml index a7862ffcf1..0b59cc1b06 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml @@ -26,7 +26,7 @@ wysyłanie plików nie jest jeszcze obsługiwane odbieranie plików nie jest jeszcze obsługiwane Próbowanie połączenia z serwerem używanym do odbierania wiadomości od tego kontaktu. - Próbowanie połączenia z serwerem używanym do odbierania wiadomości od tego kontaktu (błąd: %1$s). + Próbowanie połączenia z serwerem używanym do odbierania wiadomości od tego kontaktu (błąd: %1$s). nieznany format wiadomości SimpleX Ty diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml index 285d6f3802..c0bbe4d6bb 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml @@ -971,7 +971,7 @@ Toque para ativar o perfil. Mostrar perfil de chat Mostrar perfil - Tentando se conectar ao servidor utilizado para receber mensagens deste contato (erro:%1$s). + Tentando se conectar ao servidor utilizado para receber mensagens deste contato (erro:%1$s). Tentando se conectar ao servidor utilizado para receber mensagens deste contato. Você está conectado ao servidor usado para receber mensagens desse contato. Seu servidor diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml index 585b967613..2f19492237 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml @@ -52,7 +52,7 @@ Toate conversațiile și mesajele vor fi șterse – această acțiune nu poate fi anulată! Toate mesajele vor fi șterse - această acțiune nu poate fi anulată! Permite reacții la mesaje doar dacă și contactul tău le permite. - Permite ștergerea ireversibilă a mesajelor trimise. (24 ore) + Permite ștergerea definitivă a mesajelor trimise. (24 de ore) Permite trimiterea de fișiere și media. Toate datele sunt șterse la introducerea lor. Toți membrii grupului vor rămâne conectați. @@ -63,12 +63,12 @@ Permite apeluri doar dacă le permite contactul tău. Permiteți mesaje vocale? Permite mesaje vocale doar dacă contactul tău le permite. - Permite ștergerea mesajelor ireversibile doar dacă și contactul tău îți permite ție. (24 ore) + Permite ștergerea definitivă a mesajelor doar dacă și contactul tău o permite. (24 de ore) Permite reacții la mesaje. Permite trimiterea de mesaje directe membrilor. Permite trimiterea de mesaje efemere. Permite trimiterea de mesaje vocale. - Permite mesaje efemere doar dacă le permite contactul tău. + Permite mesaje autodistructive doar dacă și contactul tău le permite. Permite contactelor tale să trimită mesaje care dispar. Aspect Versiunea aplicației @@ -88,16 +88,16 @@ Camera și microfon Atenție: arhiva va fi ștearsă.]]> Anulează previzualizarea fișierului - Un profil de chat gol cu numele furnizat este creat, iar aplicația se deschide ca de obicei. + Un profil de conversație gol cu numele furnizat este creat, iar aplicația se deschide ca de obicei. Se conectează deja! Aplicația poate primi notificări doar când rulează, niciun serviciu în fundal nu va fi lansat. Se alătură deja grupului! Mereu pornit Un nou profil aleatoriu va fi distribuit. - Android Keystore este utilizat pentru a stoca în siguranță fraza de acces – acest lucru permite funcționarea serviciului de notificare. + Android Keystore este folosit pentru a stoca în siguranță parola. Acest lucru permite funcționarea serviciului de notificări. și %d alte evenimente Răspunde la apel - Android Keystore va fi utilizat pentru a stoca în siguranță fraza de acces după ce reporniți aplicația sau schimbați fraza de acces – acest lucru va permite primirea notificărilor. + Android Keystore va fi folosit pentru a stoca în siguranță parola după ce repornești aplicația sau schimbi parola — acest lucru va permite primirea de notificări. APLICAȚIE Creează grup Apeluri audio și video @@ -143,7 +143,7 @@ Creează-ți profilul Apeluri audio și video Audio pornit - Frază de acces actuală… + Parola actuală… Creează link de grup Creează link creator @@ -171,13 +171,13 @@ Elimină membru Eliminați membrul? Resetează la valorile implicite - Resetați culoarea + Resetezi culoarea Resetați culorile Elimină imaginea Repetă încărcarea apel respins - Eliminați fraza de acces din Keystore? - Eliminați fraza de acces din setări? + Elimini parola din Keystore? + Elimini parola din setări? Repetă cererea de conectare? Necesar Reîncearcă @@ -190,20 +190,20 @@ Renegociezi criptarea? Resetare Respinge - Salvați fraza de acces în setări - Salvați și actualizați profilul grupului + Salvează parola în setări + Salvează și actualizează profilul grupului Repetă cererea de alăturare? Reporniți conversația salvat salvat de la %s Salvează Salvat - Salvat de la + Salvat din Renegociază - Salvați serverele + Salvează serverele Serverele ICE WebRTC salvate vor fi eliminate. - Salvați parola profilului - Salvați fraza de acces și deschideți chatul + Salvează parola profilului + Salvează parola și deschide conversația %s și %s Renegociază criptarea Salvează și notifică contactul @@ -216,8 +216,8 @@ Salvezi setările? Salvezi preferințele? Repornire - Restaurați copia de rezervă a bazei de date - Restaurați copia de rezervă a bazei de date? + Restaurează backupul bazei de date + Restaurezi backupul bazei de date? Eroare de restaurare a bazei de date %1$s eliminat %s și %s conectați @@ -225,15 +225,15 @@ Salvează Arată Respinge - Salvați serverele? + Salvezi serverele? Apel respins Mesaj salvat - Reporniți aplicația pentru a crea un nou profil de chat. - Salvați fraza de acces în Keystore - Salvați profilul grupului + Repornește aplicația pentru a crea un profil de conversație nou. + Salvează parola în Keystore + Salvează profilul grupului Repetă Trimite previzualizări de linkuri - Setați frază de acces + Setează parola Partajează adresa Trimis la Secundar @@ -244,26 +244,26 @@ Versiunea serverului este incompatibilă cu setările de rețea. Trimitere prin trimiterea de fișiere nu este încă acceptată - Scanați codul de securitate din aplicația contactului dvs. + Scanează codul de securitate din aplicația persoanei tale de contact. Selectează contactele Autodistrugere Răspuns trimis Partajează media… Partajează mesaj… - Afișează lista de chat în fereastră nouă + Afișează lista de conversații într-o fereastră nouă Afișează consola în fereastră nouă - Configurați fraza de acces a bazei de date - Setați parola bazei de date + Configurează parola bazei de date + Setează parola bazei de date setați adresă de contact nouă %s (actual) Codul sesiunii Expeditorul a anulat transferul de fișiere. - Serverul necesită autorizare pentru a crea cozi, verificați parola + Serverul necesită autorizare pentru a crea cozi. Verifică parola Partajează trimitere eșuată Caută sau lipește linkul SimpleX Setează numele contactului - Salvați mesajul de bun venit? + Salvezi mesajul de bun venit? Evaluarea securității Scanează codul QR de pe desktop Caută @@ -276,14 +276,14 @@ Mesaj trimis Scanează cod QR Trimite - Trimiteți întrebări și idei + Trimite întrebări și idei Afișează opțiunile pentru dezvoltatori sec Mesajele trimise vor fi șterse după ora setată. - Serverul necesită autorizare pentru încărcare, verificați parola + Serverul necesită autorizare pentru a încărca fișiere. Verifică parola Afișează contactul și mesajul Partajează fișier… - Nume contact… + Setează numele contactului… Trimite mesaj Trimiteți un mesaj care dispare (scanează sau lipește din clipboard) @@ -316,7 +316,7 @@ codul de securitate a fost schimbat Afișează ultimele mesaje Trimiteți un mesaj direct - Setați tema implicită + Setează tema implicită SimpleX SimpleX nu poate rula în fundal. Vei primi notificări doar atunci când aplicația rulează. Serviciul SimpleX Chat @@ -324,7 +324,7 @@ Oprire Linkuri SimpleX Linkurile SimpleX sunt interzise. - Securitatea chatului SimpleX a fost auditată de Trail of Bits. + Securitatea SimpleX Chat a fost auditată de Trail of Bits. Mesaje SimpleX Chat Apeluri SimpleX Chat Adresă SimpleX @@ -351,13 +351,13 @@ Niște servere au eșuat testul: Difuzor oprit Difuzor pornit - Copie de rezervă a datelor aplicației + Backup al datelor aplicației Tema aplicației Accent suplimentar 2 Începe conversația Toate modurile de culoare Folosește mereu releu - Aplică pentru + Aplică la Începe o nouă conversație Acordă o stea pe GitHub criptare standard end-to-end @@ -386,14 +386,14 @@ Creat la Migrează de pe alt dispozitiv pe noul dispozitiv și scanați codul QR.]]> Te conectezi prin adresa de contact? - Doriți să vă conectați printr-un link de unică folosință? + Vrei să te conectezi printr-un link unic? Conectare incognito Contactul deja există Schimbă codul de acces Puteți activa SimpleX Lock din Setări. Conversații Alege un fișier - Contactul dvs. trebuie să fie online pentru ca conexiunea să se finalizeze.\nPuteți anula această conexiune și elimina contactul (și încerca mai târziu cu un link nou). + Persoana ta de contact trebuie să fie online pentru ca procesul de conectare să se finalizeze.\nPoți anula această conexiune și șterge contactul (apoi poți încerca mai târziu cu un link nou). eroare apel Apelurile tale Schimbă @@ -404,8 +404,8 @@ Preferințe contact Te conectezi cu %1$s? Anulează previzualizarea imaginii - Discută cu dezvoltatorii - Trebuie să introduceți fraza de acces de fiecare dată când porniți aplicația – aceasta nu este stocată pe dispozitiv. + Conversează cu dezvoltatorii + Trebuie să introduci parola de fiecare dată când pornești aplicația - aceasta nu este stocată pe dispozitiv. blocat Preferințe conversație Mod întunecat @@ -422,7 +422,7 @@ Conectare prin link Puteți vedea din nou linkul de invitație în detaliile conexiunii. Profilurile tale de conversație - Creează profil de chat + Creează un profil de conversație apel încheiat %1$s Creează Bluetooth @@ -433,9 +433,9 @@ Contacte CONVERSAȚII BAZĂ DE DATE CONVERSAȚIE - Baza de date a conversației ștearsă + Baza de date a conversației a fost ștearsă Conversația rulează - Baza dvs. de date a chatului + Baza de date a conversațiilor tale Baza de date a conversațiilor tale nu este criptată - setează o parolă pentru a o proteja. Parola pentru criptarea bazei de date va fi actualizată și stocată în setări. ați schimbat rolul %s în %s @@ -479,17 +479,17 @@ Copiat Camera Ați invitat un contact - Profilul tău de chat va fi trimis\ncontactului tău + Profilul tău de conversație va fi trimis contactului tău. Contribuie Profil conversație aldin Puteți folosi marcarea pentru a formata mesajele: se apelează… Schimbă modul de autodistrugere - Baza de date a conversației importată + Baza de date a conversației a fost importată Trebuie să utilizați cea mai recentă versiune a bazei de date de chat DOAR pe un singur dispozitiv, altfel este posibil să nu mai primiți mesaje de la unele contacte. Nu se poate accesa Keystore pentru a salva parola bazei de date - Conversație migrată! + Conversația a fost migrată! Continuă apel în curs Apel în curs @@ -498,7 +498,7 @@ Nume contact Creează adresă Bază de date criptată! - Schimbați fraza de acces a bazei de date? + Schimbi parola bazei de date? Conversația este oprită Puteți porni discuția din Setările aplicației / Baza de date sau repornind aplicația. ați ieșit @@ -531,7 +531,7 @@ Șterge Confirmați fișiere de la servere necunoscute. am schimbat adresa pentru tine - Confirmați parola nouă… + Confirmă noua parolă… Conectare conexiune %1$d conexiune stabilită @@ -543,7 +543,7 @@ se conectează… Schimbați modul de blocare Versiunea aplicației: %s - pentru fiecare profil de conversație pe care le aveți în aplicație]]> + pentru fiecare profil de conversație pe care îl ai în aplicație.]]> Permiteți downgrade-ul pentru fiecare contact și membru al grupului.\nRețineți: dacă aveți multe conexiuni, consumul bateriei și al traficului de date poate crește considerabil, iar unele conexiuni pot eșua.]]> Conexiune terminată @@ -561,7 +561,7 @@ se conectează Schimbați rolul de grup? Modul color - După profilul de chat (implicit) sau după conexiune (BETA). + După profilul de conversație (implicit) sau după conexiune (BETA). Compară codurile de securitate cu contactele tale. Conexiune oprită Conexiune oprită @@ -579,7 +579,7 @@ se conectează… Eroare de conexiune (AUTENTIFICARE) Optimizarea bateriei este activă, dezactivând serviciul de fundal și cererile periodice pentru mesaje noi. Le puteți reactiva din setări. - conectat + Conectat Creează grup:: pentru a crea un nou grup.]]> Ștergeți conversația? Șterge @@ -595,7 +595,7 @@ rolul tău a fost schimbat în %s conectat se conectează - conectat + Conectat Ștergeți Buton de închidere Ștergeți verificarea @@ -607,7 +607,7 @@ complet De reținut: folosirea aceleiași baze de date în două aparate, va intrerupe descifrarea mesajelor din conexiunile tale, ca măsură de protecție.]]> Confirmați încărcarea - Confirmați că țineți minte parola de la baza de date pentru a o migra. + Confirmă că-ți amintești parola bazei de date pentru a o migra. Conexiune Ștergi profilul de conversație? Șterge pentru mine @@ -638,7 +638,7 @@ Descriere Șterge fișierul Șterge coada - Ștergeți contactul? + Ștergi contactul? Șterge Șterge Ștergi fișierele și conținutul media? @@ -655,20 +655,20 @@ Șterge Ștergeți mesajul? Livrare - Ștergeți conexiunea în așteptare? + Ștergi conexiunea în așteptare? Ștergeți serverul Șterge baza de date contact șters Ștergi grupul? Șterge baza de date de pe acest dispozitiv - Șterge profil de conversație - Șterge fișierele pentru toate profilurile de chat + Șterge profilul de conversație + Șterge fișierele pentru toate profilurile de conversație Oricine poate găzdui servere. Contacte arhivate Beta Apeluri interzise! Nu se poate apela membrul grupului - Bază de date conversație exportată + Baza de date a conversației a fost exportată Verifică pentru actualizări contactul %1$s s-a schimbat în %2$s Contactul este șters. @@ -688,7 +688,7 @@ BAZĂ DE DATE CONVERSAȚIE Conectează-te cu prietenii mai ușor. încercări - Completat + Finalizat ID-urile bazei de date și opțiunea de izolare a transportului. ID bază de date ID bază de date: %d @@ -697,10 +697,10 @@ Estompează pentru o mai bună confidențialitate. Statusul conexiunii și al serverelor. Eroare bază de date - Contactele pot marca mesaje pentru ștergere; dvs. le veți putea vizualiza. + Contactele pot marca mesajele pentru ștergere; tu le vei putea vizualiza. Downgrade al bazei de date Migrarea bazei de date este în curs.\nAceasta poate dura câteva minute. - Parola bazei de date și export + Parola și exportul bazei de date Toate profilurile Profil actual Confirmi ștergerea contactului? @@ -735,7 +735,7 @@ Apeluri îmbunătățite Apel audio primit Încheie apelul - marcajele şterse + marcat ca șters %d mesaje marcate șterse moderat de %s %1$d alte erori de fișier. @@ -769,7 +769,7 @@ eroare la afișarea conținutului criptare end-to-end, cu confidențialitate perfectă în avans, nerepudiere și recuperare în caz de compromitere.]]> criptare end-to-end rezistentă la computere cuantice, cu confidențialitate perfectă în avans, nerepudiere și recuperare în caz de compromitere.]]> - Acest chat este protejat prin criptare end-to-end. + Această conversație este protejată prin criptare integrală. Link complet incognito printr-un link de unică folosință a + b @@ -799,24 +799,24 @@ moderat conversație nevalidă date nevalide - Acest chat este protejat prin criptare end-to-end rezistentă la detecții cuantice. + Această conversație este protejată prin criptare end-to-end rezistentă la atacuri cuantice. Note private Eroare la citirea parolei bazei de date Versiunea bazei de date este incompatibilă Invitația la grup nu mai este validă, a fost eliminată de expeditor. Se alătură grupului - Părăsiți grupul + Părăsește grupul Eroare la ștergerea linkului de grup Eroare la trimiterea invitației dezactivat Membrii pot trimite mesaje vocale. - Filtrați conversațiile necitite și favorite. + Filtrează conversațiile necitite și favorite. Migrare în curs Managementul rețelei Finalizează migrarea pe un alt dispozitiv. Incompatibil! - Cum să utilizați serverele dvs. - Importați baza de date de chat? + Cum să utilizezi serverele tale + Importă baza de date a conversației? Ștergeți raportul Redirecționați mesajele… Șterge doar conversația @@ -829,7 +829,7 @@ PENTRU CONSOLĂ Moderat la Remediați conexiunea - Multiple profile de chat + Profiluri de conversație multiple Ascuns Introducere parolă Redirecționare server: %1$s\nEroare:%2$s @@ -866,7 +866,7 @@ Alătură-te conversațiilor de grup Livrarea mesajelor îmbunătățită Interfață lituaniană - Faceți conversațiile să arate diferit! + Fă-ți conversațiile să arate diferit! Menționați membrii 👋 Ajutați administratorii să modereze grupurile lor. Doar un singur dispozitiv poate funcționa în același timp @@ -874,9 +874,9 @@ Se descarcă arhiva Finalizați migrarea Eroare la verificarea parolei: - Eroare la importarea bazei de date de conversații + Eroare la importarea bazei de date a conversației Mesaje - Eroare la exportarea bazei de date de conversații + Eroare la exportarea bazei de date a conversației Nicio informație, încercați să reîncărcați Activează jurnalele Autentificarea dispozitivului este dezactivată. Se dezactivează SimpleX Lock. @@ -890,18 +890,18 @@ Nu există servere de mesaje. Nu există servere pentru rutarea mesajelor private. Nu există servere pentru a primi mesaje. - Pentru profilul de chat %s: + Pentru profilul de conversație %s: Nu există servere pentru a primi fișiere. Eroare la conectarea la serverul de redirecționare %1$s. Vă rugăm să încercați mai târziu. Versiunea serverului de destinație %1$s este incompatibilă cu serverul de redirecționare %2$s. Notificările instantanee sunt dezactivate! Dezactivare notificări Migrați de pe un alt dispozitiv - Eroare la ștergerea bazei de date a chatului + Eroare la ștergerea bazei de date a conversației Actualizarea bazei de date este necesară renegocierea criptării membru - Ștergeți conversația? + Ștergi conversația? eroare Link nevalid Fișierul va fi primit după ce persoana de contact va finaliza încărcarea. @@ -923,7 +923,7 @@ nu oprit` %ds - Remediați criptarea după restaurarea copiilor de rezervă. + Repară criptarea după restaurarea backupurilor. Ștergeți sau moderați până la 200 de mesaje. Primirea mesajelor Eroare la resetarea statisticilor @@ -965,13 +965,13 @@ Pentru rutare privată Activați funcția TCP keep-alive Fără sunet când este inactiv! - Teme noi de chat + Teme noi pentru conversații Descentralizarea rețelei altul Doar 10 imagini pot fi trimise simultan Mesajul este prea mare! - Imaginea va fi primită când contactul dvs. va fi online, vă rugăm să așteptați sau să verificați mai târziu! - Șterge mesajele din conversație de pe dispozitiv. + Imaginea va fi primită atunci când persoana ta de contact este online. Te rugăm să aștepți sau să verifici mai târziu! + Șterge mesajele conversației de pe dispozitivul tău. Dacă alegeți să respingeți, expeditorul NU va fi notificat. Cum se utilizează Marcare în mesaje @@ -1005,12 +1005,12 @@ Sau distribuiți în siguranță acest link pentru fișier Statusul mesajului Niciun telefon conectat - Numai tu poți șterge ireversibil mesajele (contactul tău le poate marca pentru ștergere). (24 de ore) + Doar tu poți șterge definitiv mesajele (contactul tău le poate marca pentru ștergere). (24 de ore) Doar contactul tău poate trimite mesaje vocale. Găsește conversații mai rapid Luminos Nu - Deschizi link-ul web? + Deschizi linkul web? MESAJE ȘI FIȘIERE moderator Rol inițial @@ -1023,7 +1023,7 @@ Cod QR nevalid Cod de securitate incorect! Dispare la: %s - Conectați-vă folosind credențialele dvs. + Conectează-te cu datele tale de autentificare Deschide conversația Eroare la keychain Imediat @@ -1066,9 +1066,9 @@ Nicio conversație selectată Încărcarea fișierului Eroare la salvarea fișierului - Ștergeți fără notificare + Șterge fără notificare Deconectat - Remediați conexiunea? + Repari conexiunea? Mesaj care dispare Mai mult Pentru fiecare server vor fi utilizate acreditări noi SOCKS. @@ -1082,9 +1082,9 @@ Mod de blocare Cod de acces nou Caracteristici experimentale - Fraza de acces a bazei de date este necesară pentru a deschide chatul. - Introduceți fraza de acces… - Downgrade și deschide chatul + Este necesară parola bazei de date pentru a deschide conversația. + Introdu parola… + Retrogradează și deschide conversația Vor fi afișate mesaje de la acești membri! nici unul Eroare la eliminarea membrului @@ -1094,7 +1094,7 @@ Fă profilul privat! Deschideți contiții Nu mai afișa - Permite să ai mai multe conexiuni anonime într-un singur profil de conversație, fără a partaja date între ele. + Permite să ai mai multe conexiuni anonime fără a partaja date între ele într-un singur profil de conversație. Luminos Mesajele care dispar sunt interzise în această conversație. Numai tu poți trimite mesaje care dispar. @@ -1150,7 +1150,7 @@ Nume nou afișat: Instrumente pentru dezvoltatori FIȘIERE - Deschide linkuri din lista de chat + Deschide linkurile din lista de conversații Forma mesajului Importați baza de date Arhivă nouă a bazei de date @@ -1170,7 +1170,7 @@ Nou rol de membru Invitați membrii Editează profilul grupului - Grupul va fi șters pentru dvs. - această acțiune nu poate fi anulată! + Grupul va fi șters doar pentru tine – această acțiune nu poate fi anulată! Membrul va fi eliminat din grup - acest lucru nu poate fi anulat! Membrii vor fi eliminați din chat - acest lucru nu poate fi anulat! Membrii vor fi eliminați din grup - acest lucru nu poate fi anulat! @@ -1182,7 +1182,7 @@ Textul condițiilor actuale nu a putut fi încărcat, puteți consulta condițiile prin intermediul acestui link: Eroare la actualizarea serverului Serverul operator - Ștergeți profilul de chat pentru + Șterge profilul de conversație pentru Introdu parola în câmpul de căutare Incognito Modul incognito îți protejează confidențialitatea prin utilizarea unui profil aleatoriu nou pentru fiecare contact. @@ -1191,8 +1191,8 @@ Bună ziua! Mesaje directe Mesaje care dispar - Doar contactul dvs. poate șterge ireversibil mesajele (le puteți marca pentru ștergere). (24 de ore) - Ștergerea ireversibilă a mesajelor este interzisă în această conversație. + Doar persoana ta de contact poate șterge definitiv mesajele (tu le poți marca pentru ștergere). (24 de ore) + Ștergerea definitivă a mesajelor este interzisă în această conversație. Numai tu poți trimite mesaje vocale. Reacțiile la mesaje sunt interzise în această conversație. Numai tu poți efectua apeluri. @@ -1201,12 +1201,12 @@ %d ore Mesajele directe între membri sunt interzise. Mesajele directe între membri sunt interzise în această conversație. - Membrii pot trimite link-uri SimpleX. + Membrii pot trimite linkuri SimpleX. %d saptamana oferit %s oferit %s: %2s Șterge conversația - Ștergeți conversația cu membrul? + Ștergi conversația cu membrul? Membru se va alătura grupului, acceptați membru? Ascunde ecranul aplicației din aplicațiile recente. Nume, avatare și izolare transport diferite. @@ -1216,7 +1216,7 @@ Conectare mai rapidă și mesaje mai de încredere. Interfață în japoneză și portugheză Interfață de utilizator maghiară și turcă - Redirecționați și salvați mesajele + Redirecționează și salvează mesajele Activează Flux în setările Rețea și servere pentru o mai bună confidențialitate a metadatelor. pentru o mai bună confidențialitate a metadatelor. Redirecționați până la 20 de mesaje simultan. @@ -1239,7 +1239,7 @@ Desktopul are codul de invitație greșit Descărcarea a eșuat Importul a eșuat - Introduceți fraza de acces + Introdu parola Eroare la descărcarea arhivei Migrați către un alt dispozitiv duplicate @@ -1262,16 +1262,16 @@ Invitația la grup a expirat Eroare la crearea listei de conversație Eroare la încărcarea listelor de conversații - Eroare la actualizarea listei de chat + Eroare la actualizarea listei de conversații Notificări instant Fără parolă pentru aplicație Favorite Fișier salvat - Fișierul va fi primit când contactul dvs. va fi online, vă rugăm să așteptați sau să verificați mai târziu! + Fișierul va fi primit când contactul tău este online. Te rugăm să aștepți sau să verifici mai târziu! niciun detaliu Deschide cu %s Link de invitație unic - Ștergeți lista? + Ștergi lista? Marchează ca verificat Activează pentru toți grup șters @@ -1290,7 +1290,7 @@ Sursa mesajelor rămâne privată. Mesaje în direct Asigurați-vă că configurația proxy este corectă. - Cel mai probabil acest contact a șters conexiunea cu dvs. + Cel mai probabil, această persoană a șters conexiunea cu tine. Avertizare de livrare a mesajelor Membrul va fi eliminat din chat - acest lucru nu poate fi anulat! Mesaje primite @@ -1306,7 +1306,7 @@ Gazdele Onion vor fi folosite atunci când sunt disponibile. Numai tu poți adăuga reacții la mesaje. Încă nu există conexiune directă, mesajul a fost redirecționat de administrator. - - Deschide chatul la primul mesaj necitit.\n- Sări la mesajele citate. + - Deschide conversația la primul mesaj necitit.\n- Mergi la mesajele citate. Oprit Deschide setările serverului criptare ok pentru %s @@ -1336,14 +1336,14 @@ Stabilește o conexiune privată Deschide SimpleX Chat pentru a accepta apelul Activați blocarea - Se poate întâmpla atunci când tu sau conexiunea ta ați folosit backupul vechi al bazei de date. + Acest lucru se poate întâmpla atunci când tu sau contactul tău ați folosit un backup vechi al bazei de date. Cod de acces Mod incognito Conexiune de rețea Statusul rețelei Asigurați-vă că fișierul are sintaxa YAML corectă. Exportați tema pentru a avea un exemplu de structură de fișiere tematice. Grupuri incognito - Noua experiență de chat 🎉 + Experiență nouă de conversație 🎉 Noi opțiuni media Primiți notificări când sunteți menționați. Migrare completă @@ -1357,7 +1357,7 @@ Confidențialitate și securitate îmbunătățite Moderarea grupului Fișierul a fost șters sau linkul este invalid - Pentru a continua, chatul trebuie oprit. + Pentru a continua, conversația trebuie oprită. Extinde Parola bazei de date este diferită de cea salvată în Keystore. observator @@ -1365,7 +1365,7 @@ Eroare la încărcarea detaliilor Eroare la ștergerea profilului de utilizator Descărcare - NU trimiteți mesaje direct, chiar dacă serverul dvs. sau serverul de destinație nu acceptă rutare privată. + NU trimite mesaje direct, chiar dacă serverul tău sau cel de destinație nu acceptă rutare privată. Se redirecționează %1$s mesaje grupul este șters nesincronizat @@ -1385,7 +1385,7 @@ Dezactivează (păstrează suprascrierile) Dacă introduceți parola de autodistrugere în timp ce deschideți aplicația: Mediu - Părăsiți conversația + Părăsește conversația Fără conversații cu membrii Confirmare de primire a mesajelor! minute @@ -1398,7 +1398,7 @@ %d fișier(e) cu dimensiunea totală de %s Fișiere și media invitat %1$s - Membrii pot șterge ireversibil mesajele trimise. (24 de ore) + Membrii pot șterge definitiv mesajele trimise. (24 de ore) Membrii pot trimite mesaje ce dispar. Mesajele directe între membri sunt interzise în acest grup. Mesajele care dispar sunt interzise. @@ -1406,7 +1406,7 @@ Desktop găsit Se descarcă detaliile linkului Eroare internă - Sau importați fișierul de arhivă + Sau importă un fișier de arhivă Sau lipește linkul arhivei Eroare la inițializarea WebView. Actualizați sistemul la noua versiune. Vă rugăm să contactați dezvoltatorii.\nEroare: %s Grupul va fi șters pentru toți membrii - această acțiune nu poate fi anulată! @@ -1418,7 +1418,7 @@ Descărcați %s (%s) Instalează actualizarea Nu crea adresă - De exemplu, dacă persoana de contact primește mesaje prin intermediul unui server SimpleX Chat, aplicația le va livra prin intermediul unui server Flux. + De exemplu, dacă un contact al tău primește mesaje printr-un server SimpleX Chat, aplicația ta le va trimite printr-un server Flux. Deschide Setările Safari / Site-uri web / Microfon, apoi selectează Permite pentru localhost. Apel ratat Renegocierea criptării a eșuat. @@ -1429,7 +1429,7 @@ Deschide linkul Baza de date va fi criptată, iar parola va fi stocată în setări. Eroare: %s - Părăsiți grupul? + Părăsești grupul? Grupul nu a fost găsit! Eroare la crearea contactului membrului Doar proprietarii conversației pot schimba preferințele. @@ -1445,7 +1445,7 @@ oprit activat pentru contact activat pentru tine - Profiluri de chat ascunse + Profiluri de conversație ascunse Acum administratorii pot:\n- șterge mesajele membrilor.\n- dezactiva membrii (rol de observator) Reacții la mesaje Rapid și fără așteptare până când expeditorul este online! @@ -1503,25 +1503,25 @@ Oprit Deschideți locația fișierului Ieșire fără salvare - Parola profilului ascunsă + Parolă profil ascuns italic Cum funcționează SimpleX Fără identificatori de utilizator. - Se poate întâmpla când:\n1. Mesajele au expirat în clientul care trimite după 2 zile sau pe server după 30 de zile.\n2. Decriptarea mesajelor a eșuat, deoarece tu sau contactul dvs. ați folosit o copie de rezervă a bazei de date vechi.\n3. Conexiunea a fost compromisă. - Eroare la exportul bazei de date a chatului + Acest lucru se poate întâmpla atunci când:\n1. Mesajele au expirat în aplicația de trimitere după 2 zile sau pe server după 30 de zile.\n2. Decriptarea mesajului a eșuat, deoarece tu sau contactul tău ați folosit un backup vechi al bazei de date.\n3. Conexiunea a fost compromisă. + Eroare la exportarea bazei de date a conversației Eroare la oprirea conversației niciodată Niciun fișier primit sau trimis - Frază de acces nouă… + Parolă nouă… Criptezi baza de date? - Introduceți fraza de acces corectă. + Introdu parola corectă. Vrei să te alături grupului? Migrații: %s Alătură-te - Părăsiți - Părăsiți conversația? + Părăsește + Părăsești conversația? a părăsit - invitat prin linkul dvs. de grup + invitat prin linkul tău de grup Un membru nou vrea să se alăture grupului. criptare agreată renegocierea criptării este permisă @@ -1530,7 +1530,7 @@ Moderat la: %s Numele complet al grupului: Preferințele grupului - Ștergerea ireversibilă a mesajelor este interzisă. + Ștergerea definitivă a mesajelor este interzisă. Membrii pot adăuga reacții la mesaje. Membrii pot trimite fișiere și media. Reacțiile la mesaje sunt interzise. @@ -1545,7 +1545,7 @@ Admiterea membrilor oprit Linkuri de grup - Ștergere ireversibilă a mesajului + Ștergere definitivă a mesajelor Măriți dimensiunea fontului. Deconectează Deconectat din motivul: %s @@ -1570,7 +1570,7 @@ Introduceți numele grupului: Umple Descărcați noi versiuni de pe GitHub. - Dacă ați primit un link de invitație SimpleX Chat, îl puteți deschide în browser-ul dvs.: + Dacă ai primit un link de invitație SimpleX Chat, îl poți deschide în browser-ul tău: Link nevalid! Nu Invitați membrii @@ -1604,7 +1604,7 @@ Confidențialitatea redefinită Vă rugăm să verificați conexiunea la rețea cu %1$s și să încercați din nou. Rutare mesaje private 🚀 - Bara de instrumente de chat accesibilă + Bara de instrumente pentru conversație, accesibilă Redă din lista de conversații. Vă rugăm să încercați mai târziu. Citeşte mai mult @@ -1626,31 +1626,31 @@ portul %d Periodic Servere presetate - Vă rugăm să o rețineți sau să o păstrați în siguranță - nu există nicio modalitate de a recupera o parolă pierdută! + Reține parola sau păstreaz-o în siguranță – nu există nicio modalitate de a o recupera dacă o pierzi! Confidențialitate și securitate Protejează ecranul aplicației Imagini de profil - Vă rugăm să stocați în siguranță fraza de acces, NU veți putea accesa chatul dacă o pierdeți. + Te rugăm să stochezi parola în siguranță, altfel NU vei putea accesa conversația dacă o pierzi. Interziceți trimiterea de mesaje vocale. Apeluri cu funcție picture-in-picture Interfață persană - Proxiat + Prin proxy Conexiuni de profil și server răspuns primit… Politica de confidențialitate și condițiile de utilizare. RUTAREA MESAJELOR PRIVATE - Vă rugăm să păstrați parola în siguranță, NU o veți putea schimba dacă o pierdeți. - Fraza de acces nu a fost găsită în Keystore, vă rugăm să o introduceți manual. Acest lucru se poate fi întâmplat dacă ați restaurat datele aplicației utilizând un instrument de backup. Dacă nu este cazul, vă rugăm să contactați dezvoltatorii. - Parola din Keystore nu poate fi citită. Acest lucru s-ar fi putut întâmpla după o actualizare de sistem incompatibilă cu aplicația. Dacă nu este cazul, vă rugăm să contactați dezvoltatorii. - Fost membru %1$s + Te rugăm să stochezi parola în siguranță, altfel NU o vei putea schimba dacă o pierzi. + Parola nu a fost găsită în Keystore. Te rugăm să o introduci manual. Acest lucru s-ar putea întâmpla dacă ai restaurat datele aplicației folosind un instrument de backup. Dacă nu este cazul, te rugăm să contactezi dezvoltatorii. + Parola stocată în Keystore nu poate fi citită. Acest lucru se poate întâmpla după o actualizare a sistemului incompatibilă cu aplicația. Dacă nu este cazul, te rugăm să contactezi dezvoltatorii. + Membru %1$s în așteptare recenzie în așteptare Primit la: %s Expirare protocol Număr de PING-uri - Parolă de profil + Parola profilului Interziceți reacțiile la mesaje. - Interzice ștergerea ireversibilă a mesajelor. + Interzice ștergerea definitivă a mesajelor. Interziceți trimiterea de mesaje directe către membri. Interziceți trimiterea de mesaje care dispar. Interziceți raportarea mesajelor către moderatori. @@ -1658,12 +1658,12 @@ Păstrează ultima schiță a mesajului, cu atașamente. Nume de fișiere private Criptare rezistentă cuantic - Confidențialitate pentru clienții dvs. + Confidențialitate pentru clienții tăi. Nume de fișiere media private. Aleatoriu Vă rugăm să raportați dezvoltatorilor:\n%s\n\nSe recomandă repornirea aplicației. Vă rugăm să confirmați că setările de rețea sunt corecte pentru acest dispozitiv. - Fraza de acces este necesară + Este necesară o parolă Parolă Servere presetate Lipiți adresa desktopului @@ -1680,13 +1680,13 @@ Protejați adresa IP Mesaj primit Note private - Bara de instrumente de chat accesibilă + Bara de instrumente a conversației Temă de profil Lipește linkul pe care l-ai primit Parola nu a fost modificată! Parolă setată! - Fraza de acces din Keystore nu poate fi citită, vă rugăm să o introduceți manual. Acest lucru se poate fi întâmplat după o actualizare de sistem incompatibilă cu aplicația. Dacă nu este cazul, vă rugăm să contactați dezvoltatorii. - Bare de instrumente accesibile + Parola stocată în Keystore nu poate fi citită. Te rugăm să o introduci manual. Acest lucru se poate întâmpla după o actualizare a sistemului incompatibilă cu aplicația. Dacă nu este cazul, te rugăm să contactezi dezvoltatorii. + Bări de instrumente ale aplicației, accesibile Vă rugăm să așteptați ca moderatorii grupului să vă examineze cererea de alăturare. Interziceți trimiterea de mesaje care dispar. Interziceți trimiterea de fișiere și fișiere media. @@ -1694,7 +1694,7 @@ Servere proxy Apel în așteptare Vă rugăm să raportați dezvoltatorilor. - Vă rugăm să introduceți parola anterioară după restaurarea copiei de rezervă a bazei de date. Această acțiune nu poate fi anulată. + Te rugăm să introduci parola anterioară după restaurarea backupului bazei de date. Această acțiune nu poate fi anulată. criptare e2e rezistentă cuantic Chitanțele sunt dezactivate Primit la @@ -1709,15 +1709,15 @@ Server presetat Evaluează aplicația Port - Conversațiile private, grupurile și contactele dvs. nu sunt accesibile operatorilor serverului. - Parola aleatorie este stocată în setări ca text simplu.\nO puteți modifica ulterior. - Vă rugăm să introduceți fraza de acces curentă corectă. + Conversațiile private, grupurile și contactele tale nu sunt accesibile operatorilor serverului. + O parolă aleatorie este stocată în setări, ca text simplu.\nO poți schimba ulterior. + Te rugăm să introduci parola actuală\ncorectă. Previzualizare - Verificați dacă dispozitivul mobil și desktopul sunt conectate la aceeași rețea locală și dacă firewall-ul desktopului permite conexiunea.\nVă rugăm să comunicați orice alte probleme dezvoltatorilor. + Te rugăm să verifici dacă dispozitivul mobil și cel desktop sunt conectate la aceeași rețea locală și dacă firewall-ul de pe desktop permite conexiunea.\nTe rugăm să raportezi orice alte probleme dezvoltatorilor. Servere conectate anterior În așteptare confirmare primită… - Protejează-ți profilurile de chat cu o parolă! + Protejează-ți profilurile de conversații cu o parolă! Te rog să verifici dacă ai folosit linkul corect sau cere contactului tău să îți trimită unul nou. Eroare de rutare privată respins @@ -1729,7 +1729,7 @@ Server adăugat la operatorul %s. Setați mesajul afișat noilor membri! Setați-l în locul autentificării sistemului. - Setați parola pentru export + Setează o parolă pentru export Raportați conținutul: doar moderatorii grupului îl vor vedea. Adresa serverului Primirea fișierului va fi oprită. @@ -1742,11 +1742,11 @@ Salvează și reconectează-te Raportați altceva: doar moderatorii grupului îl vor vedea. caută - Setați numele chatului… - Serverul de retransmisie este utilizat doar dacă este necesar. O altă parte poate observa adresa dvs. IP. - Resetați toate statisticile? + Setează numele conversației… + Serverul de retransmisie este utilizat doar dacă este necesar. O altă parte poate observa adresa ta IP. + Resetezi toate statisticile? Resetați toate sugestiile - Trimiteți mesaje direct atunci când adresa IP este protejată și serverul dvs. sau serverul de destinație nu acceptă rutare privată. + Trimite mesaje direct atunci când adresa IP este protejată, iar serverul tău sau cel de destinație nu acceptă rutare privată. Parola de autodistrugere activată! Setați parola Examinați condițiile @@ -1782,7 +1782,7 @@ Istoric recent și bot de directoare îmbunătățit. Setează expirarea mesajelor în conversații. Trimiterea confirmărilor de livrare va fi activată pentru toate contactele. - Trimiterea confirmărilor de livrare va fi activată pentru toate contactele din toate profilurile de chat vizibile. + Trimiterea confirmărilor de livrare va fi activată pentru toate contactele din toate profilurile de conversații vizibile. Securizat Selectat %d Trimiterea confirmărilor este activată pentru %d contacte @@ -1796,12 +1796,12 @@ Trimiterea confirmărilor este activată pentru %d grupuri Parolă de autodistrugere Trimiteți rapoarte private - Trimiteți mesaje direct atunci când serverul dvs. sau serverul de destinație nu acceptă rutare privată. + Trimite mesaje direct atunci când serverul tău sau cel de destinație nu acceptă rutarea privată. eliminat din grup cererea de înscriere a fost respinsă Selectează profilul de conversație Salvează setările adresei SimpleX - Salvați lista + Salvează lista TRIMITE CONFIRMĂRI DE LIVRARE LA Parola de autodistrugere a fost schimbată! Înregistrare actualizată la @@ -1823,7 +1823,7 @@ Înregistrați mesajul vocal Scanează / Lipește link Server - Reporniți aplicația pentru a utiliza baza de date de chat importată. + Repornește aplicația pentru a folosi baza de date de conversații importată. Eliminați membri? Primirea prin intermediul Raportarea mesajelor este interzisă în acest grup. @@ -1836,17 +1836,17 @@ Adresa serverului este incompatibilă cu setările de rețea: %1$s. Coadă securizată Este posibil ca expeditorul să fi șters cererea de conectare. - Versiunea serverului este incompatibilă cu aplicația dvs.: %1$s. + Versiunea serverului este incompatibilă cu aplicația ta: %1$s. Deblocare Încărcarea a eșuat Aplicația se poate închide după 1 minut în fundal. Cu sunet - Această setare se aplică mesajelor din profilul tău de chat actual + Această setare se aplică mesajelor din profilul de conversație actual Folosiți servere SimpleX Chat? Proxy SOCKS Nume de utilizator SimpleX Chat și Flux au ajuns la un acord pentru a include în aplicație servere operate de Flux. - Aplicația preia mesaje noi periodic — folosește câteva procente din baterie pe zi. Aplicația nu utilizează notificări push — datele de pe dispozitivul dvs. nu sunt trimise către servere. + Aplicația preia mesajele noi periodic și consumă câteva procente din baterie pe zi. Aplicația nu folosește notificări push, așadar datele de pe dispozitivul tău nu sunt trimise către servere. Blocare SimpleX Cheie greșită sau adresă necunoscută a fragmentului de fișier - cel mai probabil fișierul este șters. Prea multe videoclipuri! @@ -1857,7 +1857,7 @@ Fișiere încărcate Folosește profilul curent Folosește noul profil incognito - Vizualizarea s-a blocat + Vizualizarea a eșuat Funcție lentă Blocare SimpleX Deblocați pentru toți @@ -1869,7 +1869,7 @@ Mulțumim utilizatorilor – contribuiți prin Weblate! Cu un consum redus de energie al bateriei. Cu un consum redus de energie al bateriei. - Începând de la %s.\nToate datele sunt păstrate private pe dispozitivul dvs. + Începând cu %s.\nToate datele sunt păstrate confidențiale pe dispozitivul tău. Abonat Erori de încărcare Apel video @@ -1893,7 +1893,7 @@ Mesaj vocal (%1$s) Trimite Comută lista de conversații: - Pentru a primi notificări, vă rugăm să introduceți fraza de acces a bazei de date + Pentru a primi notificări, te rugăm să introduci parola bazei de date Se așteaptă videoclipul Mesaje nelivrate Testul a eșuat la pasul %s. @@ -1902,8 +1902,8 @@ Bun venit, %1$s! Atinge pentru a începe o conversație nouă Folosește acreditări proxy diferite pentru fiecare conexiune. - Adresă SimpleX sau link unic? - Pentru a vă proteja confidențialitatea, SimpleX folosește ID-uri separate pentru fiecare dintre contactele dvs. + Adresă SimpleX sau link de unică folosință? + Pentru a-ți proteja confidențialitatea, SimpleX folosește ID-uri separate pentru fiecare persoană de contact. Ai fost invitat în grup Pentru a primi Utilizare pentru fișiere @@ -1911,7 +1911,7 @@ Temă Utilizați aplicația cu o singură mână. Videoclipul va fi primit după ce persoana de contact termină de încărcat. - Videoclipul va fi recepționat când contactul dvs. va fi online, vă rugăm să așteptați sau să verificați mai târziu! + Videoclipul va fi primit atunci când persoana ta de contact este online. Te rugăm să aștepți sau să verifici mai târziu! video Utilizarea serverelor SimpleX Chat. Folosiți proxy SOCKS? @@ -1923,7 +1923,7 @@ Începând de la %s. Se așteaptă conectarea mobilului: Server XFTP - Această acțiune nu poate fi anulată - profilul, contactele, mesajele și fișierele dvs. vor fi pierdute iremediabil. + Această acțiune nu poate fi anulată - profilul, contactele, mesajele și fișierele tale vor fi pierdute definitiv. Sistem Deblocați membrul? Cu sunet @@ -1933,7 +1933,7 @@ necunoscut Poți ascunde sau dezactiva notificările unui profil de utilizator – ține apăsat pentru meniu. Ați permis - Partajează linkul unic cu un prieten + Partajează link de unică folosință cu un prieten Mesaje vocale Mesaj vocal… Setări proxy SOCKS @@ -1946,14 +1946,14 @@ Mesajele vocale sunt interzise! Ați acceptat conexiunea Partajează adresa public - Partajează acest link de invitație unic - Pentru a verifica criptarea end-to-end cu contactul dvs., comparați (sau scanați) codul de pe dispozitivele dvs. + Partajează acest link de invitație de unică folosință + Pentru a verifica criptarea end-to-end, compară (sau scanează) codul de pe dispozitivele voastre cu persoana de contact. Utilizare pentru conexiuni noi Utilizați proxy SOCKS Oprești partajarea adresei? Când aplicația rulează Aplicația vă protejează confidențialitatea utilizând operatori diferiți în fiecare conversație. - Vizualizați condițiile + Vizualizează condițiile Afișează doar contactul Deblocare Porniți @@ -1963,8 +1963,8 @@ Stop Opriți primirea fișierului? Se așteaptă fișierul - Această acțiune nu poate fi anulată - mesajele trimise și primite în acest chat anterior celei selectate vor fi șterse. - Vizualizați codul de securitate + Această acțiune nu poate fi anulată - mesajele trimise și primite în această conversație, mai vechi decât data selectată, vor fi șterse. + Vizualizează codul de securitate Atinge pentru a lipi linkul Textul pe care l-ai lipit nu este un link SimpleX. Stabil @@ -1989,13 +1989,13 @@ Videoclipuri și fișiere de până la 1 GB Comută modul incognito la conectare. Formă imagine de profil - Comutați profilul de chat pentru invitațiile unice. + Schimbă profilul de conversație pentru invitații de unică folosință. Puteți activa mai târziu prin Setări săptămâni Acesta este linkul tău unic, valabil o singură dată! Servere necunoscute! Fără Tor sau VPN, adresa ta IP va fi vizibilă pentru aceste retransmiteri XFTP:\n%1$s. - Actualizați parola bazei de date + Actualizează parola bazei de date Opriți trimiterea fișierului? tăiere Viitorul mesageriei @@ -2006,7 +2006,7 @@ Da Coada Utilizare de pe desktop - Opriți chatul pentru a exporta, importa sau șterge baza de date a chatului. Nu veți putea primi și trimite mesaje în timp ce chatul este oprit. + Oprește conversațiile pentru a exporta, importa sau șterge baza de date a conversațiilor. Nu vei putea primi și trimite mesaje cât timp conversațiile sunt oprite. Unele fișiere nu au fost exportate Parola este stocată în setări ca text simplu. deblocat %s @@ -2070,20 +2070,20 @@ WiFi Oprire Mesaje ignorate - Parola va fi stocată în setări ca text simplu după ce o modificați sau reporniți aplicația. + Parola va fi stocată în setări ca text simplu după ce o schimbi sau repornești aplicația. Conexiune TCP Erori de abonare Atingeți pentru a vă conecta incognito Imaginea nu poate fi decodificată. Vă rugăm să încercați o altă imagine sau să contactați dezvoltatorii. - Serverele pentru fișierele noi din profilul tău de chat actual + Serverele pentru fișierele noi ale profilului tău de conversații actual Acest grup nu mai există. - Se încearcă conectarea la serverul folosit pentru a primi mesaje de la acest contact (eroare: %1$s). + Se încearcă conectarea la serverul folosit pentru a primi mesaje de la acest contact (eroare: %1$s). Actualizarea setărilor va reconecta clientul la toate serverele. Acest șir de caractere nu este un link! S-a atins timpul de expirare la conectarea la desktop Pentru a te proteja împotriva înlocuirii linkului tău, poți compara codurile de securitate ale contactelor. - Pentru a vă proteja informațiile, activați SimpleX Lock.\nVi se va solicita să finalizați autentificarea înainte ca această funcție să fie activată. - Dacă contactul dvs. a șters conexiunea sau acest link a fost deja utilizat, ar putea fi o eroare – vă rugăm să o raportați.\nPentru a vă conecta, vă rugăm să rugați contactul dvs. să creeze un alt link de conexiune și să verificați dacă aveți o conexiune la rețea stabilă. + Pentru a-ți proteja informațiile, activează Blocare SimpleX.\nVa trebui să finalizezi autentificarea înainte de a activa această funcție. + Dacă persoana ta de contact nu a șters conexiunea sau dacă acest link a mai fost folosit, este posibil să fie o eroare. Te rugăm să o raportezi.\nPentru a te conecta, roagă persoana ta de contact să creeze un nou link și asigură-te că ai o conexiune stabilă la internet. Când partajezi un profil incognito cu cineva, acest profil va fi folosit pentru grupurile la care te invită. Mesaj vocal La conectarea apelurilor audio și video. @@ -2099,7 +2099,7 @@ (pentru a partaja cu persoana de contact) Pentru a începe o nouă conversație Video - Partajează link unic + Partajează link de unică folosință Acest link nu este un link de conectare valid! Acest cod QR nu este un link! Link scurt @@ -2108,14 +2108,14 @@ Folosește rutare privată cu servere necunoscute. Folosește rutare privată cu servere necunoscute atunci când adresa IP nu este protejată. Folosiți portul TCP %1$s atunci când nu este specificat niciun port. - Nu stocăm niciunul dintre contactele sau mesajele dvs. (odată livrate) pe servere. + Nu stocăm niciunul dintre contactele sau mesajele tale (odată livrate) pe servere. prin releu Soft Puternic A doua ticăitură pe care am ratat-o! ✅ Mulțumim utilizatorilor – contribuiți prin Weblate! Afișează procentul - Serverele pentru conexiuni noi ale profilului dvs. de chat actual + Serverele pentru noile conexiuni ale profilului tău de conversație actual Afișează apeluri API lente Pentru a fi notificat despre noile versiuni, activați verificarea periodică pentru versiunile Stabile sau Beta. Folosește o parolă aleatorie @@ -2158,7 +2158,7 @@ Blocarea SimpleX nu este activă! Autentificare sistem Baza de date nu funcționează corect. Atingeți pentru a afla mai multe. - Raportul va fi arhivat pentru dvs. + Raportul va fi arhivat. Atingeți Creează o adresă SimpleX în meniu pentru a o crea mai târziu. Acest text este disponibil în setări Ești invitat în grup @@ -2166,25 +2166,25 @@ Se așteaptă imaginea Se așteaptă videoclipul Eroare temporară de fișier - Eliminare favorit + Elimină din favorite Mențiuni necitite Conexiunea pe care ați acceptat-o va fi anulată! Contactul cu care ați partajat acest link NU se va putea conecta! vrea să se conecteze cu tine! Partajează adresa SimpleX pe rețelele de socializare. - Pentru a se conecta, contactul dvs. poate scana codul QR sau poate folosi linkul din aplicație. - Puteți seta numele conexiunii, pentru a vă aminti cu cine a fost partajat linkul. - Adresa SimpleX și linkurile unice pot fi partajate în siguranță prin orice aplicație de mesagerie. + Pentru a te conecta, persoana ta de contact poate scana codul QR sau poate folosi linkul din aplicație. + Poți seta numele conexiunii, ca să-ți amintești cu cine ai partajat linkul. + Adresa SimpleX și linkurile de unică folosință pot fi partajate în siguranță prin orice aplicație de mesagerie. Codul scanat nu este un cod QR de tip link SimpleX. Server de testare Servere de testare - Folosește acreditări aleatorii + Folosește credențiale aleatorii Actualizare disponibilă: %s Folosește chatul Hash-ul mesajului anterior este diferit. Această acțiune nu poate fi anulată - mesajele trimise și primite anterior celei selectate vor fi șterse. Poate dura câteva minute. - Frază de acces greșită! - Încercarea de a schimba fraza de acces a bazei de date nu a fost finalizată. + Parolă greșită! + Încercarea de a schimba parola bazei de date nu a fost finalizată. Ai fost invitat(ă) în grup. Alătură-te pentru a te conecta cu membrii grupului. profil de grup actualizat Condiții actualizate @@ -2194,8 +2194,8 @@ Acest link a fost utilizat cu un alt dispozitiv mobil, vă rugăm să creați un link nou pe desktop. Verifică conexiunile Poți să încerci încă o dată. - Nu ești conectat la aceste servere. Rutarea privată este utilizată pentru a livra mesaje către ele. - Se oprește chatul + Nu ești conectat la aceste servere. Pentru livrarea mesajelor către ele se folosește rutarea privată. + Se opresc conversațiile Total ești observator Videoclipul nu poate fi decodificat. Vă rugăm să încercați un alt videoclip sau să contactați dezvoltatorii. @@ -2218,9 +2218,9 @@ %s.]]> %s]]> Arhivare raport - Chatul va fi șters pentru tine - această acțiune nu poate fi anulată! + Conversația va fi ștearsă doar pentru tine - această acțiune nu poate fi anulată! Utilizarea bateriei aplicației / Nerestricționat în setările aplicației.]]> - Ghidul utilizatorului.]]> + Ghidul utilizatorului.]]> V-ați alăturat acestui grup. Se conectează la membrul invitat al grupului. Profilul tău va fi trimis persoanei de contact de la care ai primit acest link. Te vei conecta cu toți membrii grupului. @@ -2241,11 +2241,11 @@ Puteți vizualiza în continuare conversația cu %1$s în lista de conversații. Condiții acceptate %s.]]> - %1$d eroare(erori) de fișier:\n%2$s + %1$d erori de fișier:\n%2$s Conexiune blocată Conexiunea este blocată de operatorul serverului:\n%1$s. criptate end-to-end, cu securitate post-cuantică în mesajele directe.]]> - Bare de instrumente + Bările de instrumente ale aplicației Securitate îmbunătățită ✅ Toate conversațiile vor fi eliminate din lista %s, iar lista va fi ștearsă Conexiunea ta a fost mutată la %s, dar a apărut o eroare la schimbarea profilului. @@ -2257,7 +2257,7 @@ Întreabă %s.]]> Schimbați ștergerea automată a mesajelor? - Adresă sau link unic? + Adresă sau link de unică folosință? Sesiune de aplicație Contactele tale Datele tale de conectare pot fi trimise necriptat. @@ -2266,11 +2266,11 @@ Verifică mesajele la fiecare 10 minute %s, acceptați condițiile de utilizare.]]> Confirmă-ți datele de autentificare - Schimbă profilurile de chat + Schimbă profilurile de conversație Contacte Conexiunea necesită renegocierea criptării. Adresa ta SimpleX - Creează link unic + Creează un link de unică folosință Profilul tău actual Aplicația rulează întotdeauna în fundal acceptat %1$s @@ -2279,14 +2279,14 @@ Servere media și de fișiere adăugate Estompare - conectare la serviciul de directoare (BETA)!\n- confirmări de livrare (până la 20 de membri).\n- mai rapid și mai stabil. - Arhivează contactele pentru a discuta mai târziu. + Arhivează contactele pentru a conversa mai târziu. %1$s.]]> Ai partajat o cale de fișier nevalidă. Raportează problema dezvoltatorilor aplicației. Deschide în aplicația mobilă, apoi atinge Conectare în aplicație.]]> Actualizează adresa Configurați operatorii serverului Condițiile vor fi acceptate pentru operatorii activați după 30 de zile. - Faceți clic pe butonul de informații de lângă câmpul de adresă pentru a permite utilizarea microfonului. + Apasă pe butonul de informații de lângă bara de adrese pentru a permite accesul la microfon. Colţ Puteți salva arhiva exportată. Nu veți mai primi mesaje de la acest chat. Istoricul conversației va fi păstrat. @@ -2294,7 +2294,7 @@ Încercați să invitați persoana de contact cu care ați partajat un profil incognito în grupul în care utilizați profilul principal Poți partaja un link sau un cod QR - oricine se va putea alătura grupului. Nu vei pierde membrii grupului dacă îl ștergi ulterior. Blocați membrii pentru toți? - Chat + Conversație %s.]]> Condiții acceptate pe: %s. Condițiile vor fi acceptate automat pentru operatorii activați pe: %s. @@ -2304,7 +2304,7 @@ Condiții de utilizare Permite raportarea mesajelor către moderatori. Conversează cu membrii - Discută cu administratorii + Convorbire cu administratori Acceptă Acceptă membru Date de mesaj îmbunătățite. @@ -2312,14 +2312,14 @@ Confidențialitate și securitate îmbunătățite %s este inactiv]]> %s lipsește]]> - %1$s!]]> + %1$s!]]> Confirmat Erori de confirmare Vi se va solicita să vă autentificați când porniți sau reluați aplicația după 30 de secunde în fundal. - conecta la dezvoltatorii SimpleX Chat pentru a adresa orice întrebări și pentru a primi actualizări.]]> + să te conectezi la dezvoltatorii SimpleX Chat pentru a le adresa orice întrebări și pentru a primi actualizări.]]> Adaugă prieteni Adaugă membri echipei - Chatul există deja! + Conversația există deja! Deschide în aplicația mobilă.]]> Continuă %s a fost deconectat]]> @@ -2336,7 +2336,7 @@ Adaugă la listă Modificați lista invitație acceptată - Puteți să vă împărtășiți adresa ca link sau cod QR - oricine se poate conecta la dvs. + Îți poți partaja adresa sub formă de link sau cod QR - oricine se poate conecta cu tine. Acceptă Confidențialitatea ta Acceptă condițiile @@ -2350,10 +2350,10 @@ Dispozitive Xiaomi: vă rugăm să activați Pornirea automată în setările de sistem pentru ca notificările să funcționeze.]]> Arhivă Pictogramă de context - Puteți vizualiza rapoartele dvs. în Chat cu administratorii. - Schimbați ordinea - Vei fi conectat(ă) atunci când dispozitivul contactului tău este online, te rugăm să aștepți sau să verifici mai târziu! - scana codul QR în apelul video sau contactul dvs. poate partaja un link de invitație.]]> + Poți vizualiza rapoartele tale în conversația cu administratorii. + Schimbă ordinea + Te vei conecta atunci când dispozitivul contactului tău va fi online, te rugăm să aștepți sau să verifici mai târziu! + scana codul QR în timpul apelului video sau contactul tău poate partaja un link de invitație.]]> Profilul tău %1$s va fi distribuit. Nu îți vei pierde contactele dacă ulterior îți ștergi adresa. Securitatea conexiunii @@ -2361,7 +2361,7 @@ Toate serverele Profilul tău este stocat pe dispozitiv și este partajat doar cu contactele tale. Serverele SimpleX nu pot vedea profilul tău. Baza ta de date actuală de chat va fi ȘTERSĂ și ÎNLOCUITĂ cu cea importată.\nAceastă acțiune nu poate fi anulată - profilul, contactele, mesajele și fișierele tale vor fi pierdute iremediabil. - Discută cu administratorii + Convorbire cu administratori Poți partaja această adresă cu contactele tale pentru a le permite să se conecteze cu %s. Conversează cu membrul Cererea de conectare va fi trimisă acestui membru al grupului. @@ -2371,7 +2371,7 @@ (nou)]]> Serverul tău Trebuie să îi permiți contactului tău să te sune pentru a-l putea suna. - Adresa serverului dvs. + Adresa serverului tău Serverele tale Vei fi conectat la grup atunci când dispozitivul gazdei grupului va fi online, te rugăm să aștepți sau să verifici mai târziu! Tu decizi cine se poate conecta. @@ -2387,15 +2387,15 @@ Acceptă ca membru toți Afaceri - Prin utilizarea SimpleX Chat sunteți de acord să:\n- trimiteți doar conținut legal în grupurile publice.\n- respectați ceilalți utilizatori – fără spam. - doar cu un singur contact - partajează-l personal sau prin orice altă aplicație de mesagerie.]]> + Prin utilizarea SimpleX Chat ești de acord să:\n- trimiți doar conținut legal în grupurile publice.\n- respecți ceilalți utilizatori – fără spam. + doar cu un singur contact - partajează-l personal sau prin orice aplicație de mesagerie.]]> Nu trebuie să utilizați aceeași bază de date pe două dispozitive. arătați codul QR în apelul video sau distribuiți linkul.]]> Utilizare de pe desktop în aplicația mobilă și scanează codul QR.]]> SimpleX rulează în fundal în loc să utilizeze notificări push.]]> Utilizarea bateriei aplicației / Nerestricționat în setările aplicației.]]> Bucăți descărcate - Chatul va fi șters pentru toți membrii - această acțiune nu poate fi anulată! + Conversația va fi ștearsă pentru toți membrii - această acțiune nu poate fi anulată! Conexiunea nu este gata. Conținutul încalcă condițiile de utilizare Folosești un profil incognito pentru acest grup - pentru a preveni partajarea profilului principal, invitarea contactelor nu este permisă. @@ -2410,7 +2410,7 @@ Biografie prea mare Nu se poate modifica profilul Conversează cu administratorii - Conversează cu membrii înainte să se alăture + Conversează cu membrii înainte ca aceștia să se alăture. Conectează-te Conectează-te mai rapid! 🚀 contactul ar trebui să accepte… @@ -2422,7 +2422,7 @@ 4 noi limbi de interfață Catalană, Indoneziană, Română și Vietnameză - mulțumită utilizatorilor noștri! criptare end-to-end.]]> - Deschide conversație nouă + Deschide o conversație nouă Creează un grup nou Deschide pentru a accepta Deschide pentru conectare @@ -2481,7 +2481,7 @@ Grupul tău Doar tu poți trimite fișiere și conținut media. Doar contactele tale pot trimite fișiere și conținut media. - Deschide link securizat + Deschide linkul curat Respinge cererea de contact Timp expirare conexiune TCP în fundal Linkul va fi scurt, iar profilul grupului va fi partajat prin acesta. @@ -2495,4 +2495,7 @@ Atinge Alătură-te grupului Actualizează linkul grupului Profilul tău + Link de releu SimpleX + Grup + Eroare la marcarea conversației cu membrul ca fiind citită diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml index 7351687957..95e5ed9409 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml @@ -14,7 +14,7 @@ ошибка соединяется Установлено соединение с сервером, через который Вы получаете сообщения от этого контакта. - Устанавливается соединение с сервером, через который Вы получаете сообщения от этого контакта (ошибка: %1$s). + Устанавливается соединение с сервером, через который Вы получаете сообщения от этого контакта (ошибка: %1$s). Устанавливается соединение с сервером, через который Вы получаете сообщения от этого контакта. удалено @@ -42,7 +42,7 @@ SimpleX одноразовая ссылка SimpleX ссылка группы через %1$s - SimpleX ссылки + Ссылки SimpleX Описание Полная ссылка В браузере @@ -76,8 +76,8 @@ Ошибка удаления ожидаемого соединения Ошибка при изменении адреса Ошибка теста на шаге %s. - Сервер требует авторизации для создания очередей, проверьте пароль - Возможно, хэш сертификата в адресе сервера неверный + Сервер требует авторизации для создания очередей, проверьте пароль. + Хэш в адресе сервера не соответствует сертификату. Соединение Создание очереди Защита очереди @@ -207,7 +207,7 @@ В настоящее время максимальный поддерживаемый размер файла составляет %1$s. Ожидается приём файла Файл будет принят, когда Ваш контакт будет в сети, подождите или проверьте позже! - Файл сохранен + Файл сохранён Файл не найден Ошибка сохранения файла @@ -267,7 +267,7 @@ Нажмите кнопку сверху, затем: Чтобы соединиться через ссылку - Если Вы получили ссылку с приглашением из SimpleX Chat, Вы можете открыть ее в браузере: + Если Вы получили ссылку с приглашением из SimpleX Chat, Вы можете открыть её в браузере: Сканировать QR-код.]]> Open in mobile app на веб странице, затем нажмите Соединиться в приложении.]]> @@ -379,12 +379,12 @@ Используются серверы предоставленные SimpleX Chat. Инфо Как использовать серверы - Сохраненные WebRTC ICE серверы будут удалены. - Ваши ICE серверы - Настройка ICE серверов - ICE серверы (один на строке) - Ошибка при сохранении ICE серверов - Пожалуйста, проверьте, что адреса WebRTC ICE серверов имеют правильный формат, каждый адрес на отдельной строке и не повторяется. + Сохранённые WebRTC ICE-серверы будут удалены. + Ваши ICE-серверы + Настройка ICE-серверов + ICE-серверы (один на строке) + Ошибка при сохранении ICE-серверов + Пожалуйста, проверьте, что адреса WebRTC ICE-серверов имеют правильный формат, каждый адрес на отдельной строке и не повторяется. Сохранить Сеть и серверы Настройки сети @@ -412,7 +412,7 @@ Имя профиля: Полное имя: Ваш активный профиль - Ваш профиль хранится на Вашем устройстве и отправляется только Вашим контактам. SimpleX серверы не могут получить доступ к Вашему профилю. + Ваш профиль хранится на Вашем устройстве и отправляется только Вашим контактам. Серверы SimpleX не могут получить доступ к Вашему профилю. Поменять аватар Удалить аватар Сохранить предпочтения? @@ -503,8 +503,8 @@ Принимать Показывать Выключить - Ваши ICE серверы - WebRTC ICE серверы + Ваши ICE-серверы + WebRTC ICE-серверы Relay-сервер защищает Ваш IP-адрес, но может отслеживать продолжительность звонка. Relay-сервер используется только при необходимости. Другая сторона может видеть Ваш IP-адрес. @@ -633,22 +633,22 @@ Android Keystore используется для безопасного хранения пароля - это позволяет стабильно получать уведомления в фоновом режиме. База данных зашифрована случайным паролем, Вы можете его поменять. Внимание: Вы не сможете восстановить или поменять пароль, если потеряете его.]]> - Пароль базы данных будет безопасно сохранен в Android Keystore после запуска чата или изменения пароля - это позволит стабильно получать уведомления. - Пароль не сохранен на устройстве — Вы будете должны ввести его при каждом запуске чата. + Пароль базы данных будет безопасно сохранён в Android Keystore после запуска чата или изменения пароля - это позволит стабильно получать уведомления. + Пароль не сохранён на устройстве — Вы будете должны ввести его при каждом запуске чата. Зашифровать базу данных? Поменять пароль базы данных? База данных будет зашифрована. - База данных будет зашифрована и пароль сохранен в Keystore. - Пароль базы данных будет изменен и сохранен в Keystore. + База данных будет зашифрована и пароль сохранён в Keystore. + Пароль базы данных будет изменён и сохранён в Keystore. Пароль базы данных будет изменен. - Пожалуйста, надежно сохраните пароль, Вы НЕ сможете его поменять, если потеряете. - Пожалуйста, надежно сохраните пароль, Вы НЕ сможете открыть чат, если потеряете его. + Пожалуйста, надёжно сохраните пароль, Вы НЕ сможете его поменять, если потеряете. + Пожалуйста, надёжно сохраните пароль, Вы НЕ сможете открыть чат, если потеряете его. Неправильный пароль базы данных База данных зашифрована Ошибка базы данных Ошибка Keystore - Пароль базы данных отличается от сохраненного в Keystore. + Пароль базы данных отличается от сохранённого в Keystore. Файл: %s Введите пароль базы данных, чтобы открыть чат. Ошибка: %s @@ -682,7 +682,7 @@ Выйти Выйти из группы Вы перестанете получать сообщения от этой группы. История чата будет сохранена. - Пригласить членов группы + Пригласить в группу Группа неактивна Приглашение истекло! Приглашение в группу больше не действительно, оно было удалено отправителем. @@ -752,7 +752,7 @@ Нельзя пригласить контакт! Вы пытаетесь пригласить контакт, который знает Ваш профиль инкогнито, в группу, где Вы используете основной профиль. - Пригласить членов группы + Пригласить в группу %1$s ЧЛЕНОВ ГРУППЫ Вы: %1$s Удалить группу @@ -1433,8 +1433,8 @@ Использовать активный профиль Использовать новый профиль инкогнито Расход батареи приложением / Без ограничений в настройках приложения.]]> - База данных будет зашифрована, и пароль сохранен в настройках. - Шифруйте сохраненные файлы и медиа + База данных будет зашифрована, и пароль сохранён в настройках. + Шифруйте сохранённые файлы и медиа Обратите внимание: соединение с серверами файлов и сообщений устанавливаются через SOCKS-прокси. Звонки и картинки ссылок используют прямое соединение.]]> Шифровать локальные файлы Приложение для компьютера! @@ -1496,7 +1496,7 @@ Вступление в группу уже начато! %1$d сообщений отмодерировано членом %2$s %s был отключен]]> - Быстрое вступление и надежная доставка сообщений. + Быстрое вступление и надёжная доставка сообщений. Соединиться с самим собой? Связанные мобильные Компьютер @@ -1608,7 +1608,7 @@ Камера недоступна Код доступа в приложение Добавить контакт: создать новую ссылку-приглашение или подключиться через полученную ссылку.]]> - Чат остановлен. Если вы уже использовали эту базу данных на другом устройстве, перенесите ее обратно до запуска чата. + Чат остановлен. Если вы уже использовали эту базу данных на другом устройстве, перенесите её обратно до запуска чата. Соединение остановлено Создано Оставить @@ -1678,7 +1678,7 @@ Или отсканируйте QR-код Вы можете увидеть ссылку-приглашение снова открыв соединение. Показывать медленные вызовы API - Бывший член %1$s + Член группы %1$s Сохранённое сообщение неизвестно неизвестный статус @@ -1739,7 +1739,7 @@ Остановка чата Архивировать и загрузить Подтвердить загрузку - Все ваши контакты, разговоры и файлы будут надежно зашифрованы и загружены на выбранные XFTP-серверы. + Все ваши контакты, разговоры и файлы будут надёжно зашифрованы и загружены на выбранные XFTP-серверы. Ошибка загрузки Повторить загрузку Вы можете попробовать ещё раз. @@ -1753,7 +1753,7 @@ Внимание: запуск чата на нескольких устройствах не поддерживается и приведет к сбоям доставки сообщений. не должны использовать одну и ту же базу данных на двух устройствах.]]> Проверьте подключение к Интернету и повторите попытку - Подтвердите, что Вы помните пароль базы данных для ее миграции. + Подтвердите, что Вы помните пароль базы данных для её миграции. Проверка пароля базы данных Проверить пароль Ошибка подтверждения пароля: @@ -1801,7 +1801,7 @@ Наушники Громкоговоритель Звуки во время звонков - Более надежное соединение с сетью. + Более надёжное соединение с сетью. Статус сети сохранено сохранено из %s @@ -1815,7 +1815,7 @@ Ссылки SimpleX Разрешить отправлять ссылки SimpleX. Запретить отправку ссылок SimpleX - Члены могут отправлять ссылки SimpleX + Члены могут отправлять SimpleX ссылки. админы все члены владельцы @@ -2000,7 +2000,7 @@ Блоков удалено Блоков принято Подписок игнорировано - Ошибка копирования + Скопировать ошибку видеозвонок Контакт будет удален — это нельзя отменить! Оставить разговор @@ -2138,7 +2138,7 @@ Сохранение %1$s сообщений Убедитесь, что конфигурация прокси правильная. Аутентификация прокси - Использовать случайные учетные данные + Использовать случайные учётные данные Режим системы Ошибка пересылки сообщений %1$d ошибок файлов:\n%2$s @@ -2184,13 +2184,13 @@ Переключайте профиль чата для одноразовых приглашений. Аудит SimpleX протоколов от Trail of Bits. Чтобы совершать звонки, разрешите использовать микрофон. Завершите вызов и попробуйте позвонить снова. - Не использовать учетные данные с прокси. + Не использовать учётные данные с прокси. Ошибка сохранения прокси Пароль - Использовать разные учетные данные прокси для каждого соединения. - Использовать разные учетные данные прокси для каждого профиля. + Использовать разные учётные данные прокси для каждого соединения. + Использовать разные учётные данные прокси для каждого профиля. Имя пользователя - Ваши учетные данные могут быть отправлены в незашифрованном виде. + Ваши учётные данные могут быть отправлены в незашифрованном виде. Удалить архив? Загруженный архив базы данных будет навсегда удален с серверов. Принятые условия @@ -2348,7 +2348,7 @@ Все чаты будут удалены из списка %s, а сам список удален Добавить список Примечания - Открыто с %s + Открыть в %s Создать список Добавить в список Изменить список @@ -2464,7 +2464,7 @@ Частные разговоры, группы и Ваши контакты недоступны для операторов серверов. Настроить операторов серверов Политика конфиденциальности и условия использования. - все + всех Принять Член группы хочет присоединиться. Принять? группа удалена @@ -2479,7 +2479,7 @@ ожидает одобрения Отклонить Отклонить члена группы? - Ошибка при удалении чата с членом группы + Ошибка при удалении чата Полная ссылка Ошибка при вступлении члена группы Ссылка не поддерживается @@ -2492,7 +2492,7 @@ Одобрять членов группы Чаты с членами группы Приём членов в группу - Одобрять членов для вступления в группу. + Вручную одобрять членов для вступления в группу. Нет чатов с членами группы Принять как читателя Принять в группу @@ -2512,7 +2512,7 @@ Установить вступление в группу Удалить чат с членом группы? Удалить разговор - принят %1$s + принял %1$s Чат с админами Вы приняты 1 чат с членом группы @@ -2610,4 +2610,9 @@ Открыть очищенную ссылку Открыть полную ссылку Удалять параметры отслеживания + Ошибка прочтения чата + Хэш в адресе пересылающего сервера не соответствует сертификату: %1$s. + Хэш в адресе сервера не соответствует сертификату: %1$s. + Ссылка SimpleX relay + Хэш в адресе сервера назначения не соответствует сертификату: %1$s. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml index b4d854c3d1..411cbde4c4 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml @@ -1043,7 +1043,7 @@ คุณได้รับเชิญให้เข้าร่วมกลุ่ม โปรไฟล์แบบสุ่มของคุณ ธีม - กำลังพยายามเชื่อมต่อกับเซิร์ฟเวอร์ที่ใช้รับข้อความจากผู้ติดต่อนี้ (ข้อผิดพลาด: %1$s) + กำลังพยายามเชื่อมต่อกับเซิร์ฟเวอร์ที่ใช้รับข้อความจากผู้ติดต่อนี้ (ข้อผิดพลาด: %1$s) SimpleX คุณเชื่อมต่อกับเซิร์ฟเวอร์ที่ใช้รับข้อความจากผู้ติดต่อนี้ โปรไฟล์ของคุณจะถูกส่งไปยังผู้ติดต่อที่ส่งลิงก์นี้มาให้คุณ diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml index 43e67bb51b..501f07ea50 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml @@ -1023,7 +1023,7 @@ Sohbet profillerini parola ile koru! Daha az pil kullanımı Gizliliği korumak için, SimpleX her bir konuşma için farklı bir ID kullanır. - Bu kişiden mesaj almak için kullanılan sunucuya bağlanılmaya çalışılıyor (hata: %1$s). + Bu kişiden mesaj almak için kullanılan sunucuya bağlanılmaya çalışılıyor (hata: %1$s). Alıcılar güncellemeleri siz yazdıkça görürler. Bilgilerinizi kullanarak giriş yapın Mesajlarda Markdown @@ -2503,4 +2503,10 @@ Açıklama çok büyük Kaybolma süresi yalnızca yeni kişiler için ayarlanır. Gizli profil kullan + Botu kullanmak için açın + Botu kullanmak için Bağlan tuşuna basın. + Bot + Komutlar gönderebilmek için bağlanmanış olmanız gereklidir. + Üye silinmiş - isteği kabul edemeyecek + Grup linkini güncelle diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml index ddd54b717f..6d498ef4ed 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml @@ -124,7 +124,7 @@ помилка підключення Ви підключені до сервера для отримання повідомлень від цього контакту. - Спроба підключитися до сервера для отримання повідомлень від цього контакту (помилка: %1$s). + Спроба підключитися до сервера для отримання повідомлень від цього контакту (помилка: %1$s). видалено Спроба підключитися до сервера для отримання повідомлень від цього контакту. відзначено як видалено @@ -2512,4 +2512,23 @@ Ваш контакт Ваша група Ваш профіль + Дозволяйте файли та медіа лише за умови, що ваш контакт їх дозволяє. + Дозвольте своїм контактам надсилати файли та медіа. + Бот + Ви, і ваш контакт можете надсилати файли та медіа. + ЗАПИТИ НА ЗВ’ЯЗОК ВІД ГРУП + Застарілі опції + Помилка при відмітці як прочитане + Файли та медіа заборонені у цьому чаті. + Відбиток у адресі сервера переадресації не співпадає з сертифікатом: %1$s. + Відбиток у адресі сервера не співпадає з сертифікатом: %1$s. + Користувача видалено - не може прийняти запит. + Тільки ви можете надсилати файли та медіа. + Лише ваш контакт може надсилати файли та медіа. + Відкрити чисте посилання + Відкрити повне посилання + Відкрити для використання бота + Заборонити надсилання файлів і медіа. + Видалити відстеження посилань + запит на підключення до групи %1$s diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml index b71350ea50..ae22c40277 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml @@ -2034,7 +2034,7 @@ Bật Tổng không xác định - Đang cố gắng kết nối tới máy chủ dùng để nhận tin nhắn từ liên hệ này (lỗi: %1$s). + Đang cố gắng kết nối tới máy chủ dùng để nhận tin nhắn từ liên hệ này (lỗi: %1$s). Đang cố gắng kết nối tới máy chủ dùng để nhận tin nhắn từ liên hệ này. Để xác minh mã hóa đầu cuối với liên hệ của bạn, so sánh (hoặc quét) mã trên các thiết bị của các bạn. Cách ly truyền tải diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml index fe2dce437b..5f729d4ea3 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml @@ -575,7 +575,7 @@ 更新网络设置? 只有你可以不可逆地删除消息(你的联系人可以将它们标记为删除)。(24小时) 重新启动应用程序以创建新的聊天资料。 - 服务器需要授权才能创建队列,检查密码 + 服务器需要授权才能创建队列,检查密码。 测试在步骤 %s 失败。 你已经有一个显示名相同的聊天资料。请选择另一个名字。 已发送 @@ -589,8 +589,8 @@ 已删除 %1$s 你删除了 %1$s 你的个人资料将发送给你收到此链接的联系人。 - 正在尝试连接到用于从该联系人接收消息的服务器(错误:%1$s)。 - 你已连接到用于接收该联系人消息的服务器。 + 正在尝试连接到用于从该联系人接收消息的服务器(错误:%1$s)。 + 你已连接到用于接收该连接消息的服务器。 你分享了一次性链接 很可能此联系人已经删除了与你的联系。 资料图片占位符 @@ -609,7 +609,7 @@ 你当前的聊天数据库将被删除并替换为导入的数据库。 \n此操作无法撤消——你的个人资料、联系人、消息和文件将不可逆地丢失。 已邀请 %1$s 保存群资料 - 服务器地址中的证书指纹可能不正确 + 服务器地址指纹和证书不匹配。 请使用 %1$s 检查你的网络连接,然后重试。 多个聊天资料 数据库不能正常工作。点击了解更多 @@ -777,7 +777,7 @@ 给我们发电子邮件 SMP 服务器 尚不支持发送文件 - 正在尝试连接到用于从该联系人接收消息的服务器。 + 尝试连接到用于从该连接接收消息的服务器。 尚不支持接收文件 未知消息格式 测试服务器 @@ -1004,7 +1004,7 @@ 视频已发送 要求接收视频 视频将在你的联系人完成上传后收到。 - 服务器需要授权来上传,检查密码 + 服务器需要授权来上传,检查密码。 上传文件 XFTP 服务器 你的 XFTP 服务器 @@ -1557,7 +1557,7 @@ 显示名无效。请另选一个名称。 慢函数 显示缓慢的 API 调用 - 过往成员 %1$s + 成员 %1$s 未知 未知状态 开发者选项 @@ -2409,7 +2409,7 @@ 和一名成员的一个聊天 无法发送消息 你离开了 - 删除和成员的聊天出错 + 删除聊天出错 你无法发送消息! 禁用了联系人 群被删除了 @@ -2519,4 +2519,10 @@ 打开完整链接 删除链接跟踪 SimpleX 中继链接 + 标记为已读时出错 + 目标服务器地址的指纹和证书不匹配:%1$s。 + 转发服务器地址的指纹和证书不匹配:%1$s。 + 服务器地址证书和证书不匹配:%1$s。 + 无订阅 + 未连接到用于从该连接接收消息的服务器(无订阅)。 diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml index ee0c785e9f..f9a1a5b131 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml @@ -238,7 +238,7 @@ 錯誤 連接中 你已連接到此聯絡人使用的伺服器以接收訊息。 - 嘗試連接至用於接收此聯絡人訊息的伺服器 (錯誤:%1$s)。 + 嘗試連接至用於接收此聯絡人訊息的伺服器 (錯誤:%1$s)。 正在嘗試連接到用於接收此聯絡人訊息伺服器。 已刪除 已標記為已刪除 @@ -2125,4 +2125,7 @@ 簡化的匿名模式 點擊以連接 從桌面使用 + 開啓新聊天 + 接受聯絡請求 + 接受聯絡請求 diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/SimplexService.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/SimplexService.desktop.kt index a9d411916c..d29871f842 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/SimplexService.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/SimplexService.desktop.kt @@ -1,6 +1,3 @@ package chat.simplex.common.platform -actual fun getWakeLock(timeout: Long): (() -> Unit) { - return {} -} - +actual fun getWakeLock(timeout: Long): (() -> Unit) = {} diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index 02e2e78729..e02424822d 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -24,13 +24,13 @@ android.nonTransitiveRClass=true kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.jvm.target=11 -android.version_name=6.4.4-beta.0 -android.version_code=316 +android.version_name=6.5-beta.1 +android.version_code=327 android.bundle=false -desktop.version_name=6.4.4-beta.0 -desktop.version_code=118 +desktop.version_name=6.5-beta.1 +desktop.version_code=124 kotlin.version=2.1.20 gradle.plugin.version=8.7.0 diff --git a/apps/simplex-directory-service/Main.hs b/apps/simplex-directory-service/Main.hs index 2091ab444b..33145497ea 100644 --- a/apps/simplex-directory-service/Main.hs +++ b/apps/simplex-directory-service/Main.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} module Main where @@ -5,17 +6,22 @@ module Main where import Directory.Options import Directory.Service import Directory.Store -import Simplex.Chat.Controller (ChatConfig (..), ChatHooks (..), defaultChatHooks) -import Simplex.Chat.Core +import Directory.Store.Migrate import Simplex.Chat.Terminal (terminalChatConfig) main :: IO () main = do - opts@DirectoryOpts {directoryLog, runCLI} <- welcomeGetOpts - st <- restoreDirectoryStore directoryLog - if runCLI - then directoryServiceCLI st opts - else do - env <- newServiceState opts - let cfg = terminalChatConfig {chatHooks = defaultChatHooks {acceptMember = Just $ acceptMemberHook opts env}} - simplexChatCore cfg (mkChatOpts opts) $ directoryService st opts env + opts@DirectoryOpts {directoryLog, migrateDirectoryLog, runCLI} <- welcomeGetOpts + case migrateDirectoryLog of + Just cmd -> migrate cmd opts terminalChatConfig + Nothing -> do + st <- openDirectoryLog directoryLog + if runCLI + then directoryServiceCLI st opts + else directoryService st opts terminalChatConfig + where + migrate = \case + MLCheck -> checkDirectoryLog + MLImport -> importDirectoryLogToDB + MLExport -> exportDBToDirectoryLog + MLListing -> saveGroupListingFiles diff --git a/apps/simplex-directory-service/src/Directory/Events.hs b/apps/simplex-directory-service/src/Directory/Events.hs index 290742ce03..1f075c677c 100644 --- a/apps/simplex-directory-service/src/Directory/Events.hs +++ b/apps/simplex-directory-service/src/Directory/Events.hs @@ -66,7 +66,7 @@ crDirectoryEvent :: Either ChatError ChatEvent -> Maybe DirectoryEvent crDirectoryEvent = \case Right evt -> crDirectoryEvent_ evt Left e -> case e of - ChatErrorAgent {agentError = BROKER _ NETWORK} -> Nothing + ChatErrorAgent {agentError = BROKER _ (NETWORK _)} -> Nothing ChatErrorAgent {agentError = BROKER _ TIMEOUT} -> Nothing _ -> Just $ DELogChatResponse $ "chat error: " <> tshow e @@ -135,6 +135,7 @@ data DirectoryCmdTag (r :: DirectoryRole) where DCInviteOwnerToGroup_ :: DirectoryCmdTag 'DRAdmin -- DCAddBlockedWord_ :: DirectoryCmdTag 'DRAdmin -- DCRemoveBlockedWord_ :: DirectoryCmdTag 'DRAdmin + DCPromoteGroup_ :: DirectoryCmdTag 'DRSuperUser DCExecuteCommand_ :: DirectoryCmdTag 'DRSuperUser deriving instance Show (DirectoryCmdTag r) @@ -157,7 +158,7 @@ data DirectoryCmd (r :: DirectoryRole) where DCMemberRole :: UserGroupRegId -> Maybe GroupName -> Maybe GroupMemberRole -> DirectoryCmd 'DRUser DCGroupFilter :: UserGroupRegId -> Maybe GroupName -> Maybe DirectoryMemberAcceptance -> DirectoryCmd 'DRUser DCShowUpgradeGroupLink :: GroupId -> Maybe GroupName -> DirectoryCmd 'DRUser - DCApproveGroup :: {groupId :: GroupId, displayName :: GroupName, groupApprovalId :: GroupApprovalId} -> DirectoryCmd 'DRAdmin + DCApproveGroup :: {groupId :: GroupId, displayName :: GroupName, groupApprovalId :: GroupApprovalId, promote :: Maybe Bool} -> DirectoryCmd 'DRAdmin DCRejectGroup :: GroupId -> GroupName -> DirectoryCmd 'DRAdmin DCSuspendGroup :: GroupId -> GroupName -> DirectoryCmd 'DRAdmin DCResumeGroup :: GroupId -> GroupName -> DirectoryCmd 'DRAdmin @@ -167,6 +168,7 @@ data DirectoryCmd (r :: DirectoryRole) where DCInviteOwnerToGroup :: GroupId -> GroupName -> DirectoryCmd 'DRAdmin -- DCAddBlockedWord :: Text -> DirectoryCmd 'DRAdmin -- DCRemoveBlockedWord :: Text -> DirectoryCmd 'DRAdmin + DCPromoteGroup :: GroupId -> GroupName -> Bool -> DirectoryCmd 'DRSuperUser DCExecuteCommand :: String -> DirectoryCmd 'DRSuperUser DCUnknownCommand :: DirectoryCmd 'DRUser DCCommandError :: DirectoryCmdTag r -> DirectoryCmd r @@ -211,6 +213,7 @@ directoryCmdP = "invite" -> au DCInviteOwnerToGroup_ -- "block_word" -> au DCAddBlockedWord_ -- "unblock_word" -> au DCRemoveBlockedWord_ + "promote" -> su DCPromoteGroup_ "exec" -> su DCExecuteCommand_ "x" -> su DCExecuteCommand_ _ -> fail "bad command tag" @@ -270,7 +273,8 @@ directoryCmdP = DCApproveGroup_ -> do (groupId, displayName) <- gc (,) groupApprovalId <- A.space *> A.decimal - pure DCApproveGroup {groupId, displayName, groupApprovalId} + promote <- Just <$> (" promote=" *> onOffP) <|> pure Nothing + pure DCApproveGroup {groupId, displayName, groupApprovalId, promote} DCRejectGroup_ -> gc DCRejectGroup DCSuspendGroup_ -> gc DCSuspendGroup DCResumeGroup_ -> gc DCResumeGroup @@ -283,12 +287,17 @@ directoryCmdP = DCInviteOwnerToGroup_ -> gc DCInviteOwnerToGroup -- DCAddBlockedWord_ -> DCAddBlockedWord <$> wordP -- DCRemoveBlockedWord_ -> DCRemoveBlockedWord <$> wordP + DCPromoteGroup_ -> do + (groupId, displayName) <- gc (,) + promote <- A.space *> onOffP + pure $ DCPromoteGroup groupId displayName promote DCExecuteCommand_ -> DCExecuteCommand . T.unpack <$> (spacesP *> A.takeText) where gc f = f <$> (spacesP *> A.decimal) <*> (A.char ':' *> displayNameTextP) gc_ f = f <$> (spacesP *> A.decimal) <*> optional (A.char ':' *> displayNameTextP) -- wordP = spacesP *> A.takeTill isSpace spacesP = A.takeWhile1 isSpace + onOffP = (A.string "on" $> True) <|> (A.string "off" $> False) directoryCmdTag :: DirectoryCmd r -> Text directoryCmdTag = \case @@ -314,6 +323,7 @@ directoryCmdTag = \case DCInviteOwnerToGroup {} -> "invite" -- DCAddBlockedWord _ -> "block_word" -- DCRemoveBlockedWord _ -> "unblock_word" + DCPromoteGroup {} -> "promote" DCExecuteCommand _ -> "exec" DCUnknownCommand -> "unknown" DCCommandError _ -> "error" diff --git a/apps/simplex-directory-service/src/Directory/Listing.hs b/apps/simplex-directory-service/src/Directory/Listing.hs new file mode 100644 index 0000000000..0d4e8d351c --- /dev/null +++ b/apps/simplex-directory-service/src/Directory/Listing.hs @@ -0,0 +1,148 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE TupleSections #-} +{-# LANGUAGE TypeApplications #-} +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} + +module Directory.Listing where + +import Control.Applicative ((<|>)) +import Control.Monad +import Crypto.Hash (Digest, MD5) +import qualified Crypto.Hash as CH +import qualified Data.Aeson as J +import qualified Data.Aeson.TH as JQ +import qualified Data.ByteArray as BA +import Data.ByteString (ByteString) +import qualified Data.ByteString.Base64 as B64 +import qualified Data.ByteString.Base64.URL as B64URL +import qualified Data.ByteString.Char8 as B +import qualified Data.ByteString.Lazy as LB +import Data.Int (Int64) +import Data.List (isPrefixOf) +import Data.Maybe (catMaybes, fromMaybe) +import Data.Text (Text) +import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) +import Data.Time.Clock +import Data.Time.Clock.System +import Data.Time.Format.ISO8601 (iso8601Show) +import Directory.Store +import Simplex.Chat.Markdown +import Simplex.Chat.Types +import Simplex.Messaging.Agent.Protocol +import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, taggedObjectJSON) +import System.Directory +import System.FilePath + +directoryDataPath :: String +directoryDataPath = "data" + +listingFileName :: String +listingFileName = "listing.json" + +promotedFileName :: String +promotedFileName = "promoted.json" + +listingImageFolder :: String +listingImageFolder = "images" + +data DirectoryEntryType = DETGroup + { admission :: Maybe GroupMemberAdmission, + summary :: GroupSummary + } + +$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "DET") ''DirectoryEntryType) + +data DirectoryEntry = DirectoryEntry + { entryType :: DirectoryEntryType, + displayName :: Text, + groupLink :: CreatedLinkContact, + shortDescr :: Maybe MarkdownList, + welcomeMessage :: Maybe MarkdownList, + imageFile :: Maybe String, + activeAt :: Maybe UTCTime, + createdAt :: Maybe UTCTime + } + +$(JQ.deriveJSON defaultJSON ''DirectoryEntry) + +data DirectoryListing = DirectoryListing {entries :: [DirectoryEntry]} + +$(JQ.deriveJSON defaultJSON ''DirectoryListing) + +type ImageFileData = ByteString + +newOrActive :: NominalDiffTime +newOrActive = 30 * nominalDay + +recentRoundedTime :: Int64 -> UTCTime -> UTCTime -> Maybe UTCTime +recentRoundedTime roundTo now t + | diffUTCTime now t > newOrActive = Nothing + | otherwise = + let secs = (systemSeconds (utcToSystemTime t) `div` roundTo) * roundTo + in Just $ systemToUTCTime $ MkSystemTime secs 0 + +groupDirectoryEntry :: UTCTime -> GroupInfo -> Maybe GroupLink -> Maybe (DirectoryEntry, Maybe (FilePath, ImageFileData)) +groupDirectoryEntry now GroupInfo {groupProfile, chatTs, createdAt, groupSummary} gLink_ = + let GroupProfile {displayName, shortDescr, description, image, memberAdmission} = groupProfile + entryType = DETGroup memberAdmission groupSummary + entry groupLink = + let de = + DirectoryEntry + { entryType, + displayName, + groupLink, + shortDescr = toFormattedText <$> shortDescr, + welcomeMessage = toFormattedText <$> description, + imageFile = fst <$> imgData, + activeAt = recentRoundedTime 900 now $ fromMaybe createdAt chatTs, + createdAt = recentRoundedTime 86400 now createdAt + } + imgData = imgFileData groupLink =<< image + in (de, imgData) + in (entry . connLinkContact) <$> gLink_ + where + imgFileData :: CreatedConnLink 'CMContact -> ImageData -> Maybe (FilePath, ByteString) + imgFileData groupLink (ImageData img) = + let (img', imgExt) = + fromMaybe (img, ".jpg") $ + (,".jpg") <$> T.stripPrefix "data:image/jpg;base64," img + <|> (,".png") <$> T.stripPrefix "data:image/png;base64," img + imgName = B.unpack $ B64URL.encodeUnpadded $ BA.convert $ (CH.hash :: ByteString -> Digest MD5) $ strEncode (connFullLink groupLink) + imgFile = listingImageFolder imgName <> imgExt + in case B64.decode $ encodeUtf8 img' of + Right img'' -> Just (imgFile, img'') + Left _ -> Nothing + +generateListing :: FilePath -> [(GroupInfo, GroupReg, Maybe GroupLink)] -> IO () +generateListing dir gs = do + createDirectoryIfMissing True dir + oldDirs <- filter ((directoryDataPath <> ".") `isPrefixOf`) <$> listDirectory dir + ts <- getCurrentTime + let newDirPath = directoryDataPath <> "." <> iso8601Show ts <> "/" + newDir = dir newDirPath + createDirectoryIfMissing True (newDir listingImageFolder) + gs' <- + fmap catMaybes $ forM gs $ \(g, gr, link_) -> + forM (groupDirectoryEntry ts g link_) $ \(g', img) -> do + forM_ img $ \(imgFile, imgData) -> B.writeFile (newDir imgFile) imgData + pure (g', gr) + saveListing newDir listingFileName gs' + saveListing newDir promotedFileName $ filter (\(_, GroupReg {promoted}) -> promoted) gs' + -- atomically update the link + let newSymLink = newDir <> ".link" + symLink = dir directoryDataPath + createDirectoryLink newDirPath newSymLink + renamePath newSymLink symLink + mapM_ (removePathForcibly . (dir )) oldDirs + where + saveListing newDir f = LB.writeFile (newDir f) . J.encode . DirectoryListing . map fst + +toFormattedText :: Text -> MarkdownList +toFormattedText t = fromMaybe [FormattedText Nothing t] $ parseMaybeMarkdownList t diff --git a/apps/simplex-directory-service/src/Directory/Options.hs b/apps/simplex-directory-service/src/Directory/Options.hs index 2eba633609..fb82c27b78 100644 --- a/apps/simplex-directory-service/src/Directory/Options.hs +++ b/apps/simplex-directory-service/src/Directory/Options.hs @@ -7,16 +7,20 @@ module Directory.Options ( DirectoryOpts (..), + MigrateLog (..), getDirectoryOpts, mkChatOpts, ) where +import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) import Options.Applicative import Simplex.Chat.Bot.KnownContacts import Simplex.Chat.Controller (updateStr, versionNumber, versionString) import Simplex.Chat.Options (ChatCmdLog (..), ChatOpts (..), CoreChatOpts, CreateBotOpts (..), coreChatOptsP) +import Simplex.Messaging.Parsers (parseAll) data DirectoryOpts = DirectoryOpts { coreOptions :: CoreChatOpts, @@ -30,12 +34,16 @@ data DirectoryOpts = DirectoryOpts profileNameLimit :: Int, captchaGenerator :: Maybe FilePath, directoryLog :: Maybe FilePath, + migrateDirectoryLog :: Maybe MigrateLog, serviceName :: T.Text, runCLI :: Bool, searchResults :: Int, + webFolder :: Maybe FilePath, testing :: Bool } +data MigrateLog = MLCheck | MLImport | MLExport | MLListing + directoryOpts :: FilePath -> FilePath -> Parser DirectoryOpts directoryOpts appDir defaultDbName = do coreOptions <- coreChatOptsP appDir defaultDbName @@ -106,24 +114,39 @@ directoryOpts appDir defaultDbName = do <> help "Executable to generate captcha files, must accept text as parameter and save file to stdout as base64 up to 12500 bytes" ) directoryLog <- - Just - <$> strOption + optional $ + strOption ( long "directory-file" <> metavar "DIRECTORY_FILE" <> help "Append only log for directory state" ) + migrateDirectoryLog <- + optional $ + option + parseMigrateLog + ( long "migrate-directory-file" + <> metavar "MIGRATE_COMMAND" + <> help "Command to import/export directory log file" + ) serviceName <- strOption ( long "service-name" <> metavar "SERVICE_NAME" - <> help "The display name of the directory service bot, without *'s and spaces (SimpleX-Directory)" - <> value "SimpleX-Directory" + <> help "The display name of the directory service bot, without *'s and spaces (SimpleX Directory)" + <> value "SimpleX Directory" ) runCLI <- switch ( long "run-cli" <> help "Run directory service as CLI" ) + webFolder <- + optional $ + strOption + ( long "web-folder" + <> metavar "WEB_FOLDER" + <> help "Folder to store static web assets" + ) pure DirectoryOpts { coreOptions, @@ -137,9 +160,11 @@ directoryOpts appDir defaultDbName = do profileNameLimit, captchaGenerator, directoryLog, + migrateDirectoryLog, serviceName = T.pack serviceName, runCLI, searchResults = 10, + webFolder, testing = False } @@ -172,3 +197,14 @@ mkChatOpts DirectoryOpts {coreOptions, serviceName} = createBot = Just CreateBotOpts {botDisplayName = serviceName, allowFiles = False}, maintenance = False } + +parseMigrateLog :: ReadM MigrateLog +parseMigrateLog = eitherReader $ parseAll mlP . encodeUtf8 . T.pack + where + mlP = + A.takeTill (== ' ') >>= \case + "check" -> pure MLCheck + "import" -> pure MLImport + "export" -> pure MLExport + "listing" -> pure MLListing + _ -> fail "bad MigrateLog" diff --git a/apps/simplex-directory-service/src/Directory/Search.hs b/apps/simplex-directory-service/src/Directory/Search.hs index 5b0d650444..d71c128370 100644 --- a/apps/simplex-directory-service/src/Directory/Search.hs +++ b/apps/simplex-directory-service/src/Directory/Search.hs @@ -1,12 +1,5 @@ -{-# LANGUAGE DuplicateRecordFields #-} -{-# LANGUAGE NamedFieldPuns #-} - module Directory.Search where -import Data.List (sortOn) -import Data.Ord (Down (..)) -import Data.Set (Set) -import qualified Data.Set as S import Data.Text (Text) import Data.Time.Clock (UTCTime) import Simplex.Chat.Types @@ -14,19 +7,7 @@ import Simplex.Chat.Types data SearchRequest = SearchRequest { searchType :: SearchType, searchTime :: UTCTime, - sentGroups :: Set GroupId + lastGroup :: GroupId -- cursor for search } data SearchType = STAll | STRecent | STSearch Text - -takeTop :: Int -> [GroupInfoSummary] -> [GroupInfoSummary] -takeTop n = take n . sortOn (\(GIS _ GroupSummary {currentMembers}) -> Down currentMembers) - -takeRecent :: Int -> [GroupInfoSummary] -> [GroupInfoSummary] -takeRecent n = take n . sortOn (\(GIS GroupInfo {createdAt} _) -> Down createdAt) - -groupIds :: [GroupInfoSummary] -> Set GroupId -groupIds = S.fromList . map (\(GIS GroupInfo {groupId} _) -> groupId) - -filterNotSent :: Set GroupId -> [GroupInfoSummary] -> [GroupInfoSummary] -filterNotSent sentGroups = filter (\(GIS GroupInfo {groupId} _) -> groupId `S.notMember` sentGroups) diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index 1cbf419f41..5901e1d61a 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -8,29 +8,27 @@ {-# LANGUAGE OverloadedLists #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TupleSections #-} +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} module Directory.Service ( welcomeGetOpts, directoryService, directoryServiceCLI, - newServiceState, - acceptMemberHook ) where import Control.Concurrent (forkIO) -import Control.Concurrent.Async import Control.Concurrent.STM -import qualified Control.Exception as E import Control.Logger.Simple import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class +import Data.Bifunctor (first) import Data.List (find, intercalate) import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.Map.Strict as M -import Data.Maybe (fromMaybe, isJust, isNothing, maybeToList) -import Data.Set (Set) +import Data.Maybe (fromMaybe, isJust, isNothing) import qualified Data.Set as S import Data.Text (Text) import qualified Data.Text as T @@ -40,36 +38,37 @@ import Data.Time.LocalTime (getCurrentTimeZone) import Directory.BlockedWords import Directory.Captcha import Directory.Events +import Directory.Listing import Directory.Options import Directory.Search import Directory.Store +import Directory.Store.Migrate +import Directory.Util import Simplex.Chat.Bot import Simplex.Chat.Bot.KnownContacts import Simplex.Chat.Controller import Simplex.Chat.Core -import Simplex.Chat.Markdown (FormattedText (..), Format (..), parseMaybeMarkdownList, viewName) +import Simplex.Chat.Markdown (Format (..), FormattedText (..), parseMaybeMarkdownList, viewName) import Simplex.Chat.Messages import Simplex.Chat.Options import Simplex.Chat.Protocol (MsgContent (..)) -import Simplex.Chat.Store (GroupLink (..)) import Simplex.Chat.Store.Direct (getContact) -import Simplex.Chat.Store.Groups (getGroupInfo, getGroupLink, getGroupSummary, setGroupCustomData) +import Simplex.Chat.Store.Groups (getGroupLink, getGroupMember, setGroupCustomData) -- TODO remove setGroupCustomData import Simplex.Chat.Store.Profiles (GroupLinkInfo (..), getGroupLinkInfo) import Simplex.Chat.Store.Shared (StoreError (..)) import Simplex.Chat.Terminal (terminalChatConfig) import Simplex.Chat.Terminal.Main (simplexChatCLI') import Simplex.Chat.Types +import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.View (serializeChatError, serializeChatResponse, simplexChatContact, viewContactName, viewGroupName) -import Simplex.Messaging.Agent.Protocol (AConnectionLink (..), ConnectionLink (..), CreatedConnLink (..)) -import Simplex.Messaging.Agent.Store.Common (withTransaction) -import Simplex.Messaging.Agent.Protocol (SConnectionMode (..), sameConnReqContact, sameShortLinkContact) -import qualified Simplex.Messaging.Agent.Store.DB as DB +import Simplex.Messaging.Agent.Protocol (AConnectionLink (..), ConnectionLink (..), CreatedConnLink (..), SConnectionMode (..), sameConnReqContact, sameShortLinkContact) import Simplex.Messaging.Encoding.String import Simplex.Messaging.TMap (TMap) import qualified Simplex.Messaging.TMap as TM -import Simplex.Messaging.Util (safeDecodeUtf8, tshow, ($>>=), (<$$>)) +import Simplex.Messaging.Util (eitherToMaybe, raceAny_, safeDecodeUtf8, tshow, unlessM, (<$$>)) import System.Directory (getAppUserDataDirectory) +import System.Exit (exitFailure) import System.Process (readProcess) data GroupProfileUpdate @@ -94,7 +93,8 @@ data GroupRolesStatus data ServiceState = ServiceState { searchRequests :: TMap ContactId SearchRequest, blockedWordsCfg :: BlockedWordsConfig, - pendingCaptchas :: TMap GroupMemberId PendingCaptcha + pendingCaptchas :: TMap GroupMemberId PendingCaptcha, + updateListingsJob :: TMVar ChatController } data PendingCaptcha = PendingCaptcha @@ -117,7 +117,8 @@ newServiceState opts = do searchRequests <- TM.emptyIO blockedWordsCfg <- readBlockedWordsConfig opts pendingCaptchas <- TM.emptyIO - pure ServiceState {searchRequests, blockedWordsCfg, pendingCaptchas} + updateListingsJob <- newEmptyTMVarIO + pure ServiceState {searchRequests, blockedWordsCfg, pendingCaptchas, updateListingsJob} welcomeGetOpts :: IO DirectoryOpts welcomeGetOpts = do @@ -139,27 +140,99 @@ welcomeGetOpts = do knownContact KnownContact {contactId, localDisplayName = n} = knownName contactId n knownName i n = show i <> ":" <> T.unpack (viewName n) -directoryServiceCLI :: DirectoryStore -> DirectoryOpts -> IO () +directoryServiceCLI :: DirectoryLog -> DirectoryOpts -> IO () directoryServiceCLI st opts = do env <- newServiceState opts eventQ <- newTQueueIO let eventHook cc resp = atomically $ resp <$ writeTQueue eventQ (cc, resp) - chatHooks = defaultChatHooks {eventHook = Just eventHook, acceptMember = Just $ acceptMemberHook opts env} - race_ - (simplexChatCLI' terminalChatConfig {chatHooks} (mkChatOpts opts) Nothing) - (processEvents eventQ env) + chatHooks = + defaultChatHooks + { preStartHook = Just $ directoryPreStartHook opts, + postStartHook = Just $ directoryPostStartHook opts env, + eventHook = Just eventHook, + acceptMember = Just $ acceptMemberHook opts env + } + raceAny_ $ + [ simplexChatCLI' terminalChatConfig {chatHooks} (mkChatOpts opts) Nothing, + processEvents eventQ env + ] + <> updateListingsThread_ opts env where processEvents eventQ env = forever $ do (cc, resp) <- atomically $ readTQueue eventQ u_ <- readTVarIO (currentUser cc) forM_ u_ $ \user -> directoryServiceEvent st opts env user cc resp -directoryService :: DirectoryStore -> DirectoryOpts -> ServiceState -> User -> ChatController -> IO () -directoryService st opts@DirectoryOpts {testing} env user cc = do - initializeBotAddress' (not testing) cc - race_ (forever $ void getLine) . forever $ do - (_, resp) <- atomically . readTBQueue $ outputQ cc - directoryServiceEvent st opts env user cc resp +updateListingDelay :: Int +updateListingDelay = 5 * 60 * 1000000 -- update every 5 minutes + +updateListingsThread_ :: DirectoryOpts -> ServiceState -> [IO ()] +updateListingsThread_ opts env = maybe [] (\f -> [updateListingsThread f]) $ webFolder opts + where + updateListingsThread f = do + cc <- atomically $ takeTMVar $ updateListingsJob env + forever $ do + u <- readTVarIO $ currentUser cc + forM_ u $ \user -> updateGroupListingFiles cc user f + delay <- registerDelay updateListingDelay + atomically $ void (takeTMVar $ updateListingsJob env) `orElse` unlessM (readTVar delay) retry + +listingsUpdated :: ServiceState -> ChatController -> IO () +listingsUpdated env = void . atomically . tryPutTMVar (updateListingsJob env) + +directoryPreStartHook :: DirectoryOpts -> ChatController -> IO () +directoryPreStartHook opts ChatController {config, chatStore} = runDirectoryMigrations opts config chatStore + +directoryPostStartHook :: DirectoryOpts -> ServiceState -> ChatController -> IO () +directoryPostStartHook opts env cc = + readTVarIO (currentUser cc) >>= \case + Nothing -> putStrLn "No current user" >> exitFailure + Just User {userId, profile = p@LocalProfile {preferences}} -> do + listingsUpdated env cc + let cmds = fromMaybe [] $ preferences >>= commands_ + unless (cmds == directoryCommands) $ do + let prefs = (fromMaybe emptyChatPrefs preferences) {files = Just FilesPreference {allow = FANo}, commands = Just directoryCommands} :: Preferences + p' = (fromLocalProfile p) {displayName = serviceName opts, peerType = Just CPTBot, preferences = Just prefs} :: Profile + liftIO $ + sendChatCmd cc (APIUpdateProfile userId p') >>= \case + Right CRUserProfileUpdated {} -> putStrLn "Updated directory commands" + Right r -> putStrLn ("Error: unexpected response " <> show r) >> exitFailure + Left e -> putStrLn ("Error: " <> show e) >> exitFailure + +directoryCommands :: [ChatBotCommand] +directoryCommands = + [ CBCCommand "new" "New groups" Nothing, + CBCCommand "help" "How to submit your group" Nothing, + CBCCommand "list" "Your own groups" Nothing, + CBCMenu + "Group settings" + [ CBCCommand "role" "View new member role" idParam, + CBCCommand "filter" "Anti-spam filter" idParam, + CBCCommand "link" "View and upgrade group link" idParam, + CBCCommand "delete" "Remove a group from directory" (Just ":''") + ] + ] + where + idParam = Just "" + +directoryService :: DirectoryLog -> DirectoryOpts -> ChatConfig -> IO () +directoryService st opts@DirectoryOpts {testing} cfg = do + env <- newServiceState opts + let chatHooks = + defaultChatHooks + { preStartHook = Just $ directoryPreStartHook opts, + postStartHook = Just $ directoryPostStartHook opts env, + acceptMember = Just $ acceptMemberHook opts env + } + simplexChatCore cfg {chatHooks} (mkChatOpts opts) $ \user cc -> do + initializeBotAddress' (not testing) cc + raceAny_ $ + [ forever $ void getLine, + forever $ do + (_, resp) <- atomically . readTBQueue $ outputQ cc + directoryServiceEvent st opts env user cc resp + ] + <> updateListingsThread_ opts env acceptMemberHook :: DirectoryOpts -> ServiceState -> GroupInfo -> GroupLinkInfo -> Profile -> IO (Either GroupRejectionReason (GroupAcceptance, GroupMemberRole)) acceptMemberHook @@ -184,7 +257,7 @@ acceptMemberHook when (hasBlockedWords blockedWordsCfg displayName) $ throwError GRRBlockedName groupMemberAcceptance :: GroupInfo -> DirectoryMemberAcceptance -groupMemberAcceptance GroupInfo {customData} = memberAcceptance $ fromCustomData customData +groupMemberAcceptance GroupInfo {customData} = (\DirectoryGroupData {memberAcceptance = ma} -> ma) $ fromCustomData customData useMemberFilter :: Maybe ImageData -> Maybe ProfileCondition -> Bool useMemberFilter img_ = \case @@ -202,7 +275,7 @@ readBlockedWordsConfig DirectoryOpts {blockedFragmentsFile, blockedWordsFile, na unless testing $ putStrLn $ "Blocked fragments: " <> show (length blockedFragments) <> ", blocked words: " <> show (length blockedWords) <> ", spelling rules: " <> show (M.size spelling) pure BlockedWordsConfig {blockedFragments, blockedWords, extensionRules, spelling} -directoryServiceEvent :: DirectoryStore -> DirectoryOpts -> ServiceState -> User -> ChatController -> Either ChatError ChatEvent -> IO () +directoryServiceEvent :: DirectoryLog -> DirectoryOpts -> ServiceState -> User -> ChatController -> Either ChatError ChatEvent -> IO () directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName, ownersGroup, searchResults} env@ServiceState {searchRequests} user@User {userId} cc event = forM_ (crDirectoryEvent event) $ \case DEContactConnected ct -> deContactConnected ct @@ -234,12 +307,16 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName forM_ adminUsers $ \KnownContact {contactId} -> action contactId withSuperUsers action = void . forkIO $ forM_ superUsers $ \KnownContact {contactId} -> action contactId notifyAdminUsers s = withAdminUsers $ \contactId -> sendMessage' cc contactId s - notifyOwner GroupReg {dbContactId} = sendMessage' cc dbContactId + notifyOwner = sendMessage' cc . dbContactId ctId `isOwner` GroupReg {dbContactId} = ctId == dbContactId - withGroupReg GroupInfo {groupId, localDisplayName} err action = do - getGroupReg st groupId >>= \case - Just gr -> action gr - Nothing -> logError $ "Error: " <> err <> ", group: " <> localDisplayName <> ", can't find group registration ID " <> tshow groupId + withGroupReg :: GroupInfo -> Text -> (GroupReg -> IO ()) -> IO () + withGroupReg GroupInfo {groupId, localDisplayName} err action = + getGroupReg cc groupId >>= \case + Right gr -> action gr + Left e -> do + let msg = "Error: " <> err <> ", group: " <> tshow groupId <> " " <> localDisplayName <> ", " <> T.pack e + notifyAdminUsers msg + logError msg groupInfoText p@GroupProfile {description = d} = groupNameDescr p <> maybe "" ("\nWelcome message:\n" <>) d groupNameDescr GroupProfile {displayName = n, fullName = fn, shortDescr = sd_} = n <> maybe "" (\d' -> " (" <> d' <> ")") descr @@ -254,56 +331,41 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName groupAlreadyListed GroupInfo {groupProfile = p} = "The group " <> groupNameDescr p <> " is already listed in the directory, please choose another name." - getGroups :: Text -> IO (Maybe [GroupInfoSummary]) - getGroups = getGroups_ . Just - - getGroups_ :: Maybe Text -> IO (Maybe [GroupInfoSummary]) - getGroups_ search_ = - sendChatCmd cc (APIListGroups userId Nothing $ T.unpack <$> search_) >>= \case - Right CRGroupsList {groups} -> pure $ Just groups - _ -> pure Nothing - - getDuplicateGroup :: GroupInfo -> IO (Maybe DuplicateGroup) - getDuplicateGroup GroupInfo {groupId, groupProfile = GroupProfile {displayName, fullName}} = - getGroups fullName >>= mapM duplicateGroup + getDuplicateGroup :: GroupInfo -> IO (Either String DuplicateGroup) + getDuplicateGroup GroupInfo {groupId, groupProfile = GroupProfile {displayName}} = + duplicateGroup <$$> getDuplicateGroupRegs cc user displayName where - sameGroupNotRemoved (GIS g@GroupInfo {groupId = gId, groupProfile = GroupProfile {displayName = n, fullName = fn}} _) = - gId /= groupId && n == displayName && fn == fullName && not (memberRemoved $ membership g) - duplicateGroup [] = pure DGUnique - duplicateGroup groups = do - let gs = filter sameGroupNotRemoved groups - if null gs - then pure DGUnique - else do - (lgs, rgs) <- atomically $ (,) <$> readTVar (listedGroups st) <*> readTVar (reservedGroups st) - let reserved = any (\(GIS GroupInfo {groupId = gId} _) -> gId `S.member` lgs || gId `S.member` rgs) gs - if reserved - then pure DGReserved - else do - removed <- foldM (\r -> fmap (r &&) . isGroupRemoved) True gs - pure $ if removed then DGUnique else DGRegistered - isGroupRemoved (GIS GroupInfo {groupId = gId} _) = - getGroupReg st gId >>= \case - Just GroupReg {groupRegStatus} -> groupRemoved <$> readTVarIO groupRegStatus - Nothing -> pure True + duplicateGroup [] = DGUnique + duplicateGroup ((GroupInfo {groupId = gId, membership}, GroupReg {groupRegStatus = status}) : groups) + | gId == groupId || memberRemoved membership = duplicateGroup groups + | otherwise = case grDirectoryStatus status of + DSListed -> DGReserved + DSReserved -> DGReserved + DSRegistered -> case duplicateGroup groups of + DGReserved -> DGReserved + _ -> DGRegistered + DSRemoved -> duplicateGroup groups - processInvitation :: Contact -> GroupInfo -> IO () - processInvitation ct g@GroupInfo {groupId, groupProfile = GroupProfile {displayName}} = do - void $ addGroupReg st ct g GRSProposed - r <- sendChatCmd cc $ APIJoinGroup groupId MFNone - sendMessage cc ct $ case r of - Right CRUserAcceptedGroupSent {} -> "Joining the group " <> displayName <> "…" - _ -> "Error joining group " <> displayName <> ", please re-send the invitation!" + processInvitation :: Contact -> GroupInfo -> Maybe GroupReg -> IO () + processInvitation ct g@GroupInfo {groupId, groupProfile = GroupProfile {displayName}} = \case + Nothing -> addGroupReg notifyAdminUsers st cc ct g GRSProposed joinGroup + Just _gr -> setGroupStatus notifyAdminUsers st env cc groupId GRSProposed joinGroup + where + joinGroup _ = do + r <- sendChatCmd cc $ APIJoinGroup groupId MFNone + sendMessage cc ct $ case r of + Right CRUserAcceptedGroupSent {} -> "Joining the group " <> displayName <> "…" + _ -> "Error joining group " <> displayName <> ", please re-send the invitation!" deContactConnected :: Contact -> IO () deContactConnected ct = when (contactDirect ct) $ do logInfo $ (viewContactName ct) <> " connected" sendMessage cc ct $ - ("Welcome to " <> serviceName <> " service!\n") - <> "Send a search string to find groups or */help* to learn how to add groups to directory.\n\n\ - \For example, send _privacy_ to find groups about privacy.\n\ - \Or send */all* or */new* to list groups.\n\n\ - \Content and privacy policy: https://simplex.chat/docs/directory.html" + ("Welcome to " <> serviceName <> "!\n\n") + <> "🔍 Send search string to find groups - try _security_.\n\ + \/help - how to submit your group.\n\ + \/new - recent groups.\n\n\ + \[Directory rules](https://simplex.chat/docs/directory.html)." deGroupInvitation :: Contact -> GroupInfo -> GroupMemberRole -> GroupMemberRole -> IO () deGroupInvitation ct g@GroupInfo {groupProfile = p@GroupProfile {displayName}} fromMemberRole memberRole = do @@ -312,15 +374,15 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName Just msg -> sendMessage cc ct msg Nothing -> getDuplicateGroup g >>= \case - Just DGUnique -> processInvitation ct g - Just DGRegistered -> askConfirmation - Just DGReserved -> sendMessage cc ct $ groupAlreadyListed g - Nothing -> sendMessage cc ct "Error: getDuplicateGroup. Please notify the developers." + Right DGUnique -> processInvitation ct g Nothing + Right DGRegistered -> askConfirmation + Right DGReserved -> sendMessage cc ct $ groupAlreadyListed g + Left e -> sendMessage cc ct $ "Error: getDuplicateGroup. Please notify the developers.\n" <> T.pack e where - askConfirmation = do - ugrId <- addGroupReg st ct g GRSPendingConfirmation - sendMessage cc ct $ "The group " <> groupNameDescr p <> " is already submitted to the directory.\nTo confirm the registration, please send:" - sendMessage cc ct $ "/confirm " <> tshow ugrId <> ":" <> viewName displayName + askConfirmation = + addGroupReg notifyAdminUsers st cc ct g GRSPendingConfirmation $ \GroupReg {userGroupRegId} -> do + sendMessage cc ct $ "The group " <> groupNameDescr p <> " is already submitted to the directory.\nTo confirm the registration, please send:" + sendMessage cc ct $ "/confirm " <> tshow userGroupRegId <> ":" <> viewName displayName badRolesMsg :: GroupRolesStatus -> Maybe Text badRolesMsg = \case @@ -329,9 +391,9 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName GRSContactNotOwner -> Just "You must have a group *owner* role to register the group" GRSBadRoles -> Just "You must have a group *owner* role and you must grant directory service *admin* role to register the group" - getGroupRolesStatus :: GroupInfo -> GroupReg -> IO (Maybe GroupRolesStatus) - getGroupRolesStatus GroupInfo {membership = GroupMember {memberRole = serviceRole}} gr = - rStatus <$$> getGroupMember gr + getGroupRolesStatus :: GroupInfo -> GroupReg -> IO (Either String GroupRolesStatus) + getGroupRolesStatus GroupInfo {groupId, membership = GroupMember {memberRole = serviceRole}} gr = + rStatus <$$> getOwnerGroupMember groupId gr where rStatus GroupMember {memberRole} = groupRolesStatus memberRole serviceRole @@ -342,50 +404,52 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName (GROwner, _) -> GRSServiceNotAdmin _ -> GRSBadRoles - getGroupMember :: GroupReg -> IO (Maybe GroupMember) - getGroupMember GroupReg {dbGroupId, dbOwnerMemberId} = - readTVarIO dbOwnerMemberId - $>>= \mId -> resp <$> sendChatCmd cc (APIGroupMemberInfo dbGroupId mId) - where - resp = \case - Right CRGroupMemberInfo {member} -> Just member - _ -> Nothing + getOwnerGroupMember :: GroupId -> GroupReg -> IO (Either String GroupMember) + getOwnerGroupMember gId GroupReg {dbOwnerMemberId} = case dbOwnerMemberId of + Just mId -> withDB "getGroupMember" cc $ \db -> withExceptT show $ getGroupMember db (vr cc) user gId mId + Nothing -> pure $ Left "no owner member in group registration" deServiceJoinedGroup :: ContactId -> GroupInfo -> GroupMember -> IO () - deServiceJoinedGroup ctId g owner = do + deServiceJoinedGroup ctId g@GroupInfo {groupId} owner = do logInfo $ "service joined group " <> viewGroupName g withGroupReg g "joined group" $ \gr -> when (ctId `isOwner` gr) $ do - setGroupRegOwner st gr owner - let GroupInfo {groupId, groupProfile = GroupProfile {displayName}} = g - notifyOwner gr $ "Joined the group " <> displayName <> ", creating the link…" - sendChatCmd cc (APICreateGroupLink groupId GRMember) >>= \case - Right CRGroupLinkCreated {groupLink = GroupLink {connLinkContact = gLink}} -> do - setGroupStatus st gr GRSPendingUpdate - notifyOwner - gr - "Created the public link to join the group via this directory service that is always online.\n\n\ - \Please add it to the group welcome message.\n\ - \For example, add:" - notifyOwner gr $ "Link to join the group " <> displayName <> ": " <> groupLinkText gLink - Left (ChatError e) -> case e of - CEGroupUserRole {} -> notifyOwner gr "Failed creating group link, as service is no longer an admin." - CEGroupMemberUserRemoved -> notifyOwner gr "Failed creating group link, as service is removed from the group." - CEGroupNotJoined _ -> notifyOwner gr $ unexpectedError "group not joined" - CEGroupMemberNotActive -> notifyOwner gr $ unexpectedError "service membership is not active" - _ -> notifyOwner gr $ unexpectedError "can't create group link" - _ -> notifyOwner gr $ unexpectedError "can't create group link" + let GroupInfo {groupProfile = GroupProfile {displayName}} = g + setGroupRegOwner cc groupId owner >>= \case + Left e -> do + let msg = "Error updating group " <> tshow groupId <> " owner: " <> T.pack e + logError msg + notifyOwner gr msg + Right () -> do + logGUpdateOwner st groupId $ groupMemberId' owner + notifyOwner gr $ "Joined the group " <> displayName <> ", creating the link…" + sendChatCmd cc (APICreateGroupLink groupId GRMember) >>= \case + Right CRGroupLinkCreated {groupLink = GroupLink {connLinkContact = gLink}} -> + setGroupStatus notifyAdminUsers st env cc groupId GRSPendingUpdate $ \gr' -> do + notifyOwner + gr' + "Created the public link to join the group via this directory service that is always online.\n\n\ + \Please add it to the group welcome message.\n\ + \For example, add:" + notifyOwner gr' $ "Link to join the group " <> displayName <> ": " <> groupLinkText gLink + Left (ChatError e) -> case e of + CEGroupUserRole {} -> notifyOwner gr "Failed creating group link, as service is no longer an admin." + CEGroupMemberUserRemoved -> notifyOwner gr "Failed creating group link, as service is removed from the group." + CEGroupNotJoined _ -> notifyOwner gr $ unexpectedError "group not joined" + CEGroupMemberNotActive -> notifyOwner gr $ unexpectedError "service membership is not active" + _ -> notifyOwner gr $ unexpectedError "can't create group link" + _ -> notifyOwner gr $ unexpectedError "can't create group link" deGroupUpdated :: GroupMember -> GroupInfo -> GroupInfo -> IO () deGroupUpdated m@GroupMember {memberProfile = LocalProfile {displayName = mName}} fromGroup toGroup = do logInfo $ "group updated " <> viewGroupName toGroup unless (sameProfile p p') $ do - withGroupReg toGroup "group updated" $ \gr -> do + withGroupReg toGroup "group updated" $ \gr@GroupReg {groupRegStatus} -> do let userGroupRef = userGroupReference gr toGroup byMember = case memberContactId m of Just ctId | ctId `isOwner` gr -> "" -- group registration owner, not any group owner. _ -> " by " <> mName -- owner notification from directory will include the name. - readTVarIO (groupRegStatus gr) >>= \case + case groupRegStatus of GRSPendingConfirmation -> pure () GRSProposed -> pure () GRSPendingUpdate -> @@ -400,7 +464,9 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName GPServiceLinkError -> do notifyOwner gr $ ("Error: " <> serviceName <> " has no group link for " <> userGroupRef) - <> " after profile was updated" <> byMember <> ". Please report the error to the developers." + <> " after profile was updated" + <> byMember + <> ". Please report the error to the developers." logError $ "Error: no group link for " <> userGroupRef GRSPendingApproval n -> processProfileChange gr byMember False $ n + 1 GRSActive -> processProfileChange gr byMember True 1 @@ -414,35 +480,32 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName GroupProfile {displayName = n, fullName = fn, shortDescr = sd, image = i, description = d} GroupProfile {displayName = n', fullName = fn', shortDescr = sd', image = i', description = d'} = n == n' && fn == fn' && i == i' && sd == sd' && (T.words <$> d) == (T.words <$> d') - groupLinkAdded gr byMember = do + groupLinkAdded gr byMember = getDuplicateGroup toGroup >>= \case - Nothing -> notifyOwner gr "Error: getDuplicateGroup. Please notify the developers." - Just DGReserved -> notifyOwner gr $ groupAlreadyListed toGroup - _ -> do - let gaId = 1 - setGroupStatus st gr $ GRSPendingApproval gaId - notifyOwner gr $ - ("Thank you! The group link for " <> userGroupReference gr toGroup <> " is added to the welcome message" <> byMember) + Left e -> notifyOwner gr $ "Error: getDuplicateGroup. Please notify the developers.\n" <> T.pack e + Right DGReserved -> notifyOwner gr $ groupAlreadyListed toGroup + _ -> setGroupStatus notifyAdminUsers st env cc groupId (GRSPendingApproval gaId) $ \gr' -> do + notifyOwner gr' $ + ("Thank you! The group link for " <> userGroupReference gr' toGroup <> " is added to the welcome message" <> byMember) <> ".\nYou will be notified once the group is added to the directory - it may take up to 48 hours." - checkRolesSendToApprove gr gaId + checkRolesSendToApprove gr' gaId + where + gaId = 1 processProfileChange gr byMember isActive n' = do let userGroupRef = userGroupReference gr toGroup groupRef = groupReference toGroup groupProfileUpdate >>= \case - GPNoServiceLink -> do - setGroupStatus st gr GRSPendingUpdate - notifyOwner gr $ + GPNoServiceLink -> setGroupStatus notifyAdminUsers st env cc groupId GRSPendingUpdate $ \gr' -> do + notifyOwner gr' $ ("The group profile is updated for " <> userGroupRef <> byMember <> ", but no link is added to the welcome message.\n\n") <> "The group will remain hidden from the directory until the group link is added and the group is re-approved." - GPServiceLinkRemoved -> do - setGroupStatus st gr GRSPendingUpdate - notifyOwner gr $ + GPServiceLinkRemoved -> setGroupStatus notifyAdminUsers st env cc groupId GRSPendingUpdate $ \gr' -> do + notifyOwner gr' $ ("The group link for " <> userGroupRef <> " is removed from the welcome message" <> byMember) <> ".\n\nThe group is hidden from the directory until the group link is added and the group is re-approved." notifyAdminUsers $ "The group link is removed from " <> groupRef <> ", de-listed." - GPServiceLinkAdded _ -> do - setGroupStatus st gr $ GRSPendingApproval n' - notifyOwner gr $ + GPServiceLinkAdded _ -> setGroupStatus notifyAdminUsers st env cc groupId (GRSPendingApproval n') $ \gr' -> do + notifyOwner gr' $ ("The group link is added to " <> userGroupRef <> byMember) <> "!\nIt is hidden from the directory until approved." notifyAdminUsers $ "The group link is added to " <> groupRef <> byMember <> "." @@ -453,13 +516,12 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName ("The group " <> userGroupRef <> " is updated" <> byMember) <> "!\nThe group is listed in directory." notifyAdminUsers $ "The group " <> groupRef <> " is updated" <> byMember <> " - only link or whitespace changes.\nThe group remained listed in directory." - | otherwise -> do - setGroupStatus st gr $ GRSPendingApproval n' - notifyOwner gr $ + | otherwise -> setGroupStatus notifyAdminUsers st env cc groupId (GRSPendingApproval n') $ \gr' -> do + notifyOwner gr' $ ("The group " <> userGroupRef <> " is updated" <> byMember) <> "!\nIt is hidden from the directory until approved." notifyAdminUsers $ "The group " <> groupRef <> " is updated" <> byMember <> "." - checkRolesSendToApprove gr n' + checkRolesSendToApprove gr' n' where onlyLinkChanged GroupProfile {displayName = dn, fullName = fn, shortDescr = sd, image = i, description = d} @@ -487,12 +549,12 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName _ -> GPServiceLinkError checkRolesSendToApprove gr gaId = do (badRolesMsg <$$> getGroupRolesStatus toGroup gr) >>= \case - Nothing -> notifyOwner gr "Error: getGroupRolesStatus. Please notify the developers." - Just (Just msg) -> notifyOwner gr msg - Just Nothing -> sendToApprove toGroup gr gaId + Left e -> notifyOwner gr $ "Error: getGroupRolesStatus. Please notify the developers.\n" <> T.pack e + Right (Just msg) -> notifyOwner gr msg + Right Nothing -> sendToApprove toGroup gr gaId dePendingMember :: GroupInfo -> GroupMember -> IO () - dePendingMember g@GroupInfo {groupProfile = GroupProfile {displayName}} m + dePendingMember g@GroupInfo {groupProfile = GroupProfile {displayName}} m | memberRequiresCaptcha a m = sendMemberCaptcha g m Nothing captchaNotice 0 | otherwise = approvePendingMember a g m where @@ -522,7 +584,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName approvePendingMember :: DirectoryMemberAcceptance -> GroupInfo -> GroupMember -> IO () approvePendingMember a g@GroupInfo {groupId} m@GroupMember {memberProfile = LocalProfile {displayName, image}} = do - gli_ <- join <$> withDB' "getGroupLinkInfo" cc (\db -> getGroupLinkInfo db userId groupId) + gli_ <- join . eitherToMaybe <$> withDB' "getGroupLinkInfo" cc (\db -> getGroupLinkInfo db userId groupId) let role = if useMemberFilter image (makeObserver a) then GRObserver else maybe GRMember (\GroupLinkInfo {memberRole} -> memberRole) gli_ gmId = groupMemberId' m sendChatCmd cc (APIAcceptMember groupId gmId role) >>= \case @@ -569,37 +631,36 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName useMemberFilter image $ passCaptcha a sendToApprove :: GroupInfo -> GroupReg -> GroupApprovalId -> IO () - sendToApprove GroupInfo {groupProfile = p@GroupProfile {displayName, image = image'}} GroupReg {dbGroupId, dbContactId} gaId = do + sendToApprove GroupInfo {groupId, groupProfile = p@GroupProfile {displayName, image = image'}, groupSummary} GroupReg {dbContactId, promoted} gaId = do ct_ <- getContact' cc user dbContactId - gr_ <- getGroupAndSummary cc user dbGroupId - let membersStr = maybe "" (\(_, s) -> "_" <> tshow (currentMembers s) <> " members_\n") gr_ + let membersStr = "_" <> tshow (currentMembers groupSummary) <> " members_\n" text = - maybe ("The group ID " <> tshow dbGroupId <> " submitted: ") (\c -> localDisplayName' c <> " submitted the group ID " <> tshow dbGroupId <> ": ") ct_ + either (\_ -> "The group ID " <> tshow groupId <> " submitted: ") (\c -> localDisplayName' c <> " submitted the group ID " <> tshow groupId <> ": ") ct_ <> ("\n" <> groupInfoText p <> "\n" <> membersStr <> "\nTo approve send:") msg = maybe (MCText text) (\image -> MCImage {text, image}) image' withAdminUsers $ \cId -> do sendComposedMessage' cc cId Nothing msg - sendMessage' cc cId $ "/approve " <> tshow dbGroupId <> ":" <> viewName displayName <> " " <> tshow gaId + sendMessage' cc cId $ "/approve " <> tshow groupId <> ":" <> viewName displayName <> " " <> tshow gaId <> if promoted then " promote=on" else "" deContactRoleChanged :: GroupInfo -> ContactId -> GroupMemberRole -> IO () - deContactRoleChanged g@GroupInfo {membership = GroupMember {memberRole = serviceRole}} ctId contactRole = do + deContactRoleChanged g@GroupInfo {groupId, membership = GroupMember {memberRole = serviceRole}} ctId contactRole = do logInfo $ "contact ID " <> tshow ctId <> " role changed in group " <> viewGroupName g <> " to " <> tshow contactRole - withGroupReg g "contact role changed" $ \gr -> do + withGroupReg g "contact role changed" $ \gr@GroupReg {groupRegStatus} -> do let userGroupRef = userGroupReference gr g uCtRole = "Your role in the group " <> userGroupRef <> " is changed to " <> ctRole - when (ctId `isOwner` gr) $ do - readTVarIO (groupRegStatus gr) >>= \case - GRSSuspendedBadRoles -> when (rStatus == GRSOk) $ do - setGroupStatus st gr GRSActive - notifyOwner gr $ uCtRole <> ".\n\nThe group is listed in the directory again." - notifyAdminUsers $ "The group " <> groupRef <> " is listed " <> suCtRole - GRSPendingApproval gaId -> when (rStatus == GRSOk) $ do + when (ctId `isOwner` gr) $ + case groupRegStatus of + GRSSuspendedBadRoles | rStatus == GRSOk -> + setGroupStatus notifyAdminUsers st env cc groupId GRSActive $ \gr' -> do + notifyOwner gr' $ uCtRole <> ".\n\nThe group is listed in the directory again." + notifyAdminUsers $ "The group " <> groupRef <> " is listed " <> suCtRole + GRSPendingApproval gaId | rStatus == GRSOk -> do sendToApprove g gr gaId notifyOwner gr $ uCtRole <> ".\n\nThe group is submitted for approval." - GRSActive -> when (rStatus /= GRSOk) $ do - setGroupStatus st gr GRSSuspendedBadRoles - notifyOwner gr $ uCtRole <> ".\n\nThe group is no longer listed in the directory." - notifyAdminUsers $ "The group " <> groupRef <> " is de-listed " <> suCtRole + GRSActive | rStatus /= GRSOk -> + setGroupStatus notifyAdminUsers st env cc groupId GRSSuspendedBadRoles $ \gr' -> do + notifyOwner gr' $ uCtRole <> ".\n\nThe group is no longer listed in the directory." + notifyAdminUsers $ "The group " <> groupRef <> " is de-listed " <> suCtRole _ -> pure () where rStatus = groupRolesStatus contactRole serviceRole @@ -608,65 +669,64 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName suCtRole = "(user role is set to " <> ctRole <> ")." deServiceRoleChanged :: GroupInfo -> GroupMemberRole -> IO () - deServiceRoleChanged g serviceRole = do + deServiceRoleChanged g@GroupInfo {groupId} serviceRole = do logInfo $ "service role changed in group " <> viewGroupName g <> " to " <> tshow serviceRole - withGroupReg g "service role changed" $ \gr -> do + withGroupReg g "service role changed" $ \gr@GroupReg {groupRegStatus} -> do let userGroupRef = userGroupReference gr g uSrvRole = serviceName <> " role in the group " <> userGroupRef <> " is changed to " <> srvRole - readTVarIO (groupRegStatus gr) >>= \case - GRSSuspendedBadRoles -> when (serviceRole == GRAdmin) $ - whenContactIsOwner gr $ do - setGroupStatus st gr GRSActive - notifyOwner gr $ uSrvRole <> ".\n\nThe group is listed in the directory again." - notifyAdminUsers $ "The group " <> groupRef <> " is listed " <> suSrvRole - GRSPendingApproval gaId -> when (serviceRole == GRAdmin) $ + case groupRegStatus of + GRSSuspendedBadRoles | serviceRole == GRAdmin -> + whenContactIsOwner gr $ + setGroupStatus notifyAdminUsers st env cc groupId GRSActive $ \gr' -> do + notifyOwner gr' $ uSrvRole <> ".\n\nThe group is listed in the directory again." + notifyAdminUsers $ "The group " <> groupRef <> " is listed " <> suSrvRole + GRSPendingApproval gaId | serviceRole == GRAdmin -> whenContactIsOwner gr $ do sendToApprove g gr gaId notifyOwner gr $ uSrvRole <> ".\n\nThe group is submitted for approval." - GRSActive -> when (serviceRole /= GRAdmin) $ do - setGroupStatus st gr GRSSuspendedBadRoles - notifyOwner gr $ uSrvRole <> ".\n\nThe group is no longer listed in the directory." - notifyAdminUsers $ "The group " <> groupRef <> " is de-listed " <> suSrvRole + GRSActive | serviceRole /= GRAdmin -> + setGroupStatus notifyAdminUsers st env cc groupId GRSSuspendedBadRoles $ \gr' -> do + notifyOwner gr' $ uSrvRole <> ".\n\nThe group is no longer listed in the directory." + notifyAdminUsers $ "The group " <> groupRef <> " is de-listed " <> suSrvRole _ -> pure () where groupRef = groupReference g srvRole = "*" <> strEncodeTxt serviceRole <> "*" suSrvRole = "(" <> serviceName <> " role is changed to " <> srvRole <> ")." whenContactIsOwner gr action = - getGroupMember gr + getOwnerGroupMember groupId gr >>= mapM_ (\cm@GroupMember {memberRole} -> when (memberRole == GROwner && memberActive cm) action) deContactRemovedFromGroup :: ContactId -> GroupInfo -> IO () - deContactRemovedFromGroup ctId g = do + deContactRemovedFromGroup ctId g@GroupInfo {groupId} = do logInfo $ "contact ID " <> tshow ctId <> " removed from group " <> viewGroupName g withGroupReg g "contact removed" $ \gr -> do - when (ctId `isOwner` gr) $ do - setGroupStatus st gr GRSRemoved - notifyOwner gr $ "You are removed from the group " <> userGroupReference gr g <> ".\n\nThe group is no longer listed in the directory." - notifyAdminUsers $ "The group " <> groupReference g <> " is de-listed (group owner is removed)." + when (ctId `isOwner` gr) $ + setGroupStatus notifyAdminUsers st env cc groupId GRSRemoved $ \gr' -> do + notifyOwner gr' $ "You are removed from the group " <> userGroupReference gr' g <> ".\n\nThe group is no longer listed in the directory." + notifyAdminUsers $ "The group " <> groupReference g <> " is de-listed (group owner is removed)." deContactLeftGroup :: ContactId -> GroupInfo -> IO () - deContactLeftGroup ctId g = do + deContactLeftGroup ctId g@GroupInfo {groupId} = do logInfo $ "contact ID " <> tshow ctId <> " left group " <> viewGroupName g - withGroupReg g "contact left" $ \gr -> do - when (ctId `isOwner` gr) $ do - setGroupStatus st gr GRSRemoved - notifyOwner gr $ "You left the group " <> userGroupReference gr g <> ".\n\nThe group is no longer listed in the directory." - notifyAdminUsers $ "The group " <> groupReference g <> " is de-listed (group owner left)." + -- TODO combine + withGroupReg g "contact left" $ \gr -> + when (ctId `isOwner` gr) $ + setGroupStatus notifyAdminUsers st env cc groupId GRSRemoved $ \gr' -> do + notifyOwner gr' $ "You left the group " <> userGroupReference gr g <> ".\n\nThe group is no longer listed in the directory." + notifyAdminUsers $ "The group " <> groupReference g <> " is de-listed (group owner left)." deServiceRemovedFromGroup :: GroupInfo -> IO () - deServiceRemovedFromGroup g = do + deServiceRemovedFromGroup g@GroupInfo {groupId} = do logInfo $ "service removed from group " <> viewGroupName g - withGroupReg g "service removed" $ \gr -> do - setGroupStatus st gr GRSRemoved + setGroupStatus notifyAdminUsers st env cc groupId GRSRemoved $ \gr -> do notifyOwner gr $ serviceName <> " is removed from the group " <> userGroupReference gr g <> ".\n\nThe group is no longer listed in the directory." notifyAdminUsers $ "The group " <> groupReference g <> " is de-listed (directory service is removed)." deGroupDeleted :: GroupInfo -> IO () - deGroupDeleted g = do + deGroupDeleted g@GroupInfo {groupId} = do logInfo $ "group removed " <> viewGroupName g - withGroupReg g "group removed" $ \gr -> do - setGroupStatus st gr GRSRemoved + setGroupStatus notifyAdminUsers st env cc groupId GRSRemoved $ \gr -> do notifyOwner gr $ "The group " <> userGroupReference gr g <> " is deleted.\n\nThe group is no longer listed in the directory." notifyAdminUsers $ "The group " <> groupReference g <> " is de-listed (group is deleted)." @@ -674,76 +734,76 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName deUserCommand ct ciId = \case DCHelp DHSRegistration -> sendMessage cc ct $ - "You must be the owner to add the group to the directory:\n\ - \1. Invite " + "You must be the group owner to add it to the directory:\n\n\ + \1️⃣ *Invite* " <> serviceName - <> " bot to your group as *admin* (you can send `/list` to see all groups you submitted).\n\ - \2. " - <> serviceName - <> " bot will create a public group link for the new members to join even when you are offline.\n\ - \3. You will then need to add this link to the group welcome message.\n\ - \4. Once the link is added, service admins will approve the group (it can take up to 48 hours), and everybody will be able to find it in directory.\n\n\ - \Start from inviting the bot to your group as admin - it will guide you through the process." + <> " bot to your group as *admin* - it will create a link for new members to join.\n\ + \2️⃣ *Add* this link to the group's welcome message.\n\ + \3️⃣ We *review* your group. Once *approved*, anybody can find it.\n\n\ + \_We usually approve within a day, except holidays_. [More details](https://simplex.chat/docs/directory.html#adding-groups-to-the-directory)." DCHelp DHSCommands -> sendMessage cc ct $ - "*/help commands* - receive this help message.\n\ - \*/help* - how to register your group to be added to directory.\n\ - \*/list* - list the groups you registered.\n\ - \*/delete :* - remove the group you submitted from directory, with _ID_ and _name_ as shown by */list* command.\n\ - \*/role * - view and set default member role for your group.\n\ - \*/filter * - view and set spam filter settings for group.\n\n\ + "/'help commands' - receive this help message.\n\ + \/help - how to register your group to be added to directory.\n\ + \/list - list the groups you registered.\n\ + \`/role ` - view and set default member role for your group.\n\ + \`/filter ` - view and set spam filter settings for group.\n\ + \`/link ` - view and upgrade group link.\n\ + \`/delete :` - remove the group you submitted from directory, with _ID_ and _name_ as shown by /list command.\n\n\ \To search for groups, send the search text." - DCSearchGroup s -> withFoundListedGroups (Just s) $ sendSearchResults s + DCSearchGroup s -> + sendFoundListedGroups (STSearch s) Nothing "No groups found" $ \gs n -> -- $ sendSearchResults s + let more = if n > length gs then ", sending top " <> tshow (length gs) else "" + in "Found " <> tshow n <> " group(s)" <> more <> "." DCSearchNext -> atomically (TM.lookup (contactId' ct) searchRequests) >>= \case - Just search@SearchRequest {searchType, searchTime} -> do + Just SearchRequest {searchType, searchTime, lastGroup} -> do currentTime <- getCurrentTime if diffUTCTime currentTime searchTime > 300 -- 5 minutes then do atomically $ TM.delete (contactId' ct) searchRequests showAllGroups - else case searchType of - STSearch s -> withFoundListedGroups (Just s) $ sendNextSearchResults takeTop search - STAll -> withFoundListedGroups Nothing $ sendNextSearchResults takeTop search - STRecent -> withFoundListedGroups Nothing $ sendNextSearchResults takeRecent search + else + sendFoundListedGroups searchType (Just lastGroup) "No more groups" $ \gs _ -> + "Sending " <> tshow (length gs) <> " more group(s)." Nothing -> showAllGroups where showAllGroups = deUserCommand ct ciId DCAllGroups - DCAllGroups -> withFoundListedGroups Nothing $ sendAllGroups takeTop "top" STAll - DCRecentGroups -> withFoundListedGroups Nothing $ sendAllGroups takeRecent "the most recent" STRecent + DCAllGroups -> sendFoundListedGroups STAll Nothing "No groups listed" $ allGroupsReply "top" + DCRecentGroups -> sendFoundListedGroups STRecent Nothing "No groups listed" $ allGroupsReply "the most recent" DCSubmitGroup _link -> pure () DCConfirmDuplicateGroup ugrId gName -> - withUserGroupReg ugrId gName $ \g@GroupInfo {groupProfile = GroupProfile {displayName}} gr -> - readTVarIO (groupRegStatus gr) >>= \case - GRSPendingConfirmation -> - getDuplicateGroup g >>= \case - Nothing -> sendMessage cc ct "Error: getDuplicateGroup. Please notify the developers." - Just DGReserved -> sendMessage cc ct $ groupAlreadyListed g - _ -> processInvitation ct g - _ -> sendReply $ "Error: the group ID " <> tshow ugrId <> " (" <> displayName <> ") is not pending confirmation." + withUserGroupReg ugrId gName $ \g@GroupInfo {groupProfile = GroupProfile {displayName}} gr@GroupReg {groupRegStatus} -> case groupRegStatus of + GRSPendingConfirmation -> + getDuplicateGroup g >>= \case + Left e -> sendMessage cc ct $ "Error: getDuplicateGroup. Please notify the developers.\n" <> T.pack e + Right DGReserved -> sendMessage cc ct $ groupAlreadyListed g + _ -> processInvitation ct g $ Just gr + _ -> sendReply $ "Error: the group ID " <> tshow ugrId <> " (" <> displayName <> ") is not pending confirmation." DCListUserGroups -> - getUserGroupRegs st (contactId' ct) >>= \grs -> do - sendReply $ tshow (length grs) <> " registered group(s)" - void . forkIO $ forM_ (reverse grs) $ \gr@GroupReg {dbGroupId, userGroupRegId} -> - let useGroupId = if isAdmin then dbGroupId else userGroupRegId - in sendGroupInfo ct gr useGroupId Nothing + getUserGroupRegs cc user (contactId' ct) >>= \case + Left e -> sendReply $ "Error reading groups: " <> T.pack e + Right gs -> sendGroupsInfo ct ciId isAdmin (gs, length gs) DCDeleteGroup gId gName -> - (if isAdmin then withGroupAndReg sendReply else withUserGroupReg) gId gName $ \GroupInfo {groupProfile = GroupProfile {displayName}} gr -> do - delGroupReg st gr - sendReply $ (if isAdmin then "The group " else "Your group ") <> displayName <> " is deleted from the directory" + (if isAdmin then withGroupAndReg sendReply else withUserGroupReg) gId gName $ \GroupInfo {groupProfile = GroupProfile {displayName}} GroupReg {dbGroupId} -> do + delGroupReg cc dbGroupId >>= \case + Right () -> do + logGDelete st dbGroupId + sendReply $ (if isAdmin then "The group " else "Your group ") <> displayName <> " is deleted from the directory" + Left e -> sendReply $ "Error deleting group " <> displayName <> ": " <> T.pack e DCMemberRole gId gName_ mRole_ -> (if isAdmin then withGroupAndReg_ sendReply else withUserGroupReg_) gId gName_ $ \g _gr -> do let GroupInfo {groupProfile = GroupProfile {displayName = n}} = g case mRole_ of Nothing -> getGroupLink' cc user g >>= \case - Just GroupLink {connLinkContact = gLink, acceptMemberRole} -> do + Right GroupLink {connLinkContact = gLink, acceptMemberRole} -> do let anotherRole = case acceptMemberRole of GRObserver -> GRMember; _ -> GRObserver sendReply $ initialRole n acceptMemberRole - <> ("Send */role " <> tshow gId <> " " <> strEncodeTxt anotherRole <> "* to change it.\n\n") + <> ("Send /'role " <> tshow gId <> " " <> strEncodeTxt anotherRole <> "' to change it.\n\n") <> onlyViaLink gLink - Nothing -> sendReply $ "Error: failed reading the initial member role for the group " <> n + Left _ -> sendReply $ "Error: failed reading the initial member role for the group " <> n Just mRole -> do setGroupLinkRole cc g mRole >>= \case Just gLink -> sendReply $ initialRole n mRole <> "\n" <> onlyViaLink gLink @@ -759,22 +819,25 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName Just a' | a /= a' -> do let d = toCustomData $ DirectoryGroupData a' withDB' "setGroupCustomData" cc (\db -> setGroupCustomData db user g $ Just d) >>= \case - Just () -> sendSettigns n a' " set to" - Nothing -> sendReply $ "Error changing spam filter settings for group " <> n + Right () -> sendSettigns n a' " set to" + Left e -> sendReply $ "Error changing spam filter settings for group " <> n <> ": " <> T.pack e _ -> sendSettigns n a "" where sendSettigns n a setTo = sendReply $ - T.unlines + T.unlines $ [ "Spam filter settings for group " <> n <> setTo <> ":", "- reject long/inappropriate names: " <> showCondition (rejectNames a), "- pass captcha to join: " <> showCondition (passCaptcha a), -- "- make observer: " <> showCondition (makeObserver a) <> (if isJust (makeObserver a) then "" else " (use default set with /role command)"), - "", + "" -- "Use */filter " <> tshow gId <> " * to change spam filter level: no (disable), basic, moderate, strong.", -- "Or use */filter " <> tshow gId <> " [name[=noimage]] [captcha[=noimage]] [observer[=noimage]]* for advanced filter configuration." - "Use */filter " <> tshow gId <> " [name] [captcha]* to enable and */filter " <> tshow gId <> " off* to disable filter." ] + <> ["/'filter " <> tshow gId <> " name' - enable name filter" | isNothing (rejectNames a)] + <> ["/'filter " <> tshow gId <> " captcha' - enable captcha challenge" | isNothing (passCaptcha a)] + <> ["/'filter " <> tshow gId <> " name captcha' - enable both" | isNothing (rejectNames a) || isNothing (passCaptcha a)] + <> ["/'filter " <> tshow gId <> " off' - disable filter" | isJust (rejectNames a) || isJust (passCaptcha a)] showCondition = \case Nothing -> "_disabled_" Just PCAll -> "_enabled_" @@ -785,12 +848,13 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName withGroupLinkResult groupRef (sendChatCmd cc $ APIGetGroupLink groupId) $ \GroupLink {connLinkContact = gLink@(CCLink _ sLnk_), acceptMemberRole, shortLinkDataSet, shortLinkLargeDataSet = BoolDef slLargeDataSet} -> do let shouldBeUpgraded = isNothing sLnk_ || not shortLinkDataSet || not slLargeDataSet - sendReply $ T.unlines $ - [ "The link to join the group " <> groupRef <> ":", - groupLinkText gLink, - "New member role: " <> strEncodeTxt acceptMemberRole - ] - <> ["The link is being upgraded..." | shouldBeUpgraded] + sendReply $ + T.unlines $ + [ "The link to join the group " <> groupRef <> ":", + groupLinkText gLink, + "New member role: " <> strEncodeTxt acceptMemberRole + ] + <> ["The link is being upgraded..." | shouldBeUpgraded] when shouldBeUpgraded $ do let send = sendComposedMessage cc ct Nothing . MCText . T.unlines withGroupLinkResult groupRef (sendChatCmd cc $ APIAddGroupShortLink groupId) $ @@ -798,13 +862,16 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName (Just _, Just _) -> send ["The group link is upgraded for: " <> groupRef, "No changes to group needed."] (Nothing, Just sLnk) -> - sendComposedMessages cc (SRDirect $ contactId' ct) - [ MCText $ T.unlines - [ "Please replace the old link in welcome message of your group " <> groupRef, - "If this is the only change, the group will remain listed in directory without re-approval.", - "", - "The new link:" - ], + sendComposedMessages + cc + (SRDirect $ contactId' ct) + [ MCText $ + T.unlines + [ "Please replace the old link in welcome message of your group " <> groupRef, + "If this is the only change, the group will remain listed in directory without re-approval.", + "", + "The new link:" + ], MCText $ strEncodeTxt sLnk ] (_, Nothing) -> @@ -830,88 +897,68 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName isAdmin = knownCt `elem` adminUsers || knownCt `elem` superUsers withUserGroupReg ugrId = withUserGroupReg_ ugrId . Just withUserGroupReg_ ugrId gName_ action = - getUserGroupReg st (contactId' ct) ugrId >>= \case - Nothing -> sendReply $ "Group ID " <> tshow ugrId <> " not found" - Just gr@GroupReg {dbGroupId} -> do - getGroup cc user dbGroupId >>= \case - Nothing -> sendReply $ "Group ID " <> tshow ugrId <> " not found" - Just g@GroupInfo {groupProfile = GroupProfile {displayName}} - | maybe True (displayName ==) gName_ -> action g gr - | otherwise -> sendReply $ "Group ID " <> tshow ugrId <> " has the display name " <> displayName + getUserGroupReg cc user (contactId' ct) ugrId >>= \case + -- TODO differentiate group not found error + Left e -> sendReply $ "Group ID " <> tshow ugrId <> " error:" <> T.pack e + Right (g@GroupInfo {groupProfile = GroupProfile {displayName}}, gr) + | maybe True (displayName ==) gName_ -> action g gr + | otherwise -> sendReply $ "Group ID " <> tshow ugrId <> " has the display name " <> displayName sendReply = mkSendReply ct ciId - withFoundListedGroups s_ action = - getGroups_ s_ >>= \case - Just groups -> filterListedGroups st groups >>= action - Nothing -> sendReply "Error: getGroups. Please notify the developers." - sendSearchResults s = \case - [] -> sendReply "No groups found" - gs -> do - let gs' = takeTop searchResults gs - moreGroups = length gs - length gs' - more = if moreGroups > 0 then ", sending top " <> tshow (length gs') else "" - reply = "Found " <> tshow (length gs) <> " group(s)" <> more <> "." - updateSearchRequest (STSearch s) $ groupIds gs' - sendFoundGroups reply gs' moreGroups - sendAllGroups takeFirst sortName searchType = \case - [] -> sendReply "No groups listed" - gs -> do - let gs' = takeFirst searchResults gs - moreGroups = length gs - length gs' - more = if moreGroups > 0 then ", sending " <> sortName <> " " <> tshow (length gs') else "" - reply = tshow (length gs) <> " group(s) listed" <> more <> "." - updateSearchRequest searchType $ groupIds gs' - sendFoundGroups reply gs' moreGroups - sendNextSearchResults takeFirst SearchRequest {searchType, sentGroups} = \case - [] -> do - sendReply "Sorry, no more groups" - atomically $ TM.delete (contactId' ct) searchRequests - gs -> do - let gs' = takeFirst searchResults $ filterNotSent sentGroups gs - sentGroups' = sentGroups <> groupIds gs' - moreGroups = length gs - S.size sentGroups' - reply = "Sending " <> tshow (length gs') <> " more group(s)." - updateSearchRequest searchType sentGroups' - sendFoundGroups reply gs' moreGroups - updateSearchRequest :: SearchType -> Set GroupId -> IO () - updateSearchRequest searchType sentGroups = do + sendFoundListedGroups searchType lastGroup_ notFound replyStr = + searchListedGroups cc user searchType lastGroup_ searchResults >>= \case + Right ([], _) -> do + atomically $ TM.delete (contactId' ct) searchRequests + sendReply notFound + Right (gs, n) -> do + let moreGroups = n - length gs + updateSearchRequest searchType $ last gs + sendFoundGroups (replyStr gs n) gs moreGroups + Left e -> sendReply $ "Error: searchListedGroups. Please notify the developers.\n" <> T.pack e + allGroupsReply sortName gs n = + let more = if n > length gs then ", sending " <> sortName <> " " <> tshow (length gs) else "" + in tshow n <> " group(s) listed" <> more <> "." + updateSearchRequest :: SearchType -> (GroupInfo, GroupReg) -> IO () + updateSearchRequest searchType (GroupInfo {groupId}, _) = do searchTime <- getCurrentTime - let search = SearchRequest {searchType, searchTime, sentGroups} + let search = SearchRequest {searchType, searchTime, lastGroup = groupId} atomically $ TM.insert (contactId' ct) search searchRequests sendFoundGroups reply gs moreGroups = void . forkIO $ sendComposedMessages_ cc (SRDirect $ contactId' ct) msgs where msgs = replyMsg :| map foundGroup gs <> [moreMsg | moreGroups > 0] replyMsg = (Just ciId, MCText reply) - foundGroup (GIS GroupInfo {groupId, groupProfile = p@GroupProfile {image = image_}} GroupSummary {currentMembers}) = + foundGroup (GroupInfo {groupId, groupProfile = p@GroupProfile {image = image_}, groupSummary = GroupSummary {currentMembers}}, _) = let membersStr = "_" <> tshow currentMembers <> " members_" showId = if isAdmin then tshow groupId <> ". " else "" text = showId <> groupInfoText p <> "\n" <> membersStr in (Nothing, maybe (MCText text) (\image -> MCImage {text, image}) image_) - moreMsg = (Nothing, MCText $ "Send */next* or just *.* for " <> tshow moreGroups <> " more result(s).") + moreMsg = (Nothing, MCText $ "Send /next for " <> tshow moreGroups <> " more result(s).") deAdminCommand :: Contact -> ChatItemId -> DirectoryCmd 'DRAdmin -> IO () deAdminCommand ct ciId cmd | knownCt `elem` adminUsers || knownCt `elem` superUsers = case cmd of - DCApproveGroup {groupId, displayName = n, groupApprovalId} -> - withGroupAndReg sendReply groupId n $ \g gr@GroupReg {userGroupRegId = ugrId} -> - readTVarIO (groupRegStatus gr) >>= \case + DCApproveGroup {groupId, displayName = n, groupApprovalId, promote} -> + withGroupAndReg sendReply groupId n $ \g gr@GroupReg {userGroupRegId = ugrId, promoted} -> + case groupRegStatus gr of GRSPendingApproval gaId - | gaId == groupApprovalId -> do + | gaId == groupApprovalId -> getDuplicateGroup g >>= \case - Nothing -> sendReply "Error: getDuplicateGroup. Please notify the developers." - Just DGReserved -> sendReply $ "The group " <> groupRef <> " is already listed in the directory." - _ -> do - getGroupRolesStatus g gr >>= \case - Just GRSOk -> do - setGroupStatus st gr GRSActive + Left e -> sendReply $ "Error: getDuplicateGroup. Please notify the developers.\n" <> T.pack e + Right DGReserved -> sendReply $ "The group " <> groupRef <> " is already listed in the directory." + _ -> getGroupRolesStatus g gr >>= \case + Right GRSOk -> do + let grPromoted' + | promoted || knownCt `elem` superUsers = fromMaybe promoted promote + | otherwise = False + setGroupStatusPromo sendReply st env cc gr GRSActive grPromoted' $ do let approved = "The group " <> userGroupReference' gr n <> " is approved" notifyOwner gr $ (approved <> " and listed in directory - please moderate it!\n") - <> "Please note: if you change the group profile it will be hidden from directory until it is re-approved.\n\n" + <> "_Please note_: if you change the group profile it will be hidden from directory until it is re-approved.\n\n" <> "Supported commands:\n" - <> ("- */filter " <> tshow ugrId <> "* - to configure anti-spam filter.\n") - <> ("- */role " <> tshow ugrId <> "* - to set default member role.\n") - <> "- */help commands* - other commands." + <> ("/'filter " <> tshow ugrId <> "' - to configure anti-spam filter.\n") + <> ("/'role " <> tshow ugrId <> "' - to set default member role.\n") + <> ("/'link " <> tshow ugrId <> "' - to view/upgrade group link.") invited <- forM ownersGroup $ \og@KnownGroup {localDisplayName = ogName} -> do inviteToOwnersGroup og gr $ \case @@ -919,12 +966,12 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName owner <- groupOwnerInfo groupRef $ dbContactId gr pure $ "Invited " <> owner <> " to owners' group " <> viewName ogName Left err -> pure err - sendReply $ "Group approved!" <> maybe "" ("\n" <>) invited - notifyOtherSuperUsers $ approved <> " by " <> viewName (localDisplayName' ct) <> fromMaybe "" invited - Just GRSServiceNotAdmin -> replyNotApproved serviceNotAdmin - Just GRSContactNotOwner -> replyNotApproved "user is not an owner." - Just GRSBadRoles -> replyNotApproved $ "user is not an owner, " <> serviceNotAdmin - Nothing -> sendReply "Error: getGroupRolesStatus. Please notify the developers." + sendReply $ "Group approved" <> (if grPromoted' then " (promoted)" else "") <> "!" <> maybe "" ("\n" <>) invited + notifyOtherSuperUsers $ approved <> " by " <> viewName (localDisplayName' ct) <> maybe "" ("\n" <>) invited + Right GRSServiceNotAdmin -> replyNotApproved serviceNotAdmin + Right GRSContactNotOwner -> replyNotApproved "user is not an owner." + Right GRSBadRoles -> replyNotApproved $ "user is not an owner, " <> serviceNotAdmin + Left e -> sendReply $ "Error: getGroupRolesStatus. Please notify the developers.\n" <> T.pack e where replyNotApproved reason = sendReply $ "Group is not approved: " <> reason serviceNotAdmin = serviceName <> " is not an admin." @@ -936,32 +983,36 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName DCSuspendGroup groupId gName -> do let groupRef = groupReference' groupId gName withGroupAndReg sendReply groupId gName $ \_ gr -> - readTVarIO (groupRegStatus gr) >>= \case - GRSActive -> do - setGroupStatus st gr GRSSuspended + case groupRegStatus gr of + GRSActive -> setGroupStatus sendReply st env cc groupId GRSSuspended $ \gr' -> do let suspended = "The group " <> userGroupReference' gr gName <> " is suspended" - notifyOwner gr $ suspended <> " and hidden from directory. Please contact the administrators." + notifyOwner gr' $ suspended <> " and hidden from directory. Please contact the administrators." sendReply "Group suspended!" notifyOtherSuperUsers $ suspended <> " by " <> viewName (localDisplayName' ct) _ -> sendReply $ "The group " <> groupRef <> " is not active, can't be suspended." DCResumeGroup groupId gName -> do let groupRef = groupReference' groupId gName withGroupAndReg sendReply groupId gName $ \_ gr -> - readTVarIO (groupRegStatus gr) >>= \case - GRSSuspended -> do - setGroupStatus st gr GRSActive + case groupRegStatus gr of + GRSSuspended -> setGroupStatus sendReply st env cc groupId GRSActive $ \gr' -> do let groupStr = "The group " <> userGroupReference' gr gName - notifyOwner gr $ groupStr <> " is listed in the directory again!" + notifyOwner gr' $ groupStr <> " is listed in the directory again!" sendReply "Group listing resumed!" notifyOtherSuperUsers $ groupStr <> " listing resumed by " <> viewName (localDisplayName' ct) _ -> sendReply $ "The group " <> groupRef <> " is not suspended, can't be resumed." - DCListLastGroups count -> listGroups count False - DCListPendingGroups count -> listGroups count True + DCListLastGroups count -> + listLastGroups cc user count >>= \case + Left e -> sendReply $ "Error reading groups: " <> T.pack e + Right gs -> sendGroupsInfo ct ciId True $ first reverse gs + DCListPendingGroups count -> + listPendingGroups cc user count >>= \case + Left e -> sendReply $ "Error reading groups: " <> T.pack e + Right gs -> sendGroupsInfo ct ciId True $ first reverse gs DCSendToGroupOwner groupId gName msg -> do let groupRef = groupReference' groupId gName - withGroupAndReg sendReply groupId gName $ \_ gr@GroupReg {dbContactId} -> do + withGroupAndReg sendReply groupId gName $ \_ gr@GroupReg {dbContactId = ctId} -> do notifyOwner gr msg - owner <- groupOwnerInfo groupRef dbContactId + owner <- groupOwnerInfo groupRef ctId sendReply $ "Forwarded to " <> owner DCInviteOwnerToGroup groupId gName -> case ownersGroup of Just og@KnownGroup {localDisplayName = ogName} -> @@ -970,7 +1021,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName Right () -> do let groupRef = groupReference' groupId gName owner <- groupOwnerInfo groupRef ctId - let invited = " invited " <> owner <> " to owners' group " <> viewName ogName + let invited = " invited " <> owner <> " to owners' group " <> viewName ogName notifyOtherSuperUsers $ viewName (localDisplayName' ct) <> invited sendReply $ "you" <> invited Left err -> sendReply err @@ -983,17 +1034,6 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName knownCt = knownContact ct sendReply = mkSendReply ct ciId notifyOtherSuperUsers s = withSuperUsers $ \ctId -> unless (ctId == contactId' ct) $ sendMessage' cc ctId s - listGroups count pending = - readTVarIO (groupRegs st) >>= \groups -> do - grs <- - if pending - then filterM (fmap pendingApproval . readTVarIO . groupRegStatus) groups - else pure groups - sendReply $ tshow (length grs) <> " registered group(s)" <> (if length grs > count then ", showing the last " <> tshow count else "") - void . forkIO $ forM_ (reverse $ take count grs) $ \gr@GroupReg {dbGroupId, dbContactId} -> do - ct_ <- getContact' cc user dbContactId - let ownerStr = "Owner: " <> maybe "getContact error" localDisplayName' ct_ - sendGroupInfo ct gr dbGroupId $ Just ownerStr inviteToOwnersGroup :: KnownGroup -> GroupReg -> (Either Text () -> IO a) -> IO a inviteToOwnersGroup KnownGroup {groupId = ogId} GroupReg {dbContactId = ctId} cont = sendChatCmd cc (APIListMembers ogId) >>= \case @@ -1007,7 +1047,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName r -> contErr r r -> contErr r where - alreadyMember = isJust . find ((Just ctId == ) . memberContactId) + alreadyMember = isJust . find ((Just ctId ==) . memberContactId) contErr r = do let err = "error inviting contact ID " <> tshow ctId <> " to owners' group: " <> tshow r putStrLn $ T.unpack err @@ -1016,11 +1056,17 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName owner_ <- getContact' cc user dbContactId let ownerInfo = "the owner of the group " <> groupRef ownerName ct' = "@" <> viewName (localDisplayName' ct') <> ", " - pure $ maybe "" ownerName owner_ <> ownerInfo + pure $ either (const "") ownerName owner_ <> ownerInfo deSuperUserCommand :: Contact -> ChatItemId -> DirectoryCmd 'DRSuperUser -> IO () deSuperUserCommand ct ciId cmd | knownContact ct `elem` superUsers = case cmd of + DCPromoteGroup groupId gName promote' -> + withGroupAndReg sendReply groupId gName $ \_ gr@GroupReg {groupRegStatus, promoted} -> do + let notify = sendReply $ "Group promotion " <> (if promote' then "enabled" <> (if groupRegStatus == GRSActive then "." else ", but the group is not listed.") else "disabled.") + if promote' /= promoted + then setGroupPromoted sendReply st env cc gr promote' notify + else notify DCExecuteCommand cmdStr -> sendChatCmdStr cc cmdStr >>= \case Right r -> do @@ -1045,57 +1091,90 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName withGroupAndReg_ :: (Text -> IO ()) -> GroupId -> Maybe GroupName -> (GroupInfo -> GroupReg -> IO ()) -> IO () withGroupAndReg_ sendReply gId gName_ action = - getGroup cc user gId >>= \case - Nothing -> sendReply $ "Group ID " <> tshow gId <> " not found (getGroup)" - Just g@GroupInfo {groupProfile = GroupProfile {displayName}} + getGroupAndReg cc user gId >>= \case + Left e -> sendReply $ "Group " <> tshow gId <> " error (getGroup): " <> T.pack e + Right (g@GroupInfo {groupProfile = GroupProfile {displayName}}, gr) | maybe False (displayName ==) gName_ -> - getGroupReg st gId >>= \case - Nothing -> sendReply $ "Registration for group ID " <> tshow gId <> " not found (getGroupReg)" - Just gr -> action g gr + action g gr | otherwise -> sendReply $ "Group ID " <> tshow gId <> " has the display name " <> displayName - sendGroupInfo :: Contact -> GroupReg -> GroupId -> Maybe Text -> IO () - sendGroupInfo ct gr@GroupReg {dbGroupId} useGroupId ownerStr_ = do - grStatus <- readTVarIO $ groupRegStatus gr - let statusStr = "Status: " <> groupRegStatusText grStatus - getGroupAndSummary cc user dbGroupId >>= \case - Just (GroupInfo {groupProfile = p@GroupProfile {image = image_}}, GroupSummary {currentMembers}) -> do - let membersStr = "_" <> tshow currentMembers <> " members_" - text = T.unlines $ [tshow useGroupId <> ". " <> groupInfoText p] <> maybeToList ownerStr_ <> [membersStr, statusStr] + getOwnersInfo :: [(GroupInfo, GroupReg)] -> IO [((GroupInfo, GroupReg), Maybe (Either String Contact))] + getOwnersInfo gs = + fmap (either (\e -> map (,Just (Left e)) gs) id) $ withDB' "getOwnersInfo" cc $ \db -> + mapM (\g@(_, gr) -> fmap ((g,) . Just . first show) $ runExceptT $ getContact db (vr cc) user $ dbContactId gr) gs + + sendGroupsInfo :: Contact -> ChatItemId -> Bool -> ([(GroupInfo, GroupReg)], Int) -> IO () + sendGroupsInfo ct ciId isAdmin (gs, n) = do + let more = if n > length gs then ", showing the last " <> tshow (length gs) else "" + replyMsg = (Just ciId, MCText $ tshow n <> " registered group(s)" <> more) + gs' <- if isAdmin then getOwnersInfo gs else pure $ map (,Nothing) gs + sendComposedMessages_ cc (SRDirect $ contactId' ct) $ replyMsg :| map groupMessage gs' + where + groupMessage ((g, gr), ct_) = + let GroupInfo {groupId, groupProfile = p@GroupProfile {image = image_}, groupSummary} = g + GroupReg {userGroupRegId, groupRegStatus} = gr + useGroupId = if isAdmin then groupId else userGroupRegId + statusStr = "Status: " <> groupRegStatusText groupRegStatus + membersStr = "_" <> tshow (currentMembers groupSummary) <> " members_" + cmds = "/'role " <> tshow useGroupId <> "', /'filter " <> tshow useGroupId <> "'" + ownerStr = maybe "" (("Owner: " <>) . either (("getContact error: " <>) . T.pack) localDisplayName') ct_ + text = T.unlines $ [tshow useGroupId <> ". " <> groupInfoText p] ++ [ownerStr | isAdmin] ++ [membersStr, statusStr, cmds] msg = maybe (MCText text) (\image -> MCImage {text, image}) image_ - sendComposedMessage cc ct Nothing msg - Nothing -> do - let text = T.unlines $ [tshow useGroupId <> ". Error: getGroup. Please notify the developers."] <> maybeToList ownerStr_ <> [statusStr] - sendComposedMessage cc ct Nothing $ MCText text + in (Nothing, msg) -getContact' :: ChatController -> User -> ContactId -> IO (Maybe Contact) -getContact' cc user ctId = withDB "getContact" cc $ \db -> getContact db (vr cc) user ctId +setGroupStatusPromo :: (Text -> IO ()) -> DirectoryLog -> ServiceState -> ChatController -> GroupReg -> GroupRegStatus -> Bool -> IO () -> IO () +setGroupStatusPromo sendReply st env cc GroupReg {dbGroupId = gId} grStatus' grPromoted' continue = do + let status' = grDirectoryStatus grStatus' + setGroupStatusPromoStore cc gId grStatus' grPromoted' >>= \case + Left e -> sendReply $ "Error updating group " <> tshow gId <> " status: " <> T.pack e + Right (status, grPromoted) -> do + when ((status == DSListed || status' == DSListed) && (status /= status' || grPromoted /= grPromoted')) $ + listingsUpdated env cc + logGUpdateStatus st gId grStatus' + logGUpdatePromotion st gId grPromoted' + continue -getGroup :: ChatController -> User -> GroupId -> IO (Maybe GroupInfo) -getGroup cc user gId = withDB "getGroupInfo" cc $ \db -> getGroupInfo db (vr cc) user gId +addGroupReg :: (Text -> IO ()) -> DirectoryLog -> ChatController -> Contact -> GroupInfo -> GroupRegStatus -> (GroupReg -> IO ()) -> IO () +addGroupReg sendMsg st cc ct g@GroupInfo {groupId} grStatus continue = + addGroupRegStore cc ct g grStatus >>= \case + Left e -> sendMsg $ "Error creating group registation for group " <> tshow groupId <> ": " <> T.pack e + Right gr -> do + logGCreate st gr + continue gr -withDB' :: Text -> ChatController -> (DB.Connection -> IO a) -> IO (Maybe a) -withDB' cxt cc a = withDB cxt cc $ ExceptT . fmap Right . a +setGroupStatus :: (Text -> IO ()) -> DirectoryLog -> ServiceState -> ChatController -> GroupId -> GroupRegStatus -> (GroupReg -> IO ()) -> IO () +setGroupStatus sendMsg st env cc gId grStatus' continue = do + let status' = grDirectoryStatus grStatus' + setGroupStatusStore cc gId grStatus' >>= \case + Left e -> sendMsg $ "Error updating group " <> tshow gId <> " status: " <> T.pack e + Right (grStatus, gr) -> do + let status = grDirectoryStatus grStatus + when ((status == DSListed || status' == DSListed) && status /= status') $ listingsUpdated env cc + logGUpdateStatus st gId grStatus' + continue gr -withDB :: Text -> ChatController -> (DB.Connection -> ExceptT StoreError IO a) -> IO (Maybe a) -withDB cxt ChatController {chatStore} action = do - r_ :: Either ChatError a <- withTransaction chatStore (runExceptT . withExceptT ChatErrorStore . action) `E.catches` handleDBErrors - case r_ of - Right r -> pure $ Just r - Left e -> Nothing <$ logError ("Database error: " <> cxt <> " " <> tshow e) +setGroupPromoted :: (Text -> IO ()) -> DirectoryLog -> ServiceState -> ChatController -> GroupReg -> Bool -> IO () -> IO () +setGroupPromoted sendReply st env cc GroupReg {dbGroupId = gId} grPromoted' continue = + setGroupPromotedStore cc gId grPromoted' >>= \case + Left e -> sendReply $ "Error updating group " <> tshow gId <> " status: " <> T.pack e + Right (status, grPromoted) -> do + when (status == DSListed && grPromoted' /= grPromoted) $ listingsUpdated env cc + logGUpdatePromotion st gId grPromoted' + continue -getGroupAndSummary :: ChatController -> User -> GroupId -> IO (Maybe (GroupInfo, GroupSummary)) -getGroupAndSummary cc user gId = - withDB "getGroupAndSummary" cc $ \db -> (,) <$> getGroupInfo db (vr cc) user gId <*> liftIO (getGroupSummary db user gId) +updateGroupListingFiles :: ChatController -> User -> FilePath -> IO () +updateGroupListingFiles cc u dir = + getAllListedGroups cc u >>= \case + Right gs -> generateListing dir gs + Left e -> logError $ "generateListing error: failed to read groups: " <> T.pack e -vr :: ChatController -> VersionRangeChat -vr ChatController {config = ChatConfig {chatVRange}} = chatVRange -{-# INLINE vr #-} +getContact' :: ChatController -> User -> ContactId -> IO (Either String Contact) +getContact' cc user ctId = withDB "getContact" cc $ \db -> withExceptT show $ getContact db (vr cc) user ctId -getGroupLink' :: ChatController -> User -> GroupInfo -> IO (Maybe GroupLink) +getGroupLink' :: ChatController -> User -> GroupInfo -> IO (Either String GroupLink) getGroupLink' cc user gInfo = - withDB "getGroupLink" cc $ \db -> getGroupLink db user gInfo + withDB "getGroupLink" cc $ \db -> withExceptT groupDBError $ getGroupLink db user gInfo setGroupLinkRole :: ChatController -> GroupInfo -> GroupMemberRole -> IO (Maybe CreatedLinkContact) setGroupLinkRole cc GroupInfo {groupId} mRole = resp <$> sendChatCmd cc (APIGroupLinkMemberRole groupId mRole) diff --git a/apps/simplex-directory-service/src/Directory/Store.hs b/apps/simplex-directory-service/src/Directory/Store.hs index 9498fedf95..b78b446821 100644 --- a/apps/simplex-directory-service/src/Directory/Store.hs +++ b/apps/simplex-directory-service/src/Directory/Store.hs @@ -1,28 +1,48 @@ {-# LANGUAGE BangPatterns #-} +{-# LANGUAGE CPP #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE TupleSections #-} +{-# LANGUAGE TypeOperators #-} +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} module Directory.Store - ( DirectoryStore (..), + ( DirectoryLog (..), GroupReg (..), GroupRegStatus (..), UserGroupRegId, GroupApprovalId, DirectoryGroupData (..), DirectoryMemberAcceptance (..), + DirectoryStatus (..), ProfileCondition (..), - restoreDirectoryStore, - addGroupReg, + DirectoryLogRecord (..), + openDirectoryLog, + readDirectoryLogData, + addGroupRegStore, + insertGroupReg, delGroupReg, - setGroupStatus, + deleteGroupReg, + setGroupStatusStore, + setGroupStatusPromoStore, + setGroupPromotedStore, + grDirectoryStatus, setGroupRegOwner, - getGroupReg, getUserGroupReg, getUserGroupRegs, - filterListedGroups, + getAllGroupRegs_, + getDuplicateGroupRegs, + getGroupReg, + getGroupAndReg, + listLastGroups, + listPendingGroups, + getAllListedGroups, + getAllListedGroups_, + searchListedGroups, groupRegStatusText, pendingApproval, groupRemoved, @@ -31,13 +51,21 @@ module Directory.Store noJoinFilter, basicJoinFilter, moderateJoinFilter, - strongJoinFilter + strongJoinFilter, + groupDBError, + logGCreate, + logGDelete, + logGUpdateOwner, + logGUpdateStatus, + logGUpdatePromotion, ) where -import Control.Concurrent.STM +import Control.Applicative ((<|>)) import Control.Monad -import Data.Aeson ((.=), (.:)) +import Control.Monad.Except +import Control.Monad.IO.Class +import Data.Aeson ((.:), (.=)) import qualified Data.Aeson.KeyMap as JM import qualified Data.Aeson.TH as JQ import qualified Data.Aeson.Types as JT @@ -45,41 +73,51 @@ import qualified Data.Attoparsec.ByteString.Char8 as A import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.Int (Int64) -import Data.List (find, foldl', sortOn) +import Data.List (sortOn) import Data.Map (Map) import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe, isJust) -import Data.Set (Set) -import qualified Data.Set as S import Data.Text (Text) +import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) +import Data.Time.Clock (UTCTime (..), getCurrentTime) +import Data.Time.Clock.System (systemEpochDay) +import Directory.Search +import Directory.Util +import Simplex.Chat.Controller +import Simplex.Chat.Protocol (supportedChatVRange) +import Simplex.Chat.Options.DB (FromField (..), ToField (..)) +import Simplex.Chat.Store +import Simplex.Chat.Store.Groups +import Simplex.Chat.Store.Shared (groupInfoQueryFields, groupInfoQueryFrom) import Simplex.Chat.Types +import Simplex.Messaging.Agent.Store.DB (BoolInt (..), fromTextField_) +import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON) -import Simplex.Messaging.Util (ifM) -import System.Directory (doesFileExist, renameFile) +import Simplex.Messaging.Util (eitherToMaybe, firstRow, maybeFirstRow', safeDecodeUtf8) import System.IO (BufferMode (..), Handle, IOMode (..), hSetBuffering, openFile) -data DirectoryStore = DirectoryStore - { groupRegs :: TVar [GroupReg], - listedGroups :: TVar (Set GroupId), - reservedGroups :: TVar (Set GroupId), - directoryLogFile :: Maybe Handle +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (Only (..), Query, (:.) (..)) +import Database.PostgreSQL.Simple.SqlQQ (sql) +#else +import Database.SQLite.Simple (Only (..), Query, (:.) (..)) +import Database.SQLite.Simple.QQ (sql) +#endif + +data DirectoryLog = DirectoryLog + { directoryLogFile :: Maybe Handle } data GroupReg = GroupReg { dbGroupId :: GroupId, userGroupRegId :: UserGroupRegId, dbContactId :: ContactId, - dbOwnerMemberId :: TVar (Maybe GroupMemberId), - groupRegStatus :: TVar GroupRegStatus - } - -data GroupRegData = GroupRegData - { dbGroupId_ :: GroupId, - userGroupRegId_ :: UserGroupRegId, - dbContactId_ :: ContactId, - dbOwnerMemberId_ :: Maybe GroupMemberId, - groupRegStatus_ :: GroupRegStatus + dbOwnerMemberId :: Maybe GroupMemberId, + groupRegStatus :: GroupRegStatus, + promoted :: Bool, + createdAt :: UTCTime } data DirectoryGroupData = DirectoryGroupData @@ -140,7 +178,7 @@ data GroupRegStatus | GRSSuspended | GRSSuspendedBadRoles | GRSRemoved - deriving (Show) + deriving (Eq, Show) pendingApproval :: GroupRegStatus -> Bool pendingApproval = \case @@ -153,6 +191,7 @@ groupRemoved = \case _ -> False data DirectoryStatus = DSListed | DSReserved | DSRegistered | DSRemoved + deriving (Eq) groupRegStatusText :: GroupRegStatus -> Text groupRegStatusText = \case @@ -188,105 +227,249 @@ toCustomData :: DirectoryGroupData -> CustomData toCustomData DirectoryGroupData {memberAcceptance} = CustomData $ JM.fromList ["memberAcceptance" .= memberAcceptance] -addGroupReg :: DirectoryStore -> Contact -> GroupInfo -> GroupRegStatus -> IO UserGroupRegId -addGroupReg st ct GroupInfo {groupId} grStatus = do - grData <- addGroupReg_ - logGCreate st grData - pure $ userGroupRegId_ grData +addGroupRegStore :: ChatController -> Contact -> GroupInfo -> GroupRegStatus -> IO (Either String GroupReg) +addGroupRegStore cc Contact {contactId = dbContactId} GroupInfo {groupId = dbGroupId} groupRegStatus = + withDB' "addGroupRegStore" cc $ \db -> do + createdAt <- getCurrentTime + maxUgrId <- + maybeFirstRow' 0 (fromMaybe 0 . fromOnly) $ + DB.query db "SELECT MAX(user_group_reg_id) FROM sx_directory_group_regs WHERE contact_id = ?" (Only dbContactId) + let gr = GroupReg {dbGroupId, userGroupRegId = maxUgrId + 1, dbContactId, dbOwnerMemberId = Nothing, groupRegStatus, promoted = False, createdAt} + insertGroupReg db gr + pure gr + +insertGroupReg :: DB.Connection -> GroupReg -> IO () +insertGroupReg db GroupReg {dbGroupId, userGroupRegId, dbContactId, dbOwnerMemberId, groupRegStatus, promoted, createdAt} = do + DB.execute + db + [sql| + INSERT INTO sx_directory_group_regs + (group_id, user_group_reg_id, contact_id, owner_member_id, group_reg_status, group_promoted, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?) + |] + (dbGroupId, userGroupRegId, dbContactId, dbOwnerMemberId, groupRegStatus, BI promoted, createdAt, createdAt) + +delGroupReg :: ChatController -> GroupId -> IO (Either String ()) +delGroupReg cc gId = withDB' "delGroupReg" cc (`deleteGroupReg` gId) + +deleteGroupReg :: DB.Connection -> GroupId -> IO () +deleteGroupReg db gId = DB.execute db "DELETE FROM sx_directory_group_regs WHERE group_id = ?" (Only gId) + +setGroupStatusStore :: ChatController -> GroupId -> GroupRegStatus -> IO (Either String (GroupRegStatus, GroupReg)) +setGroupStatusStore cc gId grStatus' = + withDB "setGroupStatusStore" cc $ \db -> do + gr <- getGroupReg_ db gId + ts <- liftIO getCurrentTime + liftIO $ DB.execute db "UPDATE sx_directory_group_regs SET group_reg_status = ?, updated_at = ? WHERE group_id = ?" (grStatus', ts, gId) + pure (groupRegStatus gr, gr {groupRegStatus = grStatus'}) + +setGroupStatusPromoStore :: ChatController -> GroupId -> GroupRegStatus -> Bool -> IO (Either String (DirectoryStatus, Bool)) +setGroupStatusPromoStore cc gId grStatus' grPromoted' = + withDB "setGroupStatusPromoStore" cc $ \db -> do + GroupReg {groupRegStatus, promoted} <- getGroupReg_ db gId + ts <- liftIO getCurrentTime + liftIO $ DB.execute db "UPDATE sx_directory_group_regs SET group_reg_status = ?, group_promoted = ?, updated_at = ? WHERE group_id = ?" (grStatus', BI grPromoted', ts, gId) + pure (grDirectoryStatus groupRegStatus, promoted) + +setGroupPromotedStore :: ChatController -> GroupId -> Bool -> IO (Either String (DirectoryStatus, Bool)) +setGroupPromotedStore cc gId grPromoted' = + withDB "setGroupPromotedStore" cc $ \db -> do + GroupReg {groupRegStatus, promoted} <- getGroupReg_ db gId + ts <- liftIO getCurrentTime + liftIO $ DB.execute db "UPDATE sx_directory_group_regs SET group_promoted = ?, updated_at = ? WHERE group_id = ?" (BI grPromoted', ts, gId) + pure (grDirectoryStatus groupRegStatus, promoted) + +groupDBError :: StoreError -> String +groupDBError = \case + SEGroupNotFound _ -> "group not found" + e -> show e + +setGroupRegOwner :: ChatController -> GroupId -> GroupMember -> IO (Either String ()) +setGroupRegOwner cc gId owner = do + ts <- getCurrentTime + withDB' "setGroupRegOwner" cc $ \db -> + DB.execute + db + [sql| + UPDATE sx_directory_group_regs + SET owner_member_id = ?, updated_at = ? + WHERE group_id = ? + |] + (groupMemberId' owner, ts, gId) + +getGroupReg :: ChatController -> GroupId -> IO (Either String GroupReg) +getGroupReg cc gId = withDB "getGroupReg" cc (`getGroupReg_` gId) + +getGroupReg_ :: DB.Connection -> GroupId -> ExceptT String IO GroupReg +getGroupReg_ db gId = + ExceptT $ firstRow rowToGroupReg "group registration not found" $ + DB.query + db + [sql| + SELECT group_id, user_group_reg_id, contact_id, owner_member_id, group_reg_status, group_promoted, created_at + FROM sx_directory_group_regs + WHERE group_id = ? + |] + (Only gId) + +getGroupAndReg :: ChatController -> User -> GroupId -> IO (Either String (GroupInfo, GroupReg)) +getGroupAndReg cc user@User {userId, userContactId} gId = + withDB "getGroupAndReg" cc $ \db -> + ExceptT $ firstRow (toGroupInfoReg (vr cc) user) ("group " ++ show gId ++ " not found") $ + DB.query db (groupReqQuery <> " AND g.group_id = ?") (userId, userContactId, gId) + +getUserGroupReg :: ChatController -> User -> ContactId -> UserGroupRegId -> IO (Either String (GroupInfo, GroupReg)) +getUserGroupReg cc user@User {userId, userContactId} ctId ugrId = + withDB "getUserGroupReg" cc $ \db -> + ExceptT $ firstRow (toGroupInfoReg (vr cc) user) ("group " ++ show ugrId ++ " not found") $ + DB.query db (groupReqQuery <> " AND r.contact_id = ? AND r.user_group_reg_id = ?") (userId, userContactId, ctId, ugrId) + +getUserGroupRegs :: ChatController -> User -> ContactId -> IO (Either String [(GroupInfo, GroupReg)]) +getUserGroupRegs cc user@User {userId, userContactId} ctId = + withDB' "getUserGroupRegs" cc $ \db -> + map (toGroupInfoReg (vr cc) user) + <$> DB.query db (groupReqQuery <> " AND r.contact_id = ? ORDER BY r.user_group_reg_id") (userId, userContactId, ctId) + +getAllListedGroups :: ChatController -> User -> IO (Either String [(GroupInfo, GroupReg, Maybe GroupLink)]) +getAllListedGroups cc user = withDB' "getAllListedGroups" cc $ \db -> getAllListedGroups_ db (vr cc) user + +getAllListedGroups_ :: DB.Connection -> VersionRangeChat -> User -> IO [(GroupInfo, GroupReg, Maybe GroupLink)] +getAllListedGroups_ db vr' user@User {userId, userContactId} = + DB.query db (groupReqQuery <> " AND r.group_reg_status = ?") (userId, userContactId, GRSActive) + >>= mapM (withGroupLink . toGroupInfoReg vr' user) where - addGroupReg_ = do - let grData = GroupRegData {dbGroupId_ = groupId, userGroupRegId_ = 1, dbContactId_ = ctId, dbOwnerMemberId_ = Nothing, groupRegStatus_ = grStatus} - gr <- dataToGroupReg grData - atomically $ stateTVar (groupRegs st) $ \grs -> - let ugrId = 1 + foldl' maxUgrId 0 grs - grData' = grData {userGroupRegId_ = ugrId} - gr' = gr {userGroupRegId = ugrId} - in (grData', gr' : grs) - ctId = contactId' ct - maxUgrId mx GroupReg {dbContactId, userGroupRegId} - | dbContactId == ctId && userGroupRegId > mx = userGroupRegId - | otherwise = mx + withGroupLink (g, gr) = (g,gr,) . eitherToMaybe <$> runExceptT (getGroupLink db user g) -delGroupReg :: DirectoryStore -> GroupReg -> IO () -delGroupReg st GroupReg {dbGroupId = gId, groupRegStatus} = do - logGDelete st gId - atomically $ writeTVar groupRegStatus GRSRemoved - atomically $ unlistGroup st gId - atomically $ modifyTVar' (groupRegs st) $ filter ((gId /=) . dbGroupId) - -setGroupStatus :: DirectoryStore -> GroupReg -> GroupRegStatus -> IO () -setGroupStatus st gr grStatus = do - logGUpdateStatus st (dbGroupId gr) grStatus - atomically $ do - writeTVar (groupRegStatus gr) grStatus - updateListing st $ dbGroupId gr +searchListedGroups :: ChatController -> User -> SearchType -> Maybe GroupId -> Int -> IO (Either String ([(GroupInfo, GroupReg)], Int)) +searchListedGroups cc user@User {userId, userContactId} searchType lastGroup_ pageSize = + withDB' "searchListedGroups" cc $ \db -> + case searchType of + STAll -> case lastGroup_ of + Nothing -> do + gs <- groups $ DB.query db (listedGroupQuery <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, pageSize) + n <- count $ DB.query db countQuery' (Only GRSActive) + pure (gs, n) + Just gId -> do + gs <- groups $ DB.query db (listedGroupQuery <> " AND r.group_id > ? " <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, gId, pageSize) + n <- count $ DB.query db (countQuery' <> " AND r.group_id > ? " <> orderBy) (GRSActive, gId) + pure (gs, n) + where + countQuery' = countQuery <> " WHERE r.group_reg_status = ? " + orderBy = " ORDER BY g.summary_current_members_count DESC " + STRecent -> case lastGroup_ of + Nothing -> do + gs <- groups $ DB.query db (listedGroupQuery <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, pageSize) + n <- count $ DB.query db countQuery' (Only GRSActive) + pure (gs, n) + Just gId -> do + gs <- groups $ DB.query db (listedGroupQuery <> " AND r.group_id > ? " <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, gId, pageSize) + n <- count $ DB.query db (countQuery' <> " AND r.group_id > ? " <> orderBy) (GRSActive, gId) + pure (gs, n) + where + countQuery' = countQuery <> " WHERE r.group_reg_status = ? " + orderBy = " ORDER BY r.created_at DESC " + STSearch search -> case lastGroup_ of + Nothing -> do + gs <- groups $ DB.query db (listedGroupQuery <> searchCond <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, s, s, s, s, pageSize) + n <- count $ DB.query db (countQuery' <> searchCond) (GRSActive, s, s, s, s) + pure (gs, n) + Just gId -> do + gs <- groups $ DB.query db (listedGroupQuery <> " AND r.group_id > ? " <> searchCond <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, gId, s, s, s, s, pageSize) + n <- count $ DB.query db (countQuery' <> " AND r.group_id > ? " <> searchCond <> orderBy) (GRSActive, gId, s, s, s, s) + pure (gs, n) + where + s = T.toLower search + countQuery' = countQuery <> " JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id WHERE r.group_reg_status = ? " + orderBy = " ORDER BY g.summary_current_members_count DESC " where - updateListing = case grDirectoryStatus grStatus of - DSListed -> listGroup - DSReserved -> reserveGroup - DSRegistered -> unlistGroup - DSRemoved -> unlistGroup + groups = (map (toGroupInfoReg (vr cc) user) <$>) + count = maybeFirstRow' 0 fromOnly + listedGroupQuery = groupReqQuery <> " AND r.group_reg_status = ? " + countQuery = "SELECT COUNT(1) FROM groups g JOIN sx_directory_group_regs r ON g.group_id = r.group_id " + searchCond = + [sql| + AND (LOWER(gp.display_name) LIKE '%' || ? || '%' + OR LOWER(gp.full_name) LIKE '%' || ? || '%' + OR LOWER(gp.short_descr) LIKE '%' || ? || '%' + OR LOWER(gp.description) LIKE '%' || ? || '%' + ) + |] -setGroupRegOwner :: DirectoryStore -> GroupReg -> GroupMember -> IO () -setGroupRegOwner st gr owner = do - let memberId = groupMemberId' owner - logGUpdateOwner st (dbGroupId gr) memberId - atomically $ writeTVar (dbOwnerMemberId gr) (Just memberId) +getAllGroupRegs_ :: DB.Connection -> User -> IO [(GroupInfo, GroupReg)] +getAllGroupRegs_ db user@User {userId, userContactId} = + map (toGroupInfoReg supportedChatVRange user) + <$> DB.query db groupReqQuery (userId, userContactId) -getGroupReg :: DirectoryStore -> GroupId -> IO (Maybe GroupReg) -getGroupReg st gId = find ((gId ==) . dbGroupId) <$> readTVarIO (groupRegs st) +getDuplicateGroupRegs :: ChatController -> User -> Text -> IO (Either String [(GroupInfo, GroupReg)]) +getDuplicateGroupRegs cc user@User {userId, userContactId} displayName = + withDB' "getDuplicateGroupRegs" cc $ \db -> + map (toGroupInfoReg (vr cc) user) + <$> DB.query db (groupReqQuery <> " AND gp.display_name = ?") (userId, userContactId, displayName) -getUserGroupReg :: DirectoryStore -> ContactId -> UserGroupRegId -> IO (Maybe GroupReg) -getUserGroupReg st ctId ugrId = find (\r -> ctId == dbContactId r && ugrId == userGroupRegId r) <$> readTVarIO (groupRegs st) +listLastGroups :: ChatController -> User -> Int -> IO (Either String ([(GroupInfo, GroupReg)], Int)) +listLastGroups cc user@User {userId, userContactId} count = + withDB' "getUserGroupRegs" cc $ \db -> do + gs <- + map (toGroupInfoReg (vr cc) user) + <$> DB.query db (groupReqQuery <> " ORDER BY group_reg_id DESC LIMIT ?") (userId, userContactId, count) + n <- maybeFirstRow' 0 fromOnly $ DB.query_ db "SELECT COUNT(1) FROM sx_directory_group_regs" + pure (gs, n) -getUserGroupRegs :: DirectoryStore -> ContactId -> IO [GroupReg] -getUserGroupRegs st ctId = filter ((ctId ==) . dbContactId) <$> readTVarIO (groupRegs st) +listPendingGroups :: ChatController -> User -> Int -> IO (Either String ([(GroupInfo, GroupReg)], Int)) +listPendingGroups cc user@User {userId, userContactId} count = + withDB' "getUserGroupRegs" cc $ \db -> do + gs <- + map (toGroupInfoReg (vr cc) user) + <$> DB.query db (groupReqQuery <> " AND r.group_reg_status LIKE 'pending_approval%' ORDER BY group_reg_id DESC LIMIT ?") (userId, userContactId, count) + n <- maybeFirstRow' 0 fromOnly $ DB.query_ db "SELECT COUNT(1) FROM sx_directory_group_regs WHERE group_reg_status LIKE 'pending_approval%'" + pure (gs, n) -filterListedGroups :: DirectoryStore -> [GroupInfoSummary] -> IO [GroupInfoSummary] -filterListedGroups st gs = do - lgs <- readTVarIO $ listedGroups st - pure $ filter (\(GIS GroupInfo {groupId} _) -> groupId `S.member` lgs) gs +toGroupInfoReg :: VersionRangeChat -> User -> (GroupInfoRow :. GroupRegRow) -> (GroupInfo, GroupReg) +toGroupInfoReg vr' User {userContactId} (groupRow :. grRow) = + (toGroupInfo vr' userContactId [] groupRow, rowToGroupReg grRow) -listGroup :: DirectoryStore -> GroupId -> STM () -listGroup st gId = do - modifyTVar' (listedGroups st) $ S.insert gId - modifyTVar' (reservedGroups st) $ S.delete gId +type GroupRegRow = (GroupId, UserGroupRegId, ContactId, Maybe GroupMemberId, GroupRegStatus, BoolInt, UTCTime) -reserveGroup :: DirectoryStore -> GroupId -> STM () -reserveGroup st gId = do - modifyTVar' (listedGroups st) $ S.delete gId - modifyTVar' (reservedGroups st) $ S.insert gId +rowToGroupReg :: GroupRegRow -> GroupReg +rowToGroupReg (dbGroupId, userGroupRegId, dbContactId, dbOwnerMemberId, groupRegStatus, BI promoted, createdAt) = + GroupReg {dbGroupId, userGroupRegId, dbContactId, dbOwnerMemberId, groupRegStatus, promoted, createdAt} -unlistGroup :: DirectoryStore -> GroupId -> STM () -unlistGroup st gId = do - modifyTVar' (listedGroups st) $ S.delete gId - modifyTVar' (reservedGroups st) $ S.delete gId +groupReqQuery :: Query +groupReqQuery = groupInfoQueryFields <> groupRegFields <> groupInfoQueryFrom <> groupRegFromCond + where + groupRegFields = ", r.group_id, r.user_group_reg_id, r.contact_id, r.owner_member_id, r.group_reg_status, r.group_promoted, r.created_at " + groupRegFromCond = " JOIN sx_directory_group_regs r ON r.group_id = g.group_id WHERE g.user_id = ? AND mu.contact_id = ? " data DirectoryLogRecord - = GRCreate GroupRegData + = GRCreate GroupReg | GRDelete GroupId | GRUpdateStatus GroupId GroupRegStatus + | GRUpdatePromotion GroupId Bool | GRUpdateOwner GroupId GroupMemberId data DLRTag = GRCreate_ | GRDelete_ | GRUpdateStatus_ + | GRUpdatePromotion_ | GRUpdateOwner_ -logDLR :: DirectoryStore -> DirectoryLogRecord -> IO () +logDLR :: DirectoryLog -> DirectoryLogRecord -> IO () logDLR st r = forM_ (directoryLogFile st) $ \h -> B.hPutStrLn h (strEncode r) -logGCreate :: DirectoryStore -> GroupRegData -> IO () +logGCreate :: DirectoryLog -> GroupReg -> IO () logGCreate st = logDLR st . GRCreate -logGDelete :: DirectoryStore -> GroupId -> IO () +logGDelete :: DirectoryLog -> GroupId -> IO () logGDelete st = logDLR st . GRDelete -logGUpdateStatus :: DirectoryStore -> GroupId -> GroupRegStatus -> IO () +logGUpdateStatus :: DirectoryLog -> GroupId -> GroupRegStatus -> IO () logGUpdateStatus st gId = logDLR st . GRUpdateStatus gId -logGUpdateOwner :: DirectoryStore -> GroupId -> GroupMemberId -> IO () +logGUpdatePromotion :: DirectoryLog -> GroupId -> Bool -> IO () +logGUpdatePromotion st gId = logDLR st . GRUpdatePromotion gId + +logGUpdateOwner :: DirectoryLog -> GroupId -> GroupMemberId -> IO () logGUpdateOwner st gId = logDLR st . GRUpdateOwner gId instance StrEncoding DLRTag where @@ -294,12 +477,14 @@ instance StrEncoding DLRTag where GRCreate_ -> "GCREATE" GRDelete_ -> "GDELETE" GRUpdateStatus_ -> "GSTATUS" + GRUpdatePromotion_ -> "GPROMOTE" GRUpdateOwner_ -> "GOWNER" strP = A.takeTill (== ' ') >>= \case "GCREATE" -> pure GRCreate_ "GDELETE" -> pure GRDelete_ "GSTATUS" -> pure GRUpdateStatus_ + "GPROMOTE" -> pure GRUpdatePromotion_ "GOWNER" -> pure GRUpdateOwner_ _ -> fail "invalid DLRTag" @@ -308,30 +493,35 @@ instance StrEncoding DirectoryLogRecord where GRCreate gr -> strEncode (GRCreate_, gr) GRDelete gId -> strEncode (GRDelete_, gId) GRUpdateStatus gId grStatus -> strEncode (GRUpdateStatus_, gId, grStatus) + GRUpdatePromotion gId promoted -> strEncode (GRUpdatePromotion_, gId, promoted) GRUpdateOwner gId grOwnerId -> strEncode (GRUpdateOwner_, gId, grOwnerId) strP = strP_ >>= \case GRCreate_ -> GRCreate <$> strP GRDelete_ -> GRDelete <$> strP GRUpdateStatus_ -> GRUpdateStatus <$> A.decimal <*> _strP + GRUpdatePromotion_ -> GRUpdatePromotion <$> A.decimal <*> _strP GRUpdateOwner_ -> GRUpdateOwner <$> A.decimal <* A.space <*> A.decimal -instance StrEncoding GroupRegData where - strEncode GroupRegData {dbGroupId_, userGroupRegId_, dbContactId_, dbOwnerMemberId_, groupRegStatus_} = - B.unwords - [ "group_id=" <> strEncode dbGroupId_, - "user_group_id=" <> strEncode userGroupRegId_, - "contact_id=" <> strEncode dbContactId_, - "owner_member_id=" <> strEncode dbOwnerMemberId_, - "status=" <> strEncode groupRegStatus_ +instance StrEncoding GroupReg where + strEncode GroupReg {dbGroupId, userGroupRegId, dbContactId, dbOwnerMemberId, groupRegStatus, promoted} = + B.unwords $ + [ "group_id=" <> strEncode dbGroupId, + "user_group_id=" <> strEncode userGroupRegId, + "contact_id=" <> strEncode dbContactId, + "owner_member_id=" <> strEncode dbOwnerMemberId, + "status=" <> strEncode groupRegStatus ] + <> ["promoted=" <> strEncode promoted | promoted] strP = do - dbGroupId_ <- "group_id=" *> strP_ - userGroupRegId_ <- "user_group_id=" *> strP_ - dbContactId_ <- "contact_id=" *> strP_ - dbOwnerMemberId_ <- "owner_member_id=" *> strP_ - groupRegStatus_ <- "status=" *> strP - pure GroupRegData {dbGroupId_, userGroupRegId_, dbContactId_, dbOwnerMemberId_, groupRegStatus_} + dbGroupId <- "group_id=" *> strP_ + userGroupRegId <- "user_group_id=" *> strP_ + dbContactId <- "contact_id=" *> strP_ + dbOwnerMemberId <- "owner_member_id=" *> strP_ + groupRegStatus <- "status=" *> strP + promoted <- (" promoted=" *> strP) <|> pure False + let createdAt = UTCTime systemEpochDay 0 + pure GroupReg {dbGroupId, userGroupRegId, dbContactId, dbOwnerMemberId, groupRegStatus, promoted, createdAt} instance StrEncoding GroupRegStatus where strEncode = \case @@ -355,70 +545,30 @@ instance StrEncoding GroupRegStatus where "removed" -> pure GRSRemoved _ -> fail "invalid GroupRegStatus" -dataToGroupReg :: GroupRegData -> IO GroupReg -dataToGroupReg GroupRegData {dbGroupId_, userGroupRegId_, dbContactId_, dbOwnerMemberId_, groupRegStatus_} = do - dbOwnerMemberId <- newTVarIO dbOwnerMemberId_ - groupRegStatus <- newTVarIO groupRegStatus_ - pure - GroupReg - { dbGroupId = dbGroupId_, - userGroupRegId = userGroupRegId_, - dbContactId = dbContactId_, - dbOwnerMemberId, - groupRegStatus - } +instance ToField GroupRegStatus where toField = toField . safeDecodeUtf8 . strEncode -restoreDirectoryStore :: Maybe FilePath -> IO DirectoryStore -restoreDirectoryStore = \case - Just f -> ifM (doesFileExist f) (restore f) (newFile f >>= newDirectoryStore . Just) - Nothing -> newDirectoryStore Nothing +instance FromField GroupRegStatus where fromField = fromTextField_ $ eitherToMaybe . strDecode . encodeUtf8 + +openDirectoryLog :: Maybe FilePath -> IO DirectoryLog +openDirectoryLog = \case + Just f -> DirectoryLog . Just <$> openLogFile f + Nothing -> pure $ DirectoryLog Nothing where - newFile f = do - h <- openFile f WriteMode + openLogFile f = do + h <- openFile f AppendMode hSetBuffering h LineBuffering pure h - restore f = do - grs <- readDirectoryData f - renameFile f (f <> ".bak") - h <- writeDirectoryData f grs -- compact - mkDirectoryStore h grs -emptyStoreData :: ([GroupReg], Set GroupId, Set GroupId) -emptyStoreData = ([], S.empty, S.empty) - -newDirectoryStore :: Maybe Handle -> IO DirectoryStore -newDirectoryStore = (`mkDirectoryStore_` emptyStoreData) - -mkDirectoryStore :: Handle -> [GroupRegData] -> IO DirectoryStore -mkDirectoryStore h groups = - foldM addGroupRegData emptyStoreData groups >>= mkDirectoryStore_ (Just h) - where - addGroupRegData (!grs, !listed, !reserved) gr@GroupRegData {dbGroupId_ = gId} = do - gr' <- dataToGroupReg gr - let grs' = gr' : grs - pure $ case grDirectoryStatus $ groupRegStatus_ gr of - DSListed -> (grs', S.insert gId listed, reserved) - DSReserved -> (grs', listed, S.insert gId reserved) - DSRegistered -> (grs', listed, reserved) - DSRemoved -> (grs, listed, reserved) - -mkDirectoryStore_ :: Maybe Handle -> ([GroupReg], Set GroupId, Set GroupId) -> IO DirectoryStore -mkDirectoryStore_ h (grs, listed, reserved) = do - groupRegs <- newTVarIO grs - listedGroups <- newTVarIO listed - reservedGroups <- newTVarIO reserved - pure DirectoryStore {groupRegs, listedGroups, reservedGroups, directoryLogFile = h} - -readDirectoryData :: FilePath -> IO [GroupRegData] -readDirectoryData f = - sortOn dbGroupId_ . M.elems +readDirectoryLogData :: FilePath -> IO [GroupReg] +readDirectoryLogData f = + sortOn dbGroupId . M.elems <$> (foldM processDLR M.empty . B.lines =<< B.readFile f) where - processDLR :: Map GroupId GroupRegData -> ByteString -> IO (Map GroupId GroupRegData) + processDLR :: Map GroupId GroupReg -> ByteString -> IO (Map GroupId GroupReg) processDLR m l = case strDecode l of Left e -> m <$ putStrLn ("Error parsing log record: " <> e <> ", " <> B.unpack (B.take 80 l)) Right r -> case r of - GRCreate gr@GroupRegData {dbGroupId_ = gId} -> do + GRCreate gr@GroupReg {dbGroupId = gId} -> do when (isJust $ M.lookup gId m) $ putStrLn $ "Warning: duplicate group with ID " <> show gId <> ", group replaced." @@ -426,16 +576,12 @@ readDirectoryData f = GRDelete gId -> case M.lookup gId m of Just _ -> pure $ M.delete gId m Nothing -> m <$ putStrLn ("Warning: no group with ID " <> show gId <> ", deletion ignored.") - GRUpdateStatus gId groupRegStatus_ -> case M.lookup gId m of - Just gr -> pure $ M.insert gId gr {groupRegStatus_} m + GRUpdateStatus gId groupRegStatus -> case M.lookup gId m of + Just gr -> pure $ M.insert gId gr {groupRegStatus} m Nothing -> m <$ putStrLn ("Warning: no group with ID " <> show gId <> ", status update ignored.") + GRUpdatePromotion gId promoted -> case M.lookup gId m of + Just gr -> pure $ M.insert gId gr {promoted} m + Nothing -> m <$ putStrLn ("Warning: no group with ID " <> show gId <> ", promotion update ignored.") GRUpdateOwner gId grOwnerId -> case M.lookup gId m of - Just gr -> pure $ M.insert gId gr {dbOwnerMemberId_ = Just grOwnerId} m + Just gr -> pure $ M.insert gId gr {dbOwnerMemberId = Just grOwnerId} m Nothing -> m <$ putStrLn ("Warning: no group with ID " <> show gId <> ", owner update ignored.") - -writeDirectoryData :: FilePath -> [GroupRegData] -> IO Handle -writeDirectoryData f grs = do - h <- openFile f WriteMode - hSetBuffering h LineBuffering - forM_ grs $ B.hPutStrLn h . strEncode . GRCreate - pure h diff --git a/apps/simplex-directory-service/src/Directory/Store/Migrate.hs b/apps/simplex-directory-service/src/Directory/Store/Migrate.hs new file mode 100644 index 0000000000..ad37eba6d7 --- /dev/null +++ b/apps/simplex-directory-service/src/Directory/Store/Migrate.hs @@ -0,0 +1,148 @@ +{-# LANGUAGE CPP #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Directory.Store.Migrate where + +import Control.Concurrent.STM +import Control.Monad +import Control.Monad.Except +import qualified Data.ByteString.Char8 as B +import Data.List (find) +import Data.Maybe (fromMaybe) +import qualified Data.Text as T +import Directory.Listing +import Directory.Options +import Directory.Store +import Simplex.Chat (createChatDatabase) +import Simplex.Chat.Controller (ChatConfig (..), ChatDatabase (..)) +import Simplex.Chat.Options (CoreChatOpts (..)) +import Simplex.Chat.Options.DB +import Simplex.Chat.Protocol (supportedChatVRange) +import Simplex.Chat.Store.Groups (getGroupInfo, getHostMember) +import Simplex.Chat.Store.Profiles (getUsers) +import Simplex.Chat.Types +import Simplex.Messaging.Agent.Store.Common +import qualified Simplex.Messaging.Agent.Store.DB as DB +import Simplex.Messaging.Agent.Store.Interface (closeDBStore, migrateDBSchema) +import Simplex.Messaging.Agent.Store.Shared (MigrationConfig (..), MigrationConfirmation (..)) +import Simplex.Messaging.Encoding.String +import qualified Simplex.Messaging.TMap as TM +import Simplex.Messaging.Util (whenM) +import System.Directory (doesFileExist, renamePath) +import System.Exit (exitFailure) +import System.IO (IOMode (..), withFile) + +#if defined(dbPostgres) +import Directory.Store.Postgres.Migrations +#else +import Directory.Store.SQLite.Migrations +#endif + +runDirectoryMigrations :: DirectoryOpts -> ChatConfig -> DBStore -> IO () +runDirectoryMigrations opts ChatConfig {confirmMigrations} chatStore = + migrateDBSchema + chatStore + (toDBOpts dbOptions chatSuffix False) + (Just "sx_directory_migrations") + directorySchemaMigrations + MigrationConfig {confirm, backupPath = Nothing} + >>= either (exit . ("directory migrations " <>) . show) pure + where + DirectoryOpts {coreOptions = CoreChatOpts {dbOptions, yesToUpMigrations}} = opts + confirm = if confirmMigrations == MCConsole && yesToUpMigrations then MCYesUp else confirmMigrations + +checkDirectoryLog :: DirectoryOpts -> ChatConfig -> IO () +checkDirectoryLog opts cfg = + withDirectoryLog opts $ \logFile -> withChatStore opts $ \st -> do + runDirectoryMigrations opts cfg st + gs <- readDirectoryLogData logFile + withActiveUser st $ \user -> withTransaction st $ \db -> do + mapM_ (verifyGroupRegistration db user) gs + putStrLn $ show (length gs) <> " group registrations OK" + +importDirectoryLogToDB :: DirectoryOpts -> ChatConfig -> IO () +importDirectoryLogToDB opts cfg = do + withDirectoryLog opts $ \logFile -> withChatStore opts $ \st -> do + runDirectoryMigrations opts cfg st + gs <- readDirectoryLogData logFile + ctRegs <- TM.emptyIO + withActiveUser st $ \user -> withTransaction st $ \db -> do + forM_ gs $ \gr -> + whenM (verifyGroupRegistration db user gr) $ do + putStrLn $ "importing group " <> show (dbGroupId gr) + insertGroupReg db =<< fixUserGroupRegId ctRegs gr + renamePath logFile (logFile ++ ".bak") + putStrLn $ show (length gs) <> " group registrations imported" + where + fixUserGroupRegId ctRegs gr@GroupReg {dbGroupId, dbContactId} = do + ugIds <- fromMaybe [] <$> TM.lookupIO dbContactId ctRegs + gr' <- + if userGroupRegId gr `elem` ugIds + then do + let ugId = maximum ugIds + 1 + putStrLn $ "Warning: updating userGroupRegId for group " <> show dbGroupId <> ", contact " <> show dbContactId + pure gr {userGroupRegId = ugId} + else pure gr + atomically $ TM.insert dbContactId (userGroupRegId gr' : ugIds) ctRegs + pure gr' + +exit :: String -> IO a +exit err = putStrLn ("Error: " <> err) >> exitFailure + +exportDBToDirectoryLog :: DirectoryOpts -> ChatConfig -> IO () +exportDBToDirectoryLog opts cfg = + withDirectoryLog opts $ \logFile -> withChatStore opts $ \st -> do + whenM (doesFileExist logFile) $ exit $ "directory log file " ++ logFile ++ " already exists" + runDirectoryMigrations opts cfg st + withActiveUser st $ \user -> do + gs <- withFile logFile WriteMode $ \h -> withTransaction st $ \db -> do + gs <- getAllGroupRegs_ db user + forM_ gs $ \(_, gr) -> + whenM (verifyGroupRegistration db user gr) $ + B.hPutStrLn h $ strEncode $ GRCreate gr + pure gs + putStrLn $ show (length gs) <> " group registrations exported" + +saveGroupListingFiles :: DirectoryOpts -> ChatConfig -> IO () +saveGroupListingFiles opts _cfg = case webFolder opts of + Nothing -> exit "use --web-folder to generate listings" + Just dir -> + withChatStore opts $ \st -> withActiveUser st $ \user -> + withTransaction st $ \db -> + getAllListedGroups_ db supportedChatVRange user >>= generateListing dir + +verifyGroupRegistration :: DB.Connection -> User -> GroupReg -> IO Bool +verifyGroupRegistration db user GroupReg {dbGroupId = gId, dbContactId = ctId, dbOwnerMemberId, groupRegStatus} = + runExceptT (getGroupInfo db supportedChatVRange user gId) >>= \case + Left e -> False <$ putStrLn ("Error: loading group " <> show gId <> " (skipping): " <> show e) + Right GroupInfo {localDisplayName} -> do + let groupRef = show gId <> " " <> T.unpack localDisplayName + runExceptT (getHostMember db supportedChatVRange user gId) >>= \case + Left e -> False <$ putStrLn ("Error: loading host member of group " <> groupRef <> " (skipping): " <> show e) + Right GroupMember {groupMemberId = mId', memberContactId = ctId'} -> case dbOwnerMemberId of + Nothing -> True <$ putStrLn ("Warning: group " <> groupRef <> " has no owner member ID, host member ID is " <> show mId' <> ", registration status: " <> B.unpack (strEncode groupRegStatus)) + Just mId + | mId /= mId' -> False <$ putStrLn ("Error: different host member ID of " <> groupRef <> " (skipping): " <> show mId') + | otherwise -> True <$ unless (Just ctId == ctId') (putStrLn $ "Warning: bad group " <> groupRef <> " contact ID: " <> show ctId') + +withDirectoryLog :: DirectoryOpts -> (FilePath -> IO ()) -> IO () +withDirectoryLog DirectoryOpts {directoryLog} action = + maybe (exit "directory log file not specified") action directoryLog + +withChatStore :: DirectoryOpts -> (DBStore -> IO ()) -> IO () +withChatStore DirectoryOpts {coreOptions = CoreChatOpts {dbOptions, yesToUpMigrations, migrationBackupPath}} action = + createChatDatabase dbOptions migrationConfig >>= \case + Left e -> exit $ show e + Right ChatDatabase {chatStore, agentStore} -> do + action chatStore + closeDBStore chatStore + closeDBStore agentStore + where + migrationConfig = MigrationConfig (if yesToUpMigrations then MCYesUp else MCConsole) migrationBackupPath + +withActiveUser :: DBStore -> (User -> IO ()) -> IO () +withActiveUser st action = withTransaction st getUsers >>= maybe (exit "no active user") action . find activeUser diff --git a/apps/simplex-directory-service/src/Directory/Store/Postgres/Migrations.hs b/apps/simplex-directory-service/src/Directory/Store/Postgres/Migrations.hs new file mode 100644 index 0000000000..4a801fee74 --- /dev/null +++ b/apps/simplex-directory-service/src/Directory/Store/Postgres/Migrations.hs @@ -0,0 +1,52 @@ +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE QuasiQuotes #-} + +module Directory.Store.Postgres.Migrations where + +import Data.List (sortOn) +import Data.Text (Text) +import qualified Data.Text as T +import Simplex.Messaging.Agent.Store.Shared (Migration (..)) +import Text.RawString.QQ (r) + +directorySchemaMigrations :: [Migration] +directorySchemaMigrations = sortOn name $ map migration schemaMigrations + where + migration (name, up, down) = Migration {name, up, down} + +schemaMigrations :: [(String, Text, Maybe Text)] +schemaMigrations = + [ ("20250924_directory_schema", m20250924_directory_schema, Just down_m20250924_directory_schema) + ] + +m20250924_directory_schema :: Text +m20250924_directory_schema = + T.pack + [r| +CREATE TABLE sx_directory_group_regs( + group_reg_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + group_id BIGINT NOT NULL REFERENCES groups ON UPDATE RESTRICT ON DELETE CASCADE, + user_group_reg_id BIGINT NOT NULL, + contact_id BIGINT NOT NULL REFERENCES contacts(contact_id) ON UPDATE RESTRICT ON DELETE CASCADE, + owner_member_id BIGINT REFERENCES group_members(group_member_id) ON UPDATE RESTRICT ON DELETE CASCADE, + group_reg_status TEXT NOT NULL, + group_promoted SMALLINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + updated_at TIMESTAMPTZ NOT NULL DEFAULT (now()) +); + +CREATE UNIQUE INDEX idx_sx_directory_group_regs_group_id ON sx_directory_group_regs(group_id); +CREATE UNIQUE INDEX idx_sx_directory_group_regs_owner_member_id ON sx_directory_group_regs(owner_member_id); +CREATE UNIQUE INDEX idx_sx_directory_group_regs_owner_contact_id_user_group_reg_id ON sx_directory_group_regs(contact_id, user_group_reg_id); + |] + +down_m20250924_directory_schema :: Text +down_m20250924_directory_schema = + T.pack + [r| +DROP INDEX idx_sx_directory_group_regs_group_id; +DROP INDEX idx_sx_directory_group_regs_owner_member_id; +DROP INDEX idx_sx_directory_group_regs_owner_contact_id_user_group_reg_id; + +DROP TABLE sx_directory_group_regs; + |] diff --git a/apps/simplex-directory-service/src/Directory/Store/SQLite/Migrations.hs b/apps/simplex-directory-service/src/Directory/Store/SQLite/Migrations.hs new file mode 100644 index 0000000000..f35f9e250a --- /dev/null +++ b/apps/simplex-directory-service/src/Directory/Store/SQLite/Migrations.hs @@ -0,0 +1,49 @@ +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE QuasiQuotes #-} + +module Directory.Store.SQLite.Migrations (directorySchemaMigrations) where + +import Data.List (sortOn) +import Database.SQLite.Simple (Query (..)) +import Database.SQLite.Simple.QQ (sql) +import Simplex.Messaging.Agent.Store.Shared (Migration (..)) + +directorySchemaMigrations :: [Migration] +directorySchemaMigrations = sortOn name $ map migration schemaMigrations + where + migration (name, up, down) = Migration {name, up = fromQuery up, down = fromQuery <$> down} + +schemaMigrations :: [(String, Query, Maybe Query)] +schemaMigrations = + [ ("20250924_directory_schema", m20250924_directory_schema, Just down_m20250924_directory_schema) + ] + +m20250924_directory_schema :: Query +m20250924_directory_schema = + [sql| +CREATE TABLE sx_directory_group_regs( + group_reg_id INTEGER PRIMARY KEY AUTOINCREMENT, + group_id INTEGER NOT NULL REFERENCES groups ON UPDATE RESTRICT ON DELETE CASCADE, + user_group_reg_id INTEGER NOT NULL, + contact_id INTEGER NOT NULL REFERENCES contacts(contact_id) ON UPDATE RESTRICT ON DELETE CASCADE, + owner_member_id INTEGER REFERENCES group_members(group_member_id) ON UPDATE RESTRICT ON DELETE CASCADE, + group_reg_status TEXT NOT NULL, + group_promoted INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT(datetime('now')), + updated_at TEXT NOT NULL DEFAULT(datetime('now')) +); + +CREATE UNIQUE INDEX idx_sx_directory_group_regs_group_id ON sx_directory_group_regs(group_id); +CREATE UNIQUE INDEX idx_sx_directory_group_regs_owner_member_id ON sx_directory_group_regs(owner_member_id); +CREATE UNIQUE INDEX idx_sx_directory_group_regs_owner_contact_id_user_group_reg_id ON sx_directory_group_regs(contact_id, user_group_reg_id); + |] + +down_m20250924_directory_schema :: Query +down_m20250924_directory_schema = + [sql| +DROP INDEX idx_sx_directory_group_regs_group_id; +DROP INDEX idx_sx_directory_group_regs_owner_member_id; +DROP INDEX idx_sx_directory_group_regs_owner_contact_id_user_group_reg_id; + +DROP TABLE sx_directory_group_regs; + |] diff --git a/apps/simplex-directory-service/src/Directory/Util.hs b/apps/simplex-directory-service/src/Directory/Util.hs new file mode 100644 index 0000000000..a4b79a1bef --- /dev/null +++ b/apps/simplex-directory-service/src/Directory/Util.hs @@ -0,0 +1,31 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} + +module Directory.Util where + +import Control.Logger.Simple +import Control.Monad.Except +import Data.Text (Text) +import qualified Data.Text as T +import Simplex.Chat.Controller +import Simplex.Chat.Types +import Simplex.Messaging.Agent.Store.Common (withTransaction) +import qualified Simplex.Messaging.Agent.Store.DB as DB +import Simplex.Messaging.Util (catchAll) + +vr :: ChatController -> VersionRangeChat +vr ChatController {config = ChatConfig {chatVRange}} = chatVRange +{-# INLINE vr #-} + +withDB' :: Text -> ChatController -> (DB.Connection -> IO a) -> IO (Either String a) +withDB' cxt cc a = withDB cxt cc $ ExceptT . fmap Right . a + +withDB :: Text -> ChatController -> (DB.Connection -> ExceptT String IO a) -> IO (Either String a) +withDB cxt ChatController {chatStore} action = do + r_ <- withTransaction chatStore (runExceptT . action) `catchAll` (pure . Left . show) + case r_ of + Left e -> logError $ "Database error: " <> cxt <> " " <> T.pack e + Right _ -> pure () + pure r_ diff --git a/bots/api/COMMANDS.md b/bots/api/COMMANDS.md index 4ea8b7e466..f3bb915665 100644 --- a/bots/api/COMMANDS.md +++ b/bots/api/COMMANDS.md @@ -42,6 +42,7 @@ This file is generated automatically. - [APIAddContact](#apiaddcontact) - [APIConnectPlan](#apiconnectplan) - [APIConnect](#apiconnect) +- [Connect](#connect) - [APIAcceptContact](#apiacceptcontact) - [APIRejectContact](#apirejectcontact) @@ -90,13 +91,17 @@ Create bot address. '/_address ' + str(userId) # Python ``` -**Response**: +**Responses**: UserContactLinkCreated: User contact address created. - type: "userContactLinkCreated" - user: [User](./TYPES.md#user) - connLinkContact: [CreatedConnLink](./TYPES.md#createdconnlink) +ChatCmdError: Command error. +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + --- @@ -123,12 +128,16 @@ Delete bot address. '/_delete_address ' + str(userId) # Python ``` -**Response**: +**Responses**: UserContactLinkDeleted: User contact address deleted. - type: "userContactLinkDeleted" - user: [User](./TYPES.md#user) +ChatCmdError: Command error. +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + --- @@ -155,13 +164,17 @@ Get bot address and settings. '/_show_address ' + str(userId) # Python ``` -**Response**: +**Responses**: UserContactLink: User contact address. - type: "userContactLink" - user: [User](./TYPES.md#user) - contactLink: [UserContactLink](./TYPES.md#usercontactlink) +ChatCmdError: Command error. +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + --- @@ -189,7 +202,7 @@ Add address to bot profile. '/_profile_address ' + str(userId) + ' ' + ('on' if enable else 'off') # Python ``` -**Response**: +**Responses**: UserProfileUpdated: User profile updated. - type: "userProfileUpdated" @@ -198,6 +211,10 @@ UserProfileUpdated: User profile updated. - toProfile: [Profile](./TYPES.md#profile) - updateSummary: [UserProfileUpdateSummary](./TYPES.md#userprofileupdatesummary) +ChatCmdError: Command error. +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + --- @@ -225,13 +242,17 @@ Set bot address settings. '/_address_settings ' + str(userId) + ' ' + json.dumps(settings) # Python ``` -**Response**: +**Responses**: UserContactLinkUpdated: User contact address updated. - type: "userContactLinkUpdated" - user: [User](./TYPES.md#user) - contactLink: [UserContactLink](./TYPES.md#usercontactlink) +ChatCmdError: Command error. +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + --- @@ -266,13 +287,17 @@ Send messages. '/_send ' + str(sendRef) + (' live=on' if liveMessage else '') + ((' ttl=' + str(ttl)) if ttl is not None else '') + ' json ' + json.dumps(composedMessages) # Python ``` -**Response**: +**Responses**: NewChatItems: New messages. - type: "newChatItems" - user: [User](./TYPES.md#user) - chatItems: [[AChatItem](./TYPES.md#achatitem)] +ChatCmdError: Command error. +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + --- @@ -349,7 +374,7 @@ Delete message. '/_delete item ' + str(chatRef) + ' ' + ','.join(map(str, chatItemIds)) + ' ' + str(deleteMode) # Python ``` -**Response**: +**Responses**: ChatItemsDeleted: Messages deleted. - type: "chatItemsDeleted" @@ -358,6 +383,10 @@ ChatItemsDeleted: Messages deleted. - byUser: bool - timed: bool +ChatCmdError: Command error. +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + --- @@ -385,7 +414,7 @@ Moderate message. Requires Moderator role (and higher than message author's). '/_delete member item #' + str(groupId) + ' ' + ','.join(map(str, chatItemIds)) # Python ``` -**Response**: +**Responses**: ChatItemsDeleted: Messages deleted. - type: "chatItemsDeleted" @@ -394,6 +423,10 @@ ChatItemsDeleted: Messages deleted. - byUser: bool - timed: bool +ChatCmdError: Command error. +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + --- @@ -423,7 +456,7 @@ Add/remove message reaction. '/_reaction ' + str(chatRef) + ' ' + str(chatItemId) + ' ' + ('on' if add else 'off') + ' ' + json.dumps(reaction) # Python ``` -**Response**: +**Responses**: ChatItemReaction: Message reaction. - type: "chatItemReaction" @@ -431,6 +464,10 @@ ChatItemReaction: Message reaction. - added: bool - reaction: [ACIReaction](./TYPES.md#acireaction) +ChatCmdError: Command error. +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + --- @@ -478,6 +515,10 @@ RcvFileAcceptedSndCancelled: File accepted, but no longer sent. - user: [User](./TYPES.md#user) - rcvFileTransfer: [RcvFileTransfer](./TYPES.md#rcvfiletransfer) +ChatCmdError: Command error. +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + --- @@ -559,7 +600,7 @@ Add contact to group. Requires bot to have Admin role. '/_add #' + str(groupId) + ' ' + str(contactId) + ' ' + str(memberRole) # Python ``` -**Response**: +**Responses**: SentGroupInvitation: Group invitation sent. - type: "sentGroupInvitation" @@ -568,6 +609,10 @@ SentGroupInvitation: Group invitation sent. - contact: [Contact](./TYPES.md#contact) - member: [GroupMember](./TYPES.md#groupmember) +ChatCmdError: Command error. +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + --- @@ -594,7 +639,7 @@ Join group. '/_join #' + str(groupId) # Python ``` -**Response**: +**Responses**: UserAcceptedGroupSent: User accepted group invitation. - type: "userAcceptedGroupSent" @@ -602,6 +647,10 @@ UserAcceptedGroupSent: User accepted group invitation. - groupInfo: [GroupInfo](./TYPES.md#groupinfo) - hostContact: [Contact](./TYPES.md#contact)? +ChatCmdError: Command error. +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + --- @@ -673,7 +722,7 @@ Set members role. Requires Admin role. '/_member role #' + str(groupId) + ' ' + ','.join(map(str, groupMemberIds)) + ' ' + str(memberRole) # Python ``` -**Response**: +**Responses**: MembersRoleUser: Members role changed by user. - type: "membersRoleUser" @@ -682,6 +731,10 @@ MembersRoleUser: Members role changed by user. - members: [[GroupMember](./TYPES.md#groupmember)] - toRole: [GroupMemberRole](./TYPES.md#groupmemberrole) +ChatCmdError: Command error. +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + --- @@ -710,7 +763,7 @@ Block members. Requires Moderator role. '/_block #' + str(groupId) + ' ' + ','.join(map(str, groupMemberIds)) + ' blocked=' + ('on' if blocked else 'off') # Python ``` -**Response**: +**Responses**: MembersBlockedForAllUser: Members blocked for all by admin. - type: "membersBlockedForAllUser" @@ -719,6 +772,10 @@ MembersBlockedForAllUser: Members blocked for all by admin. - members: [[GroupMember](./TYPES.md#groupmember)] - blocked: bool +ChatCmdError: Command error. +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + --- @@ -789,13 +846,17 @@ Leave group. '/_leave #' + str(groupId) # Python ``` -**Response**: +**Responses**: LeftMemberUser: User left group. - type: "leftMemberUser" - user: [User](./TYPES.md#user) - groupInfo: [GroupInfo](./TYPES.md#groupinfo) +ChatCmdError: Command error. +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + --- @@ -822,13 +883,17 @@ Get group members. '/_members #' + str(groupId) # Python ``` -**Response**: +**Responses**: GroupMembers: Group members. - type: "groupMembers" - user: [User](./TYPES.md#user) - group: [Group](./TYPES.md#group) +ChatCmdError: Command error. +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + --- @@ -857,13 +922,17 @@ Create group. '/_group ' + str(userId) + (' incognito=on' if incognito else '') + ' ' + json.dumps(groupProfile) # Python ``` -**Response**: +**Responses**: GroupCreated: Group created. - type: "groupCreated" - user: [User](./TYPES.md#user) - groupInfo: [GroupInfo](./TYPES.md#groupinfo) +ChatCmdError: Command error. +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + --- @@ -891,7 +960,7 @@ Update group profile. '/_group_profile #' + str(groupId) + ' ' + json.dumps(groupProfile) # Python ``` -**Response**: +**Responses**: GroupUpdated: Group updated. - type: "groupUpdated" @@ -900,6 +969,10 @@ GroupUpdated: Group updated. - toGroup: [GroupInfo](./TYPES.md#groupinfo) - member_: [GroupMember](./TYPES.md#groupmember)? +ChatCmdError: Command error. +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + --- @@ -932,7 +1005,7 @@ Create group link. '/_create link #' + str(groupId) + ' ' + str(memberRole) # Python ``` -**Response**: +**Responses**: GroupLinkCreated: Group link created. - type: "groupLinkCreated" @@ -940,6 +1013,10 @@ GroupLinkCreated: Group link created. - groupInfo: [GroupInfo](./TYPES.md#groupinfo) - groupLink: [GroupLink](./TYPES.md#grouplink) +ChatCmdError: Command error. +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + --- @@ -967,7 +1044,7 @@ Set member role for group link. '/_set link role #' + str(groupId) + ' ' + str(memberRole) # Python ``` -**Response**: +**Responses**: GroupLink: Group link. - type: "groupLink" @@ -975,6 +1052,10 @@ GroupLink: Group link. - groupInfo: [GroupInfo](./TYPES.md#groupinfo) - groupLink: [GroupLink](./TYPES.md#grouplink) +ChatCmdError: Command error. +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + --- @@ -1001,13 +1082,17 @@ Delete group link. '/_delete link #' + str(groupId) # Python ``` -**Response**: +**Responses**: GroupLinkDeleted: Group link deleted. - type: "groupLinkDeleted" - user: [User](./TYPES.md#user) - groupInfo: [GroupInfo](./TYPES.md#groupinfo) +ChatCmdError: Command error. +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + --- @@ -1034,7 +1119,7 @@ Get group link. '/_get link #' + str(groupId) # Python ``` -**Response**: +**Responses**: GroupLink: Group link. - type: "groupLink" @@ -1042,6 +1127,10 @@ GroupLink: Group link. - groupInfo: [GroupInfo](./TYPES.md#groupinfo) - groupLink: [GroupLink](./TYPES.md#grouplink) +ChatCmdError: Command error. +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + --- @@ -1074,7 +1163,7 @@ Create 1-time invitation link. '/_connect ' + str(userId) + (' incognito=on' if incognito else '') # Python ``` -**Response**: +**Responses**: Invitation: One-time invitation. - type: "invitation" @@ -1082,6 +1171,10 @@ Invitation: One-time invitation. - connLinkInvitation: [CreatedConnLink](./TYPES.md#createdconnlink) - connection: [PendingContactConnection](./TYPES.md#pendingcontactconnection) +ChatCmdError: Command error. +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + --- @@ -1109,7 +1202,7 @@ Determine SimpleX link type and if the bot is already connected via this link. '/_connect plan ' + str(userId) + ' ' + connectionLink # Python ``` -**Response**: +**Responses**: ConnectionPlan: Connection link information. - type: "connectionPlan" @@ -1117,32 +1210,36 @@ ConnectionPlan: Connection link information. - connLink: [CreatedConnLink](./TYPES.md#createdconnlink) - connectionPlan: [ConnectionPlan](./TYPES.md#connectionplan) +ChatCmdError: Command error. +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + --- ### APIConnect -Connect via SimpleX link. The link can be 1-time invitation link, contact address or group link +Connect via prepared SimpleX link. The link can be 1-time invitation link, contact address or group link *Network usage*: interactive. **Parameters**: - userId: int64 - incognito: bool -- connLink_: [CreatedConnLink](./TYPES.md#createdconnlink)? +- preparedLink_: [CreatedConnLink](./TYPES.md#createdconnlink)? **Syntax**: ``` -/_connect +/_connect [ ] ``` ```javascript -'/_connect ' + userId + ' ' + connLink_.toString() // JavaScript +'/_connect ' + userId + (preparedLink_ ? ' ' + preparedLink_.toString() : '') // JavaScript ``` ```python -'/_connect ' + str(userId) + ' ' + str(connLink_) # Python +'/_connect ' + str(userId) + ((' ' + str(preparedLink_)) if preparedLink_ is not None else '') # Python ``` **Responses**: @@ -1164,6 +1261,60 @@ SentInvitation: Invitation sent to contact address. - connection: [PendingContactConnection](./TYPES.md#pendingcontactconnection) - customUserProfile: [Profile](./TYPES.md#profile)? +ChatCmdError: Command error. +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + +--- + + +### Connect + +Connect via SimpleX link as string in the active user profile. + +*Network usage*: interactive. + +**Parameters**: +- incognito: bool +- connLink_: string? + +**Syntax**: + +``` +/connect[ ] +``` + +```javascript +'/connect' + (connLink_ ? ' ' + connLink_ : '') // JavaScript +``` + +```python +'/connect' + ((' ' + connLink_) if connLink_ is not None else '') # Python +``` + +**Responses**: + +SentConfirmation: Confirmation sent to one-time invitation. +- type: "sentConfirmation" +- user: [User](./TYPES.md#user) +- connection: [PendingContactConnection](./TYPES.md#pendingcontactconnection) +- customUserProfile: [Profile](./TYPES.md#profile)? + +ContactAlreadyExists: Contact already exists. +- type: "contactAlreadyExists" +- user: [User](./TYPES.md#user) +- contact: [Contact](./TYPES.md#contact) + +SentInvitation: Invitation sent to contact address. +- type: "sentInvitation" +- user: [User](./TYPES.md#user) +- connection: [PendingContactConnection](./TYPES.md#pendingcontactconnection) +- customUserProfile: [Profile](./TYPES.md#profile)? + +ChatCmdError: Command error. +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + --- @@ -1190,13 +1341,17 @@ Accept contact request. '/_accept ' + str(contactReqId) # Python ``` -**Response**: +**Responses**: AcceptingContactRequest: Contact request accepted. - type: "acceptingContactRequest" - user: [User](./TYPES.md#user) - contact: [Contact](./TYPES.md#contact) +ChatCmdError: Command error. +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + --- @@ -1223,7 +1378,7 @@ Reject contact request. The user who sent the request is **not notified**. '/_reject ' + str(contactReqId) # Python ``` -**Response**: +**Responses**: ContactRequestRejected: Contact request rejected. - type: "contactRequestRejected" @@ -1231,6 +1386,10 @@ ContactRequestRejected: Contact request rejected. - contactRequest: [UserContactRequest](./TYPES.md#usercontactrequest) - contact_: [Contact](./TYPES.md#contact)? +ChatCmdError: Command error. +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + --- @@ -1262,13 +1421,17 @@ Get contacts. '/_contacts ' + str(userId) # Python ``` -**Response**: +**Responses**: ContactsList: Contacts. - type: "contactsList" - user: [User](./TYPES.md#user) - contacts: [[Contact](./TYPES.md#contact)] +ChatCmdError: Command error. +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + --- @@ -1297,12 +1460,16 @@ Get groups. '/_groups ' + str(userId) + ((' @' + str(contactId_)) if contactId_ is not None else '') + ((' ' + search) if search is not None else '') # Python ``` -**Response**: +**Responses**: GroupsList: Groups. - type: "groupsList" - user: [User](./TYPES.md#user) -- groups: [[GroupInfoSummary](./TYPES.md#groupinfosummary)] +- groups: [[GroupInfo](./TYPES.md#groupinfo)] + +ChatCmdError: Command error. +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) --- @@ -1348,6 +1515,10 @@ GroupDeletedUser: User deleted group. - user: [User](./TYPES.md#user) - groupInfo: [GroupInfo](./TYPES.md#groupinfo) +ChatCmdError: Command error. +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + --- @@ -1368,12 +1539,16 @@ Get active user profile /user ``` -**Response**: +**Responses**: ActiveUser: Active user profile. - type: "activeUser" - user: [User](./TYPES.md#user) +ChatCmdError: Command error. +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + --- @@ -1400,12 +1575,16 @@ Create new user profile '/_create user ' + json.dumps(newUser) # Python ``` -**Response**: +**Responses**: ActiveUser: Active user profile. - type: "activeUser" - user: [User](./TYPES.md#user) +ChatCmdError: Command error. +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + **Errors**: - UserExists: User or contact with this name already exists. - InvalidDisplayName: Invalid user display name. @@ -1425,12 +1604,16 @@ Get all user profiles /users ``` -**Response**: +**Responses**: UsersList: Users. - type: "usersList" - users: [[UserInfo](./TYPES.md#userinfo)] +ChatCmdError: Command error. +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + --- @@ -1458,12 +1641,16 @@ Set active user profile '/_user ' + str(userId) + ((' ' + json.dumps(viewPwd)) if viewPwd is not None else '') # Python ``` -**Response**: +**Responses**: ActiveUser: Active user profile. - type: "activeUser" - user: [User](./TYPES.md#user) +ChatCmdError: Command error. +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + **Errors**: - ChatNotStarted: Chat not started. @@ -1495,12 +1682,16 @@ Delete user profile. '/_delete user ' + str(userId) + ' del_smp=' + ('on' if delSMPQueues else 'off') + ((' ' + json.dumps(viewPwd)) if viewPwd is not None else '') # Python ``` -**Response**: +**Responses**: CmdOk: Ok. - type: "cmdOk" - user_: [User](./TYPES.md#user)? +ChatCmdError: Command error. +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + --- @@ -1528,7 +1719,7 @@ Update user profile. '/_profile ' + str(userId) + ' ' + json.dumps(profile) # Python ``` -**Response**: +**Responses**: UserProfileUpdated: User profile updated. - type: "userProfileUpdated" @@ -1537,6 +1728,14 @@ UserProfileUpdated: User profile updated. - toProfile: [Profile](./TYPES.md#profile) - updateSummary: [UserProfileUpdateSummary](./TYPES.md#userprofileupdatesummary) +UserProfileNoChange: User profile was not changed. +- type: "userProfileNoChange" +- user: [User](./TYPES.md#user) + +ChatCmdError: Command error. +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + --- @@ -1564,7 +1763,7 @@ Configure chat preference overrides for the contact. '/_set prefs @' + str(contactId) + ' ' + json.dumps(preferences) # Python ``` -**Response**: +**Responses**: ContactPrefsUpdated: Contact preferences updated. - type: "contactPrefsUpdated" @@ -1572,4 +1771,8 @@ ContactPrefsUpdated: Contact preferences updated. - fromContact: [Contact](./TYPES.md#contact) - toContact: [Contact](./TYPES.md#contact) +ChatCmdError: Command error. +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + --- diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 449d6a9294..923fab14c2 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -47,6 +47,7 @@ This file is generated automatically. - [ChatType](#chattype) - [ChatWallpaper](#chatwallpaper) - [ChatWallpaperScale](#chatwallpaperscale) +- [ClientNotice](#clientnotice) - [Color](#color) - [CommandError](#commanderror) - [CommandErrorType](#commanderrortype) @@ -89,7 +90,6 @@ This file is generated automatically. - [GroupFeature](#groupfeature) - [GroupFeatureEnabled](#groupfeatureenabled) - [GroupInfo](#groupinfo) -- [GroupInfoSummary](#groupinfosummary) - [GroupLink](#grouplink) - [GroupLinkPlan](#grouplinkplan) - [GroupMember](#groupmember) @@ -121,6 +121,7 @@ This file is generated automatically. - [MsgFilter](#msgfilter) - [MsgReaction](#msgreaction) - [MsgReceiptStatus](#msgreceiptstatus) +- [NetworkError](#networkerror) - [NewUser](#newuser) - [NoteFolder](#notefolder) - [PendingContactConnection](#pendingcontactconnection) @@ -136,7 +137,6 @@ This file is generated automatically. - [RcvConnEvent](#rcvconnevent) - [RcvDirectEvent](#rcvdirectevent) - [RcvFileDescr](#rcvfiledescr) -- [RcvFileInfo](#rcvfileinfo) - [RcvFileStatus](#rcvfilestatus) - [RcvFileTransfer](#rcvfiletransfer) - [RcvGroupEvent](#rcvgroupevent) @@ -228,14 +228,6 @@ DECRYPT_CB: RATCHET_HEADER: - type: "RATCHET_HEADER" -RATCHET_EARLIER: -- type: "RATCHET_EARLIER" -- : word32 - -RATCHET_SKIPPED: -- type: "RATCHET_SKIPPED" -- : word32 - RATCHET_SYNC: - type: "RATCHET_SYNC" @@ -297,6 +289,12 @@ AGENT: - type: "AGENT" - agentErr: [SMPAgentError](#smpagenterror) +NOTICE: +- type: "NOTICE" +- server: string +- preset: bool +- expiresAt: UTCTime? + INTERNAL: - type: "INTERNAL" - internalErr: string @@ -324,6 +322,7 @@ INACTIVE: **Record type**: - reason: [BlockingReason](#blockingreason) +- notice: [ClientNotice](#clientnotice)? --- @@ -351,6 +350,7 @@ UNEXPECTED: NETWORK: - type: "NETWORK" +- networkError: [NetworkError](#networkerror) HOST: - type: "HOST" @@ -919,6 +919,7 @@ Error: ErrorAgent: - type: "errorAgent" - agentError: [AgentErrorType](#agenterrortype) +- agentConnId: string - connectionEntity_: [ConnectionEntity](#connectionentity)? ErrorStore: @@ -1109,11 +1110,6 @@ FileAlreadyExists: - type: "fileAlreadyExists" - filePath: string -FileRead: -- type: "fileRead" -- filePath: string -- message: string - FileWrite: - type: "fileWrite" - filePath: string @@ -1359,11 +1355,11 @@ str(chatType) + str(chatId) + ((str(chatScope)) if chatScope is not None else '' ``` ```javascript -self == 'contact' ? '@' : self == 'group' ? '#' : self == 'local' ? '*' : '' // JavaScript +self == 'direct' ? '@' : self == 'group' ? '#' : self == 'local' ? '*' : '' // JavaScript ``` ```python -'@' if str(self) == 'contact' else '#' if str(self) == 'group' else '*' if str(self) == 'local' else '' # Python +'@' if str(self) == 'direct' else '#' if str(self) == 'group' else '*' if str(self) == 'local' else '' # Python ``` @@ -1390,6 +1386,14 @@ self == 'contact' ? '@' : self == 'group' ? '#' : self == 'local' ? '*' : '' // - "repeat" +--- + +## ClientNotice + +**Record type**: +- ttl: int64? + + --- ## Color @@ -1536,16 +1540,6 @@ RcvGroupMsgConnection: - groupInfo: [GroupInfo](#groupinfo) - groupMember: [GroupMember](#groupmember) -SndFileConnection: -- type: "sndFileConnection" -- entityConnection: [Connection](#connection) -- sndFileTransfer: [SndFileTransfer](#sndfiletransfer) - -RcvFileConnection: -- type: "rcvFileConnection" -- entityConnection: [Connection](#connection) -- rcvFileTransfer: [RcvFileTransfer](#rcvfiletransfer) - UserContactConnection: - type: "userContactConnection" - entityConnection: [Connection](#connection) @@ -1615,7 +1609,6 @@ Error: - localDisplayName: string - profile: [LocalProfile](#localprofile) - activeConn: [Connection](#connection)? -- viaGroup: int64? - contactUsed: bool - contactStatus: [ContactStatus](#contactstatus) - chatSettings: [ChatSettings](#chatsettings) @@ -2137,6 +2130,7 @@ MemberSupport: **Record type**: - groupId: int64 +- useRelays: bool - localDisplayName: string - groupProfile: [GroupProfile](#groupprofile) - localAlias: string @@ -2153,19 +2147,11 @@ MemberSupport: - chatItemTTL: int64? - uiThemes: [UIThemeEntityOverrides](#uithemeentityoverrides)? - customData: JSONObject? +- groupSummary: [GroupSummary](#groupsummary) - membersRequireAttention: int - viaGroupLinkUri: string? ---- - -## GroupInfoSummary - -**Record type**: -- groupInfo: [GroupInfo](#groupinfo) -- groupSummary: [GroupSummary](#groupsummary) - - --- ## GroupLink @@ -2356,7 +2342,7 @@ Known: ## GroupSummary **Record type**: -- currentMembers: int +- currentMembers: int64 --- @@ -2643,6 +2629,34 @@ Unknown: - "badMsgHash" +--- + +## NetworkError + +**Discriminated union type**: + +ConnectError: +- type: "connectError" +- connectError: string + +TLSError: +- type: "tLSError" +- tlsError: string + +UnknownCAError: +- type: "unknownCAError" + +FailedError: +- type: "failedError" + +TimeoutError: +- type: "timeoutError" + +SubscribeError: +- type: "subscribeError" +- subscribeError: string + + --- ## NewUser @@ -2908,16 +2922,6 @@ GroupInvLinkReceived: - fileDescrComplete: bool ---- - -## RcvFileInfo - -**Record type**: -- filePath: string -- connId: int64? -- agentConnId: string? - - --- ## RcvFileStatus @@ -2929,19 +2933,19 @@ New: Accepted: - type: "accepted" -- fileInfo: [RcvFileInfo](#rcvfileinfo) +- filePath: string Connected: - type: "connected" -- fileInfo: [RcvFileInfo](#rcvfileinfo) +- filePath: string Complete: - type: "complete" -- fileInfo: [RcvFileInfo](#rcvfileinfo) +- filePath: string Cancelled: - type: "cancelled" -- fileInfo_: [RcvFileInfo](#rcvfileinfo)? +- filePath_: string? --- @@ -3559,6 +3563,26 @@ InvalidQuote: InvalidMention: - type: "invalidMention" +InvalidDeliveryTask: +- type: "invalidDeliveryTask" +- taskId: int64 + +DeliveryTaskNotFound: +- type: "deliveryTaskNotFound" +- taskId: int64 + +InvalidDeliveryJob: +- type: "invalidDeliveryJob" +- jobId: int64 + +DeliveryJobNotFound: +- type: "deliveryJobNotFound" +- jobId: int64 + +WorkItemError: +- type: "workItemError" +- errContext: string + --- diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index 3804a23f31..4cce44e588 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -77,16 +77,16 @@ chatCommandsDocsData :: [(String, String, [(ConsName, [String], Text, [ConsName] chatCommandsDocsData = [ ( "Address commands", "Bots can use these commands to automatically check and create address when initialized", - [ ("APICreateMyAddress", [], "Create bot address.", ["CRUserContactLinkCreated"], [], Just UNInteractive, "/_address " <> Param "userId"), - ("APIDeleteMyAddress", [], "Delete bot address.", ["CRUserContactLinkDeleted"], [], Just UNBackground, "/_delete_address " <> Param "userId"), - ("APIShowMyAddress", [], "Get bot address and settings.", ["CRUserContactLink"], [], Nothing, "/_show_address " <> Param "userId"), - ("APISetProfileAddress", [], "Add address to bot profile.", ["CRUserProfileUpdated"], [], Just UNInteractive, "/_profile_address " <> Param "userId" <> " " <> OnOff "enable"), - ("APISetAddressSettings", [], "Set bot address settings.", ["CRUserContactLinkUpdated"], [], Just UNInteractive, "/_address_settings " <> Param "userId" <> " " <> Json "settings") + [ ("APICreateMyAddress", [], "Create bot address.", ["CRUserContactLinkCreated", "CRChatCmdError"], [], Just UNInteractive, "/_address " <> Param "userId"), + ("APIDeleteMyAddress", [], "Delete bot address.", ["CRUserContactLinkDeleted", "CRChatCmdError"], [], Just UNBackground, "/_delete_address " <> Param "userId"), + ("APIShowMyAddress", [], "Get bot address and settings.", ["CRUserContactLink", "CRChatCmdError"], [], Nothing, "/_show_address " <> Param "userId"), + ("APISetProfileAddress", [], "Add address to bot profile.", ["CRUserProfileUpdated", "CRChatCmdError"], [], Just UNInteractive, "/_profile_address " <> Param "userId" <> " " <> OnOff "enable"), + ("APISetAddressSettings", [], "Set bot address settings.", ["CRUserContactLinkUpdated", "CRChatCmdError"], [], Just UNInteractive, "/_address_settings " <> Param "userId" <> " " <> Json "settings") ] ), ( "Message commands", "Commands to send, update, delete, moderate messages and set message reactions", - [ ("APISendMessages", [], "Send messages.", ["CRNewChatItems"], [], Just UNBackground, "/_send " <> Param "sendRef" <> OnOffParam "live" "liveMessage" (Just False) <> Optional "" (" ttl=" <> Param "$0") "ttl" <> " json " <> Json "composedMessages"), + [ ("APISendMessages", [], "Send messages.", ["CRNewChatItems", "CRChatCmdError"], [], Just UNBackground, "/_send " <> Param "sendRef" <> OnOffParam "live" "liveMessage" (Just False) <> Optional "" (" ttl=" <> Param "$0") "ttl" <> " json " <> Json "composedMessages"), ( "APIUpdateChatItem", [], "Update message.", @@ -95,53 +95,54 @@ chatCommandsDocsData = Just UNBackground, "/_update item " <> Param "chatRef" <> " " <> Param "chatItemId" <> OnOffParam "live" "liveMessage" (Just False) <> " json " <> Json "updatedMessage" ), - ("APIDeleteChatItem", [], "Delete message.", ["CRChatItemsDeleted"], [], Just UNBackground, "/_delete item " <> Param "chatRef" <> " " <> Join ',' "chatItemIds" <> " " <> Param "deleteMode"), - ("APIDeleteMemberChatItem", [], "Moderate message. Requires Moderator role (and higher than message author's).", ["CRChatItemsDeleted"], [], Just UNBackground, "/_delete member item #" <> Param "groupId" <> " " <> Join ',' "chatItemIds"), - ("APIChatItemReaction", [], "Add/remove message reaction.", ["CRChatItemReaction"], [], Just UNBackground, "/_reaction " <> Param "chatRef" <> " " <> Param "chatItemId" <> " " <> OnOff "add" <> " " <> Json "reaction") + ("APIDeleteChatItem", [], "Delete message.", ["CRChatItemsDeleted", "CRChatCmdError"], [], Just UNBackground, "/_delete item " <> Param "chatRef" <> " " <> Join ',' "chatItemIds" <> " " <> Param "deleteMode"), + ("APIDeleteMemberChatItem", [], "Moderate message. Requires Moderator role (and higher than message author's).", ["CRChatItemsDeleted", "CRChatCmdError"], [], Just UNBackground, "/_delete member item #" <> Param "groupId" <> " " <> Join ',' "chatItemIds"), + ("APIChatItemReaction", [], "Add/remove message reaction.", ["CRChatItemReaction", "CRChatCmdError"], [], Just UNBackground, "/_reaction " <> Param "chatRef" <> " " <> Param "chatItemId" <> " " <> OnOff "add" <> " " <> Json "reaction") ] ), ( "File commands", "Commands to receive and to cancel files. Files are sent as part of the message, there are no separate commands to send files.", - [ ("ReceiveFile", [], "Receive file.", ["CRRcvFileAccepted", "CRRcvFileAcceptedSndCancelled"], [], Nothing, "/freceive " <> Param "fileId" <> OnOffParam "approved_relays" "userApprovedRelays" (Just False) <> OnOffParam "encrypt" "storeEncrypted" Nothing <> OnOffParam "inline" "fileInline" Nothing <> Optional "" (" " <> Param "$0") "filePath"), + [ ("ReceiveFile", [], "Receive file.", ["CRRcvFileAccepted", "CRRcvFileAcceptedSndCancelled", "CRChatCmdError"], [], Nothing, "/freceive " <> Param "fileId" <> OnOffParam "approved_relays" "userApprovedRelays" (Just False) <> OnOffParam "encrypt" "storeEncrypted" Nothing <> OnOffParam "inline" "fileInline" Nothing <> Optional "" (" " <> Param "$0") "filePath"), ("CancelFile", [], "Cancel file.", ["CRSndFileCancelled", "CRRcvFileCancelled", "CRChatCmdError"], [TD "CEFileCancel" "Cannot cancel file"], Just UNBackground, "/fcancel " <> Param "fileId") ] ), ( "Group commands", "Commands to manage and moderate groups. These commands can be used with business chats as well - they are groups. E.g., a common scenario would be to add human agents to business chat with the customer who connected via business address.", - [ ("APIAddMember", [], "Add contact to group. Requires bot to have Admin role.", ["CRSentGroupInvitation"], [], Just UNInteractive, "/_add #" <> Param "groupId" <> " " <> Param "contactId" <> " " <> Param "memberRole"), - ("APIJoinGroup", ["enableNtfs"], "Join group.", ["CRUserAcceptedGroupSent"], [], Just UNInteractive, "/_join #" <> Param "groupId"), + [ ("APIAddMember", [], "Add contact to group. Requires bot to have Admin role.", ["CRSentGroupInvitation", "CRChatCmdError"], [], Just UNInteractive, "/_add #" <> Param "groupId" <> " " <> Param "contactId" <> " " <> Param "memberRole"), + ("APIJoinGroup", ["enableNtfs"], "Join group.", ["CRUserAcceptedGroupSent", "CRChatCmdError"], [], Just UNInteractive, "/_join #" <> Param "groupId"), ("APIAcceptMember", [], "Accept group member. Requires Admin role.", ["CRMemberAccepted", "CRChatCmdError"], [TD "CEGroupMemberNotActive" "Member is not connected yet"], Just UNBackground, "/_accept member #" <> Param "groupId" <> " " <> Param "groupMemberId" <> " " <> Param "memberRole"), - ("APIMembersRole", [], "Set members role. Requires Admin role.", ["CRMembersRoleUser"], [], Just UNBackground, "/_member role #" <> Param "groupId" <> " " <> Join ',' "groupMemberIds" <> " " <> Param "memberRole"), - ("APIBlockMembersForAll", [], "Block members. Requires Moderator role.", ["CRMembersBlockedForAllUser"], [], Just UNBackground, "/_block #" <> Param "groupId" <> " " <> Join ',' "groupMemberIds" <> OnOffParam "blocked" "blocked" Nothing), + ("APIMembersRole", [], "Set members role. Requires Admin role.", ["CRMembersRoleUser", "CRChatCmdError"], [], Just UNBackground, "/_member role #" <> Param "groupId" <> " " <> Join ',' "groupMemberIds" <> " " <> Param "memberRole"), + ("APIBlockMembersForAll", [], "Block members. Requires Moderator role.", ["CRMembersBlockedForAllUser", "CRChatCmdError"], [], Just UNBackground, "/_block #" <> Param "groupId" <> " " <> Join ',' "groupMemberIds" <> OnOffParam "blocked" "blocked" Nothing), ("APIRemoveMembers", [], "Remove members. Requires Admin role.", ["CRUserDeletedMembers", "CRChatCmdError"], ["CEGroupMemberNotFound"], Just UNBackground, "/_remove #" <> Param "groupId" <> " " <> Join ',' "groupMemberIds" <> OnOffParam "messages" "withMessages" (Just False)), - ("APILeaveGroup", [], "Leave group.", ["CRLeftMemberUser"], [], Just UNBackground, "/_leave #" <> Param "groupId"), - ("APIListMembers", [], "Get group members.", ["CRGroupMembers"], [], Nothing, "/_members #" <> Param "groupId"), - ("APINewGroup", [], "Create group.", ["CRGroupCreated"], [], Nothing, "/_group " <> Param "userId" <> OnOffParam "incognito" "incognito" (Just False) <> " " <> Json "groupProfile"), - ("APIUpdateGroupProfile", [], "Update group profile.", ["CRGroupUpdated"], [], Just UNBackground, "/_group_profile #" <> Param "groupId" <> " " <> Json "groupProfile") + ("APILeaveGroup", [], "Leave group.", ["CRLeftMemberUser", "CRChatCmdError"], [], Just UNBackground, "/_leave #" <> Param "groupId"), + ("APIListMembers", [], "Get group members.", ["CRGroupMembers", "CRChatCmdError"], [], Nothing, "/_members #" <> Param "groupId"), + ("APINewGroup", [], "Create group.", ["CRGroupCreated", "CRChatCmdError"], [], Nothing, "/_group " <> Param "userId" <> OnOffParam "incognito" "incognito" (Just False) <> " " <> Json "groupProfile"), + ("APIUpdateGroupProfile", [], "Update group profile.", ["CRGroupUpdated", "CRChatCmdError"], [], Just UNBackground, "/_group_profile #" <> Param "groupId" <> " " <> Json "groupProfile") ] ), ( "Group link commands", "These commands can be used by bots that manage multiple public groups", - [ ("APICreateGroupLink", [], "Create group link.", ["CRGroupLinkCreated"], [], Just UNInteractive, "/_create link #" <> Param "groupId" <> " " <> Param "memberRole"), - ("APIGroupLinkMemberRole", [], "Set member role for group link.", ["CRGroupLink"], [], Nothing, "/_set link role #" <> Param "groupId" <> " " <> Param "memberRole"), - ("APIDeleteGroupLink", [], "Delete group link.", ["CRGroupLinkDeleted"], [], Just UNBackground, "/_delete link #" <> Param "groupId"), - ("APIGetGroupLink", [], "Get group link.", ["CRGroupLink"], [], Nothing, "/_get link #" <> Param "groupId") + [ ("APICreateGroupLink", [], "Create group link.", ["CRGroupLinkCreated", "CRChatCmdError"], [], Just UNInteractive, "/_create link #" <> Param "groupId" <> " " <> Param "memberRole"), + ("APIGroupLinkMemberRole", [], "Set member role for group link.", ["CRGroupLink", "CRChatCmdError"], [], Nothing, "/_set link role #" <> Param "groupId" <> " " <> Param "memberRole"), + ("APIDeleteGroupLink", [], "Delete group link.", ["CRGroupLinkDeleted", "CRChatCmdError"], [], Just UNBackground, "/_delete link #" <> Param "groupId"), + ("APIGetGroupLink", [], "Get group link.", ["CRGroupLink", "CRChatCmdError"], [], Nothing, "/_get link #" <> Param "groupId") ] ), ( "Connection commands", "These commands may be used to create connections. Most bots do not need to use them - bot users will connect via bot address with auto-accept enabled.", - [ ("APIAddContact", [], "Create 1-time invitation link.", ["CRInvitation"], [], Just UNInteractive, "/_connect " <> Param "userId" <> OnOffParam "incognito" "incognito" (Just False)), - ("APIConnectPlan", [], "Determine SimpleX link type and if the bot is already connected via this link.", ["CRConnectionPlan"], [], Just UNInteractive, "/_connect plan " <> Param "userId" <> " " <> Param "connectionLink"), - ("APIConnect", [], "Connect via SimpleX link. The link can be 1-time invitation link, contact address or group link", ["CRSentConfirmation", "CRContactAlreadyExists", "CRSentInvitation"], [], Just UNInteractive, "/_connect " <> Param "userId" <> " " <> Param "connLink_"), - ("APIAcceptContact", ["incognito"], "Accept contact request.", ["CRAcceptingContactRequest"], [], Just UNInteractive, "/_accept " <> Param "contactReqId"), - ("APIRejectContact", [], "Reject contact request. The user who sent the request is **not notified**.", ["CRContactRequestRejected"], [], Nothing, "/_reject " <> Param "contactReqId") + [ ("APIAddContact", [], "Create 1-time invitation link.", ["CRInvitation", "CRChatCmdError"], [], Just UNInteractive, "/_connect " <> Param "userId" <> OnOffParam "incognito" "incognito" (Just False)), + ("APIConnectPlan", [], "Determine SimpleX link type and if the bot is already connected via this link.", ["CRConnectionPlan", "CRChatCmdError"], [], Just UNInteractive, "/_connect plan " <> Param "userId" <> " " <> Param "connectionLink"), + ("APIConnect", [], "Connect via prepared SimpleX link. The link can be 1-time invitation link, contact address or group link", ["CRSentConfirmation", "CRContactAlreadyExists", "CRSentInvitation", "CRChatCmdError"], [], Just UNInteractive, "/_connect " <> Param "userId" <> Optional "" (" " <> Param "$0") "preparedLink_"), + ("Connect", [], "Connect via SimpleX link as string in the active user profile.", ["CRSentConfirmation", "CRContactAlreadyExists", "CRSentInvitation", "CRChatCmdError"], [], Just UNInteractive, "/connect" <> Optional "" (" " <> Param "$0") "connLink_"), + ("APIAcceptContact", ["incognito"], "Accept contact request.", ["CRAcceptingContactRequest", "CRChatCmdError"], [], Just UNInteractive, "/_accept " <> Param "contactReqId"), + ("APIRejectContact", [], "Reject contact request. The user who sent the request is **not notified**.", ["CRContactRequestRejected", "CRChatCmdError"], [], Nothing, "/_reject " <> Param "contactReqId") ] ), ( "Chat commands", "Commands to list and delete conversations.", - [ ("APIListContacts", [], "Get contacts.", ["CRContactsList"], [], Nothing, "/_contacts " <> Param "userId"), - ("APIListGroups", [], "Get groups.", ["CRGroupsList"], [], Nothing, "/_groups " <> Param "userId" <> Optional "" (" @" <> Param "$0") "contactId_" <> Optional "" (" " <> Param "$0") "search"), - ("APIDeleteChat", [], "Delete chat.", ["CRContactDeleted", "CRContactConnectionDeleted", "CRGroupDeletedUser"], [], Just UNBackground, "/_delete " <> Param "chatRef" <> " " <> Param "chatDeleteMode") + [ ("APIListContacts", [], "Get contacts.", ["CRContactsList", "CRChatCmdError"], [], Nothing, "/_contacts " <> Param "userId"), + ("APIListGroups", [], "Get groups.", ["CRGroupsList", "CRChatCmdError"], [], Nothing, "/_groups " <> Param "userId" <> Optional "" (" @" <> Param "$0") "contactId_" <> Optional "" (" " <> Param "$0") "search"), + ("APIDeleteChat", [], "Delete chat.", ["CRContactDeleted", "CRContactConnectionDeleted", "CRGroupDeletedUser", "CRChatCmdError"], [], Just UNBackground, "/_delete " <> Param "chatRef" <> " " <> Param "chatDeleteMode") -- ("APIChatItemsRead", [], "Mark items as read.", ["CRItemsReadForChat"], [], Nothing, ""), -- ("APIChatRead", [], "Mark chat as read.", ["CRCmdOk"], [], Nothing, ""), -- ("APIChatUnread", [], "Mark chat as unread.", ["CRCmdOk"], [], Nothing, ""), @@ -162,20 +163,20 @@ chatCommandsDocsData = ), ( "User profile commands", "Most bots don't need to use these commands, as bot profile can be configured manually via CLI or desktop client. These commands can be used by bots that need to manage multiple user profiles (e.g., the profiles of support agents).", - [ ("ShowActiveUser", [], "Get active user profile", ["CRActiveUser"], [], Nothing, "/user"), + [ ("ShowActiveUser", [], "Get active user profile", ["CRActiveUser", "CRChatCmdError"], [], Nothing, "/user"), ( "CreateActiveUser", [], "Create new user profile", - ["CRActiveUser"], + ["CRActiveUser", "CRChatCmdError"], [TD "CEUserExists" "User or contact with this name already exists", TD "CEInvalidDisplayName" "Invalid user display name"], Nothing, "/_create user " <> Json "newUser" ), - ("ListUsers", [], "Get all user profiles", ["CRUsersList"], [], Nothing, "/users"), - ("APISetActiveUser", [], "Set active user profile", ["CRActiveUser"], ["CEChatNotStarted"], Nothing, "/_user " <> Param "userId" <> Optional "" (" " <> Json "$0") "viewPwd"), - ("APIDeleteUser", [], "Delete user profile.", ["CRCmdOk"], [], Just UNBackground, "/_delete user " <> Param "userId" <> OnOffParam "del_smp" "delSMPQueues" Nothing <> Optional "" (" " <> Json "$0") "viewPwd"), - ("APIUpdateProfile", [], "Update user profile.", ["CRUserProfileUpdated"], [], Just UNBackground, "/_profile " <> Param "userId" <> " " <> Json "profile"), - ("APISetContactPrefs", [], "Configure chat preference overrides for the contact.", ["CRContactPrefsUpdated"], [], Just UNBackground, "/_set prefs @" <> Param "contactId" <> " " <> Json "preferences") + ("ListUsers", [], "Get all user profiles", ["CRUsersList", "CRChatCmdError"], [], Nothing, "/users"), + ("APISetActiveUser", [], "Set active user profile", ["CRActiveUser", "CRChatCmdError"], ["CEChatNotStarted"], Nothing, "/_user " <> Param "userId" <> Optional "" (" " <> Json "$0") "viewPwd"), + ("APIDeleteUser", [], "Delete user profile.", ["CRCmdOk", "CRChatCmdError"], [], Just UNBackground, "/_delete user " <> Param "userId" <> OnOffParam "del_smp" "delSMPQueues" Nothing <> Optional "" (" " <> Json "$0") "viewPwd"), + ("APIUpdateProfile", [], "Update user profile.", ["CRUserProfileUpdated", "CRUserProfileNoChange", "CRChatCmdError"], [], Just UNBackground, "/_profile " <> Param "userId" <> " " <> Json "profile"), + ("APISetContactPrefs", [], "Configure chat preference overrides for the contact.", ["CRContactPrefsUpdated", "CRChatCmdError"], [], Just UNBackground, "/_set prefs @" <> Param "contactId" <> " " <> Json "preferences") ] ) ] @@ -193,7 +194,6 @@ cliCommands = "ClearContact", "ClearGroup", "ClearNoteFolder", - "Connect", "ConnectSimplex", "ContactInfo", "ContactQueueInfo", @@ -349,7 +349,6 @@ undocumentedCommands = "APIGetContactCode", "APIGetGroupMemberCode", "APIGetNetworkConfig", - "APIGetNetworkStatuses", "APIGetNtfConns", "APIGetNtfToken", "APIGetReactionMembers", @@ -440,6 +439,7 @@ undocumentedCommands = "ReconnectAllServers", "ReconnectServer", "ResetAgentServersStats", + "ShowConnectionsDiff", "ResubscribeAllConnections", "SetAllContactReceipts", "SetChatItemTTL", diff --git a/bots/src/API/Docs/Events.hs b/bots/src/API/Docs/Events.hs index df50a09a8f..4a49551a54 100644 --- a/bots/src/API/Docs/Events.hs +++ b/bots/src/API/Docs/Events.hs @@ -174,19 +174,13 @@ undocumentedEvents = "CEvtContactPQEnabled", "CEvtContactRatchetSync", "CEvtContactRequestAlreadyAccepted", - "CEvtContactsDisconnected", - "CEvtContactsMerged", - "CEvtContactsSubscribed", - "CEvtContactSubError", - "CEvtContactSubSummary", "CEvtContactSwitch", "CEvtCustomChatEvent", "CEvtGroupMemberRatchetSync", "CEvtGroupMemberSwitch", "CEvtHostConnected", "CEvtHostDisconnected", - "CEvtNetworkStatus", - "CEvtNetworkStatuses", + "CEvtSubscriptionStatus", "CEvtNewRemoteHost", "CEvtNoMemberContactCreating", "CEvtNtfMessage", @@ -205,12 +199,12 @@ undocumentedEvents = "CEvtSndFileRedirectStartXFTP", "CEvtSndFileStart", -- legacy SMP files "CEvtSndStandaloneFileComplete", + "CEvtConnectionsDiff", "CEvtSubscriptionEnd", "CEvtTerminalEvent", "CEvtTimedAction", "CEvtUnknownMemberAnnounced", "CEvtUnknownMemberBlocked", "CEvtUnknownMemberCreated", - "CEvtUserAcceptedGroupSent", -- repeat group invitation after it was accepted by the user - "CEvtUserContactSubSummary" + "CEvtUserAcceptedGroupSent" -- repeat group invitation after it was accepted by the user ] diff --git a/bots/src/API/Docs/Generate.hs b/bots/src/API/Docs/Generate.hs index ad8f68bd36..334ad93bad 100644 --- a/bots/src/API/Docs/Generate.hs +++ b/bots/src/API/Docs/Generate.hs @@ -43,7 +43,7 @@ commandsDocText = <> foldMap commandDocText commands where commandDocText CCDoc {commandType = ATUnionMember tag params, commandDescr, network, syntax, responses, errors} = - ("\n\n### " <> T.pack (fstToUpper tag) <> "\n\n" <> commandDescr <> "\n\n*Network usage*: " <> networkUsage <> ".\n") + ("\n\n### " <> T.pack (fstToUpper tag) <> "\n\n" <> commandDescr <> "\n\n*Network usage*: " <> networkUsage network <> ".\n") <> (if null params then "" else paramsText) <> (if syntax == "" then "" else syntaxText (tag, params) syntax) <> (if length responses > 1 then "\n**Responses**:\n" else "\n**Response**:\n") @@ -52,10 +52,6 @@ commandsDocText = <> foldMap errorText errors <> "\n---\n" where - networkUsage = case network of - Nothing -> "no" - Just UNInteractive -> "interactive" - Just UNBackground -> "background" paramsText = "\n**Parameters**:\n" <> fieldsText "./TYPES.md" params responseText CRDoc {responseType = ATUnionMember tag fields, responseDescr} = (T.pack $ "\n" <> fstToUpper tag <> ": " <> respDescr <> ".\n") @@ -67,11 +63,17 @@ commandsDocText = let descr' = if null descr then camelToSpace err else descr in T.pack $ "- " <> fstToUpper err <> ": " <> descr' <> ".\n" +networkUsage :: Maybe UsesNetwork -> Text +networkUsage = \case + Nothing -> "no" + Just UNInteractive -> "interactive" + Just UNBackground -> "background" + syntaxText :: TypeAndFields -> Expr -> Text syntaxText r syntax = "\n**Syntax**:\n" <> "\n```\n" <> docSyntaxText r syntax <> "\n```\n" - <> (if isConst syntax then "" else "\n```javascript\n" <> jsSyntaxText r syntax <> " // JavaScript\n```\n") + <> (if isConst syntax then "" else "\n```javascript\n" <> jsSyntaxText False r syntax <> " // JavaScript\n```\n") <> (if isConst syntax then "" else "\n```python\n" <> pySyntaxText r syntax <> " # Python\n```\n") camelToSpace :: String -> String @@ -127,14 +129,14 @@ typesDocText = where self = APIRecordField "self" (ATDef td) typeFields = case typeDef of - ATDRecord fs -> L.toList fs + ATDRecord fs -> fs ATDUnion ms -> APIRecordField "type" tagType : concatMap (\(ATUnionMember _ fs) -> fs) ms where tagType = ATDef $ APITypeDef (name <> ".type") $ ATDEnum tags tags = L.map (\(ATUnionMember tag _) -> tag) ms ATDEnum _ -> [] typeDefText = \case - ATDRecord fields -> "\n**Record type**:\n" <> fieldsText "" (L.toList fields) + ATDRecord fields -> "\n**Record type**:\n" <> fieldsText "" fields ATDEnum cs -> "\n**Enum type**:\n" <> foldMap (\m -> "- \"" <> T.pack m <> "\"\n") cs ATDUnion cs -> "\n**Discriminated union type**:\n" <> foldMap constrText cs where diff --git a/bots/src/API/Docs/Generate/TypeScript.hs b/bots/src/API/Docs/Generate/TypeScript.hs new file mode 100644 index 0000000000..b69635086e --- /dev/null +++ b/bots/src/API/Docs/Generate/TypeScript.hs @@ -0,0 +1,189 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module API.Docs.Generate.TypeScript where + +import API.Docs.Commands +import API.Docs.Events +import API.Docs.Generate +import API.Docs.Responses +import API.Docs.Syntax +import API.Docs.Syntax.Types +import API.Docs.Types +import API.TypeInfo +import Data.Char (toUpper) +import qualified Data.List.NonEmpty as L +import Data.Text (Text) +import qualified Data.Text as T + +commandsCodeFile :: FilePath +commandsCodeFile = "./packages/simplex-chat-client/types/typescript/src/commands.ts" + +responsesCodeFile :: FilePath +responsesCodeFile = "./packages/simplex-chat-client/types/typescript/src/responses.ts" + +eventsCodeFile :: FilePath +eventsCodeFile = "./packages/simplex-chat-client/types/typescript/src/events.ts" + +typesCodeFile :: FilePath +typesCodeFile = "./packages/simplex-chat-client/types/typescript/src/types.ts" + +commandsCodeText :: Text +commandsCodeText = + ("// API Commands\n// " <> autoGenerated <> "\n") + <> "\nimport * as T from \"./types\"\n" + <> "\nimport {CR} from \"./responses\"\n" + <> foldMap commandCatCode chatCommandsDocs + where + commandCatCode CCCategory {categoryName, categoryDescr, commands} = + (T.pack $ "\n// " <> categoryName <> "\n// " <> categoryDescr <> "\n") + <> foldMap commandCode commands + where + commandCode CCDoc {commandType = ATUnionMember tag params, commandDescr, syntax, responses, network} = + ("\n// " <> commandDescr <> "\n") + <> ("// Network usage: " <> networkUsage network <> ".\n") + <> ("export interface " <> T.pack constrName <> " {\n") + <> fieldsCode "" "T." params + <> "}\n\n" + <> ("export namespace " <> T.pack constrName <> " {\n") + <> (" export type Response = " <> constrsCode " " "CR" (("CR." <> ) . T.pack . fstToUpper . memberTag) (map responseType responses)) + <> (if syntax == "" then "" else funcCode APITypeDef {typeName' = constrName, typeDef = ATDRecord params} syntax) + <> "}\n" + where + constrName = fstToUpper tag + +responsesCodeText :: Text +responsesCodeText = + ("// API Responses\n// " <> autoGenerated <> "\n") + <> "\nimport * as T from \"./types\"\n" + <> unionTypeCode "CR" "T." chatRespTypeDef chatRespConstrs "" + where + chatRespTypeDef = APITypeDef {typeName' = "ChatResponse", typeDef = ATDUnion chatRespConstrs} + chatRespConstrs = L.fromList $ map responseType chatResponsesDocs + +eventsCodeText :: Text +eventsCodeText = + ("// API Events\n// " <> autoGenerated <> "\n") + <> "\nimport * as T from \"./types\"\n" + <> unionTypeCode "CEvt" "T." chatEventTypeDef chatEventConstrs "" + where + chatEventTypeDef = APITypeDef {typeName' = "ChatEvent", typeDef = ATDUnion chatEventConstrs} + chatEventConstrs = L.fromList $ concatMap catEvents chatEventsDocs + catEvents CECategory {mainEvents, otherEvents} = map eventType $ mainEvents ++ otherEvents + +typesCodeText :: Text +typesCodeText = ("// API Types\n// " <> autoGenerated <> "\n") <> foldMap typeCode chatTypesDocs + where + typeCode CTDoc {typeDef = td@APITypeDef {typeName' = name, typeDef}, typeDescr, typeSyntax} = + (if T.null typeDescr then "" else "// " <> typeDescr <> "\n") + <> typeDefCode + -- <> (if typeSyntax == "" then "" else syntaxText (name, self : typeFields) typeSyntax) + where + name' = T.pack name + constrName tag = case name of + "ConnectionMode" -> T.pack $ map toUpper tag + "FileProtocol" -> T.pack $ map toUpper tag + _ -> T.replace "-" "_" $ T.pack $ fstToUpper tag + namespaceFuncCode = "\nexport namespace " <> name' <> " {" <> funcCode td typeSyntax <> "}\n" + typeDefCode = case typeDef of + ATDRecord fields -> + ("\nexport interface " <> name' <> " {\n") + <> fieldsCode "" "" fields + <> "}\n" + <> (if typeSyntax == "" then "" else namespaceFuncCode) + ATDEnum cs -> + ("\nexport enum " <> name' <> " {\n") + <> foldMap (\m -> " " <> constrName m <> " = \"" <> T.pack m <> "\",\n") cs + <> "}\n" + <> (if typeSyntax == "" then "" else namespaceFuncCode) + ATDUnion cs -> unionTypeCode name' "" td cs typeSyntax + +unionTypeCode :: Text -> String -> APITypeDef -> L.NonEmpty ATUnionMember -> Expr -> Text +unionTypeCode unionNamespace typesNamespace td@APITypeDef {typeName' = name} cs cmdSyntax = + ("\nexport type " <> name' <> " = " <> constrsCode "" name' constrTypeRef (L.toList cs) <> "\n") + <> ("export namespace " <> unionNamespace <> " {\n") + <> (" export type Tag = " <> constrsCode " " name' constrTag (L.toList cs) <> "\n") + <> (" interface Interface {\n type: Tag\n }\n") + <> foldMap constrType cs + <> (if cmdSyntax == "" then "" else funcCode td cmdSyntax) + <> "}\n" + where + name' = T.pack name + constrTypeRef (ATUnionMember tag _) = unionNamespace <> "." <> constrName tag + constrTag (ATUnionMember tag _) = T.pack $ "\"" <> tag <> "\"" + constrType c@(ATUnionMember tag fields) = + ("\n export interface " <> constrName tag <> " extends Interface {\n") + <> " type: " <> constrTag c <> "\n" + <> fieldsCode " " typesNamespace fields + <> " }\n" + constrName tag = T.replace "-" "_" (T.pack $ fstToUpper tag) + +constrsCode :: Text -> Text -> (ATUnionMember -> Text) -> [ATUnionMember] -> Text +constrsCode indent name' constr cs + | T.length (name' <> " = " <> line) <= 100 = line <> "\n" + | otherwise = "\n" <> foldMap (\c -> indent <> " | " <> c <> "\n") cs' + where + line = T.intercalate " | " cs' + cs' = map constr cs + +funcCode :: APITypeDef -> Expr -> Text +funcCode td@APITypeDef {typeName' = name, typeDef} cmdSyntax = + "\n export function cmdString(" <> param <> ": " <> T.pack name <> "): string {\n return " <> jsSyntaxText True (name, self : typeFields) cmdSyntax <> "\n }\n" + where + param = if hasParams cmdSyntax then "self" else "_self" + self = APIRecordField "self" (ATDef td) + typeFields = case typeDef of + ATDRecord fs -> fs + ATDUnion ms -> APIRecordField "type" tagType : concatMap (\(ATUnionMember _ fs) -> fs) ms + where + tagType = ATDef $ APITypeDef (name <> ".type") $ ATDEnum tags + tags = L.map (\(ATUnionMember tag _) -> tag) ms + ATDEnum _ -> [] + +fieldsCode :: Text -> String -> [APIRecordField] -> Text +fieldsCode indent namespace = foldMap $ (indent <>) . T.pack . fieldText + where + fieldText (APIRecordField name t) = " " <> name <> optional t <> ": " <> typeText t <> typeComment t <> "\n" + optional = \case + ATOptional _ -> "?" + _ -> "" + typeText = \case + ATPrim (PT t) -> typeName t + ATDef (APITypeDef t _) -> namespace <> t + ATRef t -> namespace <> t + ATOptional t -> typeText t + ATArray {elemType} -> typeText elemType <> "[]" + ATMap (PT t) valueType -> "{[key: " <> typeName t <> "]: " <> typeText valueType <> "}" + typeName = \case + TBool -> "boolean" + TInt -> "number" + TInt64 -> "number" + TWord32 -> "number" + TDouble -> "number" + TJSONObject -> "object" + TUTCTime -> "string" + t -> t + typeComment t = let c = typeComment' t in if null c then "" else " // " <> c + typeComment' = \case + ATPrim (PT t) -> typeComment_ t + ATOptional (ATPrim (PT t)) -> typeComment_ t + ATArray {elemType, nonEmpty} + | nonEmpty -> (if null c then "" else c <> ", ") <> "non-empty" + | otherwise -> c + where + c = typeComment' elemType + ATMap (PT k) v -> + let kc = typeComment_ k + vc = typeComment' v + tc t c = if null c then t else c + in if null kc && null vc then "" else tc (typeName k) kc <> " : " <> tc (typeText v) vc + _ -> "" + typeComment_ = \case + TInt -> "int" + TInt64 -> "int64" + TWord32 -> "word32" + TDouble -> "double" + TUTCTime -> "ISO-8601 timestamp" + _ -> "" diff --git a/bots/src/API/Docs/Responses.hs b/bots/src/API/Docs/Responses.hs index 96f461e696..154d44b6c2 100644 --- a/bots/src/API/Docs/Responses.hs +++ b/bots/src/API/Docs/Responses.hs @@ -88,6 +88,7 @@ chatResponsesDocsData = ("CRUserContactLinkUpdated", "User contact address updated"), ("CRUserDeletedMembers", "Members deleted"), ("CRUserProfileUpdated", "User profile updated"), + ("CRUserProfileNoChange", "User profile was not changed"), ("CRUsersList", "Users") -- ("CRApiChat", "Chat and messages"), -- ("CRApiChats", "Chats with the most recent messages"), @@ -129,6 +130,7 @@ undocumentedResponses = "CRChats", "CRChatStarted", "CRChatStopped", + "CRConnectionsDiff", "CRChatTags", "CRConnectionAliasUpdated", "CRConnectionIncognitoUpdated", @@ -161,10 +163,10 @@ undocumentedResponses = "CRGroupUserChanged", "CRItemsReadForChat", "CRJoinedGroupMember", + "CRMemberSupportChatRead", "CRMemberSupportChatDeleted", "CRMemberSupportChats", "CRNetworkConfig", - "CRNetworkStatuses", "CRNewMemberContact", "CRNewMemberContactSentInv", "CRMemberContactAccepted", @@ -195,7 +197,6 @@ undocumentedResponses = "CRUserPrivacy", "CRUserProfile", "CRUserProfileImage", - "CRUserProfileNoChange", "CRUserServers", "CRUserServersValidation", "CRVersionInfo", diff --git a/bots/src/API/Docs/Syntax.hs b/bots/src/API/Docs/Syntax.hs index df6e126ca4..83fa2bf6a2 100644 --- a/bots/src/API/Docs/Syntax.hs +++ b/bots/src/API/Docs/Syntax.hs @@ -99,8 +99,8 @@ withOptBoolParam r param p f = (ATOptional (ATPrim (PT TBool))) -> f True _ -> paramError r param p "is not [optional] boolean" -jsSyntaxText :: TypeAndFields -> Expr -> Text -jsSyntaxText r = T.replace "' + '" "" . T.pack . go Nothing True +jsSyntaxText :: Bool -> TypeAndFields -> Expr -> Text +jsSyntaxText useSelf r = T.replace "' + '" "" . T.pack . go Nothing True where go param top = \case Concat exs -> intercalate " + " $ map (go param False) $ L.toList exs @@ -109,14 +109,14 @@ jsSyntaxText r = T.replace "' + '" "" . T.pack . go Nothing True withParamType r param p $ \case ATDef td -> toStringSyntax td ATOptional (ATDef td) -> toStringSyntax td - _ -> paramName param p + _ -> paramName' useSelf param p where toStringSyntax (APITypeDef typeName _) - | typeHasSyntax typeName = paramName param p <> ".toString()" - | otherwise = paramName param p + | typeHasSyntax typeName = paramName' useSelf param p <> ".toString()" + | otherwise = paramName' useSelf param p Optional exN exJ p -> open <> n <> " ? " <> go (Just p) False exJ <> " : " <> nothing <> close where - n = paramName param p + n = paramName' useSelf param p nothing = if exN == "" then "''" else go param False exN Choice p opts else' -> withParamType r param p $ \case @@ -125,15 +125,15 @@ jsSyntaxText r = T.replace "' + '" "" . T.pack . go Nothing True _ -> paramError r param p "is not union type" where choiceSyntax = \case - APITypeDef _ (ATDUnion _) -> choices "type" + APITypeDef _ (ATDUnion _) -> choices $ (if useSelf then "self." else "") <> "type" APITypeDef _ (ATDEnum _) -> choices "self" _ -> paramError r param p "is not union type" choices var = open <> optsSyntax <> " : " <> go param top else' <> close where optsSyntax = intercalate " : " $ map (\(tag, ex) -> var <> " == '" <> tag <> "' ? " <> go param top ex) $ L.toList opts - Join c p -> paramName param p <> ".join('" <> [c] <> "')" - Json p -> "JSON.stringify(" <> paramName param p <> ")" - OnOff p -> open <> paramName param p <> " ? 'on' : 'off'" <> close + Join c p -> paramName' useSelf param p <> ".join('" <> [c] <> "')" + Json p -> "JSON.stringify(" <> paramName' useSelf param p <> ")" + OnOff p -> open <> paramName' useSelf param p <> " ? 'on' : 'off'" <> close OnOffParam name p def_ -> case def_ of Nothing -> withOptBoolParam r param p $ \optional -> @@ -141,13 +141,13 @@ jsSyntaxText r = T.replace "' + '" "" . T.pack . go Nothing True then "(typeof " <> n <> " == 'boolean' ? " <> res <> " : '')" else res where - n = paramName param p + n = paramName' useSelf param p res = "' " <> name <> "=' + (" <> n <> " ? 'on' : 'off')" Just def | def -> open <> "!" <> n <> " ? ' " <> name <> "=off' : ''" <> close | otherwise -> open <> n <> " ? ' " <> name <> "=on' : ''" <> close where - n = paramName param p + n = paramName' useSelf param p where open = if top then "" else "(" close = if top then "" else ")" @@ -215,6 +215,13 @@ pySyntaxText r = T.pack . go Nothing True close = if top then "" else ")" paramName :: Maybe ExprParam -> ExprParam -> String -paramName param_ p = case param_ of - Just param | p == "$0" -> param - _ -> p +paramName = paramName' False + +paramName' :: Bool -> Maybe ExprParam -> ExprParam -> String +paramName' useSelf param_ p + | useSelf && p' /= "self" = "self." <> p' + | otherwise = p' + where + p' = case param_ of + Just param | p == "$0" -> param + _ -> p diff --git a/bots/src/API/Docs/Syntax/Types.hs b/bots/src/API/Docs/Syntax/Types.hs index 211348d5a9..385c5e02c2 100644 --- a/bots/src/API/Docs/Syntax/Types.hs +++ b/bots/src/API/Docs/Syntax/Types.hs @@ -26,6 +26,18 @@ isConst = \case Const _ -> True _ -> False +hasParams :: Expr -> Bool +hasParams = \case + Concat es -> any (hasParams) es + Const _ -> False + Param _ -> True + Optional {} -> True + Choice {} -> True + Join {} -> True + Json _ -> True + OnOff _ -> True + OnOffParam {} -> True + instance IsString Expr where fromString = Const instance Semigroup Expr where diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index c0cf2867c2..73e427fa03 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -29,7 +29,6 @@ import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent import Simplex.Chat.Messages.CIContent.Events import Simplex.Chat.Protocol -import Simplex.Chat.Store.Groups import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared import Simplex.Chat.Types @@ -42,7 +41,8 @@ import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Client import Simplex.Messaging.Crypto.File import Simplex.Messaging.Parsers (dropPrefix, fstToLower) -import Simplex.Messaging.Protocol (BlockingInfo (..), BlockingReason (..), CommandError (..), ErrorType (..), ProxyError (..)) +import Simplex.Messaging.Protocol (BlockingInfo (..), BlockingReason (..), CommandError (..), ErrorType (..), NetworkError (..), ProxyError (..)) +import Simplex.Messaging.Protocol.Types (ClientNotice (..)) import Simplex.Messaging.Transport import Simplex.RemoteControl.Types import System.Console.ANSI.Types (Color (..)) @@ -69,7 +69,7 @@ chatTypesDocs = sortOn docTypeName $! snd $! mapAccumL toCTDoc (S.empty, M.empty let (tds', td_) = toTypeDef tds sumTypeInfo in case td_ of Just typeDef -> (tds', CTDoc {typeDef, typeSyntax, typeDescr}) - Nothing -> error $ "Recursive type: " <> typeName + Nothing -> error $ "Recursive type: " <> typeName toTypeDef :: (S.Set String, M.Map String APITypeDef) -> (SumTypeInfo, SumTypeJsonEncoding, String, [ConsName], Expr, Text) -> ((S.Set String, M.Map String APITypeDef), Maybe APITypeDef) toTypeDef acc@(!visited, !typeDefs) (STI typeName allConstrs, jsonEncoding, consPrefix, hideConstrs, _, _) = @@ -83,8 +83,8 @@ toTypeDef acc@(!visited, !typeDefs) (STI typeName allConstrs, jsonEncoding, cons [RecordTypeInfo {fieldInfos}] -> let fields = fromMaybe (error $ "Record type without fields: " <> typeName) $ L.nonEmpty fieldInfos ((visited', typeDefs'), fields') = mapAccumL (toAPIField_ typeName) (S.insert typeName visited, typeDefs) fields - td = APITypeDef typeName $ ATDRecord fields' - in ((S.insert typeName visited', M.insert typeName td typeDefs'), Just td) + td = APITypeDef typeName $ ATDRecord $ L.toList fields' + in ((S.insert typeName visited', M.insert typeName td typeDefs'), Just td) _ -> error $ "Record type with " <> show (length constrs) <> " constructors: " <> typeName STUnion -> if length constrs > 1 then toUnionType constrs else unionError constrs STUnion1 -> if length constrs == 1 then toUnionType constrs else unionError constrs @@ -98,16 +98,16 @@ toTypeDef acc@(!visited, !typeDefs) (STI typeName allConstrs, jsonEncoding, cons toUnionType constrs = let ((visited', typeDefs'), members) = mapAccumL toUnionMember (S.insert typeName visited, typeDefs) $ fromMaybe (unionError constrs) $ L.nonEmpty constrs td = APITypeDef typeName $ ATDUnion members - in ((S.insert typeName visited', M.insert typeName td typeDefs'), Just td) + in ((S.insert typeName visited', M.insert typeName td typeDefs'), Just td) toUnionMember tds RecordTypeInfo {consName, fieldInfos} = let memberTag = normalizeConsName consPrefix consName - in second (ATUnionMember memberTag) $ mapAccumL (toAPIField_ typeName) tds fieldInfos + in second (ATUnionMember memberTag) $ mapAccumL (toAPIField_ typeName) tds fieldInfos unionError constrs = error $ "Union type with " <> show (length constrs) <> " constructor(s): " <> typeName toEnumType = toEnumType_ $ normalizeConsName consPrefix toEnumType_ f constrs = let members = L.map toEnumMember $ fromMaybe (enumError constrs) $ L.nonEmpty constrs td = APITypeDef typeName $ ATDEnum members - in ((S.insert typeName visited, M.insert typeName td typeDefs), Just td) + in ((S.insert typeName visited, M.insert typeName td typeDefs), Just td) where toEnumMember RecordTypeInfo {consName, fieldInfos} = case fieldInfos of [] -> f consName @@ -121,7 +121,7 @@ toAPIField_ typeName tds (FieldInfo fieldName typeInfo) = second (APIRecordField toAPIType = \case TIType (ST name _) -> apiTypeForName name TIOptional tInfo -> second ATOptional $ toAPIType tInfo - TIArray {elemType, nonEmpty} -> second (`ATArray`nonEmpty) $ toAPIType elemType + TIArray {elemType, nonEmpty} -> second (`ATArray` nonEmpty) $ toAPIType elemType TIMap {keyType = ST name _, valueType} | name `elem` primitiveTypes -> second (ATMap (PT name)) $ toAPIType valueType | otherwise -> error $ "Non-primitive key type in " <> typeName <> ", " <> fieldName @@ -133,7 +133,7 @@ toAPIField_ typeName tds (FieldInfo fieldName typeInfo) = second (APIRecordField Nothing -> case find (\(STI name' _, _, _, _, _, _) -> name == name') chatTypesDocsData of Just sumTypeInfo -> let (tds', td_) = toTypeDef tds sumTypeInfo -- recursion to outer function, loops are resolved via type defs map lookup - in case td_ of + in case td_ of Just td -> (tds', ATDef td) Nothing -> (tds', ATRef name) Nothing -> error $ "Undefined type: " <> name @@ -199,7 +199,7 @@ chatTypesDocsData = (sti @(ContactUserPreference SimplePreference), STRecord, "", [], "", ""), (sti @(CreatedConnLink 'CMContact), STRecord, "", [], Param "connFullLink" <> Optional "" (" " <> Param "$0") "connShortLink", ""), (sti @AddressSettings, STRecord, "", [], "", ""), - (sti @AgentCryptoError, STUnion, "", [], "", ""), + (sti @AgentCryptoError, STUnion, "", ["RATCHET_EARLIER", "RATCHET_SKIPPED"], "", ""), -- TODO add fields to types (sti @AgentErrorType, STUnion, "", [], "", ""), (sti @AutoAccept, STRecord, "", [], "", ""), (sti @BlockingInfo, STRecord, "", [], "", ""), @@ -217,7 +217,7 @@ chatTypesDocsData = (sti @ChatRef, STRecord, "", [], Param "chatType" <> Param "chatId" <> Optional "" (Param "$0") "chatScope", "Used in API commands. Chat scope can only be passed with groups."), (sti @ChatSettings, STRecord, "", [], "", ""), (sti @ChatStats, STRecord, "", [], "", ""), - (sti @ChatType, STEnum, "CT", ["CTContactRequest", "CTContactConnection"], Choice "self" [("contact", "@"), ("group", "#"), ("local", "*")] "", ""), + (sti @ChatType, STEnum, "CT", ["CTContactRequest", "CTContactConnection"], Choice "self" [("direct", "@"), ("group", "#"), ("local", "*")] "", ""), (sti @ChatWallpaper, STRecord, "", [], "", ""), (sti @ChatWallpaperScale, STEnum, "CWS", [], "", ""), (sti @CICallStatus, STEnum, "CISCall", [], "", ""), @@ -229,6 +229,7 @@ chatTypesDocsData = (sti @CIMentionMember, STRecord, "", [], "", ""), (sti @CIReactionCount, STRecord, "", [], "", ""), (sti @CITimed, STRecord, "", [], "", ""), + (sti @ClientNotice, STRecord, "", [], "", ""), (sti @Color, STEnum, "", [], "", ""), (sti @CommandError, STUnion, "", [], "", ""), (sti @CommandErrorType, STUnion, "", [], "", ""), @@ -239,7 +240,7 @@ chatTypesDocsData = (sti @ConnectionMode, (STEnum' $ take 3 . consLower "CM"), "", [], "", ""), (sti @ConnectionPlan, STUnion, "CP", [], "", ""), (sti @ConnStatus, (STEnum' $ consSep "Conn" '-'), "", [], "", ""), - (sti @ConnType, (STEnum' $ consSep "Conn" '_'), "", ["ConnSndFile", "ConnRcvFile"], "", ""), + (sti @ConnType, (STEnum' $ consSep "Conn" '_'), "", [], "", ""), (sti @Contact, STRecord, "", [], "", ""), (sti @ContactAddressPlan, STUnion, "CAP", [], "", ""), (sti @ContactShortLinkData, STRecord, "", [], "", ""), @@ -267,7 +268,6 @@ chatTypesDocsData = (sti @GroupFeature, STEnum, "GF", [], "", ""), (sti @GroupFeatureEnabled, STEnum, "FE", [], "", ""), (sti @GroupInfo, STRecord, "", [], "", ""), - (sti @GroupInfoSummary, STRecord, "", [], "", ""), (sti @GroupLink, STRecord, "", [], "", ""), (sti @GroupLinkPlan, STUnion, "GLP", [], "", ""), (sti @GroupMember, STRecord, "", [], "", ""), @@ -299,6 +299,7 @@ chatTypesDocsData = (sti @MsgFilter, STEnum, "MF", [], "", ""), (sti @MsgReaction, STUnion, "MR", [], "", ""), (sti @MsgReceiptStatus, STEnum, "MR", [], "", ""), + (sti @NetworkError, STUnion, "NE", [], "", ""), (sti @NewUser, STRecord, "", [], "", ""), (sti @NoteFolder, STRecord, "", [], "", ""), (sti @PendingContactConnection, STRecord, "", [], "", ""), @@ -315,7 +316,6 @@ chatTypesDocsData = (sti @RcvConnEvent, STUnion, "RCE", [], "", ""), (sti @RcvDirectEvent, STUnion, "RDE", [], "", ""), (sti @RcvFileDescr, STRecord, "", [], "", ""), - (sti @RcvFileInfo, STRecord, "", [], "", ""), (sti @RcvFileStatus, STUnion, "RFS", [], "", ""), (sti @RcvFileTransfer, STRecord, "", [], "", ""), (sti @RcvGroupEvent, STUnion, "RGE", [], "", ""), @@ -351,7 +351,6 @@ chatTypesDocsData = (sti @XFTPErrorType, STUnion, "", [], "", ""), (sti @XFTPRcvFile, STRecord, "", [], "", ""), (sti @XFTPSndFile, STRecord, "", [], "", "") - -- (sti @DatabaseError, STUnion, "DB", [], "", ""), -- (sti @ChatItemInfo, STRecord, "", [], "", ""), -- (sti @ChatItemVersion, STRecord, "", [], "", ""), @@ -370,7 +369,7 @@ chatTypesDocsData = -- (sti @SendRef, STRecord, "", [], "", ""), -- (sti @SndQueueInfo, STRecord, "", [], "", ""), -- (sti @SndSwitchStatus, STEnum, "", [], "", ""), -- incorrect - ] + ] data SimplePreference = SimplePreference {allow :: FeatureAllowed} deriving (Generic) @@ -416,6 +415,7 @@ deriving instance Generic CIMention deriving instance Generic CIMentionMember deriving instance Generic CIReactionCount deriving instance Generic CITimed +deriving instance Generic ClientNotice deriving instance Generic Color deriving instance Generic CommandError deriving instance Generic CommandErrorType @@ -454,7 +454,6 @@ deriving instance Generic GroupChatScopeInfo deriving instance Generic GroupFeature deriving instance Generic GroupFeatureEnabled deriving instance Generic GroupInfo -deriving instance Generic GroupInfoSummary deriving instance Generic GroupLink deriving instance Generic GroupLinkPlan deriving instance Generic GroupMember @@ -492,6 +491,7 @@ deriving instance Generic MsgErrorType deriving instance Generic MsgFilter deriving instance Generic MsgReaction deriving instance Generic MsgReceiptStatus +deriving instance Generic NetworkError deriving instance Generic NewUser deriving instance Generic NoteFolder deriving instance Generic PendingContactConnection @@ -508,7 +508,6 @@ deriving instance Generic RCErrorType deriving instance Generic RcvConnEvent deriving instance Generic RcvDirectEvent deriving instance Generic RcvFileDescr -deriving instance Generic RcvFileInfo deriving instance Generic RcvFileStatus deriving instance Generic RcvFileTransfer deriving instance Generic RcvGroupEvent diff --git a/bots/src/API/TypeInfo.hs b/bots/src/API/TypeInfo.hs index 3d7269e766..df43374ffa 100644 --- a/bots/src/API/TypeInfo.hs +++ b/bots/src/API/TypeInfo.hs @@ -33,7 +33,7 @@ data APIType data APITypeDef = APITypeDef {typeName' :: String, typeDef :: APITypeDefinition} data APITypeDefinition - = ATDRecord (NonEmpty APIRecordField) + = ATDRecord [APIRecordField] | ATDUnion (NonEmpty ATUnionMember) | ATDEnum (NonEmpty String) @@ -45,10 +45,6 @@ data ATUnionMember = ATUnionMember {memberTag :: String, memberFields :: [APIRec newtype PrimitiveType = PT String --- data PrimitiveType = PTBool | PTString StringFormat | PTInt | PTInt64 | PTWord32 | PTDouble - -data StringFormat = SFTimestamp | SFBase64 | SFSimpleXLink | SFServerAddr | SFHexColor - pattern TBool :: String pattern TBool = "bool" @@ -61,8 +57,20 @@ pattern TInt = "int" pattern TInt64 :: String pattern TInt64 = "int64" +pattern TWord32 :: String +pattern TWord32 = "word32" + +pattern TDouble :: String +pattern TDouble = "double" + +pattern TJSONObject :: String +pattern TJSONObject = "JSONObject" + +pattern TUTCTime :: String +pattern TUTCTime = "UTCTime" + primitiveTypes :: [ConsName] -primitiveTypes = [TBool, TString, TInt, TInt64, "word32", "double", "JSONObject", "UTCTime"] +primitiveTypes = [TBool, TString, TInt, TInt64, TWord32, TDouble, TJSONObject, TUTCTime] data SumTypeInfo = STI {typeName :: String, recordTypes :: [RecordTypeInfo]} deriving (Show) diff --git a/cabal.project b/cabal.project index 3f9364e3f5..73a5d840f9 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 2488d7de7c554e6e1655c903540ec7ab9fc7f28c + tag: e764c3cea98417ebea59c55bd591c5d75d61d90b source-repository-package type: git @@ -48,7 +48,7 @@ source-repository-package source-repository-package type: git location: https://github.com/simplex-chat/zip.git - tag: bd421c6b19cc4c465cd7af1f6f26169fb8ee1ebc + tag: 2eff156c3aac389e35d38bf10a52733d7061640a -- waiting for published warp-tls-3.4.7 source-repository-package diff --git a/docs/ABOUT.md b/docs/ABOUT.md new file mode 100644 index 0000000000..44809b9c0c --- /dev/null +++ b/docs/ABOUT.md @@ -0,0 +1,18 @@ +--- +layout: layouts/jobs.html +permalink: /about/index.html +--- + +# About us + +SimpleX Chat Ltd is a company founded to develop SimpleX network and software. + +Our mission is to create a fully decentralized network, based on the same principles as open web, but the one that gives users full control and ownership of their identity, contacts and communities. + +## Contact us + +SimpleX: "Ask SimpleX team" contact in the app or [this address](https://simplex.chat/contact#/?v=2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion). + +Email: [chat@simplex.chat](mailto:chat@simplex.chat). You can use PGP to encrypt email messages using our key from [keys.openpgp.org](https://keys.openpgp.org/search?q=chat%40simplex.chat) (its fingerprint is `FB44 AF81 A45B DE32 7319 797C 8510 7E35 7D4A 17FC`) and making your key available for a secure reply. + +You can follow our updates on social media: [X/Twitter](https://x.com/simplexchat), [Reddit](https://www.reddit.com/r/SimpleXChat/), [Mastodon](https://mastodon.social/@simplex) and [Nostr](https://primal.net/p/npub1exv22uulqnmlluszc4yk92jhs2e5ajcs6mu3t00a6avzjcalj9csm7d828). diff --git a/docs/DIRECTORY.md b/docs/DIRECTORY.md index a55f4fc510..8659222280 100644 --- a/docs/DIRECTORY.md +++ b/docs/DIRECTORY.md @@ -1,15 +1,17 @@ --- -title: SimpleX Directory Service +title: SimpleX Directory revision: 18.08.2023 --- -# SimpleX Directory Service +# SimpleX Directory -You can use an experimental directory service to discover the groups created and registered by other users. +You can use SimpleX Directory to discover the groups created and registered by other users. ## Searching for groups -Connect to the directory service via [this address](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) and send the message containing the words you want to find in the group name or welcome message. You will receive up to 10 groups with the largest number of members in the response, together with the links to join these groups. +SimpleX Directory is available at [simplex.chat/directory](https://simplex.chat/directory/) or via this [onion link](http://isdb4l77sjqoy2qq7ipum6x3at6hyn3jmxfx4zdhc72ufbmuq4ilwkqd.onion/directory/). + +You can also connect to SimpleX Directory via [this address](https://smp4.simplex.im/a#lXUjJW5vHYQzoLYgmi8GbxkGP41_kjefFvBrdwg-0Ok) and send the message containing the words you want to find in the group name or welcome message. You will receive up to 10 groups with the largest number of members in the response, together with the links to join these groups. Please note that your search queries can be kept by the bot as the conversation history, but you can use incognito mode when connecting to the bot, to avoid correlation with any other communications. See [Privacy policy](../PRIVACY.md) for more details. @@ -94,7 +96,7 @@ Group owners are expected to moderate the content in the groups, if members post We reserve the right to not accept the group listing in the directory or cancel its listing, and there may be cases when we can't provide an explanation. We will certainly try to avoid it by communicating with the group owners first. -The combination of display name and full name has to be unique for the listed groups. +The combination of display name and full name has to be unique for the listed groups. If a group uses the name or logo of SimpleX, SimpleX network or SimpleX Chat it must be consistent with [Permitted Uses or SimpleX trademark](./TRADEMARK.md). Once the group is listed in the directory, the bot will invite you to join the group of the group owners, where you can send any ideas or suggestions for how the groups functionality should evolve, and help steer both the product and the policies. diff --git a/docs/DONATIONS.md b/docs/DONATIONS.md new file mode 100644 index 0000000000..3fdf362021 --- /dev/null +++ b/docs/DONATIONS.md @@ -0,0 +1,32 @@ +--- +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) +- BCH: [qq6c8vfvxqrk6rhdysgvkhqc24sggkfsx5nqvdlqcg](bitcoincash:qq6c8vfvxqrk6rhdysgvkhqc24sggkfsx5nqvdlqcg) +- ETH/USDT (Ethereum, Arbitrum One): [0xD7047Fe3Eecb2f2FF78d839dD927Be27Bc12c86a](ethereum:0xD7047Fe3Eecb2f2FF78d839dD927Be27Bc12c86a) +- ZEC: [t1fwjQW5gpFhDqXNhxqDWyF9j9WeKvVS5Jg](zcash:t1fwjQW5gpFhDqXNhxqDWyF9j9WeKvVS5Jg) +- ZEC shielded: [u16rnvkflumf5uw9frngc2lymvmzgdr2mmc9unyu0l44unwfmdcpfm0axujd2w34ct3ye709azxsqge45705lpvvqu264ltzvfay55ygyq](zcash:u16rnvkflumf5uw9frngc2lymvmzgdr2mmc9unyu0l44unwfmdcpfm0axujd2w34ct3ye709azxsqge45705lpvvqu264ltzvfay55ygyq) +- DOGE: [D99pV4n9TrPxBPCkQGx4w4SMSa6QjRBxPf](dogecoin:D99pV4n9TrPxBPCkQGx4w4SMSa6QjRBxPf) +- SOL: [7JCf5m3TiHmYKZVr6jCu1KeZVtb9Y1jRMQDU69p5ARnu](solana:7JCf5m3TiHmYKZVr6jCu1KeZVtb9Y1jRMQDU69p5ARnu) + +Thank you, + +Evgeny + +SimpleX Chat founder diff --git a/docs/DOWNLOADS.md b/docs/DOWNLOADS.md index df22ff64a2..a23a920bbb 100644 --- a/docs/DOWNLOADS.md +++ b/docs/DOWNLOADS.md @@ -19,9 +19,12 @@ You can get the latest beta releases from [GitHub](https://github.com/simplex-ch You can link your mobile device with desktop to use the same profile remotely, but this is only possible when both devices are connected to the same local network. -**Linux**: [AppImage](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-x86_64.AppImage) or [Flatpak](https://flathub.org/apps/chat.simplex.simplex) (most Linux distros), [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-ubuntu-20_04-x86_64.deb) (and Debian-based distros), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-ubuntu-22_04-x86_64.deb). +**Linux**: +- Most Linux distros: AppImage ([x86_64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-x86_64.AppImage), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-aarch64.AppImage)) or [Flatpak](https://flathub.org/apps/chat.simplex.simplex). +- Ubuntu 22.04 and Debian-based distros ([x86_64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-ubuntu-22_04-x86_64.deb), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-ubuntu-22_04-aarch64.deb)). +- Ubuntu 24.04 ([x86_64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-ubuntu-24_04-x86_64.deb), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-ubuntu-24_04-aarch64.deb)). -**Mac**: [aarch64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-macos-aarch64.dmg) (Apple Silicon), [x86_64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-macos-x86_64.dmg) (Intel). +**Mac**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-macos-x86_64.dmg) (Intel), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-macos-aarch64.dmg) (Apple Silicon). **Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-windows-x86_64.msi). @@ -35,8 +38,8 @@ You can link your mobile device with desktop to use the same profile remotely, b See [Using terminal app](/docs/CLI.md). -**Linux**: [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-chat-ubuntu-20_04-x86-64), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-chat-ubuntu-22_04-x86-64). +**Linux**: Ubuntu 22.04 ([x86_64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-chat-ubuntu-22_04-x86_64), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-chat-ubuntu-22_04-aarch64)), Ubuntu 24.04 ([x86_64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-chat-ubuntu-24_04-x86_64), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-chat-ubuntu-24_04-aarch64)). -**Mac** [x86_64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-chat-macos-x86-64), aarch64 - [compile from source](/docs/CLI.md#). +**Mac**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-chat-macos-x86-64), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-chat-macos-aarch64). **Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-chat-windows-x86-64). diff --git a/docs/FAQ.md b/docs/FAQ.md index eb68e948ee..a24f9f9c56 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -1,13 +1,13 @@ --- title: Frequently Asked Questions permalink: /faq/index.html -revision: 23.04.2024 +revision: 13.08.2025 --- # Frequently Asked Questions [How to use it](#how-to-use-it) -- [How do I add contacts?](#how-do-i-add-contacts) +- [How do I connect to people?](#how-do-i-connect-to-people) - [I have nobody to chat with! Where can I find any groups?](#i-have-nobody-to-chat-with-where-can-i-find-any-groups) - [What is database? What can I do with it?](#what-is-database-what-can-i-do-with-it) - [Can I send files over SimpleX? ](#can-i-send-files-over-simplex) @@ -19,7 +19,8 @@ revision: 23.04.2024 - [I want to see when my contacts read my messages](#i-want-to-see-when-my-contacts-read-my-messages) - [Can I use the same profile on desktop? Do messages sync cross-platform?](#can-i-use-the-same-profile-on-desktop-do-messages-sync-cross-platform) - [Why cannot I delete messages I sent from my contact's device?](#why-cannot-i-delete-messages-i-sent-from-my-contacts-device) -- [Why invitation links use simplex.chat domain?](#why-invitation-links-use-simplex.chat-domain) +- [What do group roles mean?](#what-do-group-roles-mean) +- [I don't want to share a web link or a QR code. How can I connect?](#i-dont-want-to-share-a-web-link-or-a-qr-code-how-can-i-connect) [Troubleshooting](#troubleshooting) - [I do not receive messages or message notifications](#i-do-not-receive-messages-or-message-notifications) @@ -50,7 +51,7 @@ revision: 23.04.2024 ### How do I connect to people? -Tap "pencil" button in the right corner, then "Create 1-time link". Share the link with the person you want to connect to. Your contact has to paste the link in the app's search bar. The link will can also be opened via the browser, once the app is installed. +Tap "pencil" button in the right corner, then "Create 1-time link". Share the link with the person you want to connect to. Your contact has to paste the link to the app's search bar. The link will can also be opened via the browser, once the app is installed. Alternatively, you can show the QR code when meeting in person or in a video call. @@ -135,13 +136,44 @@ It is also important to remember, that even if your contact enabled "Delete for When "Delete for everyone" is not enabled, you can still mark the sent message as deleted within 24 hours of sending it. In this case the recipient will see it as "deleted message", and will be able to reveal the original message. -### Why invitation links use simplex.chat domain? +### What do group roles mean? -You can replace `https://simplex.chat/` with `simplex:/` or with any other domain - the app never connect with it, ignoring it completely. It is only used to make it easier to connect for the new users who did not install the app yet. +Groups support 5 member roles: owner, admin, moderator, member and observer. -The invitation links will soon move to servers' domains. The servers already can host the pages that will be used to show QR codes. +An observer can: +- add reactions +- send reports +- talk to admins via "Chat with admins" -The link itself and the key exchange are not hosted anywhere, and the server that hosts the page to show QR code does not observe the actual connection link, because it is in the hash part of the link. The part after hash character (`#`) is not sent over the internet - the server can only see `https://simplex.chat/contact/` and the rest is processed on user's device in the browser, if you open it as a page. +A member can: +- send messages + +A moderator can: +- approve members in review ("knocking") +- moderate messages* +- block members temporarily* +- participate in "Chats with members" + +An admin can: +- add and remove members* +- change member roles*, including their own +- create group links (but the new members will join via them, so use links of admins who are always online). + +A group owner can: +- edit group profile +- delete the group + +Each role can do everything that the previous roles can, except moderators and higher cannot send reports or "Chat with admins". + +*Any actions affecting members or their messages can only be applied to members of the same or lower role (so admins can't demote owners or promote anybody to owners, etc., but they can demote other admins and themselves, or make anybody an admin). + +### I don't want to share a web link or a QR code. How can I connect? + +You can replace the server part of the link (e.g., `https://smp18.simplex.im/`) with `simplex:/` and add `?h=smp18.simplex.im` (replace it with server of your link) to the end of the link. Then it will not be possible to use it in a web browser, but it will be recognized by SimpleX Chat app. + +For example, this link: `https://smp18.simplex.im/i#E74vSxMwDnEx6DAvRCZmzBeZwwAseJUD/yVTHjaaH_EzL19DG7fvd46Mjry3IBqYT0UMo5G7l4jQ` + +becomes: `simplex:/i#E74vSxMwDnEx6DAvRCZmzBeZwwAseJUD/yVTHjaaH_EzL19DG7fvd46Mjry3IBqYT0UMo5G7l4jQ?h=smp18.simplex.im` ## Troubleshooting diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index 0cb855d729..fe0bd107ed 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -4,6 +4,10 @@ Choosing a private messenger requires the understanding of many technical terms, While this glossary aims to be factual and objective, it is not completely unbiased. We designed SimpleX to be the most private, secure and resilient communication network, and some definitions reflect this view. +## 2-factor key exchange + +The ability of communication service to ensure the security of the [key agreement protocol](#key-agreement-protocol) against [man-in-the-middle](#man-in-the-middle-attack). + ## Address portability Similarly to [phone number portability](https://en.wikipedia.org/wiki/Local_number_portability) (the ability of the customer to transfer the service to another provider without changing the number), the address portability means the ability of a communication service customer to change the service provider without changing the service address. Many [federated networks](#federated-network) support SRV records to provide address portability, but allowing service users to set up their own domains for the addresses is not as commonly supported by the available server and client software as for email. @@ -89,7 +93,7 @@ Also known as perfect forward secrecy, it is a feature of a [key agreement proto ## Key agreement protocol -Also known as key exchange, it is a process of agreeing cryptographic keys between the sender and the recipient(s) of the message. It is required for [end-to-end encryption](#end-to-end-encryption) to work. +Also known as key exchange, it is a process of agreeing cryptographic keys between the sender and the recipient(s) of the message. It is required for [end-to-end encryption](#end-to-end-encryption) to work. Unless it is possible to secure the key exchange via [some second factor](#2-factor-key-exchange), e.g. security code verification, it can be vulnerable to [man-in-the-middle attack](#man-in-the-middle-attack). [Wikipedia](https://en.wikipedia.org/wiki/Key-agreement_protocol) @@ -169,11 +173,11 @@ The advantage is that the participants do not depend on any servers. There are [ ## Post-compromise security -Also known as break-in recovery, it is the quality of the end-to-end encryption scheme allowing to recover security against a passive attacker who observes encrypted messages after compromising one (or both) of the parties. Also known as recovery from compromise or break-in recovery. [Double-ratchet algorithm](#double-ratchet-algorithm) has this quality. +The quality of the end-to-end encryption scheme allowing to recover security against a passive attacker who observes encrypted messages after compromising one (or both) of the parties. Also known as recovery from compromise or break-in recovery. [Double-ratchet algorithm](#double-ratchet-algorithm) has this quality. ## Post-quantum cryptography -Any of the proposed cryptographic systems or algorithms that are thought to be secure against an attack by a quantum computer. It appears that as of 2023 there is no system or algorithm that is proven to be secure against such attacks, or even to be secure against attacks by massively parallel conventional computers, so a general recommendation is to use post-quantum cryptographic systems in combination with the traditional cryptographic systems. +Any of the proposed cryptographic systems or algorithms that are thought to be secure against an attack by a quantum computer. It appears that as of 2025 there is no system or algorithm that is *proven* to be secure against such attacks, or even to be secure against attacks by massively parallel conventional computers, so a general recommendation is to use post-quantum hybrid cryptography - combining post-quantum and traditional algorigthms. [Wikipedia](https://en.wikipedia.org/wiki/Post-quantum_cryptography) diff --git a/docs/TRADEMARK.md b/docs/TRADEMARK.md new file mode 100644 index 0000000000..2f8617416b --- /dev/null +++ b/docs/TRADEMARK.md @@ -0,0 +1,33 @@ +# SimpleX Chat Trademark Policy + +The SimpleX Chat name and logo are trademarks of SimpleX Chat Ltd. This policy protects the brand from confusion. All uses must follow both the context rules (permitted/prohibited) and usage guidelines below. Unauthorized uses require written permission. + +## Permitted Contexts + +- **Communities/Events**: For unofficial groups or events. +- **Addresses/Entities**: To indicate a SimpleX address, person, business, or community. +- **Publications**: In articles, blogs, videos, social media posts and presentations about SimpleX Chat or SimpleX network. +- **Software Descriptions**: To state compatibility, e.g., "Compatible with SimpleX network", in docs or descriptions (not in software name/branding). + +## Prohibited Contexts + +- **Software Branding**: As part of any software's name, branding, or logo, including forks. +- **Implied Affiliation**: Suggesting endorsement, sponsorship, or official ties without permission. +- **Commercial Activities**: In ads, merchandise, or promotions without permission. +- **Any Other**: Uses not listed as permitted. + +## Usage Guidelines + +When using in permitted contexts: +- Include "Unofficial" for communities/events. +- Use logo unchanged (shape/colors), except grayscale if needed. +- Use icon or icon with "SimpleX" word. See logos [here](../media-logos/). +- Avoid modifications or implications of official status. + +## General Terms + +We reserve all trademark rights. For permissions, contact us via email or SimpleX. We may enforce against violations. Policy updates posted here. + +## Note + +SimpleX Chat software is licensed under AGPLv3 - see [LICENSE](../LICENSE). This policy covers only trademarks, not code use. diff --git a/docs/rfcs/2025-08-09-chat-widgets.md b/docs/rfcs/2025-08-09-chat-widgets.md new file mode 100644 index 0000000000..6e9a13737c --- /dev/null +++ b/docs/rfcs/2025-08-09-chat-widgets.md @@ -0,0 +1,197 @@ +# Chat widgets and activities + +## Problems + +A short-term problem is to decide and implement support for bots in the apps UI. Just released v6.4.3 includes support for bot commands, but the most commonly used UI approach for Telegram bots is inline buttons. + +They are, effectively, simple widgets constructed as multiple lines of buttons. While currently Telegram offers web apps for bots, they are much more complex to develop for both ourselves and for bot owners, and they have many issues with security model. + +Inline buttons "pros": +- super simple to develop for bot owners - they are just a 2D array of buttons with configuration, where a button can send a visible or invisible message to a bot, which in turn can update buttons however it wants. +- allow for quite advanced interfaces, with multiple layers of navigation. +- relatively simple to implement in the app. + +Inline buttons "cons": +- very bad visual design. +- very limiting, compared with full inline widgets. + +A longer-term problem is more advanced user activities with the bot and between each other, that could include: +- polls, +- "doodles" (see doodle.com), +- more advanced bot UIs, +- mini-games, +- etc. + +## Solution + +A general UX pattern that may solve both problems is "inline chat widgets". + +For the examples of the possible use cases for inline chat widgets see https://webxdc.org/apps/ + +Problems with web apps/webxdc. +- JavaScript/Web have large binary size, that is hard to justify unless an app is a browser as well (which is not impossible), +- JavaScript has a complex security model, +- Widget size are likely to be larger than our usual message size (~15kb after compression), so they have to be sent either as files or as multiple messages. + +Irrespective of what technology is used to implement widgets, there are likely to be two kinds of widgets: +- Bot UI. These widgets do not necessarily need to be "an activity" (see the next), as there is no much scrolling in the chat with the bot, but they may also benefit from being marked as "active" in the same way. +- Widgets sent by users. In this case widget once sent cannot be replaced, but it can react to events, both from the sender and from the recipients. In this case, widgets have to be linked with "chat activities", so they can be easily discovered and accessed from any place in the chat while they are active, without scrolling to the point where they were started, and with an additional message posted to the chat once they complete. + +This RFC describes both the widget security and execution model, and also a possible implementation approach. + +## Widget security and execution models + +Widgets are code that is sent by untrusted parties (or parties with the limited trust) to the users devices, so it should not be treated as trusted code. + +Rather than defining what widgets code should be prevented from doing, we should define what it can do, its execution model and lifecycle. + +At any point in time widget has: +- code - this is fixed, and cannot be changed. While this may seem as a limitation for inline buttons UI, but it can be overcome by putting the button definitions into the state, if that's what the bot wants. +- state - this is variable, and can be changed as described below. State can be anything, other than executable code, and it can be changed by events in the way widget code allows - including full state replacement (that would allow completely changing inline buttons) + +Widget can react to user and message, as shown on the diagram. + +![Widget events](./diagrams/2025-08-09-widget-state-machine.svg) + +WL stands for "Widget Library", more on that below. + +There are should be the following restrictions to widget events/processing: +- only user events (actions) can trigger sending messages, to prevent different instances of the same widget in the chat endlessly "talking" to each other. +- only one message from each remote chat peer can be sent "to the widget" to update its state. "To the widget" means that the message with event would reference the message with the widget by shared message ID that all peers in the chat have. If the widget already processed a state update it would update further state updates from this peer until user action is processed. +- once user performs some actions on the widget, further events can be processed from the same participants who previously sent events. + +This execution model prevents abuse when widget state update can be requested multiple times. At the same time, this execution model allows for all necessary interactions, including UI updates by bot in response to user actions, polls (each user would only be able to send one poll event), and two- or multi-party games where "moves" have to be made in turns - each client would know that it should not send any events until it receives moves from all parties, and other parties won't be sending events too. + +That all raises several questions: +- which layer should be enforcing this execution model. It can't be widget "code", as this is what we are defending from. It can be either core, or widget library, or maybe both. Probably enforcing it in widget library makes most sense. +- for direct chats, both peers can participate and send message events "to widget". While each party has its own instance of widget, with its own state, they would arrive to consistent state (not necessarily the same state, as it can be programmed to be different - as would be the case for games), once they process events. But for groups, there probably needs to be two options - 1. any party can participate (e.g., doodle or poll). 2. only pre-defined peers (by group member ID) can participate. This model works for business chat where bot, customer and multiple business agents can participate, but only customer and the bot would interact with the UI widget. 3. up to a certain number of peers can participate, but it's not defined in advance who they are. This model can work for multi-party games that can be entered by a certain number of members, but it's not defined in advance which ones. Once they enter, they would be fixed and will have to send events in turn. +- another question about execution model is access to any client data. On one hand, it may be used to improve user experience. On another hand, we have to ensure that data that is received from client device can participate in view computation, but cannot participate in computation of sent message. There have been ideas of data tainting (when each piece from client device is "tainted", and result of any computation where tainted data participates becomes tainted too, and tainted messages cannot be sent), but if we go this it has to be enforced outside of bot code, so that bot code cannot remove "tainting". Such client data could be dark or light color scheme, app and system language to localize UI, timezone, screen size. Asking user permission to access this data is a bad idea, as even if it is granted it can still be used to fingerprint users. + +## Proposed implementation model + +### Widget programming language + +Using JavaScript or any other traditional language is problematic, as they are all general purpose languages that cannot be sufficiently constrained to ensure that they comply with the execution model. Data tainting idea would be particularly hard to implement. One language that can achieve what is required is the language where code is data that can be analyzed, sanitized and constrained in its execution, and where tainted data cannot be untainted by untrusted code - Lisp. One variant of Lisp particularly stands out - PicoLisp, due to its simplicity, maturity and the existence of very advanced libraries. + +While in its current state [PicoLisp](https://picolisp.com/) can only be run as a standalone process, it is feasible to change to execute it as a library. Even though it is not multithreaded, it is not required as widget execution can be queued, and Lisp execution environment will be stateless - it receives widget state, participation state (who can participate and who already sent events - not in diagram) and events, computes new state, view and an optional message to send, and stops. In addition to that PicoLisp can be "hardened" to prevent it from crashing, from accessing files and sensors, etc., even in the library code, not only in widget code. + +To achieve the required security, it is might be that Widget code needs to be interpreted by Lisp library that in its turn needs to be interpreted by PicoLisp, but possibly there can be more efficient approaches for secure code execution - e.g., it could analyze some part of the expressions and decide if to continue execution on the boundaries of some functions (e.g., those that can be called recursively, to protect against endless recursion of widget code). + +The design of this "framework"/widget library is not sufficiently clear, and some bottom-up exploration is needed. + +### Widget UI rendering + +PicoLisp has libraries to render on canvas, HTML and SVG. + +We considered using a UI library to render UI primitives without them being part of widget code. One possible option is [Nuklear](https://immediate-mode-ui.github.io/Nuklear/) - a self-contained C library that renders UI elements with event handlers in a platform-independent way, without any specific platform adapters. It can either return its own commands or OpenGL instructions that can then be converted to bitmaps. + +A simpler option, particularly to get to MVP sooner, seems an ad-hoc rendering of buttons and UX elements either to SVG or to bitmap - TBC. + +### A possible example of widget definition for inline buttons + +Widget function definition: + +```lisp +( de Activity (widget state) () # library code implementing activity from its definition + # where + # - widget a function without parameters evaluating to (render receive version), where: + # - render: function defined as '((state0) (...)), and evaluating to view medium, + # whatever we choose to use - e.g., SVG or bitmap, and any activity attributes + # (e.g., name, icon, enabled status, duration, etc.), e.g. as (view activity'), + # or maybe be as a single object value. + # Any user event/gesture handlers would be defined as part of this function, + # as they should be local to UI definition, and probably the event handlers + # would be required to return (state1 view message), + # where message can be NIL or it will be sent as visible message or invisible event. + # - receive: function defined as '((sender message state0) (...)) that will be + # invoked when message to widget is received, and evaluate to (state1 view) + # - ver: minimal library version that the activity requires to work + # (library would expose supported version range). + # - state: the initial widget state, can be updated by event handlers and receive function + # We don't explicitly include participants as parameter, they can be included in state. +) +``` + +Inline buttons widget: + +```lisp +( Activity + # ButtonGrid is a pre-defined widget function that returns: + # - render: converts buttons state to view + # - receive: simply replaces state with the message content, e.g. `((_sender message _state) message)` + # - version: (version 1) + # Bundling these things together results in better UX for widget developers, allowing us + # (and widget developers) to supply a single pre-defined symbol that combines + # rendering, message handling and required library version + + ButtonGrid + + # widget state is buttons, so they can be updated by bot in response to user actions + ( ( ("Reply" (reply "message")) # visible reply message (replying to widget) + ("Menu" (event ":menu")) # invisible message + ("Help" (send "/help")) # visible message + ) # a row of 3 buttons + ( ("Site" (link "https://example.com)) + ("Call" (link "+447777777777")) + ("Email" (link "info@example.com")) + ("Connect" (link "https://smp5.simplex.im/a#abcd)) + ("Copy" (copy "text to copy)) + ) # a row of 5 buttons + ) +) +``` + +Poll widget: + +```lisp +( Activity + # Poll is a predefined widget function. + # Note that the question and the poll options are part of the widget function, + # so they cannot be changed via events. + # Its receive function updates counts. + ( Poll + "Do you agree?" # could be NIL, to put question in widget's message text + "Yes" + "No" + ) + + # initial widget state is poll counts, questions could not be changed + (0 0) + # but we probably can just default missing counts to 0s, + # so the initial state would be just () or NIL + # Or we could even use variable arguments to allow omitting empty list as the initial state. +) +``` + +If we can achieve that these functions (ButtonGrid, Poll) can be implemented in PicoLisp using the library, without any special hacks, so that they can be used as examples, and yet they would also would be available as predefined, it will be a sufficient confirmation that the framework does what we need it to do, +and can be used in more advanced scenarios. + +Also, given that widgets that use predefined functions can be very concise, as shown in above examples, they can be attached not only to text messages, but to images, videos and link previews. + +Even `Activity` itself doesn't need to be part of widget message, we can treat widget code as the list of parameters passed to activity, so the poll would be as simple as: + +```lisp +((Poll "Do you agree?" "Yes" "No")) # the second absent parameter is interpreted as empty initial state +``` + +and the button grid as: + +```lisp +( ButtonGrid + ( ( ("Reply" (reply "message")) + ("Menu" (event ":menu")) + ("Help" (send "/help")) + ) + ( ("Site" (link "https://example.com)) + ("Call" (link "+447777777777")) + ("Email" (link "info@example.com")) + ("Connect" (link "https://smp5.simplex.im/a#abcd)) + ("Copy" (copy "text to copy)) + ) + ) +) +``` + +with the buttons definitions (the second parameter) being the initial state, that allows to have them fully replaced via bot's message. + +This is somehow similar to PicoLisp design itself where three core types are derived from a single root type - cell, here we have two distinct UX problems - bot UI and user activities, such as polls, - implemented via the same underlying abstraction. diff --git a/docs/rfcs/2025-08-11-channels-forwarding.md b/docs/rfcs/2025-08-11-channels-forwarding.md new file mode 100644 index 0000000000..ba0711c57a --- /dev/null +++ b/docs/rfcs/2025-08-11-channels-forwarding.md @@ -0,0 +1,237 @@ +# Channels forwarding + +This expands on the previous [channels rfc](./2025-07-30-channels.md), specifically on message forwarding by chat relays. + +## Problem + +Current implementation of groups uses forwarding mechanism for improved message delivery between connecting members. From perspective of reusing it for channels it has following limitations: +- Messages are forwarded only for the duration of members establishing connection, until they report x.grp.mem.con to forwarding admin. +- For a given pair of members only a single admin forwards messages - inviting admin (host) forwards messages between its invitees and members introduced to them. It means: + - This admin can be a single point of failure and/or can cause arbitrary delays in message delivery based on its availability when forwarding messages between these member pairs. + - Member pairs fully trust their single forwarding admin in forwarded content. + +Not limitations of protocol, but other weak points of current implementation: +- Forward operations are synchronous to message reception (by forwarding admin), so they're not resumable on failure. +- Forward operations are carried out as "group sends" to all required members (invitees/introduced members in respect to sending member), number of which is expected to grow very large in channels and can potentially have a big load on the database. + +## Solution + +Chat relays will serve as forwarding agents instead of inviting admins, with following modifications to protocol and implementation. + +### Forwarding not limited to establishing connection + +- In channels members will not connect directly to other members and channel owners. +- Chat relays will not take into consideration status of introductions. +- Chat relays will forward messages between owners and all members (from members to owners, for example, for reactions, comments). + +### All chat relays forward all messages + +- Instead of inviting admins all chat relays will forward all messages. +- Members will deduplicate messages (already implemented). +- Members should highlight differences in deduplicated messages. This would solve trust issue. + - Question: If messages are to be signed by owners, what differences can chat relays introduce? Does this point in initial doc imply owner sending different versions of message to chat relays? Losses in delivery? Answer: Not all messages will be signed. + +#### Highlighting deduplicated messages differences + +Currently we simply ignore duplicate messages - see createNewRcvMessage. + +Some options on how to highlight difference in messages from chat relays: +1. Show only the fact that there is difference. + - Persist duplicate error (SEDuplicateGroupMessage) as flag on chat item to then display warning sign in UI. +2. Save message hashes as some additional entity linked to message or chat item for each chat relay. + - If any new hash differs, set flag on chat item for warning sign in UI. + - Add ability to load additional info (with other chat item info via APIGetChatItemInfo) showing which relays sent different hashes (e.g. 1 differs from 2 others - show 1 as warning, all differ - show all as warning). +3. As previous, but save content. Possibly save content additionally to hashes, for faster comparison. + - Saving content means converting duplicate messages to chat item content, or only saving text, but latter will fail to show difference in files. +4. Create new versions as "different version of another message" chat items. + - Timestamp would have to be made the same as on original chat item, as it's not guaranteed to be the same - each chat relay will forward a broker ts based on its own metadata of receiving message from sender. + - It could be done via special replies, to re-use reply machinery. However, this won't work for messages being replies themselves then. + - Alternatively, it could say "different message from X relay". + +Service events will not have content. We still can/should compare them and indicate difference, e.g. by creating a special chat item (similar to integrity violation). + +How to compute hashes. Problem is with file descriptions, as it is legitimate case that they will be different. Hash could be computed as hash of message, excluding file description from FileInvitation. File digest and names should be the same, the problem of different names needs to be fixed. + +Files can also cause problem with showing difference in content if file preview is the same, but file hash is different. So showing difference in content does not exclude necessity of showing difference in hashes as in option 2. + +As a note, this whole section discusses an edge case of chat relays maliciously changing messages, and countermeasures to prevent them doing so undetectably. May be not worth implementing overly complex solution as MVP (options 3, 4). + +For MVP from UI standpoint this should suffice: a marker on item, that would show in info which relays delivered it with a sign which relay delivered different hash. Possibly also special event item next to marked item (with same timestamp) - for service events that don't create a chat item, or in case we don't account it for some chat items. + +### Forwarding jobs + +- Received messages will be marked for forwarding and picked up by a forwarding worker to form forwarding jobs. +- Forwarding job can be split into smaller batches by members to make smaller group sends one at a time. + +**How forwarding works now:** + +Currently we build ad-hoc forwarding instruction for each message based on its scope (GroupForwardScope). The output of processing a batch of messages is `Map GroupForwardScope (NonEmpty (ChatMessage 'Json))`. Then for each scope message batches are sent separately to different member lists (to clarify, "sent" here means scheduled for sending in agent). All this is done synchronously in receive loop. + +**How forwarding will work:** + +Chat relay should be able to handle channels consisting of hundreds of thousands of members. Synchronous forwarding will be replaced by asynchronous forwarding jobs to reduce load in the receive loop, and make forwarding resumable on failure. Also loading all members in memory will not scale, so these jobs have to be split into smaller batches. + +Question: Should synchronous processing be changed to forwarding jobs for all types of scopes, or only for Main (GFSMain - for regular messages) and All (GFSAll - for all current members and for all pending members scopes), and not for Support scope (GFSMemberSupport)? + - Pros/cons of persisting jobs for all types of scopes: + - Possibly more uniform processing. However, for support scope group member id is required. Either encode scope as json or add field for scope group member id for persistence (see schema below). + - Requires forwarding worker to have more logic ("if"), or different worker types. + - Pros/cons of persisting jobs only for Main and All scopes: + - Forwarding worker and persistence are simpler, but logic lives in 2 places (synchronous as now + worker). + - As a note, support scopes have small number of members to forward to, so optimizing forward for them is not a necessity. + +Answer: All forwarding logic will become asynchronous. This will reduce load in the receive loop for inviting admins in regular groups as well. + +Forwarding jobs for different groups can be concurrent, inside group should be sequential to follow order of messages. One approach could be to create a dedicated forwarding worker for each group. Different scope types can also use dedicated workers (possibly: 1 worker for all + main scopes, 1 - for all support scopes), so heavy full group forwarding wouldn't slow down forwarding in much smaller support scopes. + +Forwarding workers will re-use worker abstraction from agent. + +Forwarding workers should use low priority db pool. + +We roughly need following data for a forwarding job: +- group id, +- forwarding scope (forward_scope), +- sending member - to include memberId in XGrpMsgForward, +- broker timestamp of received messages batch to include in XGrpMsgForward, +- "message from channel" flag from sending owner - see "Hiding sending owner id" section below (group_as_sender), +- list of messages (events). + +**How to build message batches for a forwarding job:** + +Currently for each message a full MsgBody is saved on `messages` table record, which is just a ByteString. Also if received messages were batched, full batch body is saved for each message record (it's a questionable design). For example, here is message body of batched deletion of 2 chat items, which is saved on 2 message records: + +``` +msg_body = [{"v":"1-16","msgId":"ZUd3bzVDTXFHWmc3Z29YSQ==","event":"x.msg.del","params":{"msgId":"QVVuN2RkaXg0aVJJdTZXSA=="}},{"v":"1-16","msgId":"amM5dzV2RkRjNmNYSDRLQQ==","event":"x.msg.del","params":{"msgId":"NzFlV3pNRStpQTRrQjRYUg=="}}] +``` + +On the other hand for forwarding operation we require ChatMessage 'Json (for XGrpMsgForward), which could be saved on message records instead. + +As a side note, it seems we can stop saving full batch body as msg_body for received messages, as it's not used for any purpose. This field is only used for retrieving and sending pending group messages (sent, not received). + +Alternatively, we could read full msg_body, and repeatedly parse list of ChatMessage 'Json. However, this conflicts with possibility that a single batch body will have messages of different scopes, which current processing logic allows, although there's no legitimate case as of now. This means we'd have make jobs forward to different lists of members, or have some supervisor to launch different jobs, which further complicates the processing. Also, this limits forwarding to the same batch sizes (in terms of number of messages) as those batches that received messages came in, and instead we could possibly fit more messages into batches if available. Overall, persisting ChatMessage' Json on each message record, and then retrieving available messages for the job, seems preferable. + +**How to split forwarding jobs into smaller delivery batches:** + +For simplicity, this will be done only for channels, and not for inviting admins in regular groups, at least initially. Inviting admins have more complex logic, involving multiple queries and filters to retrieve less (only necessary) members, as we're trying to limit load on them, as they can be mobile clients. They will be moved to asynchronous processing too, though. + +Forwarding job will read required members in loop using a cursor (e.g., use group_member_id as a cursor) to split into further delivery jobs (group sends) by members. As a reminder, group send in terms of chat logic is not a network operation, but a request to agent. We can consider this part a black box for now, and consider if groups sends should be made concurrently later. For now though, for simplicity we can consider that group sends are done in a synchronous loop with moving cursor for member retrieval. Cursor position can be remembered on the job record after each group send, for recovery. + +It's unclear if there's a need for a separate lower level abstraction for delivery jobs, that could be reused for feeds and other purposes, as they would require different logic of building delivery lists, and scheduling a delivery for agent already serves a similar purpose. For now, it seems this additional abstraction is unnecessary. + +**How a forwarding job will work overall:** + +0. There is some process on start that launches necessary workers for each group/scope client serves as a forwarding agent (for those that have messages that need forwarding). Also message receive loop "kicks" necessary workers. Below we start with a worker for some group/scope trying to retrieve next work item, that is being next message(s) to forward. +1. Worker retrieves next message from `messages` table that was marked for forwarding, that matches the worker's group/scope. + - We may need a separate field on message record to track "forward pending" state, to know what to forward. Setting forward_scope as a task with addition of forward_complete flag to mark forward completion may be enough (see schema below). +2. Worker checks, if this message is already attached to some forwarding job, and the state of the job. + 1. Worker gets job record. + - If message is not attached to any job yet, worker creates a new job record, sets job id on the message record (normal execution path). + - Otherwise worker gets existing job attached to the message (recovery path). + 2. Worker determines forwarding batch encoding for the job. + - If encoding is not saved on the job, it means it wasn't determined before (normal execution): + 1. Worker retrieves more messages that match its group/scope. + - Could be in loop or in bulk. + - We won't know how much we'd need. Some heuristic could be read 100, then mark those that are used with jobId, or load more if needed. + - Also message list has to preserve reception order. + 2. Worker prepares encoding (wraps messages in XGrpMsgForward events with metadata) and saves it on the job record. + - It's important to put encoding into job and mark all relevant messages in one transaction. + - Otherwise worker uses saved encoding (recovery). +3. With moving cursor on group member id for member retrieval, forward encoded batch. In loop: + 1. Retrieve members up to some limit and filtering according to a cursor. In normal execution path on first iteration job would not have a previously saved cursor yet. + - For Main scope all current members will be retrieved. + - For Support scopes - moderators and above + scope member. For support scope job can be done without a cursor, instead simply loading all its members and forwarding. + - For All scope - all current and pending members (In channels there is no need for member review. So possibly we don't have to account for All scope). + - Main difference from forwarding in regular groups here is that for inviting admins we retrieve only introduced and invited members for the message's sending member, that are not yet connected. For channels there will be no such filtering. + 2. Group send encoded batch to retrieved members. + 3. Update cursor on the job. +4. Marks all messages attached to the job as forwarded (forward_complete), deletes the job record. + - Possibly do in cleanup manager and delete after say a week. + +Schema draft: + +```sql +CREATE TABLE forwarding_jobs ( + forwarding_job_id INTEGER PRIMARY KEY, + msg_batch_encoding TEXT, + cursor_group_member_id INTEGER +) + +ALTER TABLE messages ADD COLUMN chat_message_json TEXT; +ALTER TABLE messages ADD COLUMN forward_scope TEXT; +ALTER TABLE messages ADD COLUMN group_as_sender INTEGER NOT NULL DEFAULT 0; -- for "message from channel" flag from owner +ALTER TABLE messages ADD COLUMN forward_complete INTEGER NOT NULL DEFAULT 0; +ALTER TABLE messages ADD COLUMN forwarding_job_id INTEGER REFERENCES forwarding_jobs ON DELETE SET NULL; + +-- indexes for fkey, search based on group/scope and order; +``` + +## Other considerations + +### Hiding sending owner id + +Initial doc mentions "messages sent from channel name" in minimal testable scope. This would be a feature allowing owners to send messages to channel without it being clear for members which owner sent it. For this, we'd also like to hide sending owner's member id from forwarding operation. + +This means MemberId in XGrpMsgForward should become optional. + +```haskell +XGrpMsgForward :: Maybe MemberId -> ChatMessage 'Json -> UTCTime -> ChatMsgEvent 'Json +``` + +Receiving members would then save this message as received from group. We already have necessary machinery for chat items persistence - see ShowGroupAsSender. However, message processing logic would have to be reworked to allow for absence of group member. Also, sending owner would have to indicate it to chat relays in XMsgNew (TBC - other events?). + +```haskell +XMsgNew :: ShowGroupAsSender -> MsgContainer -> ChatMsgEvent 'Json +``` + +ShowGroupAsSender can be in MsgContainer, not separate. + +Chat relays should not hide sending owner from other owners: sending owner should be visible to them, but message shown as from channel. This means forwarding job has to be split into separate sends with different sets of XGrpMsgForward events (with and without sending owner's member id). + +### Connection deleting events + +Forwarding regular messages, reactions, updates and many other events is straightforward. However, naive processing of some events currently breaks forwarding logic, specifically member and group deletion. + +As it is now, forwarding operation follows event processing. So, processing of XGrpMemDel, XGrpDel first deletes member connection (or connections with all members in case of group deletion), then attempts to forward these events, which in reality never works as at this point connection is already deleted. + +Currently we ignore this problem (there are TODOs), as all members according to protocol connect with each other, and forwarding serves only as message delivery improvement/backup, and not a main route, and so members in at least supposedly most cases receive these events from the original sender. + +With chat relays, however, no messages are sent directly from owners to members, so if logic is kept as is these events would never be received. So, their processing should be special cased to delay until after forwarding operation. + +## Further considerations, ideas (Update) + +- Sending member profiles. + - Profiles of owners (and admins, moderators) should be sent to all members on joining. + - Profiles of other members should be sent on interaction with them. + - For MVP: we can show counts on reactions and avoid solving it, but we should have a design to solve this problem, as it would be necessary for comments and later for large groups. + - Solution draft - partition members based on join timestamp and sender last interaction time: + - Track last interaction timestamp on each member. + - When forwarding from this member partition members into two parts: include profile for those who joined after interaction, don't include for those who joined before interaction. + - This suggests that job would be divided into two parts. + - Protocol to request member profile as a fallback. + - This becomes more complex in case batch has multiple members - for n required profiles to send, recipients need to be partitioned into n + 1 parts. + - Better solution - schedule profile deliveries separately from batch on first post-join delivery per sender. + - Track last_profile_delivery_ts (for sender), join_ts (for recipient) on member records. + - On the sender's first overall interaction (last_profile_delivery_ts is null), first create a special task to deliver profile to all (in scope All). + - On following sends on batching, for senders whose last_profile_delivery_ts < any member's join_ts (i.e., some member missed the initial broadcast), create a profile delivery task for those specific senders. + - Message task should have a flag whether profile should be delivered to anyone (set to false on first profile delivery). Checking last_profile_delivery_ts is null seems to be sufficient. +- Don't partition for owners based on "message from channel" flag to simplify delivery - no need for two separate jobs/cursors. +- When chat relay receives group deletion event, or event removing it [chat relay itself] from the group: + - Chat relay should kill all forwarding workers for the group -> delete all jobs -> create one new job to forward group deletion. + - It could be a special type of job. +- Sending each reaction (and in future comment) won't scale well. + - Instead periodically send reaction and comment counts. + - Send reactions and comments themselves on request. + - This implies that instead of message records, special entity for forwarding tasks should be added, for worker to search for next work items - see more below. +- Connections with priority. + - Client could have 2 connections/queues with relay, and relay - 2 subscribers. + - Separation of responsibilities between connections/queues: + - Normal queue to be used for regular forwarding of messages, reactions, etc. + - High-priority queue to be used for serving client requests and sending important service events (e.g. ownership changes, group deletion). + - Possibly special case of connection redundancy. +- Design to be reworked to use special entity for forwarding tasks instead of relying on messages. + - Points for this: + - Batched reactions/comments counts. + - Special logic on group deletion/relay removal. + - Possibly special logic on sending member profiles, as it's not needed for all types of jobs. + - Sum type of task types. + - Some tasks may simply point to message records. + - Some tasks may be created for further updating their metadata / scheduling, e.g. "send reactions/comments count update", and the information itself may be taken from chat items. diff --git a/docs/rfcs/2025-10-23-vouchers.md b/docs/rfcs/2025-10-23-vouchers.md new file mode 100644 index 0000000000..9e11827282 --- /dev/null +++ b/docs/rfcs/2025-10-23-vouchers.md @@ -0,0 +1,208 @@ +# SimpleX Vouchers for Unlinkable Payments + +See [this doc](./2024-04-26-commercial-model.md) about commercial model that proposed the approach to making network sustainable and commercially attractive to the server operators. + +This document proposes the cryptographic design for the system of vouchers that can enable these payments. + +Big thank you to [Alain Brenzikofer](https://x.com/brenzi5), co-founder of [Integritee Network](https://x.com/integri_t_e_e), who contributed the draft of this design, which we then evolved collaboratively. + +## High-level diagram + +![Payments diagram](./diagrams/2025-10-23-vouchers-diagram.svg) + +### Coordination Layer (CL) + +Abstract component which allows all involved parties to come to consensus about voucher issuance and redemption + +* can be centralized trusted third party (TTP). +* can be a decentralized ledger with smart contracts, e.g. some L2 Ethereum blockchain with ZK-proofs support. + +### Issuing Operator (IO) + +* must be whitelisted by CL. +* CL defines voucher issuing limit. + +### Accepting Operator (AO) + +* delivers a service and accepts vouchers. + +### User + +* uses a service by an AO. +* seeks anonymity. + +### Voucher + +* token allowing limited number of transfers (0-2) to be redeemed for AO credits. +* comes in few fixed denominations around e.g. 1, 10, 100 operator credits, that would be initially set to USD 1, and adjusted for service costs that are likely to be reduced with scale and inflation. +* expected to be redeemed at low frequency: only every few days per user. + +### AO Credits + +* per-operator tokens for micropayments as-you-go. +* expected to be used to pay fractions of cents for every request to the service. +* balances maintained by the operator. + +Blind signatures to be used with operator issued credits: +- Client generates random token(s): `t[i]` +- Client sends a set of blinded tokens `blind(t[i])` when presenting a voucher. +- Operator's server signs them with operator's key and returns to the client. +- Client de-blinds them so they can be used. + +The signed tokens should include an approximate timestamp, e.g. rounded to a day (or more) - this would allow expiration of credits at the cost of acceptable reduction of anonymity set. + +These tokens would be fungible and would also have multiple denominations - the client would send new random blinded numbers to receive change on the resource provisioning requests. We can use token denominations representing powers of 2. + +When credit is presented it would be validated to prove that it is: + +1) properly signed. +2) not expired. +3) not used. + +The checks 1 and 2 are local, and can be done locally on the server. The check 3 requires verification across all operator's servers. The resource can be provisioned instantly, without waiting for the confirmation. Failed double-spend verification can result in resource cancellation. The "change" can be provided only after verification, as otherwise it may increase the number of issued credits (the provisioned resource can include "pending change" associated with it). + +Another approach would be allocating the registry for spent coins deterministically to different servers, and making these allocations known to the client, so while coins would be accepted by any operator's server, the change would be given faster if it's presented to the server with the coin registry. + +## Abstract Protocol + +Start with the most simple approach, then iterate to improve the anonymity properties. + +### v0.1: Chaumian eCash-style atomic, indivisible vouchers of single denomination + +not yet using ZK, not yet with expiry (see extension) + +``` +# user buys voucher at t1 +s = random(256 bits) +C = hash(S) +B = blind(C) +CoordinationLayer.checkIssuingLimit(issuer=I1) +App(issuer=I1).buyVoucher(ref=B) +# issuer I1 +ensure_payment() +σB = B.sign(K_I1) +# user publishes voucher at t2 +σC = σB.unblind() +CoordinationLayer.publish(σC) +# CoordinationLayer (global, trusted entity) +issuer = verify_signature(σC) +ensure_issuing_limit(issuer) +ensure_is_unknown(C) +store_unspent_voucher(C, issuer=I1) +# user redeems voucher at t3 +proof=encrypt(payload=[C, s], pubkey=CoordinationLayerKey) +ServiceProvider.redeem_voucher(proof) +# ServiceProvider SP1 +CoordinationLayer.redeem(proof, SP1) +# CoordinationLayer +[C, s] = decrypt(proof) +ensure(C=hash(s)) +atomic_invalidate_unspent_voucher(C) +clearing(1 voucher, I1 pays to SP1) +confirm_redemption(SP1) +``` + +### Unlinkability analysis + +* issuer can’t link the purchase to later redemption, not even if colluding with the ServiceProvider (assuming large number of users behaving indistinguishably). +* CoordinationLayer can trivially link timing and IP of publishing (t2) and redeeming C (t3). could collude with issuer to link redemption to purchase correlating timing and IP: + * the user can mask timing with random delays between t1-t2 to make collusion harder. + * the user can hide their IP from the CL if they use the issuer as a proxy through a TLS tunnel. That, in turn, will leak t2 to the issuer unless the user performs indistinguishable dummy requests to mask t2. + +### Adding Voucher Expiry + +Design choices for maximal anonymity set / unlinkability: + +* expiry is the same for all vouchers. +* expiry starts with the publishing step, not with the purchase. + +Extension of v0.1: + +* CoordinationLayer stores publishing date along with C. +* CoordinationLayer enforces expiry upon redemption. +* CoordinationLayer ensures issuers rotate keys every M days (to invalidate vouchers which have been issued but not published within 2xM days). + +*Alternative to allow expiry to start with purchase: blind signature with public metadata. Not trivial if issuer must verify public metadata and bind signature to ensure correctness of expiry*. + +## v0.2: Chaumian eCash-style atomic, Indivisible vouchers of single denomination plus ZK + +Avoid linkability of redemption by using a ZK set membership proof into merkle-mountain range (MMR). + +Change later steps as follows: + + +``` +... same as v0.1 +# CoordinationLayer (global) at ~t2 +... same as in v0.1, adding: +store_unspent_voucher(C, t=now, issuer=I1) +update_unspent_vouchers_mmr() +publish_mmr_root() +return [mmr_path] # to user +# user redeems the voucher at t3 +mmr_root = root of mmr_path # as received from CL upon publishing +N=hash2(s || "redeem") +proof=ZK( + secret_inputs: s, mmr_path + public_inputs: mmr_root, nullifier: N + assertions: hash(s) is leaf of mmr_path with mmr_root && N=hash2(s || "redeem") +) +ServiceProvider.redeem_voucher(proof) +# ServiceProvider SP1 +CoordinationLayer.redeem(proof, SP1) +# CoordinationLayer +ensure_unknown(proof.N) +ensure(age(proof.mmr_root) < EXPIRY) +verify(proof) +store_nullifier(proof.N) +clearing(1 voucher, I1 pays to SP1) +confirm_redemption(SP1) +``` + +(!) If the MMR is public (e.g. if the CL operating on a public ledger), the user can extend voucher expiry arbitrarily by updating their mmr_path to a newer merkle_root. Therefore, expiry can’t rely just on the age of mmr_root. For a mitigation, we need to extend the protocol and rotate MMRs. + +### Adding MMR Rotation + +1. Start a new MMR every T days +2. To mitigate the small anonymity set at the start of each new MMR, let them overlap and let the user choose which one they use. + +![MMR rotation](./diagrams/2025-10-23-vouchers-mmrs.svg) + +Upon publishing: + +* CL returns mmr1_path and mmr_2 path to the user + +Upon redemption: + +* user selects one of the two MMRs to generate the proof. Here, the user can trade off later expiry (mmr2_path, expiry2) against larger anonymity set (mmr1_path, expiry1). + +### Unlinkability Analysis + +* Generating a proof using mmr_root(t2) leaks t2. The CL could therefore still learn the exact time when the redeemed voucher was published + + * this can be mitigated by updated MMR peak-bagging before generating the proof. The user downloads the entire MMR and updates the mmr_path to a later root at e.g. t2' or t2'' (maybe partial download backward to t2 + a masking random bit further back is sufficient). If download size gets too big, reduce MMR duration T. + +* thanks to the ZK proof, now even the CoordinationLayer can’t directly link the publishing of C with the redemption, because the redemption just discloses that “one among all non-expired vouchers shall be redeemed“ (double-spending prevented through tracking nullifiers). +* the Coordination Layer still observes timing and IP address. + * users can wait until anonymity set is big enough for their requirements, but that only masks timing, not networking IP address. + * If we use the ServiceProvider as a proxy to forward the redemption proof, timing and IP leak to SP instead of CL, which is better because the SP learns the IP and timing (user behavior) anyway. trusting the SP with the proof is fine because it doesn’t disclose sensitive information and we trust them to provide their service after redemption anyway. + +### ZK Reasonings + +* to avoid trusted setup we could use STARK, not SNARK, but STARK has heavier proving complexity (expect >30s on mobile. should be evaluated with a PoC). +* we can accept a trusted setup with multiple independent parties contributing to it, with the benefit of much lighter proving. +* STARK friendly hash function: e.g. poseidon2 +* proving time (client-side) is probably still quite heavy for mobile, even if the proposed proof is pretty lean. But redeeming vouchers is only expected to happen infrequently +* verification time (CoordinationLayer side) expected to be light +* Nullifier set is bounded thanks to voucher expiry window M, so it won’t grow indefinitely. Downside: smaller anonymity set. + +Overall, SNARK seems more preferrable. + +### Possible Enhancements + +* Avoid centralized CoordinationLayer SPOF, replace with smart contract on distributed consortial ledger with non-collusion contractor validators: + * or even public permissionless blockchain. + * storing mmr_root and nullifiers onchain helps public auditability. +* publishing σC still leaks publicly observable timing because the CL has to update and publish the MMR. + * possible remedy: use TEE as a random-delay mixer proxy for the user to publish σC. +* optionally delegate heavy ZK proving to TEE for thin clients (s will be exposed to TEE trust assumptions). But then, we need to incentivize TEE-provers as they are service providers in their own right. diff --git a/docs/rfcs/2025-11-17-async-commands-acks.md b/docs/rfcs/2025-11-17-async-commands-acks.md new file mode 100644 index 0000000000..bb95c09717 --- /dev/null +++ b/docs/rfcs/2025-11-17-async-commands-acks.md @@ -0,0 +1,74 @@ +# Acknowledgments for async command responses + +## Problem + +Continuations for asynchronous commands can be forever lost if their execution fails, e.g. due to a crash. This can result in failures in establishing connections, sending post-connection auto-reply, etc. depending on other applications of asynchronous commands. + +## Solution + +An idea is to persist events in agent until chat acknowledges their processing, and replay them on next start of command processing. + +### Agent persistence + +Save response on command before notifying chat (event received by chat via subQ). + +```sql +ALTER TABLE commands ADD COLUMN event BLOB; +``` + +Type is `AEvent`, requires encoding for To and FromField instances. + +Application of chat continuations is very limited, so not all events need to be saved. In fact currently we only need 2 types of events to be recorded - see below. This breaks separation between chat and agent (agent knows which events to record), however that abstraction has long been violated. This can be a contract between agent and chat - which events to keep and acknowledge. + +TBC separate type for storing only necessary events: + +```haskell +data AEventDB where + ... -- only necessary constructors + +-- AEventDB encoding, instances + +toDBEvent :: AEvent -> AEventDB + +fromDBEvent :: AEventBD -> AEvent +``` + +Alternatively, we can save all events and require chat to acknowledge all events. This seems like an overkill and unnecessary work and generalization. + +### Agent event processing + +Currently agent deletes command records after processing. Instead it will: +- keep records until receiving acknowledgement on event; +- delete command record when receiving acknowledgment on event from chat; +- when retrieving next command to process filter out already processed commands (that have event saved); +- replay to chat unacknowledged events on starting async command processing (`resumeAllCommands`?). + +Same correlation id that is used for command can be used for acknowledging event. + +Agent API: + +```haskell +ackCommandEvent :: AgentClient -> UserId -> ACorrId -> AE () +``` + +### Command continuations + +Chat uses command continuations on following events: +- INV in group connection - XGrpMemIntro continuation (send XGrpMemInv with created connection link); +- JOINED in both direct and group (business chat) connections - send auto-reply. + +So it is enough for agent to record only INV and JOINED events, and for chat to acknowledge processing only for these events. However, as agent doesn't discriminate which INVs to save, chat should acknowledge all INVs. Another alternative is for chat to inform agent whether event should be kept when making command, e.g.: + +```haskell +createConnectionAsync :: AgentClient -> ... -> Bool -> ... + +-- Bool is flag whether to keep INV event for this command until acknowledged +``` + +Group relay protocol may add new continuations, for example for owner on adding relay link to group link (new async version of setConnShortLink api). + +Chat continuations should be idempotent. +- More important for INV event, to not repeatedly send XGrpMemIntro. +- For JOINED in worst case auto-reply would be re-sent which is not ideal but not very damaging. +- Chat can track additional state to help identify which part of event processing to replay. +- E.g. for group INV continuation chat can track that XGrpMemIntro was sent on group record. TBC per continuation. diff --git a/docs/rfcs/diagrams/2025-08-09-widget-state-machine.mmd b/docs/rfcs/diagrams/2025-08-09-widget-state-machine.mmd new file mode 100644 index 0000000000..cae598a0fb --- /dev/null +++ b/docs/rfcs/diagrams/2025-08-09-widget-state-machine.mmd @@ -0,0 +1,30 @@ +sequenceDiagram + participant Contact + participant UI + participant Core + participant DB + participant WL + note over Contact, WL: 1. Receive and initialize widget + Contact->>Core: widget code and initial state + Core->>DB: persist message with widget + Core->>UI: new message
(with widget placeholder) + Core->>WL: widget code + state + WL->>Core: new state + view + Core->>DB: persist new state + Core->>UI: view + note over Contact, WL: 2. Process user event + UI->>Core: widget user event
(UI "gesture") + DB->>Core: get code + state + Core->>WL: user event + code + state + WL->>Core: updated state + view + optional message + Core->>DB: updated state + Core->>UI: new widget view
(non-optional, for visual feedback) + Core->>Contact: optional event message "from" widget + note over Contact, WL: 3. Process user event + Contact->>Core: Message with event for widget + Core->>DB: persist message (to resume/retry) + DB->>Core: get code + state + Core->>WL: message event + code + state + WL->>Core: updated state + view + Core->>DB: updated state + Core->>UI: new widget view
(plus some visual indication) diff --git a/docs/rfcs/diagrams/2025-08-09-widget-state-machine.svg b/docs/rfcs/diagrams/2025-08-09-widget-state-machine.svg new file mode 100644 index 0000000000..c8c9dd7bb4 --- /dev/null +++ b/docs/rfcs/diagrams/2025-08-09-widget-state-machine.svg @@ -0,0 +1 @@ +WLDBCoreUIContactWLDBCoreUIContact1. Receive and initialize widget2. Process user event3. Process user eventwidget code and initial statepersist message with widgetnew message(with widget placeholder)widget code + statenew state + viewpersist new stateviewwidget user event(UI "gesture")get code + stateuser event + code + stateupdated state + view + optional messageupdated statenew widget view(non-optional, for visual feedback)optional event message "from" widgetMessage with event for widgetpersist message (to resume/retry)get code + statemessage event + code + stateupdated state + viewupdated statenew widget view(plus some visual indication) \ No newline at end of file diff --git a/docs/rfcs/diagrams/2025-10-23-vouchers-diagram.svg b/docs/rfcs/diagrams/2025-10-23-vouchers-diagram.svg new file mode 100644 index 0000000000..b182521563 --- /dev/null +++ b/docs/rfcs/diagrams/2025-10-23-vouchers-diagram.svg @@ -0,0 +1,4 @@ + + + +
Alice (identifiable)
buy voucher
Issuing Operator
Coordination Layer
issue voucher
enforce issuance limit
register voucher
unlink
Accepting Operator
Alice (incognito)
Coordination Layer
redeem voucher with proof
verify proof
issue credits
enforce expiry
prevent double-spend
clearing of voucher value IO->AO
use service
provide service
manage credit balance
transfer credits to Bob*
few $, non-xferrable
fractions of cts, xferrable within same AO
not involved
1
2
3
4
5
6
\ No newline at end of file diff --git a/docs/rfcs/diagrams/2025-10-23-vouchers-mmrs.svg b/docs/rfcs/diagrams/2025-10-23-vouchers-mmrs.svg new file mode 100644 index 0000000000..b1ea917340 --- /dev/null +++ b/docs/rfcs/diagrams/2025-10-23-vouchers-mmrs.svg @@ -0,0 +1,4 @@ + + + +
MMR1
MMR2
MMR3
C1
C2
CN
validity
t2
t
t2'
t2''
CA
expiry1
expiry2
\ No newline at end of file diff --git a/eth/nft/.gitignore b/eth/nft/.gitignore new file mode 100644 index 0000000000..d751a74728 --- /dev/null +++ b/eth/nft/.gitignore @@ -0,0 +1,2 @@ +.deps +artifacts/ diff --git a/eth/nft/.prettierrc.json b/eth/nft/.prettierrc.json new file mode 100644 index 0000000000..b2a56f2371 --- /dev/null +++ b/eth/nft/.prettierrc.json @@ -0,0 +1,38 @@ +{ + "overrides": [ + { + "files": "*.sol", + "options": { + "printWidth": 80, + "tabWidth": 4, + "useTabs": false, + "singleQuote": false, + "bracketSpacing": false + } + }, + { + "files": "*.yml", + "options": {} + }, + { + "files": "*.yaml", + "options": {} + }, + { + "files": "*.toml", + "options": {} + }, + { + "files": "*.json", + "options": {} + }, + { + "files": "*.js", + "options": {} + }, + { + "files": "*.ts", + "options": {} + } + ] +} diff --git a/eth/nft/compiler_config.json b/eth/nft/compiler_config.json new file mode 100644 index 0000000000..9026d3afa9 --- /dev/null +++ b/eth/nft/compiler_config.json @@ -0,0 +1,16 @@ + +{ + "language": "Solidity", + "settings": { + "optimizer": { + "enabled": true, + "runs": 200 + }, + "outputSelection": { + "*": { + "": ["ast"], + "*": ["abi", "metadata", "devdoc", "userdoc", "storageLayout", "evm.legacyAssembly", "evm.bytecode", "evm.deployedBytecode", "evm.methodIdentifiers", "evm.gasEstimates", "evm.assembly"] + } + } + } +} diff --git a/eth/nft/contracts/MultiERC1155.sol b/eth/nft/contracts/MultiERC1155.sol new file mode 100644 index 0000000000..b715daa6dc --- /dev/null +++ b/eth/nft/contracts/MultiERC1155.sol @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import "@openzeppelin/contracts@5.4.0/token/ERC1155/ERC1155.sol"; +import "@openzeppelin/contracts@5.4.0/access/Ownable.sol"; +import "@openzeppelin/contracts@5.4.0/utils/Base64.sol"; +import "@openzeppelin/contracts@5.4.0/utils/Strings.sol"; + +/// @title MultiERC1155 +/// @notice ERC1155 contract with sequential variants for immutable metadata. +/// @dev Non-upgradeable. Admin and minter addresses are settable by owner. Global sequential token IDs. +contract MultiERC1155 is ERC1155, Ownable { + address public admin; // can manage token IDs + address public minter; // can mint tokens for existing IDs + bool public mintingEnabled; + bool public contractLocked; // no more variants can be added, minting cannot be enabled, cannot be unlocked + + // for name and description avoid or escape double quotes, so it can be used in JSON + struct TokenInfo { + string tokenUri; + uint totalSupply; // 0 for unlimited + bool enabled; + } + + struct TokenState { + TokenInfo tokenInfo; + uint currentSupply; + bool exists; + bool locked; + } + + uint private _nextTokenId; // Global sequential token ID (starts at 1) + mapping(uint => TokenState) public tokens; + uint[] public tokenIds; + + function getTokenIds() view external returns(uint[] memory) { + return tokenIds; + } + + event AdminUpdated(address indexed newAdmin); + event MinterUpdated(address indexed newMinter); + event MintingEnabled(bool newEnabled); + event ContractLocked(); + event TokenAdded(uint indexed tokenId); + event TokenRemoved(uint indexed tokenId); + event TokenUpdated(uint indexed tokenId, bool newEnabled, uint newTotalSupply); + event TokenLocked(uint indexed tokenId); + + constructor() ERC1155("") Ownable(msg.sender) { + admin = msg.sender; + minter = msg.sender; + _nextTokenId = 1; + mintingEnabled = true; + } + + /// @notice Updates the minter address. + /// @param newAdmin The new minter. + function setAdmin(address newAdmin) external onlyOwner { + admin = newAdmin; + emit AdminUpdated(newAdmin); + } + + /// @notice Updates the minter address. + /// @param newMinter The new minter. + function setMinter(address newMinter) external onlyOwner { + minter = newMinter; + emit MinterUpdated(newMinter); + } + + /// @notice Enables/disables minting. + /// @param enabled True/false to enable/disable minting. + function toggleMinting(bool enabled) external onlyOwner { + if (mintingEnabled != enabled) { + mintingEnabled = enabled; + emit MintingEnabled(enabled); + } + } + + /// @notice Permanently locks any token changes and minting, irreversible. + function lockContract() external onlyOwner { + contractLocked = true; + mintingEnabled = false; + emit ContractLocked(); + } + + /// @notice Adds a new variant and optionally sets it as current. + /// @param info New token info. + function addToken(TokenInfo memory info) external { + require(msg.sender == admin || msg.sender == owner(), "Only admin and owner can add tokens"); + _addToken(info); + } + + function _addToken(TokenInfo memory info) internal { + require(bytes(info.tokenUri).length > 0, "Token tokenUri required"); + + uint id = _nextTokenId++; + require(!tokens[id].exists, "Contract error: token ID already exists"); + + tokens[id] = TokenState({ + tokenInfo: info, + currentSupply: 0, + exists: true, + locked: false + }); + tokenIds.push(id); + + emit TokenAdded(id); + } + + /// @notice Removes the last variant if unused (currentSupply == 0). + function removeToken(uint id) external { + require(msg.sender == admin || msg.sender == owner(), "Only admin and owner can remove tokens"); + TokenState storage token = tokens[id]; + require(token.exists, "Token ID does not exist"); + require(token.currentSupply == 0, "Tokens already minted for this ID"); + require(tokenIds.length > 1, "Cannot remove the last token ID"); + + delete tokens[id]; + for (uint i = 0; i < tokenIds.length; i++) { + if (tokenIds[i] == id) { + tokenIds[i] = tokenIds[tokenIds.length - 1]; + tokenIds.pop(); + break; + } + } + emit TokenRemoved(id); + } + + + /// @notice Enables/disables minting a specific token. + /// @param id Token ID. + /// @param newEnabled True/false to enable/disable minting. + /// @param newTotalSupply 0 for unlimited + function updateToken(uint id, bool newEnabled, uint newTotalSupply) external { + require(msg.sender == admin || msg.sender == owner(), "Only admin and owner can remove tokens"); + TokenState storage token = tokens[id]; + require(token.exists, "Token ID does not exist"); + require(!token.locked, "Token ID is locked"); + require(newTotalSupply == 0 || token.currentSupply <= newTotalSupply, "New total supply must be greater than existing token count for ID"); + + if (token.tokenInfo.enabled != newEnabled || token.tokenInfo.totalSupply != newTotalSupply) { + token.tokenInfo.enabled = newEnabled; + token.tokenInfo.totalSupply = newTotalSupply; + emit TokenUpdated(id, newEnabled, newTotalSupply); + } + } + + /// @notice permanently lock a specific token from any further changes. + /// @param id Token ID. + function lockToken(uint id) external onlyOwner { + TokenState storage token = tokens[id]; + require(token.exists, "Token ID does not exist"); + require(!token.locked, "Token ID is already locked"); + require(token.currentSupply != 0, "No tokens minted for this ID, use removeToken instead"); + token.locked = true; + token.tokenInfo.enabled = false; + emit TokenLocked(id); + } + + /// @notice Mints a token using the default variant. + /// @param to Recipient. + /// @param id Token ID. + /// @param value Amount to mint. + /// @param data Optional data. + function mint(address to, uint256 id, uint256 value, bytes calldata data) external { + require(!contractLocked, "Contract is permanently locked"); + require(mintingEnabled, "Minting is disabled"); + require(msg.sender == minter || msg.sender == admin || msg.sender == owner(), "Only minter, admin or owner can mint"); + TokenState storage token = tokens[id]; + require(token.exists, "Token ID does not exist"); + require(!token.locked, "Token ID is locked"); + require(token.tokenInfo.enabled, "Token ID is disabled"); + require(token.tokenInfo.totalSupply == 0 || token.tokenInfo.totalSupply >= token.currentSupply + value, "Token supply exceeded for this ID"); + require(value > 0, "Amount must be > 0"); + + _mint(to, id, value, data); + } + + /// @dev Hook to update supplies on transfer/burn. + function _update(address from, address to, uint[] memory ids, uint[] memory values) internal virtual override { + super._update(from, to, ids, values); + + for (uint i = 0; i < ids.length; i++) { + if (from == address(0)) { // mint + tokens[ids[i]].currentSupply += values[i]; + } + if (to == address(0)) { // burn + tokens[ids[i]].currentSupply -= values[i]; + } + } + } + + /// @notice Returns embedded JSON metadata URI. + /// @param id Token ID. + function uri(uint id) public view virtual override returns (string memory) { + TokenState storage token = tokens[id]; + require(token.exists, "Invalid token ID"); + return token.tokenInfo.tokenUri; + } +} diff --git a/eth/nft/contracts/NFTMinter.sol b/eth/nft/contracts/NFTMinter.sol new file mode 100644 index 0000000000..cce14909b9 --- /dev/null +++ b/eth/nft/contracts/NFTMinter.sol @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import "@openzeppelin/contracts@5.4.0/access/Ownable.sol"; +import "@openzeppelin/contracts@5.4.0/utils/Pausable.sol"; +import "./NFTNumbered.sol"; + +contract NFTMinter is Ownable, Pausable { + NFTNumbered public nft; + uint public mintStartTime; + uint public mintEndTime; + uint public mintCount; + + /// @param _mintEndTime Unix timestamp when minting ends, 0 to mint without time limit. + constructor(address _nftAddress, uint _mintStartTime, uint _mintEndTime, bool _paused) Ownable(msg.sender) { + _setNFT(_nftAddress); + require(_mintEndTime == 0 || _mintEndTime > _mintStartTime, "Mint end time is before start time"); + require(_mintEndTime == 0 || _mintEndTime > block.timestamp, "Mint end time is in the past"); + mintStartTime = _mintStartTime; + mintEndTime = _mintEndTime; + if (_paused) _pause(); + } + + event NFTUpdated(address indexed newNFT); + event Minted(address indexed to, uint count); + event MintStartUpdated(uint newTime); + event MintEndUpdated(uint newTime); + event MintCountReset(uint oldCount); + + /// @notice Allows anyone to mint NFT for free (gas only). Any mint restrictions other than not paused or mint time must be in NFT contract + function mint() external whenNotPaused { + require(mintEndTime == 0 || mintEndTime > block.timestamp, "Minting ended"); + require(mintStartTime <= block.timestamp, "Minting not started"); + nft.mint(msg.sender); + mintCount++; + emit Minted(msg.sender, mintCount); + } + + /// @notice Update the target NFT contract. + /// @param newNFT The new NFT contract address. + function setNFT(address newNFT) external onlyOwner { + _setNFT(newNFT); + emit NFTUpdated(newNFT); + } + + function _setNFT(address _nft) internal { + require(_nft != address(0), "NFT contract address is 0"); + uint codeSize; + assembly { codeSize := extcodesize(_nft) } + require(codeSize > 0, "Not a contract address"); + nft = NFTNumbered(_nft); + } + + /// @notice Reset mint counter. + function resetMintCount() external onlyOwner { + uint old = mintCount; + mintCount = 0; + emit MintCountReset(old); + } + + /// @notice Update the mint start timestamp. + /// @param newTime The new Unix timestamp when minting ends. + function setMintStartTime(uint256 newTime) external onlyOwner { + require(mintEndTime == 0 || newTime < mintEndTime, "Mint end time is before start time"); + mintStartTime = newTime; + emit MintStartUpdated(newTime); + } + + /// @notice Update the mint end timestamp. + /// @param newTime The new Unix timestamp when minting ends, 0 to mint without time limit. + function setMintEndTime(uint256 newTime) external onlyOwner { + require(newTime == 0 || newTime > mintStartTime, "Mint end time is before start time"); + require(newTime == 0 || newTime > block.timestamp, "Mint end time is in the past"); + mintEndTime = newTime; + emit MintEndUpdated(newTime); + } + + /// @notice Pause minting. + function pause() external onlyOwner { + _pause(); + } + + /// @notice Unpause minting. + function unpause() external onlyOwner { + _unpause(); + } + + /// @notice Withdraw any accidental ETH. + function withdraw() external onlyOwner { + payable(owner()).transfer(address(this).balance); + } +} diff --git a/eth/nft/contracts/NFTMinter_flattened.sol b/eth/nft/contracts/NFTMinter_flattened.sol new file mode 100644 index 0000000000..01b65c761d --- /dev/null +++ b/eth/nft/contracts/NFTMinter_flattened.sol @@ -0,0 +1,4100 @@ +// SPDX-License-Identifier: MIT + +// File: @openzeppelin/contracts@5.4.0/utils/Context.sol + + +// OpenZeppelin Contracts (last updated v5.0.1) (utils/Context.sol) + +pragma solidity ^0.8.20; + +/** + * @dev Provides information about the current execution context, including the + * sender of the transaction and its data. While these are generally available + * via msg.sender and msg.data, they should not be accessed in such a direct + * manner, since when dealing with meta-transactions the account sending and + * paying for execution may not be the actual sender (as far as an application + * is concerned). + * + * This contract is only required for intermediate, library-like contracts. + */ +abstract contract Context { + function _msgSender() internal view virtual returns (address) { + return msg.sender; + } + + function _msgData() internal view virtual returns (bytes calldata) { + return msg.data; + } + + function _contextSuffixLength() internal view virtual returns (uint256) { + return 0; + } +} + +// File: @openzeppelin/contracts@5.4.0/access/Ownable.sol + + +// OpenZeppelin Contracts (last updated v5.0.0) (access/Ownable.sol) + +pragma solidity ^0.8.20; + + +/** + * @dev Contract module which provides a basic access control mechanism, where + * there is an account (an owner) that can be granted exclusive access to + * specific functions. + * + * The initial owner is set to the address provided by the deployer. This can + * later be changed with {transferOwnership}. + * + * This module is used through inheritance. It will make available the modifier + * `onlyOwner`, which can be applied to your functions to restrict their use to + * the owner. + */ +abstract contract Ownable is Context { + address private _owner; + + /** + * @dev The caller account is not authorized to perform an operation. + */ + error OwnableUnauthorizedAccount(address account); + + /** + * @dev The owner is not a valid owner account. (eg. `address(0)`) + */ + error OwnableInvalidOwner(address owner); + + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + + /** + * @dev Initializes the contract setting the address provided by the deployer as the initial owner. + */ + constructor(address initialOwner) { + if (initialOwner == address(0)) { + revert OwnableInvalidOwner(address(0)); + } + _transferOwnership(initialOwner); + } + + /** + * @dev Throws if called by any account other than the owner. + */ + modifier onlyOwner() { + _checkOwner(); + _; + } + + /** + * @dev Returns the address of the current owner. + */ + function owner() public view virtual returns (address) { + return _owner; + } + + /** + * @dev Throws if the sender is not the owner. + */ + function _checkOwner() internal view virtual { + if (owner() != _msgSender()) { + revert OwnableUnauthorizedAccount(_msgSender()); + } + } + + /** + * @dev Leaves the contract without owner. It will not be possible to call + * `onlyOwner` functions. Can only be called by the current owner. + * + * NOTE: Renouncing ownership will leave the contract without an owner, + * thereby disabling any functionality that is only available to the owner. + */ + function renounceOwnership() public virtual onlyOwner { + _transferOwnership(address(0)); + } + + /** + * @dev Transfers ownership of the contract to a new account (`newOwner`). + * Can only be called by the current owner. + */ + function transferOwnership(address newOwner) public virtual onlyOwner { + if (newOwner == address(0)) { + revert OwnableInvalidOwner(address(0)); + } + _transferOwnership(newOwner); + } + + /** + * @dev Transfers ownership of the contract to a new account (`newOwner`). + * Internal function without access restriction. + */ + function _transferOwnership(address newOwner) internal virtual { + address oldOwner = _owner; + _owner = newOwner; + emit OwnershipTransferred(oldOwner, newOwner); + } +} + +// File: @openzeppelin/contracts@5.4.0/utils/Pausable.sol + + +// OpenZeppelin Contracts (last updated v5.3.0) (utils/Pausable.sol) + +pragma solidity ^0.8.20; + + +/** + * @dev Contract module which allows children to implement an emergency stop + * mechanism that can be triggered by an authorized account. + * + * This module is used through inheritance. It will make available the + * modifiers `whenNotPaused` and `whenPaused`, which can be applied to + * the functions of your contract. Note that they will not be pausable by + * simply including this module, only once the modifiers are put in place. + */ +abstract contract Pausable is Context { + bool private _paused; + + /** + * @dev Emitted when the pause is triggered by `account`. + */ + event Paused(address account); + + /** + * @dev Emitted when the pause is lifted by `account`. + */ + event Unpaused(address account); + + /** + * @dev The operation failed because the contract is paused. + */ + error EnforcedPause(); + + /** + * @dev The operation failed because the contract is not paused. + */ + error ExpectedPause(); + + /** + * @dev Modifier to make a function callable only when the contract is not paused. + * + * Requirements: + * + * - The contract must not be paused. + */ + modifier whenNotPaused() { + _requireNotPaused(); + _; + } + + /** + * @dev Modifier to make a function callable only when the contract is paused. + * + * Requirements: + * + * - The contract must be paused. + */ + modifier whenPaused() { + _requirePaused(); + _; + } + + /** + * @dev Returns true if the contract is paused, and false otherwise. + */ + function paused() public view virtual returns (bool) { + return _paused; + } + + /** + * @dev Throws if the contract is paused. + */ + function _requireNotPaused() internal view virtual { + if (paused()) { + revert EnforcedPause(); + } + } + + /** + * @dev Throws if the contract is not paused. + */ + function _requirePaused() internal view virtual { + if (!paused()) { + revert ExpectedPause(); + } + } + + /** + * @dev Triggers stopped state. + * + * Requirements: + * + * - The contract must not be paused. + */ + function _pause() internal virtual whenNotPaused { + _paused = true; + emit Paused(_msgSender()); + } + + /** + * @dev Returns to normal state. + * + * Requirements: + * + * - The contract must be paused. + */ + function _unpause() internal virtual whenPaused { + _paused = false; + emit Unpaused(_msgSender()); + } +} + +// File: @openzeppelin/contracts@5.4.0/utils/introspection/IERC165.sol + + +// OpenZeppelin Contracts (last updated v5.4.0) (utils/introspection/IERC165.sol) + +pragma solidity >=0.4.16; + +/** + * @dev Interface of the ERC-165 standard, as defined in the + * https://eips.ethereum.org/EIPS/eip-165[ERC]. + * + * Implementers can declare support of contract interfaces, which can then be + * queried by others ({ERC165Checker}). + * + * For an implementation, see {ERC165}. + */ +interface IERC165 { + /** + * @dev Returns true if this contract implements the interface defined by + * `interfaceId`. See the corresponding + * https://eips.ethereum.org/EIPS/eip-165#how-interfaces-are-identified[ERC section] + * to learn more about how these ids are created. + * + * This function call must use less than 30 000 gas. + */ + function supportsInterface(bytes4 interfaceId) external view returns (bool); +} + +// File: @openzeppelin/contracts@5.4.0/token/ERC721/IERC721.sol + + +// OpenZeppelin Contracts (last updated v5.4.0) (token/ERC721/IERC721.sol) + +pragma solidity >=0.6.2; + + +/** + * @dev Required interface of an ERC-721 compliant contract. + */ +interface IERC721 is IERC165 { + /** + * @dev Emitted when `tokenId` token is transferred from `from` to `to`. + */ + event Transfer(address indexed from, address indexed to, uint256 indexed tokenId); + + /** + * @dev Emitted when `owner` enables `approved` to manage the `tokenId` token. + */ + event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId); + + /** + * @dev Emitted when `owner` enables or disables (`approved`) `operator` to manage all of its assets. + */ + event ApprovalForAll(address indexed owner, address indexed operator, bool approved); + + /** + * @dev Returns the number of tokens in ``owner``'s account. + */ + function balanceOf(address owner) external view returns (uint256 balance); + + /** + * @dev Returns the owner of the `tokenId` token. + * + * Requirements: + * + * - `tokenId` must exist. + */ + function ownerOf(uint256 tokenId) external view returns (address owner); + + /** + * @dev Safely transfers `tokenId` token from `from` to `to`. + * + * Requirements: + * + * - `from` cannot be the zero address. + * - `to` cannot be the zero address. + * - `tokenId` token must exist and be owned by `from`. + * - If the caller is not `from`, it must be approved to move this token by either {approve} or {setApprovalForAll}. + * - If `to` refers to a smart contract, it must implement {IERC721Receiver-onERC721Received}, which is called upon + * a safe transfer. + * + * Emits a {Transfer} event. + */ + function safeTransferFrom(address from, address to, uint256 tokenId, bytes calldata data) external; + + /** + * @dev Safely transfers `tokenId` token from `from` to `to`, checking first that contract recipients + * are aware of the ERC-721 protocol to prevent tokens from being forever locked. + * + * Requirements: + * + * - `from` cannot be the zero address. + * - `to` cannot be the zero address. + * - `tokenId` token must exist and be owned by `from`. + * - If the caller is not `from`, it must have been allowed to move this token by either {approve} or + * {setApprovalForAll}. + * - If `to` refers to a smart contract, it must implement {IERC721Receiver-onERC721Received}, which is called upon + * a safe transfer. + * + * Emits a {Transfer} event. + */ + function safeTransferFrom(address from, address to, uint256 tokenId) external; + + /** + * @dev Transfers `tokenId` token from `from` to `to`. + * + * WARNING: Note that the caller is responsible to confirm that the recipient is capable of receiving ERC-721 + * or else they may be permanently lost. Usage of {safeTransferFrom} prevents loss, though the caller must + * understand this adds an external call which potentially creates a reentrancy vulnerability. + * + * Requirements: + * + * - `from` cannot be the zero address. + * - `to` cannot be the zero address. + * - `tokenId` token must be owned by `from`. + * - If the caller is not `from`, it must be approved to move this token by either {approve} or {setApprovalForAll}. + * + * Emits a {Transfer} event. + */ + function transferFrom(address from, address to, uint256 tokenId) external; + + /** + * @dev Gives permission to `to` to transfer `tokenId` token to another account. + * The approval is cleared when the token is transferred. + * + * Only a single account can be approved at a time, so approving the zero address clears previous approvals. + * + * Requirements: + * + * - The caller must own the token or be an approved operator. + * - `tokenId` must exist. + * + * Emits an {Approval} event. + */ + function approve(address to, uint256 tokenId) external; + + /** + * @dev Approve or remove `operator` as an operator for the caller. + * Operators can call {transferFrom} or {safeTransferFrom} for any token owned by the caller. + * + * Requirements: + * + * - The `operator` cannot be the address zero. + * + * Emits an {ApprovalForAll} event. + */ + function setApprovalForAll(address operator, bool approved) external; + + /** + * @dev Returns the account approved for `tokenId` token. + * + * Requirements: + * + * - `tokenId` must exist. + */ + function getApproved(uint256 tokenId) external view returns (address operator); + + /** + * @dev Returns if the `operator` is allowed to manage all of the assets of `owner`. + * + * See {setApprovalForAll} + */ + function isApprovedForAll(address owner, address operator) external view returns (bool); +} + +// File: @openzeppelin/contracts@5.4.0/token/ERC721/extensions/IERC721Metadata.sol + + +// OpenZeppelin Contracts (last updated v5.4.0) (token/ERC721/extensions/IERC721Metadata.sol) + +pragma solidity >=0.6.2; + + +/** + * @title ERC-721 Non-Fungible Token Standard, optional metadata extension + * @dev See https://eips.ethereum.org/EIPS/eip-721 + */ +interface IERC721Metadata is IERC721 { + /** + * @dev Returns the token collection name. + */ + function name() external view returns (string memory); + + /** + * @dev Returns the token collection symbol. + */ + function symbol() external view returns (string memory); + + /** + * @dev Returns the Uniform Resource Identifier (URI) for `tokenId` token. + */ + function tokenURI(uint256 tokenId) external view returns (string memory); +} + +// File: @openzeppelin/contracts@5.4.0/token/ERC721/IERC721Receiver.sol + + +// OpenZeppelin Contracts (last updated v5.4.0) (token/ERC721/IERC721Receiver.sol) + +pragma solidity >=0.5.0; + +/** + * @title ERC-721 token receiver interface + * @dev Interface for any contract that wants to support safeTransfers + * from ERC-721 asset contracts. + */ +interface IERC721Receiver { + /** + * @dev Whenever an {IERC721} `tokenId` token is transferred to this contract via {IERC721-safeTransferFrom} + * by `operator` from `from`, this function is called. + * + * It must return its Solidity selector to confirm the token transfer. + * If any other value is returned or the interface is not implemented by the recipient, the transfer will be + * reverted. + * + * The selector can be obtained in Solidity with `IERC721Receiver.onERC721Received.selector`. + */ + function onERC721Received( + address operator, + address from, + uint256 tokenId, + bytes calldata data + ) external returns (bytes4); +} + +// File: @openzeppelin/contracts@5.4.0/interfaces/draft-IERC6093.sol + + +// OpenZeppelin Contracts (last updated v5.4.0) (interfaces/draft-IERC6093.sol) +pragma solidity >=0.8.4; + +/** + * @dev Standard ERC-20 Errors + * Interface of the https://eips.ethereum.org/EIPS/eip-6093[ERC-6093] custom errors for ERC-20 tokens. + */ +interface IERC20Errors { + /** + * @dev Indicates an error related to the current `balance` of a `sender`. Used in transfers. + * @param sender Address whose tokens are being transferred. + * @param balance Current balance for the interacting account. + * @param needed Minimum amount required to perform a transfer. + */ + error ERC20InsufficientBalance(address sender, uint256 balance, uint256 needed); + + /** + * @dev Indicates a failure with the token `sender`. Used in transfers. + * @param sender Address whose tokens are being transferred. + */ + error ERC20InvalidSender(address sender); + + /** + * @dev Indicates a failure with the token `receiver`. Used in transfers. + * @param receiver Address to which tokens are being transferred. + */ + error ERC20InvalidReceiver(address receiver); + + /** + * @dev Indicates a failure with the `spender`’s `allowance`. Used in transfers. + * @param spender Address that may be allowed to operate on tokens without being their owner. + * @param allowance Amount of tokens a `spender` is allowed to operate with. + * @param needed Minimum amount required to perform a transfer. + */ + error ERC20InsufficientAllowance(address spender, uint256 allowance, uint256 needed); + + /** + * @dev Indicates a failure with the `approver` of a token to be approved. Used in approvals. + * @param approver Address initiating an approval operation. + */ + error ERC20InvalidApprover(address approver); + + /** + * @dev Indicates a failure with the `spender` to be approved. Used in approvals. + * @param spender Address that may be allowed to operate on tokens without being their owner. + */ + error ERC20InvalidSpender(address spender); +} + +/** + * @dev Standard ERC-721 Errors + * Interface of the https://eips.ethereum.org/EIPS/eip-6093[ERC-6093] custom errors for ERC-721 tokens. + */ +interface IERC721Errors { + /** + * @dev Indicates that an address can't be an owner. For example, `address(0)` is a forbidden owner in ERC-20. + * Used in balance queries. + * @param owner Address of the current owner of a token. + */ + error ERC721InvalidOwner(address owner); + + /** + * @dev Indicates a `tokenId` whose `owner` is the zero address. + * @param tokenId Identifier number of a token. + */ + error ERC721NonexistentToken(uint256 tokenId); + + /** + * @dev Indicates an error related to the ownership over a particular token. Used in transfers. + * @param sender Address whose tokens are being transferred. + * @param tokenId Identifier number of a token. + * @param owner Address of the current owner of a token. + */ + error ERC721IncorrectOwner(address sender, uint256 tokenId, address owner); + + /** + * @dev Indicates a failure with the token `sender`. Used in transfers. + * @param sender Address whose tokens are being transferred. + */ + error ERC721InvalidSender(address sender); + + /** + * @dev Indicates a failure with the token `receiver`. Used in transfers. + * @param receiver Address to which tokens are being transferred. + */ + error ERC721InvalidReceiver(address receiver); + + /** + * @dev Indicates a failure with the `operator`’s approval. Used in transfers. + * @param operator Address that may be allowed to operate on tokens without being their owner. + * @param tokenId Identifier number of a token. + */ + error ERC721InsufficientApproval(address operator, uint256 tokenId); + + /** + * @dev Indicates a failure with the `approver` of a token to be approved. Used in approvals. + * @param approver Address initiating an approval operation. + */ + error ERC721InvalidApprover(address approver); + + /** + * @dev Indicates a failure with the `operator` to be approved. Used in approvals. + * @param operator Address that may be allowed to operate on tokens without being their owner. + */ + error ERC721InvalidOperator(address operator); +} + +/** + * @dev Standard ERC-1155 Errors + * Interface of the https://eips.ethereum.org/EIPS/eip-6093[ERC-6093] custom errors for ERC-1155 tokens. + */ +interface IERC1155Errors { + /** + * @dev Indicates an error related to the current `balance` of a `sender`. Used in transfers. + * @param sender Address whose tokens are being transferred. + * @param balance Current balance for the interacting account. + * @param needed Minimum amount required to perform a transfer. + * @param tokenId Identifier number of a token. + */ + error ERC1155InsufficientBalance(address sender, uint256 balance, uint256 needed, uint256 tokenId); + + /** + * @dev Indicates a failure with the token `sender`. Used in transfers. + * @param sender Address whose tokens are being transferred. + */ + error ERC1155InvalidSender(address sender); + + /** + * @dev Indicates a failure with the token `receiver`. Used in transfers. + * @param receiver Address to which tokens are being transferred. + */ + error ERC1155InvalidReceiver(address receiver); + + /** + * @dev Indicates a failure with the `operator`’s approval. Used in transfers. + * @param operator Address that may be allowed to operate on tokens without being their owner. + * @param owner Address of the current owner of a token. + */ + error ERC1155MissingApprovalForAll(address operator, address owner); + + /** + * @dev Indicates a failure with the `approver` of a token to be approved. Used in approvals. + * @param approver Address initiating an approval operation. + */ + error ERC1155InvalidApprover(address approver); + + /** + * @dev Indicates a failure with the `operator` to be approved. Used in approvals. + * @param operator Address that may be allowed to operate on tokens without being their owner. + */ + error ERC1155InvalidOperator(address operator); + + /** + * @dev Indicates an array length mismatch between ids and values in a safeBatchTransferFrom operation. + * Used in batch transfers. + * @param idsLength Length of the array of token identifiers + * @param valuesLength Length of the array of token amounts + */ + error ERC1155InvalidArrayLength(uint256 idsLength, uint256 valuesLength); +} + +// File: @openzeppelin/contracts@5.4.0/token/ERC721/utils/ERC721Utils.sol + + +// OpenZeppelin Contracts (last updated v5.4.0) (token/ERC721/utils/ERC721Utils.sol) + +pragma solidity ^0.8.20; + + + +/** + * @dev Library that provide common ERC-721 utility functions. + * + * See https://eips.ethereum.org/EIPS/eip-721[ERC-721]. + * + * _Available since v5.1._ + */ +library ERC721Utils { + /** + * @dev Performs an acceptance check for the provided `operator` by calling {IERC721Receiver-onERC721Received} + * on the `to` address. The `operator` is generally the address that initiated the token transfer (i.e. `msg.sender`). + * + * The acceptance call is not executed and treated as a no-op if the target address doesn't contain code (i.e. an EOA). + * Otherwise, the recipient must implement {IERC721Receiver-onERC721Received} and return the acceptance magic value to accept + * the transfer. + */ + function checkOnERC721Received( + address operator, + address from, + address to, + uint256 tokenId, + bytes memory data + ) internal { + if (to.code.length > 0) { + try IERC721Receiver(to).onERC721Received(operator, from, tokenId, data) returns (bytes4 retval) { + if (retval != IERC721Receiver.onERC721Received.selector) { + // Token rejected + revert IERC721Errors.ERC721InvalidReceiver(to); + } + } catch (bytes memory reason) { + if (reason.length == 0) { + // non-IERC721Receiver implementer + revert IERC721Errors.ERC721InvalidReceiver(to); + } else { + assembly ("memory-safe") { + revert(add(reason, 0x20), mload(reason)) + } + } + } + } + } +} + +// File: @openzeppelin/contracts@5.4.0/utils/Panic.sol + + +// OpenZeppelin Contracts (last updated v5.1.0) (utils/Panic.sol) + +pragma solidity ^0.8.20; + +/** + * @dev Helper library for emitting standardized panic codes. + * + * ```solidity + * contract Example { + * using Panic for uint256; + * + * // Use any of the declared internal constants + * function foo() { Panic.GENERIC.panic(); } + * + * // Alternatively + * function foo() { Panic.panic(Panic.GENERIC); } + * } + * ``` + * + * Follows the list from https://github.com/ethereum/solidity/blob/v0.8.24/libsolutil/ErrorCodes.h[libsolutil]. + * + * _Available since v5.1._ + */ +// slither-disable-next-line unused-state +library Panic { + /// @dev generic / unspecified error + uint256 internal constant GENERIC = 0x00; + /// @dev used by the assert() builtin + uint256 internal constant ASSERT = 0x01; + /// @dev arithmetic underflow or overflow + uint256 internal constant UNDER_OVERFLOW = 0x11; + /// @dev division or modulo by zero + uint256 internal constant DIVISION_BY_ZERO = 0x12; + /// @dev enum conversion error + uint256 internal constant ENUM_CONVERSION_ERROR = 0x21; + /// @dev invalid encoding in storage + uint256 internal constant STORAGE_ENCODING_ERROR = 0x22; + /// @dev empty array pop + uint256 internal constant EMPTY_ARRAY_POP = 0x31; + /// @dev array out of bounds access + uint256 internal constant ARRAY_OUT_OF_BOUNDS = 0x32; + /// @dev resource error (too large allocation or too large array) + uint256 internal constant RESOURCE_ERROR = 0x41; + /// @dev calling invalid internal function + uint256 internal constant INVALID_INTERNAL_FUNCTION = 0x51; + + /// @dev Reverts with a panic code. Recommended to use with + /// the internal constants with predefined codes. + function panic(uint256 code) internal pure { + assembly ("memory-safe") { + mstore(0x00, 0x4e487b71) + mstore(0x20, code) + revert(0x1c, 0x24) + } + } +} + +// File: @openzeppelin/contracts@5.4.0/utils/math/SafeCast.sol + + +// OpenZeppelin Contracts (last updated v5.1.0) (utils/math/SafeCast.sol) +// This file was procedurally generated from scripts/generate/templates/SafeCast.js. + +pragma solidity ^0.8.20; + +/** + * @dev Wrappers over Solidity's uintXX/intXX/bool casting operators with added overflow + * checks. + * + * Downcasting from uint256/int256 in Solidity does not revert on overflow. This can + * easily result in undesired exploitation or bugs, since developers usually + * assume that overflows raise errors. `SafeCast` restores this intuition by + * reverting the transaction when such an operation overflows. + * + * Using this library instead of the unchecked operations eliminates an entire + * class of bugs, so it's recommended to use it always. + */ +library SafeCast { + /** + * @dev Value doesn't fit in an uint of `bits` size. + */ + error SafeCastOverflowedUintDowncast(uint8 bits, uint256 value); + + /** + * @dev An int value doesn't fit in an uint of `bits` size. + */ + error SafeCastOverflowedIntToUint(int256 value); + + /** + * @dev Value doesn't fit in an int of `bits` size. + */ + error SafeCastOverflowedIntDowncast(uint8 bits, int256 value); + + /** + * @dev An uint value doesn't fit in an int of `bits` size. + */ + error SafeCastOverflowedUintToInt(uint256 value); + + /** + * @dev Returns the downcasted uint248 from uint256, reverting on + * overflow (when the input is greater than largest uint248). + * + * Counterpart to Solidity's `uint248` operator. + * + * Requirements: + * + * - input must fit into 248 bits + */ + function toUint248(uint256 value) internal pure returns (uint248) { + if (value > type(uint248).max) { + revert SafeCastOverflowedUintDowncast(248, value); + } + return uint248(value); + } + + /** + * @dev Returns the downcasted uint240 from uint256, reverting on + * overflow (when the input is greater than largest uint240). + * + * Counterpart to Solidity's `uint240` operator. + * + * Requirements: + * + * - input must fit into 240 bits + */ + function toUint240(uint256 value) internal pure returns (uint240) { + if (value > type(uint240).max) { + revert SafeCastOverflowedUintDowncast(240, value); + } + return uint240(value); + } + + /** + * @dev Returns the downcasted uint232 from uint256, reverting on + * overflow (when the input is greater than largest uint232). + * + * Counterpart to Solidity's `uint232` operator. + * + * Requirements: + * + * - input must fit into 232 bits + */ + function toUint232(uint256 value) internal pure returns (uint232) { + if (value > type(uint232).max) { + revert SafeCastOverflowedUintDowncast(232, value); + } + return uint232(value); + } + + /** + * @dev Returns the downcasted uint224 from uint256, reverting on + * overflow (when the input is greater than largest uint224). + * + * Counterpart to Solidity's `uint224` operator. + * + * Requirements: + * + * - input must fit into 224 bits + */ + function toUint224(uint256 value) internal pure returns (uint224) { + if (value > type(uint224).max) { + revert SafeCastOverflowedUintDowncast(224, value); + } + return uint224(value); + } + + /** + * @dev Returns the downcasted uint216 from uint256, reverting on + * overflow (when the input is greater than largest uint216). + * + * Counterpart to Solidity's `uint216` operator. + * + * Requirements: + * + * - input must fit into 216 bits + */ + function toUint216(uint256 value) internal pure returns (uint216) { + if (value > type(uint216).max) { + revert SafeCastOverflowedUintDowncast(216, value); + } + return uint216(value); + } + + /** + * @dev Returns the downcasted uint208 from uint256, reverting on + * overflow (when the input is greater than largest uint208). + * + * Counterpart to Solidity's `uint208` operator. + * + * Requirements: + * + * - input must fit into 208 bits + */ + function toUint208(uint256 value) internal pure returns (uint208) { + if (value > type(uint208).max) { + revert SafeCastOverflowedUintDowncast(208, value); + } + return uint208(value); + } + + /** + * @dev Returns the downcasted uint200 from uint256, reverting on + * overflow (when the input is greater than largest uint200). + * + * Counterpart to Solidity's `uint200` operator. + * + * Requirements: + * + * - input must fit into 200 bits + */ + function toUint200(uint256 value) internal pure returns (uint200) { + if (value > type(uint200).max) { + revert SafeCastOverflowedUintDowncast(200, value); + } + return uint200(value); + } + + /** + * @dev Returns the downcasted uint192 from uint256, reverting on + * overflow (when the input is greater than largest uint192). + * + * Counterpart to Solidity's `uint192` operator. + * + * Requirements: + * + * - input must fit into 192 bits + */ + function toUint192(uint256 value) internal pure returns (uint192) { + if (value > type(uint192).max) { + revert SafeCastOverflowedUintDowncast(192, value); + } + return uint192(value); + } + + /** + * @dev Returns the downcasted uint184 from uint256, reverting on + * overflow (when the input is greater than largest uint184). + * + * Counterpart to Solidity's `uint184` operator. + * + * Requirements: + * + * - input must fit into 184 bits + */ + function toUint184(uint256 value) internal pure returns (uint184) { + if (value > type(uint184).max) { + revert SafeCastOverflowedUintDowncast(184, value); + } + return uint184(value); + } + + /** + * @dev Returns the downcasted uint176 from uint256, reverting on + * overflow (when the input is greater than largest uint176). + * + * Counterpart to Solidity's `uint176` operator. + * + * Requirements: + * + * - input must fit into 176 bits + */ + function toUint176(uint256 value) internal pure returns (uint176) { + if (value > type(uint176).max) { + revert SafeCastOverflowedUintDowncast(176, value); + } + return uint176(value); + } + + /** + * @dev Returns the downcasted uint168 from uint256, reverting on + * overflow (when the input is greater than largest uint168). + * + * Counterpart to Solidity's `uint168` operator. + * + * Requirements: + * + * - input must fit into 168 bits + */ + function toUint168(uint256 value) internal pure returns (uint168) { + if (value > type(uint168).max) { + revert SafeCastOverflowedUintDowncast(168, value); + } + return uint168(value); + } + + /** + * @dev Returns the downcasted uint160 from uint256, reverting on + * overflow (when the input is greater than largest uint160). + * + * Counterpart to Solidity's `uint160` operator. + * + * Requirements: + * + * - input must fit into 160 bits + */ + function toUint160(uint256 value) internal pure returns (uint160) { + if (value > type(uint160).max) { + revert SafeCastOverflowedUintDowncast(160, value); + } + return uint160(value); + } + + /** + * @dev Returns the downcasted uint152 from uint256, reverting on + * overflow (when the input is greater than largest uint152). + * + * Counterpart to Solidity's `uint152` operator. + * + * Requirements: + * + * - input must fit into 152 bits + */ + function toUint152(uint256 value) internal pure returns (uint152) { + if (value > type(uint152).max) { + revert SafeCastOverflowedUintDowncast(152, value); + } + return uint152(value); + } + + /** + * @dev Returns the downcasted uint144 from uint256, reverting on + * overflow (when the input is greater than largest uint144). + * + * Counterpart to Solidity's `uint144` operator. + * + * Requirements: + * + * - input must fit into 144 bits + */ + function toUint144(uint256 value) internal pure returns (uint144) { + if (value > type(uint144).max) { + revert SafeCastOverflowedUintDowncast(144, value); + } + return uint144(value); + } + + /** + * @dev Returns the downcasted uint136 from uint256, reverting on + * overflow (when the input is greater than largest uint136). + * + * Counterpart to Solidity's `uint136` operator. + * + * Requirements: + * + * - input must fit into 136 bits + */ + function toUint136(uint256 value) internal pure returns (uint136) { + if (value > type(uint136).max) { + revert SafeCastOverflowedUintDowncast(136, value); + } + return uint136(value); + } + + /** + * @dev Returns the downcasted uint128 from uint256, reverting on + * overflow (when the input is greater than largest uint128). + * + * Counterpart to Solidity's `uint128` operator. + * + * Requirements: + * + * - input must fit into 128 bits + */ + function toUint128(uint256 value) internal pure returns (uint128) { + if (value > type(uint128).max) { + revert SafeCastOverflowedUintDowncast(128, value); + } + return uint128(value); + } + + /** + * @dev Returns the downcasted uint120 from uint256, reverting on + * overflow (when the input is greater than largest uint120). + * + * Counterpart to Solidity's `uint120` operator. + * + * Requirements: + * + * - input must fit into 120 bits + */ + function toUint120(uint256 value) internal pure returns (uint120) { + if (value > type(uint120).max) { + revert SafeCastOverflowedUintDowncast(120, value); + } + return uint120(value); + } + + /** + * @dev Returns the downcasted uint112 from uint256, reverting on + * overflow (when the input is greater than largest uint112). + * + * Counterpart to Solidity's `uint112` operator. + * + * Requirements: + * + * - input must fit into 112 bits + */ + function toUint112(uint256 value) internal pure returns (uint112) { + if (value > type(uint112).max) { + revert SafeCastOverflowedUintDowncast(112, value); + } + return uint112(value); + } + + /** + * @dev Returns the downcasted uint104 from uint256, reverting on + * overflow (when the input is greater than largest uint104). + * + * Counterpart to Solidity's `uint104` operator. + * + * Requirements: + * + * - input must fit into 104 bits + */ + function toUint104(uint256 value) internal pure returns (uint104) { + if (value > type(uint104).max) { + revert SafeCastOverflowedUintDowncast(104, value); + } + return uint104(value); + } + + /** + * @dev Returns the downcasted uint96 from uint256, reverting on + * overflow (when the input is greater than largest uint96). + * + * Counterpart to Solidity's `uint96` operator. + * + * Requirements: + * + * - input must fit into 96 bits + */ + function toUint96(uint256 value) internal pure returns (uint96) { + if (value > type(uint96).max) { + revert SafeCastOverflowedUintDowncast(96, value); + } + return uint96(value); + } + + /** + * @dev Returns the downcasted uint88 from uint256, reverting on + * overflow (when the input is greater than largest uint88). + * + * Counterpart to Solidity's `uint88` operator. + * + * Requirements: + * + * - input must fit into 88 bits + */ + function toUint88(uint256 value) internal pure returns (uint88) { + if (value > type(uint88).max) { + revert SafeCastOverflowedUintDowncast(88, value); + } + return uint88(value); + } + + /** + * @dev Returns the downcasted uint80 from uint256, reverting on + * overflow (when the input is greater than largest uint80). + * + * Counterpart to Solidity's `uint80` operator. + * + * Requirements: + * + * - input must fit into 80 bits + */ + function toUint80(uint256 value) internal pure returns (uint80) { + if (value > type(uint80).max) { + revert SafeCastOverflowedUintDowncast(80, value); + } + return uint80(value); + } + + /** + * @dev Returns the downcasted uint72 from uint256, reverting on + * overflow (when the input is greater than largest uint72). + * + * Counterpart to Solidity's `uint72` operator. + * + * Requirements: + * + * - input must fit into 72 bits + */ + function toUint72(uint256 value) internal pure returns (uint72) { + if (value > type(uint72).max) { + revert SafeCastOverflowedUintDowncast(72, value); + } + return uint72(value); + } + + /** + * @dev Returns the downcasted uint64 from uint256, reverting on + * overflow (when the input is greater than largest uint64). + * + * Counterpart to Solidity's `uint64` operator. + * + * Requirements: + * + * - input must fit into 64 bits + */ + function toUint64(uint256 value) internal pure returns (uint64) { + if (value > type(uint64).max) { + revert SafeCastOverflowedUintDowncast(64, value); + } + return uint64(value); + } + + /** + * @dev Returns the downcasted uint56 from uint256, reverting on + * overflow (when the input is greater than largest uint56). + * + * Counterpart to Solidity's `uint56` operator. + * + * Requirements: + * + * - input must fit into 56 bits + */ + function toUint56(uint256 value) internal pure returns (uint56) { + if (value > type(uint56).max) { + revert SafeCastOverflowedUintDowncast(56, value); + } + return uint56(value); + } + + /** + * @dev Returns the downcasted uint48 from uint256, reverting on + * overflow (when the input is greater than largest uint48). + * + * Counterpart to Solidity's `uint48` operator. + * + * Requirements: + * + * - input must fit into 48 bits + */ + function toUint48(uint256 value) internal pure returns (uint48) { + if (value > type(uint48).max) { + revert SafeCastOverflowedUintDowncast(48, value); + } + return uint48(value); + } + + /** + * @dev Returns the downcasted uint40 from uint256, reverting on + * overflow (when the input is greater than largest uint40). + * + * Counterpart to Solidity's `uint40` operator. + * + * Requirements: + * + * - input must fit into 40 bits + */ + function toUint40(uint256 value) internal pure returns (uint40) { + if (value > type(uint40).max) { + revert SafeCastOverflowedUintDowncast(40, value); + } + return uint40(value); + } + + /** + * @dev Returns the downcasted uint32 from uint256, reverting on + * overflow (when the input is greater than largest uint32). + * + * Counterpart to Solidity's `uint32` operator. + * + * Requirements: + * + * - input must fit into 32 bits + */ + function toUint32(uint256 value) internal pure returns (uint32) { + if (value > type(uint32).max) { + revert SafeCastOverflowedUintDowncast(32, value); + } + return uint32(value); + } + + /** + * @dev Returns the downcasted uint24 from uint256, reverting on + * overflow (when the input is greater than largest uint24). + * + * Counterpart to Solidity's `uint24` operator. + * + * Requirements: + * + * - input must fit into 24 bits + */ + function toUint24(uint256 value) internal pure returns (uint24) { + if (value > type(uint24).max) { + revert SafeCastOverflowedUintDowncast(24, value); + } + return uint24(value); + } + + /** + * @dev Returns the downcasted uint16 from uint256, reverting on + * overflow (when the input is greater than largest uint16). + * + * Counterpart to Solidity's `uint16` operator. + * + * Requirements: + * + * - input must fit into 16 bits + */ + function toUint16(uint256 value) internal pure returns (uint16) { + if (value > type(uint16).max) { + revert SafeCastOverflowedUintDowncast(16, value); + } + return uint16(value); + } + + /** + * @dev Returns the downcasted uint8 from uint256, reverting on + * overflow (when the input is greater than largest uint8). + * + * Counterpart to Solidity's `uint8` operator. + * + * Requirements: + * + * - input must fit into 8 bits + */ + function toUint8(uint256 value) internal pure returns (uint8) { + if (value > type(uint8).max) { + revert SafeCastOverflowedUintDowncast(8, value); + } + return uint8(value); + } + + /** + * @dev Converts a signed int256 into an unsigned uint256. + * + * Requirements: + * + * - input must be greater than or equal to 0. + */ + function toUint256(int256 value) internal pure returns (uint256) { + if (value < 0) { + revert SafeCastOverflowedIntToUint(value); + } + return uint256(value); + } + + /** + * @dev Returns the downcasted int248 from int256, reverting on + * overflow (when the input is less than smallest int248 or + * greater than largest int248). + * + * Counterpart to Solidity's `int248` operator. + * + * Requirements: + * + * - input must fit into 248 bits + */ + function toInt248(int256 value) internal pure returns (int248 downcasted) { + downcasted = int248(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(248, value); + } + } + + /** + * @dev Returns the downcasted int240 from int256, reverting on + * overflow (when the input is less than smallest int240 or + * greater than largest int240). + * + * Counterpart to Solidity's `int240` operator. + * + * Requirements: + * + * - input must fit into 240 bits + */ + function toInt240(int256 value) internal pure returns (int240 downcasted) { + downcasted = int240(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(240, value); + } + } + + /** + * @dev Returns the downcasted int232 from int256, reverting on + * overflow (when the input is less than smallest int232 or + * greater than largest int232). + * + * Counterpart to Solidity's `int232` operator. + * + * Requirements: + * + * - input must fit into 232 bits + */ + function toInt232(int256 value) internal pure returns (int232 downcasted) { + downcasted = int232(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(232, value); + } + } + + /** + * @dev Returns the downcasted int224 from int256, reverting on + * overflow (when the input is less than smallest int224 or + * greater than largest int224). + * + * Counterpart to Solidity's `int224` operator. + * + * Requirements: + * + * - input must fit into 224 bits + */ + function toInt224(int256 value) internal pure returns (int224 downcasted) { + downcasted = int224(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(224, value); + } + } + + /** + * @dev Returns the downcasted int216 from int256, reverting on + * overflow (when the input is less than smallest int216 or + * greater than largest int216). + * + * Counterpart to Solidity's `int216` operator. + * + * Requirements: + * + * - input must fit into 216 bits + */ + function toInt216(int256 value) internal pure returns (int216 downcasted) { + downcasted = int216(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(216, value); + } + } + + /** + * @dev Returns the downcasted int208 from int256, reverting on + * overflow (when the input is less than smallest int208 or + * greater than largest int208). + * + * Counterpart to Solidity's `int208` operator. + * + * Requirements: + * + * - input must fit into 208 bits + */ + function toInt208(int256 value) internal pure returns (int208 downcasted) { + downcasted = int208(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(208, value); + } + } + + /** + * @dev Returns the downcasted int200 from int256, reverting on + * overflow (when the input is less than smallest int200 or + * greater than largest int200). + * + * Counterpart to Solidity's `int200` operator. + * + * Requirements: + * + * - input must fit into 200 bits + */ + function toInt200(int256 value) internal pure returns (int200 downcasted) { + downcasted = int200(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(200, value); + } + } + + /** + * @dev Returns the downcasted int192 from int256, reverting on + * overflow (when the input is less than smallest int192 or + * greater than largest int192). + * + * Counterpart to Solidity's `int192` operator. + * + * Requirements: + * + * - input must fit into 192 bits + */ + function toInt192(int256 value) internal pure returns (int192 downcasted) { + downcasted = int192(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(192, value); + } + } + + /** + * @dev Returns the downcasted int184 from int256, reverting on + * overflow (when the input is less than smallest int184 or + * greater than largest int184). + * + * Counterpart to Solidity's `int184` operator. + * + * Requirements: + * + * - input must fit into 184 bits + */ + function toInt184(int256 value) internal pure returns (int184 downcasted) { + downcasted = int184(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(184, value); + } + } + + /** + * @dev Returns the downcasted int176 from int256, reverting on + * overflow (when the input is less than smallest int176 or + * greater than largest int176). + * + * Counterpart to Solidity's `int176` operator. + * + * Requirements: + * + * - input must fit into 176 bits + */ + function toInt176(int256 value) internal pure returns (int176 downcasted) { + downcasted = int176(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(176, value); + } + } + + /** + * @dev Returns the downcasted int168 from int256, reverting on + * overflow (when the input is less than smallest int168 or + * greater than largest int168). + * + * Counterpart to Solidity's `int168` operator. + * + * Requirements: + * + * - input must fit into 168 bits + */ + function toInt168(int256 value) internal pure returns (int168 downcasted) { + downcasted = int168(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(168, value); + } + } + + /** + * @dev Returns the downcasted int160 from int256, reverting on + * overflow (when the input is less than smallest int160 or + * greater than largest int160). + * + * Counterpart to Solidity's `int160` operator. + * + * Requirements: + * + * - input must fit into 160 bits + */ + function toInt160(int256 value) internal pure returns (int160 downcasted) { + downcasted = int160(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(160, value); + } + } + + /** + * @dev Returns the downcasted int152 from int256, reverting on + * overflow (when the input is less than smallest int152 or + * greater than largest int152). + * + * Counterpart to Solidity's `int152` operator. + * + * Requirements: + * + * - input must fit into 152 bits + */ + function toInt152(int256 value) internal pure returns (int152 downcasted) { + downcasted = int152(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(152, value); + } + } + + /** + * @dev Returns the downcasted int144 from int256, reverting on + * overflow (when the input is less than smallest int144 or + * greater than largest int144). + * + * Counterpart to Solidity's `int144` operator. + * + * Requirements: + * + * - input must fit into 144 bits + */ + function toInt144(int256 value) internal pure returns (int144 downcasted) { + downcasted = int144(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(144, value); + } + } + + /** + * @dev Returns the downcasted int136 from int256, reverting on + * overflow (when the input is less than smallest int136 or + * greater than largest int136). + * + * Counterpart to Solidity's `int136` operator. + * + * Requirements: + * + * - input must fit into 136 bits + */ + function toInt136(int256 value) internal pure returns (int136 downcasted) { + downcasted = int136(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(136, value); + } + } + + /** + * @dev Returns the downcasted int128 from int256, reverting on + * overflow (when the input is less than smallest int128 or + * greater than largest int128). + * + * Counterpart to Solidity's `int128` operator. + * + * Requirements: + * + * - input must fit into 128 bits + */ + function toInt128(int256 value) internal pure returns (int128 downcasted) { + downcasted = int128(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(128, value); + } + } + + /** + * @dev Returns the downcasted int120 from int256, reverting on + * overflow (when the input is less than smallest int120 or + * greater than largest int120). + * + * Counterpart to Solidity's `int120` operator. + * + * Requirements: + * + * - input must fit into 120 bits + */ + function toInt120(int256 value) internal pure returns (int120 downcasted) { + downcasted = int120(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(120, value); + } + } + + /** + * @dev Returns the downcasted int112 from int256, reverting on + * overflow (when the input is less than smallest int112 or + * greater than largest int112). + * + * Counterpart to Solidity's `int112` operator. + * + * Requirements: + * + * - input must fit into 112 bits + */ + function toInt112(int256 value) internal pure returns (int112 downcasted) { + downcasted = int112(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(112, value); + } + } + + /** + * @dev Returns the downcasted int104 from int256, reverting on + * overflow (when the input is less than smallest int104 or + * greater than largest int104). + * + * Counterpart to Solidity's `int104` operator. + * + * Requirements: + * + * - input must fit into 104 bits + */ + function toInt104(int256 value) internal pure returns (int104 downcasted) { + downcasted = int104(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(104, value); + } + } + + /** + * @dev Returns the downcasted int96 from int256, reverting on + * overflow (when the input is less than smallest int96 or + * greater than largest int96). + * + * Counterpart to Solidity's `int96` operator. + * + * Requirements: + * + * - input must fit into 96 bits + */ + function toInt96(int256 value) internal pure returns (int96 downcasted) { + downcasted = int96(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(96, value); + } + } + + /** + * @dev Returns the downcasted int88 from int256, reverting on + * overflow (when the input is less than smallest int88 or + * greater than largest int88). + * + * Counterpart to Solidity's `int88` operator. + * + * Requirements: + * + * - input must fit into 88 bits + */ + function toInt88(int256 value) internal pure returns (int88 downcasted) { + downcasted = int88(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(88, value); + } + } + + /** + * @dev Returns the downcasted int80 from int256, reverting on + * overflow (when the input is less than smallest int80 or + * greater than largest int80). + * + * Counterpart to Solidity's `int80` operator. + * + * Requirements: + * + * - input must fit into 80 bits + */ + function toInt80(int256 value) internal pure returns (int80 downcasted) { + downcasted = int80(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(80, value); + } + } + + /** + * @dev Returns the downcasted int72 from int256, reverting on + * overflow (when the input is less than smallest int72 or + * greater than largest int72). + * + * Counterpart to Solidity's `int72` operator. + * + * Requirements: + * + * - input must fit into 72 bits + */ + function toInt72(int256 value) internal pure returns (int72 downcasted) { + downcasted = int72(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(72, value); + } + } + + /** + * @dev Returns the downcasted int64 from int256, reverting on + * overflow (when the input is less than smallest int64 or + * greater than largest int64). + * + * Counterpart to Solidity's `int64` operator. + * + * Requirements: + * + * - input must fit into 64 bits + */ + function toInt64(int256 value) internal pure returns (int64 downcasted) { + downcasted = int64(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(64, value); + } + } + + /** + * @dev Returns the downcasted int56 from int256, reverting on + * overflow (when the input is less than smallest int56 or + * greater than largest int56). + * + * Counterpart to Solidity's `int56` operator. + * + * Requirements: + * + * - input must fit into 56 bits + */ + function toInt56(int256 value) internal pure returns (int56 downcasted) { + downcasted = int56(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(56, value); + } + } + + /** + * @dev Returns the downcasted int48 from int256, reverting on + * overflow (when the input is less than smallest int48 or + * greater than largest int48). + * + * Counterpart to Solidity's `int48` operator. + * + * Requirements: + * + * - input must fit into 48 bits + */ + function toInt48(int256 value) internal pure returns (int48 downcasted) { + downcasted = int48(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(48, value); + } + } + + /** + * @dev Returns the downcasted int40 from int256, reverting on + * overflow (when the input is less than smallest int40 or + * greater than largest int40). + * + * Counterpart to Solidity's `int40` operator. + * + * Requirements: + * + * - input must fit into 40 bits + */ + function toInt40(int256 value) internal pure returns (int40 downcasted) { + downcasted = int40(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(40, value); + } + } + + /** + * @dev Returns the downcasted int32 from int256, reverting on + * overflow (when the input is less than smallest int32 or + * greater than largest int32). + * + * Counterpart to Solidity's `int32` operator. + * + * Requirements: + * + * - input must fit into 32 bits + */ + function toInt32(int256 value) internal pure returns (int32 downcasted) { + downcasted = int32(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(32, value); + } + } + + /** + * @dev Returns the downcasted int24 from int256, reverting on + * overflow (when the input is less than smallest int24 or + * greater than largest int24). + * + * Counterpart to Solidity's `int24` operator. + * + * Requirements: + * + * - input must fit into 24 bits + */ + function toInt24(int256 value) internal pure returns (int24 downcasted) { + downcasted = int24(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(24, value); + } + } + + /** + * @dev Returns the downcasted int16 from int256, reverting on + * overflow (when the input is less than smallest int16 or + * greater than largest int16). + * + * Counterpart to Solidity's `int16` operator. + * + * Requirements: + * + * - input must fit into 16 bits + */ + function toInt16(int256 value) internal pure returns (int16 downcasted) { + downcasted = int16(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(16, value); + } + } + + /** + * @dev Returns the downcasted int8 from int256, reverting on + * overflow (when the input is less than smallest int8 or + * greater than largest int8). + * + * Counterpart to Solidity's `int8` operator. + * + * Requirements: + * + * - input must fit into 8 bits + */ + function toInt8(int256 value) internal pure returns (int8 downcasted) { + downcasted = int8(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(8, value); + } + } + + /** + * @dev Converts an unsigned uint256 into a signed int256. + * + * Requirements: + * + * - input must be less than or equal to maxInt256. + */ + function toInt256(uint256 value) internal pure returns (int256) { + // Note: Unsafe cast below is okay because `type(int256).max` is guaranteed to be positive + if (value > uint256(type(int256).max)) { + revert SafeCastOverflowedUintToInt(value); + } + return int256(value); + } + + /** + * @dev Cast a boolean (false or true) to a uint256 (0 or 1) with no jump. + */ + function toUint(bool b) internal pure returns (uint256 u) { + assembly ("memory-safe") { + u := iszero(iszero(b)) + } + } +} + +// File: @openzeppelin/contracts@5.4.0/utils/math/Math.sol + + +// OpenZeppelin Contracts (last updated v5.3.0) (utils/math/Math.sol) + +pragma solidity ^0.8.20; + + + +/** + * @dev Standard math utilities missing in the Solidity language. + */ +library Math { + enum Rounding { + Floor, // Toward negative infinity + Ceil, // Toward positive infinity + Trunc, // Toward zero + Expand // Away from zero + } + + /** + * @dev Return the 512-bit addition of two uint256. + * + * The result is stored in two 256 variables such that sum = high * 2²⁵⁶ + low. + */ + function add512(uint256 a, uint256 b) internal pure returns (uint256 high, uint256 low) { + assembly ("memory-safe") { + low := add(a, b) + high := lt(low, a) + } + } + + /** + * @dev Return the 512-bit multiplication of two uint256. + * + * The result is stored in two 256 variables such that product = high * 2²⁵⁶ + low. + */ + function mul512(uint256 a, uint256 b) internal pure returns (uint256 high, uint256 low) { + // 512-bit multiply [high low] = x * y. Compute the product mod 2²⁵⁶ and mod 2²⁵⁶ - 1, then use + // the Chinese Remainder Theorem to reconstruct the 512 bit result. The result is stored in two 256 + // variables such that product = high * 2²⁵⁶ + low. + assembly ("memory-safe") { + let mm := mulmod(a, b, not(0)) + low := mul(a, b) + high := sub(sub(mm, low), lt(mm, low)) + } + } + + /** + * @dev Returns the addition of two unsigned integers, with a success flag (no overflow). + */ + function tryAdd(uint256 a, uint256 b) internal pure returns (bool success, uint256 result) { + unchecked { + uint256 c = a + b; + success = c >= a; + result = c * SafeCast.toUint(success); + } + } + + /** + * @dev Returns the subtraction of two unsigned integers, with a success flag (no overflow). + */ + function trySub(uint256 a, uint256 b) internal pure returns (bool success, uint256 result) { + unchecked { + uint256 c = a - b; + success = c <= a; + result = c * SafeCast.toUint(success); + } + } + + /** + * @dev Returns the multiplication of two unsigned integers, with a success flag (no overflow). + */ + function tryMul(uint256 a, uint256 b) internal pure returns (bool success, uint256 result) { + unchecked { + uint256 c = a * b; + assembly ("memory-safe") { + // Only true when the multiplication doesn't overflow + // (c / a == b) || (a == 0) + success := or(eq(div(c, a), b), iszero(a)) + } + // equivalent to: success ? c : 0 + result = c * SafeCast.toUint(success); + } + } + + /** + * @dev Returns the division of two unsigned integers, with a success flag (no division by zero). + */ + function tryDiv(uint256 a, uint256 b) internal pure returns (bool success, uint256 result) { + unchecked { + success = b > 0; + assembly ("memory-safe") { + // The `DIV` opcode returns zero when the denominator is 0. + result := div(a, b) + } + } + } + + /** + * @dev Returns the remainder of dividing two unsigned integers, with a success flag (no division by zero). + */ + function tryMod(uint256 a, uint256 b) internal pure returns (bool success, uint256 result) { + unchecked { + success = b > 0; + assembly ("memory-safe") { + // The `MOD` opcode returns zero when the denominator is 0. + result := mod(a, b) + } + } + } + + /** + * @dev Unsigned saturating addition, bounds to `2²⁵⁶ - 1` instead of overflowing. + */ + function saturatingAdd(uint256 a, uint256 b) internal pure returns (uint256) { + (bool success, uint256 result) = tryAdd(a, b); + return ternary(success, result, type(uint256).max); + } + + /** + * @dev Unsigned saturating subtraction, bounds to zero instead of overflowing. + */ + function saturatingSub(uint256 a, uint256 b) internal pure returns (uint256) { + (, uint256 result) = trySub(a, b); + return result; + } + + /** + * @dev Unsigned saturating multiplication, bounds to `2²⁵⁶ - 1` instead of overflowing. + */ + function saturatingMul(uint256 a, uint256 b) internal pure returns (uint256) { + (bool success, uint256 result) = tryMul(a, b); + return ternary(success, result, type(uint256).max); + } + + /** + * @dev Branchless ternary evaluation for `a ? b : c`. Gas costs are constant. + * + * IMPORTANT: This function may reduce bytecode size and consume less gas when used standalone. + * However, the compiler may optimize Solidity ternary operations (i.e. `a ? b : c`) to only compute + * one branch when needed, making this function more expensive. + */ + function ternary(bool condition, uint256 a, uint256 b) internal pure returns (uint256) { + unchecked { + // branchless ternary works because: + // b ^ (a ^ b) == a + // b ^ 0 == b + return b ^ ((a ^ b) * SafeCast.toUint(condition)); + } + } + + /** + * @dev Returns the largest of two numbers. + */ + function max(uint256 a, uint256 b) internal pure returns (uint256) { + return ternary(a > b, a, b); + } + + /** + * @dev Returns the smallest of two numbers. + */ + function min(uint256 a, uint256 b) internal pure returns (uint256) { + return ternary(a < b, a, b); + } + + /** + * @dev Returns the average of two numbers. The result is rounded towards + * zero. + */ + function average(uint256 a, uint256 b) internal pure returns (uint256) { + // (a + b) / 2 can overflow. + return (a & b) + (a ^ b) / 2; + } + + /** + * @dev Returns the ceiling of the division of two numbers. + * + * This differs from standard division with `/` in that it rounds towards infinity instead + * of rounding towards zero. + */ + function ceilDiv(uint256 a, uint256 b) internal pure returns (uint256) { + if (b == 0) { + // Guarantee the same behavior as in a regular Solidity division. + Panic.panic(Panic.DIVISION_BY_ZERO); + } + + // The following calculation ensures accurate ceiling division without overflow. + // Since a is non-zero, (a - 1) / b will not overflow. + // The largest possible result occurs when (a - 1) / b is type(uint256).max, + // but the largest value we can obtain is type(uint256).max - 1, which happens + // when a = type(uint256).max and b = 1. + unchecked { + return SafeCast.toUint(a > 0) * ((a - 1) / b + 1); + } + } + + /** + * @dev Calculates floor(x * y / denominator) with full precision. Throws if result overflows a uint256 or + * denominator == 0. + * + * Original credit to Remco Bloemen under MIT license (https://xn--2-umb.com/21/muldiv) with further edits by + * Uniswap Labs also under MIT license. + */ + function mulDiv(uint256 x, uint256 y, uint256 denominator) internal pure returns (uint256 result) { + unchecked { + (uint256 high, uint256 low) = mul512(x, y); + + // Handle non-overflow cases, 256 by 256 division. + if (high == 0) { + // Solidity will revert if denominator == 0, unlike the div opcode on its own. + // The surrounding unchecked block does not change this fact. + // See https://docs.soliditylang.org/en/latest/control-structures.html#checked-or-unchecked-arithmetic. + return low / denominator; + } + + // Make sure the result is less than 2²⁵⁶. Also prevents denominator == 0. + if (denominator <= high) { + Panic.panic(ternary(denominator == 0, Panic.DIVISION_BY_ZERO, Panic.UNDER_OVERFLOW)); + } + + /////////////////////////////////////////////// + // 512 by 256 division. + /////////////////////////////////////////////// + + // Make division exact by subtracting the remainder from [high low]. + uint256 remainder; + assembly ("memory-safe") { + // Compute remainder using mulmod. + remainder := mulmod(x, y, denominator) + + // Subtract 256 bit number from 512 bit number. + high := sub(high, gt(remainder, low)) + low := sub(low, remainder) + } + + // Factor powers of two out of denominator and compute largest power of two divisor of denominator. + // Always >= 1. See https://cs.stackexchange.com/q/138556/92363. + + uint256 twos = denominator & (0 - denominator); + assembly ("memory-safe") { + // Divide denominator by twos. + denominator := div(denominator, twos) + + // Divide [high low] by twos. + low := div(low, twos) + + // Flip twos such that it is 2²⁵⁶ / twos. If twos is zero, then it becomes one. + twos := add(div(sub(0, twos), twos), 1) + } + + // Shift in bits from high into low. + low |= high * twos; + + // Invert denominator mod 2²⁵⁶. Now that denominator is an odd number, it has an inverse modulo 2²⁵⁶ such + // that denominator * inv ≡ 1 mod 2²⁵⁶. Compute the inverse by starting with a seed that is correct for + // four bits. That is, denominator * inv ≡ 1 mod 2⁴. + uint256 inverse = (3 * denominator) ^ 2; + + // Use the Newton-Raphson iteration to improve the precision. Thanks to Hensel's lifting lemma, this also + // works in modular arithmetic, doubling the correct bits in each step. + inverse *= 2 - denominator * inverse; // inverse mod 2⁸ + inverse *= 2 - denominator * inverse; // inverse mod 2¹⁶ + inverse *= 2 - denominator * inverse; // inverse mod 2³² + inverse *= 2 - denominator * inverse; // inverse mod 2⁶⁴ + inverse *= 2 - denominator * inverse; // inverse mod 2¹²⁸ + inverse *= 2 - denominator * inverse; // inverse mod 2²⁵⁶ + + // Because the division is now exact we can divide by multiplying with the modular inverse of denominator. + // This will give us the correct result modulo 2²⁵⁶. Since the preconditions guarantee that the outcome is + // less than 2²⁵⁶, this is the final result. We don't need to compute the high bits of the result and high + // is no longer required. + result = low * inverse; + return result; + } + } + + /** + * @dev Calculates x * y / denominator with full precision, following the selected rounding direction. + */ + function mulDiv(uint256 x, uint256 y, uint256 denominator, Rounding rounding) internal pure returns (uint256) { + return mulDiv(x, y, denominator) + SafeCast.toUint(unsignedRoundsUp(rounding) && mulmod(x, y, denominator) > 0); + } + + /** + * @dev Calculates floor(x * y >> n) with full precision. Throws if result overflows a uint256. + */ + function mulShr(uint256 x, uint256 y, uint8 n) internal pure returns (uint256 result) { + unchecked { + (uint256 high, uint256 low) = mul512(x, y); + if (high >= 1 << n) { + Panic.panic(Panic.UNDER_OVERFLOW); + } + return (high << (256 - n)) | (low >> n); + } + } + + /** + * @dev Calculates x * y >> n with full precision, following the selected rounding direction. + */ + function mulShr(uint256 x, uint256 y, uint8 n, Rounding rounding) internal pure returns (uint256) { + return mulShr(x, y, n) + SafeCast.toUint(unsignedRoundsUp(rounding) && mulmod(x, y, 1 << n) > 0); + } + + /** + * @dev Calculate the modular multiplicative inverse of a number in Z/nZ. + * + * If n is a prime, then Z/nZ is a field. In that case all elements are inversible, except 0. + * If n is not a prime, then Z/nZ is not a field, and some elements might not be inversible. + * + * If the input value is not inversible, 0 is returned. + * + * NOTE: If you know for sure that n is (big) a prime, it may be cheaper to use Fermat's little theorem and get the + * inverse using `Math.modExp(a, n - 2, n)`. See {invModPrime}. + */ + function invMod(uint256 a, uint256 n) internal pure returns (uint256) { + unchecked { + if (n == 0) return 0; + + // The inverse modulo is calculated using the Extended Euclidean Algorithm (iterative version) + // Used to compute integers x and y such that: ax + ny = gcd(a, n). + // When the gcd is 1, then the inverse of a modulo n exists and it's x. + // ax + ny = 1 + // ax = 1 + (-y)n + // ax ≡ 1 (mod n) # x is the inverse of a modulo n + + // If the remainder is 0 the gcd is n right away. + uint256 remainder = a % n; + uint256 gcd = n; + + // Therefore the initial coefficients are: + // ax + ny = gcd(a, n) = n + // 0a + 1n = n + int256 x = 0; + int256 y = 1; + + while (remainder != 0) { + uint256 quotient = gcd / remainder; + + (gcd, remainder) = ( + // The old remainder is the next gcd to try. + remainder, + // Compute the next remainder. + // Can't overflow given that (a % gcd) * (gcd // (a % gcd)) <= gcd + // where gcd is at most n (capped to type(uint256).max) + gcd - remainder * quotient + ); + + (x, y) = ( + // Increment the coefficient of a. + y, + // Decrement the coefficient of n. + // Can overflow, but the result is casted to uint256 so that the + // next value of y is "wrapped around" to a value between 0 and n - 1. + x - y * int256(quotient) + ); + } + + if (gcd != 1) return 0; // No inverse exists. + return ternary(x < 0, n - uint256(-x), uint256(x)); // Wrap the result if it's negative. + } + } + + /** + * @dev Variant of {invMod}. More efficient, but only works if `p` is known to be a prime greater than `2`. + * + * From https://en.wikipedia.org/wiki/Fermat%27s_little_theorem[Fermat's little theorem], we know that if p is + * prime, then `a**(p-1) ≡ 1 mod p`. As a consequence, we have `a * a**(p-2) ≡ 1 mod p`, which means that + * `a**(p-2)` is the modular multiplicative inverse of a in Fp. + * + * NOTE: this function does NOT check that `p` is a prime greater than `2`. + */ + function invModPrime(uint256 a, uint256 p) internal view returns (uint256) { + unchecked { + return Math.modExp(a, p - 2, p); + } + } + + /** + * @dev Returns the modular exponentiation of the specified base, exponent and modulus (b ** e % m) + * + * Requirements: + * - modulus can't be zero + * - underlying staticcall to precompile must succeed + * + * IMPORTANT: The result is only valid if the underlying call succeeds. When using this function, make + * sure the chain you're using it on supports the precompiled contract for modular exponentiation + * at address 0x05 as specified in https://eips.ethereum.org/EIPS/eip-198[EIP-198]. Otherwise, + * the underlying function will succeed given the lack of a revert, but the result may be incorrectly + * interpreted as 0. + */ + function modExp(uint256 b, uint256 e, uint256 m) internal view returns (uint256) { + (bool success, uint256 result) = tryModExp(b, e, m); + if (!success) { + Panic.panic(Panic.DIVISION_BY_ZERO); + } + return result; + } + + /** + * @dev Returns the modular exponentiation of the specified base, exponent and modulus (b ** e % m). + * It includes a success flag indicating if the operation succeeded. Operation will be marked as failed if trying + * to operate modulo 0 or if the underlying precompile reverted. + * + * IMPORTANT: The result is only valid if the success flag is true. When using this function, make sure the chain + * you're using it on supports the precompiled contract for modular exponentiation at address 0x05 as specified in + * https://eips.ethereum.org/EIPS/eip-198[EIP-198]. Otherwise, the underlying function will succeed given the lack + * of a revert, but the result may be incorrectly interpreted as 0. + */ + function tryModExp(uint256 b, uint256 e, uint256 m) internal view returns (bool success, uint256 result) { + if (m == 0) return (false, 0); + assembly ("memory-safe") { + let ptr := mload(0x40) + // | Offset | Content | Content (Hex) | + // |-----------|------------|--------------------------------------------------------------------| + // | 0x00:0x1f | size of b | 0x0000000000000000000000000000000000000000000000000000000000000020 | + // | 0x20:0x3f | size of e | 0x0000000000000000000000000000000000000000000000000000000000000020 | + // | 0x40:0x5f | size of m | 0x0000000000000000000000000000000000000000000000000000000000000020 | + // | 0x60:0x7f | value of b | 0x<.............................................................b> | + // | 0x80:0x9f | value of e | 0x<.............................................................e> | + // | 0xa0:0xbf | value of m | 0x<.............................................................m> | + mstore(ptr, 0x20) + mstore(add(ptr, 0x20), 0x20) + mstore(add(ptr, 0x40), 0x20) + mstore(add(ptr, 0x60), b) + mstore(add(ptr, 0x80), e) + mstore(add(ptr, 0xa0), m) + + // Given the result < m, it's guaranteed to fit in 32 bytes, + // so we can use the memory scratch space located at offset 0. + success := staticcall(gas(), 0x05, ptr, 0xc0, 0x00, 0x20) + result := mload(0x00) + } + } + + /** + * @dev Variant of {modExp} that supports inputs of arbitrary length. + */ + function modExp(bytes memory b, bytes memory e, bytes memory m) internal view returns (bytes memory) { + (bool success, bytes memory result) = tryModExp(b, e, m); + if (!success) { + Panic.panic(Panic.DIVISION_BY_ZERO); + } + return result; + } + + /** + * @dev Variant of {tryModExp} that supports inputs of arbitrary length. + */ + function tryModExp( + bytes memory b, + bytes memory e, + bytes memory m + ) internal view returns (bool success, bytes memory result) { + if (_zeroBytes(m)) return (false, new bytes(0)); + + uint256 mLen = m.length; + + // Encode call args in result and move the free memory pointer + result = abi.encodePacked(b.length, e.length, mLen, b, e, m); + + assembly ("memory-safe") { + let dataPtr := add(result, 0x20) + // Write result on top of args to avoid allocating extra memory. + success := staticcall(gas(), 0x05, dataPtr, mload(result), dataPtr, mLen) + // Overwrite the length. + // result.length > returndatasize() is guaranteed because returndatasize() == m.length + mstore(result, mLen) + // Set the memory pointer after the returned data. + mstore(0x40, add(dataPtr, mLen)) + } + } + + /** + * @dev Returns whether the provided byte array is zero. + */ + function _zeroBytes(bytes memory byteArray) private pure returns (bool) { + for (uint256 i = 0; i < byteArray.length; ++i) { + if (byteArray[i] != 0) { + return false; + } + } + return true; + } + + /** + * @dev Returns the square root of a number. If the number is not a perfect square, the value is rounded + * towards zero. + * + * This method is based on Newton's method for computing square roots; the algorithm is restricted to only + * using integer operations. + */ + function sqrt(uint256 a) internal pure returns (uint256) { + unchecked { + // Take care of easy edge cases when a == 0 or a == 1 + if (a <= 1) { + return a; + } + + // In this function, we use Newton's method to get a root of `f(x) := x² - a`. It involves building a + // sequence x_n that converges toward sqrt(a). For each iteration x_n, we also define the error between + // the current value as `ε_n = | x_n - sqrt(a) |`. + // + // For our first estimation, we consider `e` the smallest power of 2 which is bigger than the square root + // of the target. (i.e. `2**(e-1) ≤ sqrt(a) < 2**e`). We know that `e ≤ 128` because `(2¹²⁸)² = 2²⁵⁶` is + // bigger than any uint256. + // + // By noticing that + // `2**(e-1) ≤ sqrt(a) < 2**e → (2**(e-1))² ≤ a < (2**e)² → 2**(2*e-2) ≤ a < 2**(2*e)` + // we can deduce that `e - 1` is `log2(a) / 2`. We can thus compute `x_n = 2**(e-1)` using a method similar + // to the msb function. + uint256 aa = a; + uint256 xn = 1; + + if (aa >= (1 << 128)) { + aa >>= 128; + xn <<= 64; + } + if (aa >= (1 << 64)) { + aa >>= 64; + xn <<= 32; + } + if (aa >= (1 << 32)) { + aa >>= 32; + xn <<= 16; + } + if (aa >= (1 << 16)) { + aa >>= 16; + xn <<= 8; + } + if (aa >= (1 << 8)) { + aa >>= 8; + xn <<= 4; + } + if (aa >= (1 << 4)) { + aa >>= 4; + xn <<= 2; + } + if (aa >= (1 << 2)) { + xn <<= 1; + } + + // We now have x_n such that `x_n = 2**(e-1) ≤ sqrt(a) < 2**e = 2 * x_n`. This implies ε_n ≤ 2**(e-1). + // + // We can refine our estimation by noticing that the middle of that interval minimizes the error. + // If we move x_n to equal 2**(e-1) + 2**(e-2), then we reduce the error to ε_n ≤ 2**(e-2). + // This is going to be our x_0 (and ε_0) + xn = (3 * xn) >> 1; // ε_0 := | x_0 - sqrt(a) | ≤ 2**(e-2) + + // From here, Newton's method give us: + // x_{n+1} = (x_n + a / x_n) / 2 + // + // One should note that: + // x_{n+1}² - a = ((x_n + a / x_n) / 2)² - a + // = ((x_n² + a) / (2 * x_n))² - a + // = (x_n⁴ + 2 * a * x_n² + a²) / (4 * x_n²) - a + // = (x_n⁴ + 2 * a * x_n² + a² - 4 * a * x_n²) / (4 * x_n²) + // = (x_n⁴ - 2 * a * x_n² + a²) / (4 * x_n²) + // = (x_n² - a)² / (2 * x_n)² + // = ((x_n² - a) / (2 * x_n))² + // ≥ 0 + // Which proves that for all n ≥ 1, sqrt(a) ≤ x_n + // + // This gives us the proof of quadratic convergence of the sequence: + // ε_{n+1} = | x_{n+1} - sqrt(a) | + // = | (x_n + a / x_n) / 2 - sqrt(a) | + // = | (x_n² + a - 2*x_n*sqrt(a)) / (2 * x_n) | + // = | (x_n - sqrt(a))² / (2 * x_n) | + // = | ε_n² / (2 * x_n) | + // = ε_n² / | (2 * x_n) | + // + // For the first iteration, we have a special case where x_0 is known: + // ε_1 = ε_0² / | (2 * x_0) | + // ≤ (2**(e-2))² / (2 * (2**(e-1) + 2**(e-2))) + // ≤ 2**(2*e-4) / (3 * 2**(e-1)) + // ≤ 2**(e-3) / 3 + // ≤ 2**(e-3-log2(3)) + // ≤ 2**(e-4.5) + // + // For the following iterations, we use the fact that, 2**(e-1) ≤ sqrt(a) ≤ x_n: + // ε_{n+1} = ε_n² / | (2 * x_n) | + // ≤ (2**(e-k))² / (2 * 2**(e-1)) + // ≤ 2**(2*e-2*k) / 2**e + // ≤ 2**(e-2*k) + xn = (xn + a / xn) >> 1; // ε_1 := | x_1 - sqrt(a) | ≤ 2**(e-4.5) -- special case, see above + xn = (xn + a / xn) >> 1; // ε_2 := | x_2 - sqrt(a) | ≤ 2**(e-9) -- general case with k = 4.5 + xn = (xn + a / xn) >> 1; // ε_3 := | x_3 - sqrt(a) | ≤ 2**(e-18) -- general case with k = 9 + xn = (xn + a / xn) >> 1; // ε_4 := | x_4 - sqrt(a) | ≤ 2**(e-36) -- general case with k = 18 + xn = (xn + a / xn) >> 1; // ε_5 := | x_5 - sqrt(a) | ≤ 2**(e-72) -- general case with k = 36 + xn = (xn + a / xn) >> 1; // ε_6 := | x_6 - sqrt(a) | ≤ 2**(e-144) -- general case with k = 72 + + // Because e ≤ 128 (as discussed during the first estimation phase), we know have reached a precision + // ε_6 ≤ 2**(e-144) < 1. Given we're operating on integers, then we can ensure that xn is now either + // sqrt(a) or sqrt(a) + 1. + return xn - SafeCast.toUint(xn > a / xn); + } + } + + /** + * @dev Calculates sqrt(a), following the selected rounding direction. + */ + function sqrt(uint256 a, Rounding rounding) internal pure returns (uint256) { + unchecked { + uint256 result = sqrt(a); + return result + SafeCast.toUint(unsignedRoundsUp(rounding) && result * result < a); + } + } + + /** + * @dev Return the log in base 2 of a positive value rounded towards zero. + * Returns 0 if given 0. + */ + function log2(uint256 x) internal pure returns (uint256 r) { + // If value has upper 128 bits set, log2 result is at least 128 + r = SafeCast.toUint(x > 0xffffffffffffffffffffffffffffffff) << 7; + // If upper 64 bits of 128-bit half set, add 64 to result + r |= SafeCast.toUint((x >> r) > 0xffffffffffffffff) << 6; + // If upper 32 bits of 64-bit half set, add 32 to result + r |= SafeCast.toUint((x >> r) > 0xffffffff) << 5; + // If upper 16 bits of 32-bit half set, add 16 to result + r |= SafeCast.toUint((x >> r) > 0xffff) << 4; + // If upper 8 bits of 16-bit half set, add 8 to result + r |= SafeCast.toUint((x >> r) > 0xff) << 3; + // If upper 4 bits of 8-bit half set, add 4 to result + r |= SafeCast.toUint((x >> r) > 0xf) << 2; + + // Shifts value right by the current result and use it as an index into this lookup table: + // + // | x (4 bits) | index | table[index] = MSB position | + // |------------|---------|-----------------------------| + // | 0000 | 0 | table[0] = 0 | + // | 0001 | 1 | table[1] = 0 | + // | 0010 | 2 | table[2] = 1 | + // | 0011 | 3 | table[3] = 1 | + // | 0100 | 4 | table[4] = 2 | + // | 0101 | 5 | table[5] = 2 | + // | 0110 | 6 | table[6] = 2 | + // | 0111 | 7 | table[7] = 2 | + // | 1000 | 8 | table[8] = 3 | + // | 1001 | 9 | table[9] = 3 | + // | 1010 | 10 | table[10] = 3 | + // | 1011 | 11 | table[11] = 3 | + // | 1100 | 12 | table[12] = 3 | + // | 1101 | 13 | table[13] = 3 | + // | 1110 | 14 | table[14] = 3 | + // | 1111 | 15 | table[15] = 3 | + // + // The lookup table is represented as a 32-byte value with the MSB positions for 0-15 in the last 16 bytes. + assembly ("memory-safe") { + r := or(r, byte(shr(r, x), 0x0000010102020202030303030303030300000000000000000000000000000000)) + } + } + + /** + * @dev Return the log in base 2, following the selected rounding direction, of a positive value. + * Returns 0 if given 0. + */ + function log2(uint256 value, Rounding rounding) internal pure returns (uint256) { + unchecked { + uint256 result = log2(value); + return result + SafeCast.toUint(unsignedRoundsUp(rounding) && 1 << result < value); + } + } + + /** + * @dev Return the log in base 10 of a positive value rounded towards zero. + * Returns 0 if given 0. + */ + function log10(uint256 value) internal pure returns (uint256) { + uint256 result = 0; + unchecked { + if (value >= 10 ** 64) { + value /= 10 ** 64; + result += 64; + } + if (value >= 10 ** 32) { + value /= 10 ** 32; + result += 32; + } + if (value >= 10 ** 16) { + value /= 10 ** 16; + result += 16; + } + if (value >= 10 ** 8) { + value /= 10 ** 8; + result += 8; + } + if (value >= 10 ** 4) { + value /= 10 ** 4; + result += 4; + } + if (value >= 10 ** 2) { + value /= 10 ** 2; + result += 2; + } + if (value >= 10 ** 1) { + result += 1; + } + } + return result; + } + + /** + * @dev Return the log in base 10, following the selected rounding direction, of a positive value. + * Returns 0 if given 0. + */ + function log10(uint256 value, Rounding rounding) internal pure returns (uint256) { + unchecked { + uint256 result = log10(value); + return result + SafeCast.toUint(unsignedRoundsUp(rounding) && 10 ** result < value); + } + } + + /** + * @dev Return the log in base 256 of a positive value rounded towards zero. + * Returns 0 if given 0. + * + * Adding one to the result gives the number of pairs of hex symbols needed to represent `value` as a hex string. + */ + function log256(uint256 x) internal pure returns (uint256 r) { + // If value has upper 128 bits set, log2 result is at least 128 + r = SafeCast.toUint(x > 0xffffffffffffffffffffffffffffffff) << 7; + // If upper 64 bits of 128-bit half set, add 64 to result + r |= SafeCast.toUint((x >> r) > 0xffffffffffffffff) << 6; + // If upper 32 bits of 64-bit half set, add 32 to result + r |= SafeCast.toUint((x >> r) > 0xffffffff) << 5; + // If upper 16 bits of 32-bit half set, add 16 to result + r |= SafeCast.toUint((x >> r) > 0xffff) << 4; + // Add 1 if upper 8 bits of 16-bit half set, and divide accumulated result by 8 + return (r >> 3) | SafeCast.toUint((x >> r) > 0xff); + } + + /** + * @dev Return the log in base 256, following the selected rounding direction, of a positive value. + * Returns 0 if given 0. + */ + function log256(uint256 value, Rounding rounding) internal pure returns (uint256) { + unchecked { + uint256 result = log256(value); + return result + SafeCast.toUint(unsignedRoundsUp(rounding) && 1 << (result << 3) < value); + } + } + + /** + * @dev Returns whether a provided rounding mode is considered rounding up for unsigned integers. + */ + function unsignedRoundsUp(Rounding rounding) internal pure returns (bool) { + return uint8(rounding) % 2 == 1; + } +} + +// File: @openzeppelin/contracts@5.4.0/utils/math/SignedMath.sol + + +// OpenZeppelin Contracts (last updated v5.1.0) (utils/math/SignedMath.sol) + +pragma solidity ^0.8.20; + + +/** + * @dev Standard signed math utilities missing in the Solidity language. + */ +library SignedMath { + /** + * @dev Branchless ternary evaluation for `a ? b : c`. Gas costs are constant. + * + * IMPORTANT: This function may reduce bytecode size and consume less gas when used standalone. + * However, the compiler may optimize Solidity ternary operations (i.e. `a ? b : c`) to only compute + * one branch when needed, making this function more expensive. + */ + function ternary(bool condition, int256 a, int256 b) internal pure returns (int256) { + unchecked { + // branchless ternary works because: + // b ^ (a ^ b) == a + // b ^ 0 == b + return b ^ ((a ^ b) * int256(SafeCast.toUint(condition))); + } + } + + /** + * @dev Returns the largest of two signed numbers. + */ + function max(int256 a, int256 b) internal pure returns (int256) { + return ternary(a > b, a, b); + } + + /** + * @dev Returns the smallest of two signed numbers. + */ + function min(int256 a, int256 b) internal pure returns (int256) { + return ternary(a < b, a, b); + } + + /** + * @dev Returns the average of two signed numbers without overflow. + * The result is rounded towards zero. + */ + function average(int256 a, int256 b) internal pure returns (int256) { + // Formula from the book "Hacker's Delight" + int256 x = (a & b) + ((a ^ b) >> 1); + return x + (int256(uint256(x) >> 255) & (a ^ b)); + } + + /** + * @dev Returns the absolute unsigned value of a signed value. + */ + function abs(int256 n) internal pure returns (uint256) { + unchecked { + // Formula from the "Bit Twiddling Hacks" by Sean Eron Anderson. + // Since `n` is a signed integer, the generated bytecode will use the SAR opcode to perform the right shift, + // taking advantage of the most significant (or "sign" bit) in two's complement representation. + // This opcode adds new most significant bits set to the value of the previous most significant bit. As a result, + // the mask will either be `bytes32(0)` (if n is positive) or `~bytes32(0)` (if n is negative). + int256 mask = n >> 255; + + // A `bytes32(0)` mask leaves the input unchanged, while a `~bytes32(0)` mask complements it. + return uint256((n + mask) ^ mask); + } + } +} + +// File: @openzeppelin/contracts@5.4.0/utils/Strings.sol + + +// OpenZeppelin Contracts (last updated v5.4.0) (utils/Strings.sol) + +pragma solidity ^0.8.20; + + + + +/** + * @dev String operations. + */ +library Strings { + using SafeCast for *; + + bytes16 private constant HEX_DIGITS = "0123456789abcdef"; + uint8 private constant ADDRESS_LENGTH = 20; + uint256 private constant SPECIAL_CHARS_LOOKUP = + (1 << 0x08) | // backspace + (1 << 0x09) | // tab + (1 << 0x0a) | // newline + (1 << 0x0c) | // form feed + (1 << 0x0d) | // carriage return + (1 << 0x22) | // double quote + (1 << 0x5c); // backslash + + /** + * @dev The `value` string doesn't fit in the specified `length`. + */ + error StringsInsufficientHexLength(uint256 value, uint256 length); + + /** + * @dev The string being parsed contains characters that are not in scope of the given base. + */ + error StringsInvalidChar(); + + /** + * @dev The string being parsed is not a properly formatted address. + */ + error StringsInvalidAddressFormat(); + + /** + * @dev Converts a `uint256` to its ASCII `string` decimal representation. + */ + function toString(uint256 value) internal pure returns (string memory) { + unchecked { + uint256 length = Math.log10(value) + 1; + string memory buffer = new string(length); + uint256 ptr; + assembly ("memory-safe") { + ptr := add(add(buffer, 0x20), length) + } + while (true) { + ptr--; + assembly ("memory-safe") { + mstore8(ptr, byte(mod(value, 10), HEX_DIGITS)) + } + value /= 10; + if (value == 0) break; + } + return buffer; + } + } + + /** + * @dev Converts a `int256` to its ASCII `string` decimal representation. + */ + function toStringSigned(int256 value) internal pure returns (string memory) { + return string.concat(value < 0 ? "-" : "", toString(SignedMath.abs(value))); + } + + /** + * @dev Converts a `uint256` to its ASCII `string` hexadecimal representation. + */ + function toHexString(uint256 value) internal pure returns (string memory) { + unchecked { + return toHexString(value, Math.log256(value) + 1); + } + } + + /** + * @dev Converts a `uint256` to its ASCII `string` hexadecimal representation with fixed length. + */ + function toHexString(uint256 value, uint256 length) internal pure returns (string memory) { + uint256 localValue = value; + bytes memory buffer = new bytes(2 * length + 2); + buffer[0] = "0"; + buffer[1] = "x"; + for (uint256 i = 2 * length + 1; i > 1; --i) { + buffer[i] = HEX_DIGITS[localValue & 0xf]; + localValue >>= 4; + } + if (localValue != 0) { + revert StringsInsufficientHexLength(value, length); + } + return string(buffer); + } + + /** + * @dev Converts an `address` with fixed length of 20 bytes to its not checksummed ASCII `string` hexadecimal + * representation. + */ + function toHexString(address addr) internal pure returns (string memory) { + return toHexString(uint256(uint160(addr)), ADDRESS_LENGTH); + } + + /** + * @dev Converts an `address` with fixed length of 20 bytes to its checksummed ASCII `string` hexadecimal + * representation, according to EIP-55. + */ + function toChecksumHexString(address addr) internal pure returns (string memory) { + bytes memory buffer = bytes(toHexString(addr)); + + // hash the hex part of buffer (skip length + 2 bytes, length 40) + uint256 hashValue; + assembly ("memory-safe") { + hashValue := shr(96, keccak256(add(buffer, 0x22), 40)) + } + + for (uint256 i = 41; i > 1; --i) { + // possible values for buffer[i] are 48 (0) to 57 (9) and 97 (a) to 102 (f) + if (hashValue & 0xf > 7 && uint8(buffer[i]) > 96) { + // case shift by xoring with 0x20 + buffer[i] ^= 0x20; + } + hashValue >>= 4; + } + return string(buffer); + } + + /** + * @dev Returns true if the two strings are equal. + */ + function equal(string memory a, string memory b) internal pure returns (bool) { + return bytes(a).length == bytes(b).length && keccak256(bytes(a)) == keccak256(bytes(b)); + } + + /** + * @dev Parse a decimal string and returns the value as a `uint256`. + * + * Requirements: + * - The string must be formatted as `[0-9]*` + * - The result must fit into an `uint256` type + */ + function parseUint(string memory input) internal pure returns (uint256) { + return parseUint(input, 0, bytes(input).length); + } + + /** + * @dev Variant of {parseUint-string} that parses a substring of `input` located between position `begin` (included) and + * `end` (excluded). + * + * Requirements: + * - The substring must be formatted as `[0-9]*` + * - The result must fit into an `uint256` type + */ + function parseUint(string memory input, uint256 begin, uint256 end) internal pure returns (uint256) { + (bool success, uint256 value) = tryParseUint(input, begin, end); + if (!success) revert StringsInvalidChar(); + return value; + } + + /** + * @dev Variant of {parseUint-string} that returns false if the parsing fails because of an invalid character. + * + * NOTE: This function will revert if the result does not fit in a `uint256`. + */ + function tryParseUint(string memory input) internal pure returns (bool success, uint256 value) { + return _tryParseUintUncheckedBounds(input, 0, bytes(input).length); + } + + /** + * @dev Variant of {parseUint-string-uint256-uint256} that returns false if the parsing fails because of an invalid + * character. + * + * NOTE: This function will revert if the result does not fit in a `uint256`. + */ + function tryParseUint( + string memory input, + uint256 begin, + uint256 end + ) internal pure returns (bool success, uint256 value) { + if (end > bytes(input).length || begin > end) return (false, 0); + return _tryParseUintUncheckedBounds(input, begin, end); + } + + /** + * @dev Implementation of {tryParseUint-string-uint256-uint256} that does not check bounds. Caller should make sure that + * `begin <= end <= input.length`. Other inputs would result in undefined behavior. + */ + function _tryParseUintUncheckedBounds( + string memory input, + uint256 begin, + uint256 end + ) private pure returns (bool success, uint256 value) { + bytes memory buffer = bytes(input); + + uint256 result = 0; + for (uint256 i = begin; i < end; ++i) { + uint8 chr = _tryParseChr(bytes1(_unsafeReadBytesOffset(buffer, i))); + if (chr > 9) return (false, 0); + result *= 10; + result += chr; + } + return (true, result); + } + + /** + * @dev Parse a decimal string and returns the value as a `int256`. + * + * Requirements: + * - The string must be formatted as `[-+]?[0-9]*` + * - The result must fit in an `int256` type. + */ + function parseInt(string memory input) internal pure returns (int256) { + return parseInt(input, 0, bytes(input).length); + } + + /** + * @dev Variant of {parseInt-string} that parses a substring of `input` located between position `begin` (included) and + * `end` (excluded). + * + * Requirements: + * - The substring must be formatted as `[-+]?[0-9]*` + * - The result must fit in an `int256` type. + */ + function parseInt(string memory input, uint256 begin, uint256 end) internal pure returns (int256) { + (bool success, int256 value) = tryParseInt(input, begin, end); + if (!success) revert StringsInvalidChar(); + return value; + } + + /** + * @dev Variant of {parseInt-string} that returns false if the parsing fails because of an invalid character or if + * the result does not fit in a `int256`. + * + * NOTE: This function will revert if the absolute value of the result does not fit in a `uint256`. + */ + function tryParseInt(string memory input) internal pure returns (bool success, int256 value) { + return _tryParseIntUncheckedBounds(input, 0, bytes(input).length); + } + + uint256 private constant ABS_MIN_INT256 = 2 ** 255; + + /** + * @dev Variant of {parseInt-string-uint256-uint256} that returns false if the parsing fails because of an invalid + * character or if the result does not fit in a `int256`. + * + * NOTE: This function will revert if the absolute value of the result does not fit in a `uint256`. + */ + function tryParseInt( + string memory input, + uint256 begin, + uint256 end + ) internal pure returns (bool success, int256 value) { + if (end > bytes(input).length || begin > end) return (false, 0); + return _tryParseIntUncheckedBounds(input, begin, end); + } + + /** + * @dev Implementation of {tryParseInt-string-uint256-uint256} that does not check bounds. Caller should make sure that + * `begin <= end <= input.length`. Other inputs would result in undefined behavior. + */ + function _tryParseIntUncheckedBounds( + string memory input, + uint256 begin, + uint256 end + ) private pure returns (bool success, int256 value) { + bytes memory buffer = bytes(input); + + // Check presence of a negative sign. + bytes1 sign = begin == end ? bytes1(0) : bytes1(_unsafeReadBytesOffset(buffer, begin)); // don't do out-of-bound (possibly unsafe) read if sub-string is empty + bool positiveSign = sign == bytes1("+"); + bool negativeSign = sign == bytes1("-"); + uint256 offset = (positiveSign || negativeSign).toUint(); + + (bool absSuccess, uint256 absValue) = tryParseUint(input, begin + offset, end); + + if (absSuccess && absValue < ABS_MIN_INT256) { + return (true, negativeSign ? -int256(absValue) : int256(absValue)); + } else if (absSuccess && negativeSign && absValue == ABS_MIN_INT256) { + return (true, type(int256).min); + } else return (false, 0); + } + + /** + * @dev Parse a hexadecimal string (with or without "0x" prefix), and returns the value as a `uint256`. + * + * Requirements: + * - The string must be formatted as `(0x)?[0-9a-fA-F]*` + * - The result must fit in an `uint256` type. + */ + function parseHexUint(string memory input) internal pure returns (uint256) { + return parseHexUint(input, 0, bytes(input).length); + } + + /** + * @dev Variant of {parseHexUint-string} that parses a substring of `input` located between position `begin` (included) and + * `end` (excluded). + * + * Requirements: + * - The substring must be formatted as `(0x)?[0-9a-fA-F]*` + * - The result must fit in an `uint256` type. + */ + function parseHexUint(string memory input, uint256 begin, uint256 end) internal pure returns (uint256) { + (bool success, uint256 value) = tryParseHexUint(input, begin, end); + if (!success) revert StringsInvalidChar(); + return value; + } + + /** + * @dev Variant of {parseHexUint-string} that returns false if the parsing fails because of an invalid character. + * + * NOTE: This function will revert if the result does not fit in a `uint256`. + */ + function tryParseHexUint(string memory input) internal pure returns (bool success, uint256 value) { + return _tryParseHexUintUncheckedBounds(input, 0, bytes(input).length); + } + + /** + * @dev Variant of {parseHexUint-string-uint256-uint256} that returns false if the parsing fails because of an + * invalid character. + * + * NOTE: This function will revert if the result does not fit in a `uint256`. + */ + function tryParseHexUint( + string memory input, + uint256 begin, + uint256 end + ) internal pure returns (bool success, uint256 value) { + if (end > bytes(input).length || begin > end) return (false, 0); + return _tryParseHexUintUncheckedBounds(input, begin, end); + } + + /** + * @dev Implementation of {tryParseHexUint-string-uint256-uint256} that does not check bounds. Caller should make sure that + * `begin <= end <= input.length`. Other inputs would result in undefined behavior. + */ + function _tryParseHexUintUncheckedBounds( + string memory input, + uint256 begin, + uint256 end + ) private pure returns (bool success, uint256 value) { + bytes memory buffer = bytes(input); + + // skip 0x prefix if present + bool hasPrefix = (end > begin + 1) && bytes2(_unsafeReadBytesOffset(buffer, begin)) == bytes2("0x"); // don't do out-of-bound (possibly unsafe) read if sub-string is empty + uint256 offset = hasPrefix.toUint() * 2; + + uint256 result = 0; + for (uint256 i = begin + offset; i < end; ++i) { + uint8 chr = _tryParseChr(bytes1(_unsafeReadBytesOffset(buffer, i))); + if (chr > 15) return (false, 0); + result *= 16; + unchecked { + // Multiplying by 16 is equivalent to a shift of 4 bits (with additional overflow check). + // This guarantees that adding a value < 16 will not cause an overflow, hence the unchecked. + result += chr; + } + } + return (true, result); + } + + /** + * @dev Parse a hexadecimal string (with or without "0x" prefix), and returns the value as an `address`. + * + * Requirements: + * - The string must be formatted as `(0x)?[0-9a-fA-F]{40}` + */ + function parseAddress(string memory input) internal pure returns (address) { + return parseAddress(input, 0, bytes(input).length); + } + + /** + * @dev Variant of {parseAddress-string} that parses a substring of `input` located between position `begin` (included) and + * `end` (excluded). + * + * Requirements: + * - The substring must be formatted as `(0x)?[0-9a-fA-F]{40}` + */ + function parseAddress(string memory input, uint256 begin, uint256 end) internal pure returns (address) { + (bool success, address value) = tryParseAddress(input, begin, end); + if (!success) revert StringsInvalidAddressFormat(); + return value; + } + + /** + * @dev Variant of {parseAddress-string} that returns false if the parsing fails because the input is not a properly + * formatted address. See {parseAddress-string} requirements. + */ + function tryParseAddress(string memory input) internal pure returns (bool success, address value) { + return tryParseAddress(input, 0, bytes(input).length); + } + + /** + * @dev Variant of {parseAddress-string-uint256-uint256} that returns false if the parsing fails because input is not a properly + * formatted address. See {parseAddress-string-uint256-uint256} requirements. + */ + function tryParseAddress( + string memory input, + uint256 begin, + uint256 end + ) internal pure returns (bool success, address value) { + if (end > bytes(input).length || begin > end) return (false, address(0)); + + bool hasPrefix = (end > begin + 1) && bytes2(_unsafeReadBytesOffset(bytes(input), begin)) == bytes2("0x"); // don't do out-of-bound (possibly unsafe) read if sub-string is empty + uint256 expectedLength = 40 + hasPrefix.toUint() * 2; + + // check that input is the correct length + if (end - begin == expectedLength) { + // length guarantees that this does not overflow, and value is at most type(uint160).max + (bool s, uint256 v) = _tryParseHexUintUncheckedBounds(input, begin, end); + return (s, address(uint160(v))); + } else { + return (false, address(0)); + } + } + + function _tryParseChr(bytes1 chr) private pure returns (uint8) { + uint8 value = uint8(chr); + + // Try to parse `chr`: + // - Case 1: [0-9] + // - Case 2: [a-f] + // - Case 3: [A-F] + // - otherwise not supported + unchecked { + if (value > 47 && value < 58) value -= 48; + else if (value > 96 && value < 103) value -= 87; + else if (value > 64 && value < 71) value -= 55; + else return type(uint8).max; + } + + return value; + } + + /** + * @dev Escape special characters in JSON strings. This can be useful to prevent JSON injection in NFT metadata. + * + * WARNING: This function should only be used in double quoted JSON strings. Single quotes are not escaped. + * + * NOTE: This function escapes all unicode characters, and not just the ones in ranges defined in section 2.5 of + * RFC-4627 (U+0000 to U+001F, U+0022 and U+005C). ECMAScript's `JSON.parse` does recover escaped unicode + * characters that are not in this range, but other tooling may provide different results. + */ + function escapeJSON(string memory input) internal pure returns (string memory) { + bytes memory buffer = bytes(input); + bytes memory output = new bytes(2 * buffer.length); // worst case scenario + uint256 outputLength = 0; + + for (uint256 i; i < buffer.length; ++i) { + bytes1 char = bytes1(_unsafeReadBytesOffset(buffer, i)); + if (((SPECIAL_CHARS_LOOKUP & (1 << uint8(char))) != 0)) { + output[outputLength++] = "\\"; + if (char == 0x08) output[outputLength++] = "b"; + else if (char == 0x09) output[outputLength++] = "t"; + else if (char == 0x0a) output[outputLength++] = "n"; + else if (char == 0x0c) output[outputLength++] = "f"; + else if (char == 0x0d) output[outputLength++] = "r"; + else if (char == 0x5c) output[outputLength++] = "\\"; + else if (char == 0x22) { + // solhint-disable-next-line quotes + output[outputLength++] = '"'; + } + } else { + output[outputLength++] = char; + } + } + // write the actual length and deallocate unused memory + assembly ("memory-safe") { + mstore(output, outputLength) + mstore(0x40, add(output, shl(5, shr(5, add(outputLength, 63))))) + } + + return string(output); + } + + /** + * @dev Reads a bytes32 from a bytes array without bounds checking. + * + * NOTE: making this function internal would mean it could be used with memory unsafe offset, and marking the + * assembly block as such would prevent some optimizations. + */ + function _unsafeReadBytesOffset(bytes memory buffer, uint256 offset) private pure returns (bytes32 value) { + // This is not memory safe in the general case, but all calls to this private function are within bounds. + assembly ("memory-safe") { + value := mload(add(add(buffer, 0x20), offset)) + } + } +} + +// File: @openzeppelin/contracts@5.4.0/utils/introspection/ERC165.sol + + +// OpenZeppelin Contracts (last updated v5.4.0) (utils/introspection/ERC165.sol) + +pragma solidity ^0.8.20; + + +/** + * @dev Implementation of the {IERC165} interface. + * + * Contracts that want to implement ERC-165 should inherit from this contract and override {supportsInterface} to check + * for the additional interface id that will be supported. For example: + * + * ```solidity + * function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + * return interfaceId == type(MyInterface).interfaceId || super.supportsInterface(interfaceId); + * } + * ``` + */ +abstract contract ERC165 is IERC165 { + /// @inheritdoc IERC165 + function supportsInterface(bytes4 interfaceId) public view virtual returns (bool) { + return interfaceId == type(IERC165).interfaceId; + } +} + +// File: @openzeppelin/contracts@5.4.0/token/ERC721/ERC721.sol + + +// OpenZeppelin Contracts (last updated v5.4.0) (token/ERC721/ERC721.sol) + +pragma solidity ^0.8.20; + + + + + + + + +/** + * @dev Implementation of https://eips.ethereum.org/EIPS/eip-721[ERC-721] Non-Fungible Token Standard, including + * the Metadata extension, but not including the Enumerable extension, which is available separately as + * {ERC721Enumerable}. + */ +abstract contract ERC721 is Context, ERC165, IERC721, IERC721Metadata, IERC721Errors { + using Strings for uint256; + + // Token name + string private _name; + + // Token symbol + string private _symbol; + + mapping(uint256 tokenId => address) private _owners; + + mapping(address owner => uint256) private _balances; + + mapping(uint256 tokenId => address) private _tokenApprovals; + + mapping(address owner => mapping(address operator => bool)) private _operatorApprovals; + + /** + * @dev Initializes the contract by setting a `name` and a `symbol` to the token collection. + */ + constructor(string memory name_, string memory symbol_) { + _name = name_; + _symbol = symbol_; + } + + /// @inheritdoc IERC165 + function supportsInterface(bytes4 interfaceId) public view virtual override(ERC165, IERC165) returns (bool) { + return + interfaceId == type(IERC721).interfaceId || + interfaceId == type(IERC721Metadata).interfaceId || + super.supportsInterface(interfaceId); + } + + /// @inheritdoc IERC721 + function balanceOf(address owner) public view virtual returns (uint256) { + if (owner == address(0)) { + revert ERC721InvalidOwner(address(0)); + } + return _balances[owner]; + } + + /// @inheritdoc IERC721 + function ownerOf(uint256 tokenId) public view virtual returns (address) { + return _requireOwned(tokenId); + } + + /// @inheritdoc IERC721Metadata + function name() public view virtual returns (string memory) { + return _name; + } + + /// @inheritdoc IERC721Metadata + function symbol() public view virtual returns (string memory) { + return _symbol; + } + + /// @inheritdoc IERC721Metadata + function tokenURI(uint256 tokenId) public view virtual returns (string memory) { + _requireOwned(tokenId); + + string memory baseURI = _baseURI(); + return bytes(baseURI).length > 0 ? string.concat(baseURI, tokenId.toString()) : ""; + } + + /** + * @dev Base URI for computing {tokenURI}. If set, the resulting URI for each + * token will be the concatenation of the `baseURI` and the `tokenId`. Empty + * by default, can be overridden in child contracts. + */ + function _baseURI() internal view virtual returns (string memory) { + return ""; + } + + /// @inheritdoc IERC721 + function approve(address to, uint256 tokenId) public virtual { + _approve(to, tokenId, _msgSender()); + } + + /// @inheritdoc IERC721 + function getApproved(uint256 tokenId) public view virtual returns (address) { + _requireOwned(tokenId); + + return _getApproved(tokenId); + } + + /// @inheritdoc IERC721 + function setApprovalForAll(address operator, bool approved) public virtual { + _setApprovalForAll(_msgSender(), operator, approved); + } + + /// @inheritdoc IERC721 + function isApprovedForAll(address owner, address operator) public view virtual returns (bool) { + return _operatorApprovals[owner][operator]; + } + + /// @inheritdoc IERC721 + function transferFrom(address from, address to, uint256 tokenId) public virtual { + if (to == address(0)) { + revert ERC721InvalidReceiver(address(0)); + } + // Setting an "auth" arguments enables the `_isAuthorized` check which verifies that the token exists + // (from != 0). Therefore, it is not needed to verify that the return value is not 0 here. + address previousOwner = _update(to, tokenId, _msgSender()); + if (previousOwner != from) { + revert ERC721IncorrectOwner(from, tokenId, previousOwner); + } + } + + /// @inheritdoc IERC721 + function safeTransferFrom(address from, address to, uint256 tokenId) public { + safeTransferFrom(from, to, tokenId, ""); + } + + /// @inheritdoc IERC721 + function safeTransferFrom(address from, address to, uint256 tokenId, bytes memory data) public virtual { + transferFrom(from, to, tokenId); + ERC721Utils.checkOnERC721Received(_msgSender(), from, to, tokenId, data); + } + + /** + * @dev Returns the owner of the `tokenId`. Does NOT revert if token doesn't exist + * + * IMPORTANT: Any overrides to this function that add ownership of tokens not tracked by the + * core ERC-721 logic MUST be matched with the use of {_increaseBalance} to keep balances + * consistent with ownership. The invariant to preserve is that for any address `a` the value returned by + * `balanceOf(a)` must be equal to the number of tokens such that `_ownerOf(tokenId)` is `a`. + */ + function _ownerOf(uint256 tokenId) internal view virtual returns (address) { + return _owners[tokenId]; + } + + /** + * @dev Returns the approved address for `tokenId`. Returns 0 if `tokenId` is not minted. + */ + function _getApproved(uint256 tokenId) internal view virtual returns (address) { + return _tokenApprovals[tokenId]; + } + + /** + * @dev Returns whether `spender` is allowed to manage `owner`'s tokens, or `tokenId` in + * particular (ignoring whether it is owned by `owner`). + * + * WARNING: This function assumes that `owner` is the actual owner of `tokenId` and does not verify this + * assumption. + */ + function _isAuthorized(address owner, address spender, uint256 tokenId) internal view virtual returns (bool) { + return + spender != address(0) && + (owner == spender || isApprovedForAll(owner, spender) || _getApproved(tokenId) == spender); + } + + /** + * @dev Checks if `spender` can operate on `tokenId`, assuming the provided `owner` is the actual owner. + * Reverts if: + * - `spender` does not have approval from `owner` for `tokenId`. + * - `spender` does not have approval to manage all of `owner`'s assets. + * + * WARNING: This function assumes that `owner` is the actual owner of `tokenId` and does not verify this + * assumption. + */ + function _checkAuthorized(address owner, address spender, uint256 tokenId) internal view virtual { + if (!_isAuthorized(owner, spender, tokenId)) { + if (owner == address(0)) { + revert ERC721NonexistentToken(tokenId); + } else { + revert ERC721InsufficientApproval(spender, tokenId); + } + } + } + + /** + * @dev Unsafe write access to the balances, used by extensions that "mint" tokens using an {ownerOf} override. + * + * NOTE: the value is limited to type(uint128).max. This protect against _balance overflow. It is unrealistic that + * a uint256 would ever overflow from increments when these increments are bounded to uint128 values. + * + * WARNING: Increasing an account's balance using this function tends to be paired with an override of the + * {_ownerOf} function to resolve the ownership of the corresponding tokens so that balances and ownership + * remain consistent with one another. + */ + function _increaseBalance(address account, uint128 value) internal virtual { + unchecked { + _balances[account] += value; + } + } + + /** + * @dev Transfers `tokenId` from its current owner to `to`, or alternatively mints (or burns) if the current owner + * (or `to`) is the zero address. Returns the owner of the `tokenId` before the update. + * + * The `auth` argument is optional. If the value passed is non 0, then this function will check that + * `auth` is either the owner of the token, or approved to operate on the token (by the owner). + * + * Emits a {Transfer} event. + * + * NOTE: If overriding this function in a way that tracks balances, see also {_increaseBalance}. + */ + function _update(address to, uint256 tokenId, address auth) internal virtual returns (address) { + address from = _ownerOf(tokenId); + + // Perform (optional) operator check + if (auth != address(0)) { + _checkAuthorized(from, auth, tokenId); + } + + // Execute the update + if (from != address(0)) { + // Clear approval. No need to re-authorize or emit the Approval event + _approve(address(0), tokenId, address(0), false); + + unchecked { + _balances[from] -= 1; + } + } + + if (to != address(0)) { + unchecked { + _balances[to] += 1; + } + } + + _owners[tokenId] = to; + + emit Transfer(from, to, tokenId); + + return from; + } + + /** + * @dev Mints `tokenId` and transfers it to `to`. + * + * WARNING: Usage of this method is discouraged, use {_safeMint} whenever possible + * + * Requirements: + * + * - `tokenId` must not exist. + * - `to` cannot be the zero address. + * + * Emits a {Transfer} event. + */ + function _mint(address to, uint256 tokenId) internal { + if (to == address(0)) { + revert ERC721InvalidReceiver(address(0)); + } + address previousOwner = _update(to, tokenId, address(0)); + if (previousOwner != address(0)) { + revert ERC721InvalidSender(address(0)); + } + } + + /** + * @dev Mints `tokenId`, transfers it to `to` and checks for `to` acceptance. + * + * Requirements: + * + * - `tokenId` must not exist. + * - If `to` refers to a smart contract, it must implement {IERC721Receiver-onERC721Received}, which is called upon a safe transfer. + * + * Emits a {Transfer} event. + */ + function _safeMint(address to, uint256 tokenId) internal { + _safeMint(to, tokenId, ""); + } + + /** + * @dev Same as {xref-ERC721-_safeMint-address-uint256-}[`_safeMint`], with an additional `data` parameter which is + * forwarded in {IERC721Receiver-onERC721Received} to contract recipients. + */ + function _safeMint(address to, uint256 tokenId, bytes memory data) internal virtual { + _mint(to, tokenId); + ERC721Utils.checkOnERC721Received(_msgSender(), address(0), to, tokenId, data); + } + + /** + * @dev Destroys `tokenId`. + * The approval is cleared when the token is burned. + * This is an internal function that does not check if the sender is authorized to operate on the token. + * + * Requirements: + * + * - `tokenId` must exist. + * + * Emits a {Transfer} event. + */ + function _burn(uint256 tokenId) internal { + address previousOwner = _update(address(0), tokenId, address(0)); + if (previousOwner == address(0)) { + revert ERC721NonexistentToken(tokenId); + } + } + + /** + * @dev Transfers `tokenId` from `from` to `to`. + * As opposed to {transferFrom}, this imposes no restrictions on msg.sender. + * + * Requirements: + * + * - `to` cannot be the zero address. + * - `tokenId` token must be owned by `from`. + * + * Emits a {Transfer} event. + */ + function _transfer(address from, address to, uint256 tokenId) internal { + if (to == address(0)) { + revert ERC721InvalidReceiver(address(0)); + } + address previousOwner = _update(to, tokenId, address(0)); + if (previousOwner == address(0)) { + revert ERC721NonexistentToken(tokenId); + } else if (previousOwner != from) { + revert ERC721IncorrectOwner(from, tokenId, previousOwner); + } + } + + /** + * @dev Safely transfers `tokenId` token from `from` to `to`, checking that contract recipients + * are aware of the ERC-721 standard to prevent tokens from being forever locked. + * + * `data` is additional data, it has no specified format and it is sent in call to `to`. + * + * This internal function is like {safeTransferFrom} in the sense that it invokes + * {IERC721Receiver-onERC721Received} on the receiver, and can be used to e.g. + * implement alternative mechanisms to perform token transfer, such as signature-based. + * + * Requirements: + * + * - `tokenId` token must exist and be owned by `from`. + * - `to` cannot be the zero address. + * - `from` cannot be the zero address. + * - If `to` refers to a smart contract, it must implement {IERC721Receiver-onERC721Received}, which is called upon a safe transfer. + * + * Emits a {Transfer} event. + */ + function _safeTransfer(address from, address to, uint256 tokenId) internal { + _safeTransfer(from, to, tokenId, ""); + } + + /** + * @dev Same as {xref-ERC721-_safeTransfer-address-address-uint256-}[`_safeTransfer`], with an additional `data` parameter which is + * forwarded in {IERC721Receiver-onERC721Received} to contract recipients. + */ + function _safeTransfer(address from, address to, uint256 tokenId, bytes memory data) internal virtual { + _transfer(from, to, tokenId); + ERC721Utils.checkOnERC721Received(_msgSender(), from, to, tokenId, data); + } + + /** + * @dev Approve `to` to operate on `tokenId` + * + * The `auth` argument is optional. If the value passed is non 0, then this function will check that `auth` is + * either the owner of the token, or approved to operate on all tokens held by this owner. + * + * Emits an {Approval} event. + * + * Overrides to this logic should be done to the variant with an additional `bool emitEvent` argument. + */ + function _approve(address to, uint256 tokenId, address auth) internal { + _approve(to, tokenId, auth, true); + } + + /** + * @dev Variant of `_approve` with an optional flag to enable or disable the {Approval} event. The event is not + * emitted in the context of transfers. + */ + function _approve(address to, uint256 tokenId, address auth, bool emitEvent) internal virtual { + // Avoid reading the owner unless necessary + if (emitEvent || auth != address(0)) { + address owner = _requireOwned(tokenId); + + // We do not use _isAuthorized because single-token approvals should not be able to call approve + if (auth != address(0) && owner != auth && !isApprovedForAll(owner, auth)) { + revert ERC721InvalidApprover(auth); + } + + if (emitEvent) { + emit Approval(owner, to, tokenId); + } + } + + _tokenApprovals[tokenId] = to; + } + + /** + * @dev Approve `operator` to operate on all of `owner` tokens + * + * Requirements: + * - operator can't be the address zero. + * + * Emits an {ApprovalForAll} event. + */ + function _setApprovalForAll(address owner, address operator, bool approved) internal virtual { + if (operator == address(0)) { + revert ERC721InvalidOperator(operator); + } + _operatorApprovals[owner][operator] = approved; + emit ApprovalForAll(owner, operator, approved); + } + + /** + * @dev Reverts if the `tokenId` doesn't have a current owner (it hasn't been minted, or it has been burned). + * Returns the owner. + * + * Overrides to ownership logic should be done to {_ownerOf}. + */ + function _requireOwned(uint256 tokenId) internal view returns (address) { + address owner = _ownerOf(tokenId); + if (owner == address(0)) { + revert ERC721NonexistentToken(tokenId); + } + return owner; + } +} + +// File: @openzeppelin/contracts@5.4.0/token/ERC721/extensions/IERC721Enumerable.sol + + +// OpenZeppelin Contracts (last updated v5.4.0) (token/ERC721/extensions/IERC721Enumerable.sol) + +pragma solidity >=0.6.2; + + +/** + * @title ERC-721 Non-Fungible Token Standard, optional enumeration extension + * @dev See https://eips.ethereum.org/EIPS/eip-721 + */ +interface IERC721Enumerable is IERC721 { + /** + * @dev Returns the total amount of tokens stored by the contract. + */ + function totalSupply() external view returns (uint256); + + /** + * @dev Returns a token ID owned by `owner` at a given `index` of its token list. + * Use along with {balanceOf} to enumerate all of ``owner``'s tokens. + */ + function tokenOfOwnerByIndex(address owner, uint256 index) external view returns (uint256); + + /** + * @dev Returns a token ID at a given `index` of all the tokens stored by the contract. + * Use along with {totalSupply} to enumerate all tokens. + */ + function tokenByIndex(uint256 index) external view returns (uint256); +} + +// File: @openzeppelin/contracts@5.4.0/token/ERC721/extensions/ERC721Enumerable.sol + + +// OpenZeppelin Contracts (last updated v5.4.0) (token/ERC721/extensions/ERC721Enumerable.sol) + +pragma solidity ^0.8.20; + + + + +/** + * @dev This implements an optional extension of {ERC721} defined in the ERC that adds enumerability + * of all the token ids in the contract as well as all token ids owned by each account. + * + * CAUTION: {ERC721} extensions that implement custom `balanceOf` logic, such as {ERC721Consecutive}, + * interfere with enumerability and should not be used together with {ERC721Enumerable}. + */ +abstract contract ERC721Enumerable is ERC721, IERC721Enumerable { + mapping(address owner => mapping(uint256 index => uint256)) private _ownedTokens; + mapping(uint256 tokenId => uint256) private _ownedTokensIndex; + + uint256[] private _allTokens; + mapping(uint256 tokenId => uint256) private _allTokensIndex; + + /** + * @dev An `owner`'s token query was out of bounds for `index`. + * + * NOTE: The owner being `address(0)` indicates a global out of bounds index. + */ + error ERC721OutOfBoundsIndex(address owner, uint256 index); + + /** + * @dev Batch mint is not allowed. + */ + error ERC721EnumerableForbiddenBatchMint(); + + /// @inheritdoc IERC165 + function supportsInterface(bytes4 interfaceId) public view virtual override(IERC165, ERC721) returns (bool) { + return interfaceId == type(IERC721Enumerable).interfaceId || super.supportsInterface(interfaceId); + } + + /// @inheritdoc IERC721Enumerable + function tokenOfOwnerByIndex(address owner, uint256 index) public view virtual returns (uint256) { + if (index >= balanceOf(owner)) { + revert ERC721OutOfBoundsIndex(owner, index); + } + return _ownedTokens[owner][index]; + } + + /// @inheritdoc IERC721Enumerable + function totalSupply() public view virtual returns (uint256) { + return _allTokens.length; + } + + /// @inheritdoc IERC721Enumerable + function tokenByIndex(uint256 index) public view virtual returns (uint256) { + if (index >= totalSupply()) { + revert ERC721OutOfBoundsIndex(address(0), index); + } + return _allTokens[index]; + } + + /// @inheritdoc ERC721 + function _update(address to, uint256 tokenId, address auth) internal virtual override returns (address) { + address previousOwner = super._update(to, tokenId, auth); + + if (previousOwner == address(0)) { + _addTokenToAllTokensEnumeration(tokenId); + } else if (previousOwner != to) { + _removeTokenFromOwnerEnumeration(previousOwner, tokenId); + } + if (to == address(0)) { + _removeTokenFromAllTokensEnumeration(tokenId); + } else if (previousOwner != to) { + _addTokenToOwnerEnumeration(to, tokenId); + } + + return previousOwner; + } + + /** + * @dev Private function to add a token to this extension's ownership-tracking data structures. + * @param to address representing the new owner of the given token ID + * @param tokenId uint256 ID of the token to be added to the tokens list of the given address + */ + function _addTokenToOwnerEnumeration(address to, uint256 tokenId) private { + uint256 length = balanceOf(to) - 1; + _ownedTokens[to][length] = tokenId; + _ownedTokensIndex[tokenId] = length; + } + + /** + * @dev Private function to add a token to this extension's token tracking data structures. + * @param tokenId uint256 ID of the token to be added to the tokens list + */ + function _addTokenToAllTokensEnumeration(uint256 tokenId) private { + _allTokensIndex[tokenId] = _allTokens.length; + _allTokens.push(tokenId); + } + + /** + * @dev Private function to remove a token from this extension's ownership-tracking data structures. Note that + * while the token is not assigned a new owner, the `_ownedTokensIndex` mapping is _not_ updated: this allows for + * gas optimizations e.g. when performing a transfer operation (avoiding double writes). + * This has O(1) time complexity, but alters the order of the _ownedTokens array. + * @param from address representing the previous owner of the given token ID + * @param tokenId uint256 ID of the token to be removed from the tokens list of the given address + */ + function _removeTokenFromOwnerEnumeration(address from, uint256 tokenId) private { + // To prevent a gap in from's tokens array, we store the last token in the index of the token to delete, and + // then delete the last slot (swap and pop). + + uint256 lastTokenIndex = balanceOf(from); + uint256 tokenIndex = _ownedTokensIndex[tokenId]; + + mapping(uint256 index => uint256) storage _ownedTokensByOwner = _ownedTokens[from]; + + // When the token to delete is the last token, the swap operation is unnecessary + if (tokenIndex != lastTokenIndex) { + uint256 lastTokenId = _ownedTokensByOwner[lastTokenIndex]; + + _ownedTokensByOwner[tokenIndex] = lastTokenId; // Move the last token to the slot of the to-delete token + _ownedTokensIndex[lastTokenId] = tokenIndex; // Update the moved token's index + } + + // This also deletes the contents at the last position of the array + delete _ownedTokensIndex[tokenId]; + delete _ownedTokensByOwner[lastTokenIndex]; + } + + /** + * @dev Private function to remove a token from this extension's token tracking data structures. + * This has O(1) time complexity, but alters the order of the _allTokens array. + * @param tokenId uint256 ID of the token to be removed from the tokens list + */ + function _removeTokenFromAllTokensEnumeration(uint256 tokenId) private { + // To prevent a gap in the tokens array, we store the last token in the index of the token to delete, and + // then delete the last slot (swap and pop). + + uint256 lastTokenIndex = _allTokens.length - 1; + uint256 tokenIndex = _allTokensIndex[tokenId]; + + // When the token to delete is the last token, the swap operation is unnecessary. However, since this occurs so + // rarely (when the last minted token is burnt) that we still do the swap here to avoid the gas cost of adding + // an 'if' statement (like in _removeTokenFromOwnerEnumeration) + uint256 lastTokenId = _allTokens[lastTokenIndex]; + + _allTokens[tokenIndex] = lastTokenId; // Move the last token to the slot of the to-delete token + _allTokensIndex[lastTokenId] = tokenIndex; // Update the moved token's index + + // This also deletes the contents at the last position of the array + delete _allTokensIndex[tokenId]; + _allTokens.pop(); + } + + /** + * See {ERC721-_increaseBalance}. We need that to account tokens that were minted in batch + */ + function _increaseBalance(address account, uint128 amount) internal virtual override { + if (amount > 0) { + revert ERC721EnumerableForbiddenBatchMint(); + } + super._increaseBalance(account, amount); + } +} + +// File: contracts/NFTNumbered.sol + + +pragma solidity ^0.8.27; + + + + +/// @title NFTNumbered +/// @notice ERC721 contract with sequential variants for immutable metadata for minted tokens, allowing to change metadata for future tokens. +/// @dev Non-upgradeable. Minter address is settable by owner. Global sequential token ID. +contract NFTNumbered is ERC721Enumerable, Ownable { + address public minter; + bool public mintingLocked; + TokenInfo[] private tokenInfos; + uint public nextTokenId = 1; + + struct TokenInfo { + uint tokenId; + string tokenUri; + } + + constructor( + string memory _name, + string memory _symbol, + string memory _tokenUri + ) ERC721(_name, _symbol) Ownable(msg.sender) { + minter = msg.sender; + mintingLocked = false; + setNextTokenURI(_tokenUri); + } + + event MinterUpdated(address indexed newMinter); + event MintingLocked(); + event NextTokenURI(uint nextTokenId, string nextTokenUri); + + /// @notice Updates the minter address. + function setMinter(address newMinter) external onlyOwner whenNotLocked { + minter = newMinter; + emit MinterUpdated(newMinter); + } + + /// @notice Permanently disable minting and changes. + function lockMintingPermanently() external onlyOwner { + mintingLocked = true; + emit MintingLocked(); + } + + modifier whenNotLocked() { + require(!mintingLocked, "Contract permanently locked for changes and minting"); + _; + } + + /// @notice Sets tokenUri for future mints only. + function setNextTokenURI(string memory newTokenUri) public onlyOwner whenNotLocked { + uint len = tokenInfos.length; + TokenInfo memory newInfo = TokenInfo({tokenId: nextTokenId, tokenUri: newTokenUri}); + if (len == 0 || tokenInfos[len - 1].tokenId < nextTokenId) { // add + tokenInfos.push(newInfo); + } else { // replace + tokenInfos[len - 1] = newInfo; + } + emit NextTokenURI(nextTokenId, newTokenUri); + } + + /// @notice metadata URI of the token that will be minted next + function nextTokenURI() external view returns (string memory) { + return tokenInfos[tokenInfos.length - 1].tokenUri; + } + + /// @notice Mints a new token. + /// @param to The recipient of the token. + function mint(address to) external whenNotLocked { + require(msg.sender == owner() || msg.sender == minter, "Caller must be the owner or minter"); + require(to != address(0), "Cannot mint to zero address"); + uint tokenId = nextTokenId++; + _safeMint(to, tokenId); + } + + /// @notice Burn owned token. + function burn(uint tokenId) external { + _burn(tokenId); + } + + /// @notice prohibit approvals + // address to, uint256 tokenId + function approve(address, uint256) public virtual override (ERC721, IERC721) { + revert("Soulbound token: approvals prohibited"); + } + + /// @notice prohibit approvals + // address operator, bool approved + function setApprovalForAll(address, bool) public virtual override (ERC721, IERC721) { + revert("Soulbound token: approvals prohibited"); + } + + /// @notice Limits ownership to 1 token. + function _update(address to, uint tokenId, address auth) internal virtual override returns (address) { + require(to == address(0) || balanceOf(to) == 0, "Soulbound token: only 1 per address"); + require(to == address(0) || _ownerOf(tokenId) == address(0), "Soulbound token: transfers prohibited"); + return super._update(to, tokenId, auth); + } + + /// @notice Returns embedded JSON metadata URI. + /// @param id Token ID. + function tokenURI(uint id) public view virtual override returns (string memory) { + _requireOwned(id); + uint len = tokenInfos.length; + for (uint i = len; i > 0; ) { + unchecked { i--; } + if (tokenInfos[i].tokenId > id) continue; + return tokenInfos[i].tokenUri; + } + revert("Unknown token ID"); + } + + /// @notice Withdraw any accidental ETH. + function withdraw() external onlyOwner { + (bool success, ) = payable(owner()).call{value: address(this).balance}(""); + require(success, "Withdraw failed"); + } +} + +// File: contracts/NFTMinter.sol + + +pragma solidity ^0.8.27; + + + + +contract NFTMinter is Ownable, Pausable { + NFTNumbered public nft; + uint public mintStartTime; + uint public mintEndTime; + uint public mintCount; + + /// @param _mintEndTime Unix timestamp when minting ends, 0 to mint without time limit. + constructor(address _nftAddress, uint _mintStartTime, uint _mintEndTime, bool _paused) Ownable(msg.sender) { + _setNFT(_nftAddress); + require(_mintEndTime == 0 || _mintEndTime > _mintStartTime, "Mint end time is before start time"); + require(_mintEndTime == 0 || _mintEndTime > block.timestamp, "Mint end time is in the past"); + mintStartTime = _mintStartTime; + mintEndTime = _mintEndTime; + if (_paused) _pause(); + } + + event NFTUpdated(address indexed newNFT); + event Minted(address indexed to, uint count); + event MintStartUpdated(uint newTime); + event MintEndUpdated(uint newTime); + event MintCountReset(uint oldCount); + + /// @notice Allows anyone to mint NFT for free (gas only). Any mint restrictions other than not paused or mint time must be in NFT contract + function mint() external whenNotPaused { + require(mintEndTime == 0 || mintEndTime > block.timestamp, "Minting ended"); + require(mintStartTime <= block.timestamp, "Minting not started"); + nft.mint(msg.sender); + mintCount++; + emit Minted(msg.sender, mintCount); + } + + /// @notice Update the target NFT contract. + /// @param newNFT The new NFT contract address. + function setNFT(address newNFT) external onlyOwner { + _setNFT(newNFT); + emit NFTUpdated(newNFT); + } + + function _setNFT(address _nft) internal { + require(_nft != address(0), "NFT contract address is 0"); + uint codeSize; + assembly { codeSize := extcodesize(_nft) } + require(codeSize > 0, "Not a contract address"); + nft = NFTNumbered(_nft); + } + + /// @notice Reset mint counter. + function resetMintCount() external onlyOwner { + uint old = mintCount; + mintCount = 0; + emit MintCountReset(old); + } + + /// @notice Update the mint start timestamp. + /// @param newTime The new Unix timestamp when minting ends. + function setMintStartTime(uint256 newTime) external onlyOwner { + require(mintEndTime == 0 || newTime < mintEndTime, "Mint end time is before start time"); + mintStartTime = newTime; + emit MintStartUpdated(newTime); + } + + /// @notice Update the mint end timestamp. + /// @param newTime The new Unix timestamp when minting ends, 0 to mint without time limit. + function setMintEndTime(uint256 newTime) external onlyOwner { + require(newTime == 0 || newTime > mintStartTime, "Mint end time is before start time"); + require(newTime == 0 || newTime > block.timestamp, "Mint end time is in the past"); + mintEndTime = newTime; + emit MintEndUpdated(newTime); + } + + /// @notice Pause minting. + function pause() external onlyOwner { + _pause(); + } + + /// @notice Unpause minting. + function unpause() external onlyOwner { + _unpause(); + } + + /// @notice Withdraw any accidental ETH. + function withdraw() external onlyOwner { + payable(owner()).transfer(address(this).balance); + } +} diff --git a/eth/nft/contracts/NFTNumbered.sol b/eth/nft/contracts/NFTNumbered.sol new file mode 100644 index 0000000000..7635d776b2 --- /dev/null +++ b/eth/nft/contracts/NFTNumbered.sol @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import "@openzeppelin/contracts@5.4.0/token/ERC721/extensions/ERC721Enumerable.sol"; +import {IERC721} from "@openzeppelin/contracts@5.4.0/token/ERC721/IERC721.sol"; +import "@openzeppelin/contracts@5.4.0/access/Ownable.sol"; + +/// @title NFTNumbered +/// @notice ERC721 contract with sequential variants for immutable metadata for minted tokens, allowing to change metadata for future tokens. +/// @dev Non-upgradeable. Minter address is settable by owner. Global sequential token ID. +contract NFTNumbered is ERC721Enumerable, Ownable { + address public minter; + bool public mintingLocked; + TokenInfo[] private tokenInfos; + uint public nextTokenId = 1; + + struct TokenInfo { + uint tokenId; + string tokenUri; + } + + constructor( + string memory _name, + string memory _symbol, + string memory _tokenUri + ) ERC721(_name, _symbol) Ownable(msg.sender) { + minter = msg.sender; + mintingLocked = false; + setNextTokenURI(_tokenUri); + } + + event MinterUpdated(address indexed newMinter); + event MintingLocked(); + event NextTokenURI(uint nextTokenId, string nextTokenUri); + + /// @notice Updates the minter address. + function setMinter(address newMinter) external onlyOwner whenNotLocked { + minter = newMinter; + emit MinterUpdated(newMinter); + } + + /// @notice Permanently disable minting and changes. + function lockMintingPermanently() external onlyOwner { + mintingLocked = true; + emit MintingLocked(); + } + + modifier whenNotLocked() { + require(!mintingLocked, "Contract permanently locked for changes and minting"); + _; + } + + /// @notice Sets tokenUri for future mints only. + function setNextTokenURI(string memory newTokenUri) public onlyOwner whenNotLocked { + uint len = tokenInfos.length; + TokenInfo memory newInfo = TokenInfo({tokenId: nextTokenId, tokenUri: newTokenUri}); + if (len == 0 || tokenInfos[len - 1].tokenId < nextTokenId) { // add + tokenInfos.push(newInfo); + } else { // replace + tokenInfos[len - 1] = newInfo; + } + emit NextTokenURI(nextTokenId, newTokenUri); + } + + /// @notice metadata URI of the token that will be minted next + function nextTokenURI() external view returns (string memory) { + return tokenInfos[tokenInfos.length - 1].tokenUri; + } + + /// @notice Mints a new token. + /// @param to The recipient of the token. + function mint(address to) external whenNotLocked { + require(msg.sender == owner() || msg.sender == minter, "Caller must be the owner or minter"); + require(to != address(0), "Cannot mint to zero address"); + uint tokenId = nextTokenId++; + _safeMint(to, tokenId); + } + + /// @notice Burn owned token. + function burn(uint tokenId) external { + _burn(tokenId); + } + + /// @notice prohibit approvals + // address to, uint256 tokenId + function approve(address, uint256) public virtual override (ERC721, IERC721) { + revert("Soulbound token: approvals prohibited"); + } + + /// @notice prohibit approvals + // address operator, bool approved + function setApprovalForAll(address, bool) public virtual override (ERC721, IERC721) { + revert("Soulbound token: approvals prohibited"); + } + + /// @notice Limits ownership to 1 token. + function _update(address to, uint tokenId, address auth) internal virtual override returns (address) { + require(to == address(0) || balanceOf(to) == 0, "Soulbound token: only 1 per address"); + require(to == address(0) || _ownerOf(tokenId) == address(0), "Soulbound token: transfers prohibited"); + return super._update(to, tokenId, auth); + } + + /// @notice Returns embedded JSON metadata URI. + /// @param id Token ID. + function tokenURI(uint id) public view virtual override returns (string memory) { + _requireOwned(id); + uint len = tokenInfos.length; + for (uint i = len; i > 0; ) { + unchecked { i--; } + if (tokenInfos[i].tokenId > id) continue; + return tokenInfos[i].tokenUri; + } + revert("Unknown token ID"); + } + + /// @notice Withdraw any accidental ETH. + function withdraw() external onlyOwner { + (bool success, ) = payable(owner()).call{value: address(this).balance}(""); + require(success, "Withdraw failed"); + } +} diff --git a/eth/nft/contracts/NFTNumbered_flattened.sol b/eth/nft/contracts/NFTNumbered_flattened.sol new file mode 100644 index 0000000000..9a2ac8b0ac --- /dev/null +++ b/eth/nft/contracts/NFTNumbered_flattened.sol @@ -0,0 +1,3892 @@ +// SPDX-License-Identifier: MIT + +// File: @openzeppelin/contracts@5.4.0/utils/introspection/IERC165.sol + + +// OpenZeppelin Contracts (last updated v5.4.0) (utils/introspection/IERC165.sol) + +pragma solidity >=0.4.16; + +/** + * @dev Interface of the ERC-165 standard, as defined in the + * https://eips.ethereum.org/EIPS/eip-165[ERC]. + * + * Implementers can declare support of contract interfaces, which can then be + * queried by others ({ERC165Checker}). + * + * For an implementation, see {ERC165}. + */ +interface IERC165 { + /** + * @dev Returns true if this contract implements the interface defined by + * `interfaceId`. See the corresponding + * https://eips.ethereum.org/EIPS/eip-165#how-interfaces-are-identified[ERC section] + * to learn more about how these ids are created. + * + * This function call must use less than 30 000 gas. + */ + function supportsInterface(bytes4 interfaceId) external view returns (bool); +} + +// File: @openzeppelin/contracts@5.4.0/token/ERC721/IERC721.sol + + +// OpenZeppelin Contracts (last updated v5.4.0) (token/ERC721/IERC721.sol) + +pragma solidity >=0.6.2; + + +/** + * @dev Required interface of an ERC-721 compliant contract. + */ +interface IERC721 is IERC165 { + /** + * @dev Emitted when `tokenId` token is transferred from `from` to `to`. + */ + event Transfer(address indexed from, address indexed to, uint256 indexed tokenId); + + /** + * @dev Emitted when `owner` enables `approved` to manage the `tokenId` token. + */ + event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId); + + /** + * @dev Emitted when `owner` enables or disables (`approved`) `operator` to manage all of its assets. + */ + event ApprovalForAll(address indexed owner, address indexed operator, bool approved); + + /** + * @dev Returns the number of tokens in ``owner``'s account. + */ + function balanceOf(address owner) external view returns (uint256 balance); + + /** + * @dev Returns the owner of the `tokenId` token. + * + * Requirements: + * + * - `tokenId` must exist. + */ + function ownerOf(uint256 tokenId) external view returns (address owner); + + /** + * @dev Safely transfers `tokenId` token from `from` to `to`. + * + * Requirements: + * + * - `from` cannot be the zero address. + * - `to` cannot be the zero address. + * - `tokenId` token must exist and be owned by `from`. + * - If the caller is not `from`, it must be approved to move this token by either {approve} or {setApprovalForAll}. + * - If `to` refers to a smart contract, it must implement {IERC721Receiver-onERC721Received}, which is called upon + * a safe transfer. + * + * Emits a {Transfer} event. + */ + function safeTransferFrom(address from, address to, uint256 tokenId, bytes calldata data) external; + + /** + * @dev Safely transfers `tokenId` token from `from` to `to`, checking first that contract recipients + * are aware of the ERC-721 protocol to prevent tokens from being forever locked. + * + * Requirements: + * + * - `from` cannot be the zero address. + * - `to` cannot be the zero address. + * - `tokenId` token must exist and be owned by `from`. + * - If the caller is not `from`, it must have been allowed to move this token by either {approve} or + * {setApprovalForAll}. + * - If `to` refers to a smart contract, it must implement {IERC721Receiver-onERC721Received}, which is called upon + * a safe transfer. + * + * Emits a {Transfer} event. + */ + function safeTransferFrom(address from, address to, uint256 tokenId) external; + + /** + * @dev Transfers `tokenId` token from `from` to `to`. + * + * WARNING: Note that the caller is responsible to confirm that the recipient is capable of receiving ERC-721 + * or else they may be permanently lost. Usage of {safeTransferFrom} prevents loss, though the caller must + * understand this adds an external call which potentially creates a reentrancy vulnerability. + * + * Requirements: + * + * - `from` cannot be the zero address. + * - `to` cannot be the zero address. + * - `tokenId` token must be owned by `from`. + * - If the caller is not `from`, it must be approved to move this token by either {approve} or {setApprovalForAll}. + * + * Emits a {Transfer} event. + */ + function transferFrom(address from, address to, uint256 tokenId) external; + + /** + * @dev Gives permission to `to` to transfer `tokenId` token to another account. + * The approval is cleared when the token is transferred. + * + * Only a single account can be approved at a time, so approving the zero address clears previous approvals. + * + * Requirements: + * + * - The caller must own the token or be an approved operator. + * - `tokenId` must exist. + * + * Emits an {Approval} event. + */ + function approve(address to, uint256 tokenId) external; + + /** + * @dev Approve or remove `operator` as an operator for the caller. + * Operators can call {transferFrom} or {safeTransferFrom} for any token owned by the caller. + * + * Requirements: + * + * - The `operator` cannot be the address zero. + * + * Emits an {ApprovalForAll} event. + */ + function setApprovalForAll(address operator, bool approved) external; + + /** + * @dev Returns the account approved for `tokenId` token. + * + * Requirements: + * + * - `tokenId` must exist. + */ + function getApproved(uint256 tokenId) external view returns (address operator); + + /** + * @dev Returns if the `operator` is allowed to manage all of the assets of `owner`. + * + * See {setApprovalForAll} + */ + function isApprovedForAll(address owner, address operator) external view returns (bool); +} + +// File: @openzeppelin/contracts@5.4.0/token/ERC721/extensions/IERC721Metadata.sol + + +// OpenZeppelin Contracts (last updated v5.4.0) (token/ERC721/extensions/IERC721Metadata.sol) + +pragma solidity >=0.6.2; + + +/** + * @title ERC-721 Non-Fungible Token Standard, optional metadata extension + * @dev See https://eips.ethereum.org/EIPS/eip-721 + */ +interface IERC721Metadata is IERC721 { + /** + * @dev Returns the token collection name. + */ + function name() external view returns (string memory); + + /** + * @dev Returns the token collection symbol. + */ + function symbol() external view returns (string memory); + + /** + * @dev Returns the Uniform Resource Identifier (URI) for `tokenId` token. + */ + function tokenURI(uint256 tokenId) external view returns (string memory); +} + +// File: @openzeppelin/contracts@5.4.0/token/ERC721/IERC721Receiver.sol + + +// OpenZeppelin Contracts (last updated v5.4.0) (token/ERC721/IERC721Receiver.sol) + +pragma solidity >=0.5.0; + +/** + * @title ERC-721 token receiver interface + * @dev Interface for any contract that wants to support safeTransfers + * from ERC-721 asset contracts. + */ +interface IERC721Receiver { + /** + * @dev Whenever an {IERC721} `tokenId` token is transferred to this contract via {IERC721-safeTransferFrom} + * by `operator` from `from`, this function is called. + * + * It must return its Solidity selector to confirm the token transfer. + * If any other value is returned or the interface is not implemented by the recipient, the transfer will be + * reverted. + * + * The selector can be obtained in Solidity with `IERC721Receiver.onERC721Received.selector`. + */ + function onERC721Received( + address operator, + address from, + uint256 tokenId, + bytes calldata data + ) external returns (bytes4); +} + +// File: @openzeppelin/contracts@5.4.0/interfaces/draft-IERC6093.sol + + +// OpenZeppelin Contracts (last updated v5.4.0) (interfaces/draft-IERC6093.sol) +pragma solidity >=0.8.4; + +/** + * @dev Standard ERC-20 Errors + * Interface of the https://eips.ethereum.org/EIPS/eip-6093[ERC-6093] custom errors for ERC-20 tokens. + */ +interface IERC20Errors { + /** + * @dev Indicates an error related to the current `balance` of a `sender`. Used in transfers. + * @param sender Address whose tokens are being transferred. + * @param balance Current balance for the interacting account. + * @param needed Minimum amount required to perform a transfer. + */ + error ERC20InsufficientBalance(address sender, uint256 balance, uint256 needed); + + /** + * @dev Indicates a failure with the token `sender`. Used in transfers. + * @param sender Address whose tokens are being transferred. + */ + error ERC20InvalidSender(address sender); + + /** + * @dev Indicates a failure with the token `receiver`. Used in transfers. + * @param receiver Address to which tokens are being transferred. + */ + error ERC20InvalidReceiver(address receiver); + + /** + * @dev Indicates a failure with the `spender`’s `allowance`. Used in transfers. + * @param spender Address that may be allowed to operate on tokens without being their owner. + * @param allowance Amount of tokens a `spender` is allowed to operate with. + * @param needed Minimum amount required to perform a transfer. + */ + error ERC20InsufficientAllowance(address spender, uint256 allowance, uint256 needed); + + /** + * @dev Indicates a failure with the `approver` of a token to be approved. Used in approvals. + * @param approver Address initiating an approval operation. + */ + error ERC20InvalidApprover(address approver); + + /** + * @dev Indicates a failure with the `spender` to be approved. Used in approvals. + * @param spender Address that may be allowed to operate on tokens without being their owner. + */ + error ERC20InvalidSpender(address spender); +} + +/** + * @dev Standard ERC-721 Errors + * Interface of the https://eips.ethereum.org/EIPS/eip-6093[ERC-6093] custom errors for ERC-721 tokens. + */ +interface IERC721Errors { + /** + * @dev Indicates that an address can't be an owner. For example, `address(0)` is a forbidden owner in ERC-20. + * Used in balance queries. + * @param owner Address of the current owner of a token. + */ + error ERC721InvalidOwner(address owner); + + /** + * @dev Indicates a `tokenId` whose `owner` is the zero address. + * @param tokenId Identifier number of a token. + */ + error ERC721NonexistentToken(uint256 tokenId); + + /** + * @dev Indicates an error related to the ownership over a particular token. Used in transfers. + * @param sender Address whose tokens are being transferred. + * @param tokenId Identifier number of a token. + * @param owner Address of the current owner of a token. + */ + error ERC721IncorrectOwner(address sender, uint256 tokenId, address owner); + + /** + * @dev Indicates a failure with the token `sender`. Used in transfers. + * @param sender Address whose tokens are being transferred. + */ + error ERC721InvalidSender(address sender); + + /** + * @dev Indicates a failure with the token `receiver`. Used in transfers. + * @param receiver Address to which tokens are being transferred. + */ + error ERC721InvalidReceiver(address receiver); + + /** + * @dev Indicates a failure with the `operator`’s approval. Used in transfers. + * @param operator Address that may be allowed to operate on tokens without being their owner. + * @param tokenId Identifier number of a token. + */ + error ERC721InsufficientApproval(address operator, uint256 tokenId); + + /** + * @dev Indicates a failure with the `approver` of a token to be approved. Used in approvals. + * @param approver Address initiating an approval operation. + */ + error ERC721InvalidApprover(address approver); + + /** + * @dev Indicates a failure with the `operator` to be approved. Used in approvals. + * @param operator Address that may be allowed to operate on tokens without being their owner. + */ + error ERC721InvalidOperator(address operator); +} + +/** + * @dev Standard ERC-1155 Errors + * Interface of the https://eips.ethereum.org/EIPS/eip-6093[ERC-6093] custom errors for ERC-1155 tokens. + */ +interface IERC1155Errors { + /** + * @dev Indicates an error related to the current `balance` of a `sender`. Used in transfers. + * @param sender Address whose tokens are being transferred. + * @param balance Current balance for the interacting account. + * @param needed Minimum amount required to perform a transfer. + * @param tokenId Identifier number of a token. + */ + error ERC1155InsufficientBalance(address sender, uint256 balance, uint256 needed, uint256 tokenId); + + /** + * @dev Indicates a failure with the token `sender`. Used in transfers. + * @param sender Address whose tokens are being transferred. + */ + error ERC1155InvalidSender(address sender); + + /** + * @dev Indicates a failure with the token `receiver`. Used in transfers. + * @param receiver Address to which tokens are being transferred. + */ + error ERC1155InvalidReceiver(address receiver); + + /** + * @dev Indicates a failure with the `operator`’s approval. Used in transfers. + * @param operator Address that may be allowed to operate on tokens without being their owner. + * @param owner Address of the current owner of a token. + */ + error ERC1155MissingApprovalForAll(address operator, address owner); + + /** + * @dev Indicates a failure with the `approver` of a token to be approved. Used in approvals. + * @param approver Address initiating an approval operation. + */ + error ERC1155InvalidApprover(address approver); + + /** + * @dev Indicates a failure with the `operator` to be approved. Used in approvals. + * @param operator Address that may be allowed to operate on tokens without being their owner. + */ + error ERC1155InvalidOperator(address operator); + + /** + * @dev Indicates an array length mismatch between ids and values in a safeBatchTransferFrom operation. + * Used in batch transfers. + * @param idsLength Length of the array of token identifiers + * @param valuesLength Length of the array of token amounts + */ + error ERC1155InvalidArrayLength(uint256 idsLength, uint256 valuesLength); +} + +// File: @openzeppelin/contracts@5.4.0/token/ERC721/utils/ERC721Utils.sol + + +// OpenZeppelin Contracts (last updated v5.4.0) (token/ERC721/utils/ERC721Utils.sol) + +pragma solidity ^0.8.20; + + + +/** + * @dev Library that provide common ERC-721 utility functions. + * + * See https://eips.ethereum.org/EIPS/eip-721[ERC-721]. + * + * _Available since v5.1._ + */ +library ERC721Utils { + /** + * @dev Performs an acceptance check for the provided `operator` by calling {IERC721Receiver-onERC721Received} + * on the `to` address. The `operator` is generally the address that initiated the token transfer (i.e. `msg.sender`). + * + * The acceptance call is not executed and treated as a no-op if the target address doesn't contain code (i.e. an EOA). + * Otherwise, the recipient must implement {IERC721Receiver-onERC721Received} and return the acceptance magic value to accept + * the transfer. + */ + function checkOnERC721Received( + address operator, + address from, + address to, + uint256 tokenId, + bytes memory data + ) internal { + if (to.code.length > 0) { + try IERC721Receiver(to).onERC721Received(operator, from, tokenId, data) returns (bytes4 retval) { + if (retval != IERC721Receiver.onERC721Received.selector) { + // Token rejected + revert IERC721Errors.ERC721InvalidReceiver(to); + } + } catch (bytes memory reason) { + if (reason.length == 0) { + // non-IERC721Receiver implementer + revert IERC721Errors.ERC721InvalidReceiver(to); + } else { + assembly ("memory-safe") { + revert(add(reason, 0x20), mload(reason)) + } + } + } + } + } +} + +// File: @openzeppelin/contracts@5.4.0/utils/Context.sol + + +// OpenZeppelin Contracts (last updated v5.0.1) (utils/Context.sol) + +pragma solidity ^0.8.20; + +/** + * @dev Provides information about the current execution context, including the + * sender of the transaction and its data. While these are generally available + * via msg.sender and msg.data, they should not be accessed in such a direct + * manner, since when dealing with meta-transactions the account sending and + * paying for execution may not be the actual sender (as far as an application + * is concerned). + * + * This contract is only required for intermediate, library-like contracts. + */ +abstract contract Context { + function _msgSender() internal view virtual returns (address) { + return msg.sender; + } + + function _msgData() internal view virtual returns (bytes calldata) { + return msg.data; + } + + function _contextSuffixLength() internal view virtual returns (uint256) { + return 0; + } +} + +// File: @openzeppelin/contracts@5.4.0/utils/Panic.sol + + +// OpenZeppelin Contracts (last updated v5.1.0) (utils/Panic.sol) + +pragma solidity ^0.8.20; + +/** + * @dev Helper library for emitting standardized panic codes. + * + * ```solidity + * contract Example { + * using Panic for uint256; + * + * // Use any of the declared internal constants + * function foo() { Panic.GENERIC.panic(); } + * + * // Alternatively + * function foo() { Panic.panic(Panic.GENERIC); } + * } + * ``` + * + * Follows the list from https://github.com/ethereum/solidity/blob/v0.8.24/libsolutil/ErrorCodes.h[libsolutil]. + * + * _Available since v5.1._ + */ +// slither-disable-next-line unused-state +library Panic { + /// @dev generic / unspecified error + uint256 internal constant GENERIC = 0x00; + /// @dev used by the assert() builtin + uint256 internal constant ASSERT = 0x01; + /// @dev arithmetic underflow or overflow + uint256 internal constant UNDER_OVERFLOW = 0x11; + /// @dev division or modulo by zero + uint256 internal constant DIVISION_BY_ZERO = 0x12; + /// @dev enum conversion error + uint256 internal constant ENUM_CONVERSION_ERROR = 0x21; + /// @dev invalid encoding in storage + uint256 internal constant STORAGE_ENCODING_ERROR = 0x22; + /// @dev empty array pop + uint256 internal constant EMPTY_ARRAY_POP = 0x31; + /// @dev array out of bounds access + uint256 internal constant ARRAY_OUT_OF_BOUNDS = 0x32; + /// @dev resource error (too large allocation or too large array) + uint256 internal constant RESOURCE_ERROR = 0x41; + /// @dev calling invalid internal function + uint256 internal constant INVALID_INTERNAL_FUNCTION = 0x51; + + /// @dev Reverts with a panic code. Recommended to use with + /// the internal constants with predefined codes. + function panic(uint256 code) internal pure { + assembly ("memory-safe") { + mstore(0x00, 0x4e487b71) + mstore(0x20, code) + revert(0x1c, 0x24) + } + } +} + +// File: @openzeppelin/contracts@5.4.0/utils/math/SafeCast.sol + + +// OpenZeppelin Contracts (last updated v5.1.0) (utils/math/SafeCast.sol) +// This file was procedurally generated from scripts/generate/templates/SafeCast.js. + +pragma solidity ^0.8.20; + +/** + * @dev Wrappers over Solidity's uintXX/intXX/bool casting operators with added overflow + * checks. + * + * Downcasting from uint256/int256 in Solidity does not revert on overflow. This can + * easily result in undesired exploitation or bugs, since developers usually + * assume that overflows raise errors. `SafeCast` restores this intuition by + * reverting the transaction when such an operation overflows. + * + * Using this library instead of the unchecked operations eliminates an entire + * class of bugs, so it's recommended to use it always. + */ +library SafeCast { + /** + * @dev Value doesn't fit in an uint of `bits` size. + */ + error SafeCastOverflowedUintDowncast(uint8 bits, uint256 value); + + /** + * @dev An int value doesn't fit in an uint of `bits` size. + */ + error SafeCastOverflowedIntToUint(int256 value); + + /** + * @dev Value doesn't fit in an int of `bits` size. + */ + error SafeCastOverflowedIntDowncast(uint8 bits, int256 value); + + /** + * @dev An uint value doesn't fit in an int of `bits` size. + */ + error SafeCastOverflowedUintToInt(uint256 value); + + /** + * @dev Returns the downcasted uint248 from uint256, reverting on + * overflow (when the input is greater than largest uint248). + * + * Counterpart to Solidity's `uint248` operator. + * + * Requirements: + * + * - input must fit into 248 bits + */ + function toUint248(uint256 value) internal pure returns (uint248) { + if (value > type(uint248).max) { + revert SafeCastOverflowedUintDowncast(248, value); + } + return uint248(value); + } + + /** + * @dev Returns the downcasted uint240 from uint256, reverting on + * overflow (when the input is greater than largest uint240). + * + * Counterpart to Solidity's `uint240` operator. + * + * Requirements: + * + * - input must fit into 240 bits + */ + function toUint240(uint256 value) internal pure returns (uint240) { + if (value > type(uint240).max) { + revert SafeCastOverflowedUintDowncast(240, value); + } + return uint240(value); + } + + /** + * @dev Returns the downcasted uint232 from uint256, reverting on + * overflow (when the input is greater than largest uint232). + * + * Counterpart to Solidity's `uint232` operator. + * + * Requirements: + * + * - input must fit into 232 bits + */ + function toUint232(uint256 value) internal pure returns (uint232) { + if (value > type(uint232).max) { + revert SafeCastOverflowedUintDowncast(232, value); + } + return uint232(value); + } + + /** + * @dev Returns the downcasted uint224 from uint256, reverting on + * overflow (when the input is greater than largest uint224). + * + * Counterpart to Solidity's `uint224` operator. + * + * Requirements: + * + * - input must fit into 224 bits + */ + function toUint224(uint256 value) internal pure returns (uint224) { + if (value > type(uint224).max) { + revert SafeCastOverflowedUintDowncast(224, value); + } + return uint224(value); + } + + /** + * @dev Returns the downcasted uint216 from uint256, reverting on + * overflow (when the input is greater than largest uint216). + * + * Counterpart to Solidity's `uint216` operator. + * + * Requirements: + * + * - input must fit into 216 bits + */ + function toUint216(uint256 value) internal pure returns (uint216) { + if (value > type(uint216).max) { + revert SafeCastOverflowedUintDowncast(216, value); + } + return uint216(value); + } + + /** + * @dev Returns the downcasted uint208 from uint256, reverting on + * overflow (when the input is greater than largest uint208). + * + * Counterpart to Solidity's `uint208` operator. + * + * Requirements: + * + * - input must fit into 208 bits + */ + function toUint208(uint256 value) internal pure returns (uint208) { + if (value > type(uint208).max) { + revert SafeCastOverflowedUintDowncast(208, value); + } + return uint208(value); + } + + /** + * @dev Returns the downcasted uint200 from uint256, reverting on + * overflow (when the input is greater than largest uint200). + * + * Counterpart to Solidity's `uint200` operator. + * + * Requirements: + * + * - input must fit into 200 bits + */ + function toUint200(uint256 value) internal pure returns (uint200) { + if (value > type(uint200).max) { + revert SafeCastOverflowedUintDowncast(200, value); + } + return uint200(value); + } + + /** + * @dev Returns the downcasted uint192 from uint256, reverting on + * overflow (when the input is greater than largest uint192). + * + * Counterpart to Solidity's `uint192` operator. + * + * Requirements: + * + * - input must fit into 192 bits + */ + function toUint192(uint256 value) internal pure returns (uint192) { + if (value > type(uint192).max) { + revert SafeCastOverflowedUintDowncast(192, value); + } + return uint192(value); + } + + /** + * @dev Returns the downcasted uint184 from uint256, reverting on + * overflow (when the input is greater than largest uint184). + * + * Counterpart to Solidity's `uint184` operator. + * + * Requirements: + * + * - input must fit into 184 bits + */ + function toUint184(uint256 value) internal pure returns (uint184) { + if (value > type(uint184).max) { + revert SafeCastOverflowedUintDowncast(184, value); + } + return uint184(value); + } + + /** + * @dev Returns the downcasted uint176 from uint256, reverting on + * overflow (when the input is greater than largest uint176). + * + * Counterpart to Solidity's `uint176` operator. + * + * Requirements: + * + * - input must fit into 176 bits + */ + function toUint176(uint256 value) internal pure returns (uint176) { + if (value > type(uint176).max) { + revert SafeCastOverflowedUintDowncast(176, value); + } + return uint176(value); + } + + /** + * @dev Returns the downcasted uint168 from uint256, reverting on + * overflow (when the input is greater than largest uint168). + * + * Counterpart to Solidity's `uint168` operator. + * + * Requirements: + * + * - input must fit into 168 bits + */ + function toUint168(uint256 value) internal pure returns (uint168) { + if (value > type(uint168).max) { + revert SafeCastOverflowedUintDowncast(168, value); + } + return uint168(value); + } + + /** + * @dev Returns the downcasted uint160 from uint256, reverting on + * overflow (when the input is greater than largest uint160). + * + * Counterpart to Solidity's `uint160` operator. + * + * Requirements: + * + * - input must fit into 160 bits + */ + function toUint160(uint256 value) internal pure returns (uint160) { + if (value > type(uint160).max) { + revert SafeCastOverflowedUintDowncast(160, value); + } + return uint160(value); + } + + /** + * @dev Returns the downcasted uint152 from uint256, reverting on + * overflow (when the input is greater than largest uint152). + * + * Counterpart to Solidity's `uint152` operator. + * + * Requirements: + * + * - input must fit into 152 bits + */ + function toUint152(uint256 value) internal pure returns (uint152) { + if (value > type(uint152).max) { + revert SafeCastOverflowedUintDowncast(152, value); + } + return uint152(value); + } + + /** + * @dev Returns the downcasted uint144 from uint256, reverting on + * overflow (when the input is greater than largest uint144). + * + * Counterpart to Solidity's `uint144` operator. + * + * Requirements: + * + * - input must fit into 144 bits + */ + function toUint144(uint256 value) internal pure returns (uint144) { + if (value > type(uint144).max) { + revert SafeCastOverflowedUintDowncast(144, value); + } + return uint144(value); + } + + /** + * @dev Returns the downcasted uint136 from uint256, reverting on + * overflow (when the input is greater than largest uint136). + * + * Counterpart to Solidity's `uint136` operator. + * + * Requirements: + * + * - input must fit into 136 bits + */ + function toUint136(uint256 value) internal pure returns (uint136) { + if (value > type(uint136).max) { + revert SafeCastOverflowedUintDowncast(136, value); + } + return uint136(value); + } + + /** + * @dev Returns the downcasted uint128 from uint256, reverting on + * overflow (when the input is greater than largest uint128). + * + * Counterpart to Solidity's `uint128` operator. + * + * Requirements: + * + * - input must fit into 128 bits + */ + function toUint128(uint256 value) internal pure returns (uint128) { + if (value > type(uint128).max) { + revert SafeCastOverflowedUintDowncast(128, value); + } + return uint128(value); + } + + /** + * @dev Returns the downcasted uint120 from uint256, reverting on + * overflow (when the input is greater than largest uint120). + * + * Counterpart to Solidity's `uint120` operator. + * + * Requirements: + * + * - input must fit into 120 bits + */ + function toUint120(uint256 value) internal pure returns (uint120) { + if (value > type(uint120).max) { + revert SafeCastOverflowedUintDowncast(120, value); + } + return uint120(value); + } + + /** + * @dev Returns the downcasted uint112 from uint256, reverting on + * overflow (when the input is greater than largest uint112). + * + * Counterpart to Solidity's `uint112` operator. + * + * Requirements: + * + * - input must fit into 112 bits + */ + function toUint112(uint256 value) internal pure returns (uint112) { + if (value > type(uint112).max) { + revert SafeCastOverflowedUintDowncast(112, value); + } + return uint112(value); + } + + /** + * @dev Returns the downcasted uint104 from uint256, reverting on + * overflow (when the input is greater than largest uint104). + * + * Counterpart to Solidity's `uint104` operator. + * + * Requirements: + * + * - input must fit into 104 bits + */ + function toUint104(uint256 value) internal pure returns (uint104) { + if (value > type(uint104).max) { + revert SafeCastOverflowedUintDowncast(104, value); + } + return uint104(value); + } + + /** + * @dev Returns the downcasted uint96 from uint256, reverting on + * overflow (when the input is greater than largest uint96). + * + * Counterpart to Solidity's `uint96` operator. + * + * Requirements: + * + * - input must fit into 96 bits + */ + function toUint96(uint256 value) internal pure returns (uint96) { + if (value > type(uint96).max) { + revert SafeCastOverflowedUintDowncast(96, value); + } + return uint96(value); + } + + /** + * @dev Returns the downcasted uint88 from uint256, reverting on + * overflow (when the input is greater than largest uint88). + * + * Counterpart to Solidity's `uint88` operator. + * + * Requirements: + * + * - input must fit into 88 bits + */ + function toUint88(uint256 value) internal pure returns (uint88) { + if (value > type(uint88).max) { + revert SafeCastOverflowedUintDowncast(88, value); + } + return uint88(value); + } + + /** + * @dev Returns the downcasted uint80 from uint256, reverting on + * overflow (when the input is greater than largest uint80). + * + * Counterpart to Solidity's `uint80` operator. + * + * Requirements: + * + * - input must fit into 80 bits + */ + function toUint80(uint256 value) internal pure returns (uint80) { + if (value > type(uint80).max) { + revert SafeCastOverflowedUintDowncast(80, value); + } + return uint80(value); + } + + /** + * @dev Returns the downcasted uint72 from uint256, reverting on + * overflow (when the input is greater than largest uint72). + * + * Counterpart to Solidity's `uint72` operator. + * + * Requirements: + * + * - input must fit into 72 bits + */ + function toUint72(uint256 value) internal pure returns (uint72) { + if (value > type(uint72).max) { + revert SafeCastOverflowedUintDowncast(72, value); + } + return uint72(value); + } + + /** + * @dev Returns the downcasted uint64 from uint256, reverting on + * overflow (when the input is greater than largest uint64). + * + * Counterpart to Solidity's `uint64` operator. + * + * Requirements: + * + * - input must fit into 64 bits + */ + function toUint64(uint256 value) internal pure returns (uint64) { + if (value > type(uint64).max) { + revert SafeCastOverflowedUintDowncast(64, value); + } + return uint64(value); + } + + /** + * @dev Returns the downcasted uint56 from uint256, reverting on + * overflow (when the input is greater than largest uint56). + * + * Counterpart to Solidity's `uint56` operator. + * + * Requirements: + * + * - input must fit into 56 bits + */ + function toUint56(uint256 value) internal pure returns (uint56) { + if (value > type(uint56).max) { + revert SafeCastOverflowedUintDowncast(56, value); + } + return uint56(value); + } + + /** + * @dev Returns the downcasted uint48 from uint256, reverting on + * overflow (when the input is greater than largest uint48). + * + * Counterpart to Solidity's `uint48` operator. + * + * Requirements: + * + * - input must fit into 48 bits + */ + function toUint48(uint256 value) internal pure returns (uint48) { + if (value > type(uint48).max) { + revert SafeCastOverflowedUintDowncast(48, value); + } + return uint48(value); + } + + /** + * @dev Returns the downcasted uint40 from uint256, reverting on + * overflow (when the input is greater than largest uint40). + * + * Counterpart to Solidity's `uint40` operator. + * + * Requirements: + * + * - input must fit into 40 bits + */ + function toUint40(uint256 value) internal pure returns (uint40) { + if (value > type(uint40).max) { + revert SafeCastOverflowedUintDowncast(40, value); + } + return uint40(value); + } + + /** + * @dev Returns the downcasted uint32 from uint256, reverting on + * overflow (when the input is greater than largest uint32). + * + * Counterpart to Solidity's `uint32` operator. + * + * Requirements: + * + * - input must fit into 32 bits + */ + function toUint32(uint256 value) internal pure returns (uint32) { + if (value > type(uint32).max) { + revert SafeCastOverflowedUintDowncast(32, value); + } + return uint32(value); + } + + /** + * @dev Returns the downcasted uint24 from uint256, reverting on + * overflow (when the input is greater than largest uint24). + * + * Counterpart to Solidity's `uint24` operator. + * + * Requirements: + * + * - input must fit into 24 bits + */ + function toUint24(uint256 value) internal pure returns (uint24) { + if (value > type(uint24).max) { + revert SafeCastOverflowedUintDowncast(24, value); + } + return uint24(value); + } + + /** + * @dev Returns the downcasted uint16 from uint256, reverting on + * overflow (when the input is greater than largest uint16). + * + * Counterpart to Solidity's `uint16` operator. + * + * Requirements: + * + * - input must fit into 16 bits + */ + function toUint16(uint256 value) internal pure returns (uint16) { + if (value > type(uint16).max) { + revert SafeCastOverflowedUintDowncast(16, value); + } + return uint16(value); + } + + /** + * @dev Returns the downcasted uint8 from uint256, reverting on + * overflow (when the input is greater than largest uint8). + * + * Counterpart to Solidity's `uint8` operator. + * + * Requirements: + * + * - input must fit into 8 bits + */ + function toUint8(uint256 value) internal pure returns (uint8) { + if (value > type(uint8).max) { + revert SafeCastOverflowedUintDowncast(8, value); + } + return uint8(value); + } + + /** + * @dev Converts a signed int256 into an unsigned uint256. + * + * Requirements: + * + * - input must be greater than or equal to 0. + */ + function toUint256(int256 value) internal pure returns (uint256) { + if (value < 0) { + revert SafeCastOverflowedIntToUint(value); + } + return uint256(value); + } + + /** + * @dev Returns the downcasted int248 from int256, reverting on + * overflow (when the input is less than smallest int248 or + * greater than largest int248). + * + * Counterpart to Solidity's `int248` operator. + * + * Requirements: + * + * - input must fit into 248 bits + */ + function toInt248(int256 value) internal pure returns (int248 downcasted) { + downcasted = int248(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(248, value); + } + } + + /** + * @dev Returns the downcasted int240 from int256, reverting on + * overflow (when the input is less than smallest int240 or + * greater than largest int240). + * + * Counterpart to Solidity's `int240` operator. + * + * Requirements: + * + * - input must fit into 240 bits + */ + function toInt240(int256 value) internal pure returns (int240 downcasted) { + downcasted = int240(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(240, value); + } + } + + /** + * @dev Returns the downcasted int232 from int256, reverting on + * overflow (when the input is less than smallest int232 or + * greater than largest int232). + * + * Counterpart to Solidity's `int232` operator. + * + * Requirements: + * + * - input must fit into 232 bits + */ + function toInt232(int256 value) internal pure returns (int232 downcasted) { + downcasted = int232(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(232, value); + } + } + + /** + * @dev Returns the downcasted int224 from int256, reverting on + * overflow (when the input is less than smallest int224 or + * greater than largest int224). + * + * Counterpart to Solidity's `int224` operator. + * + * Requirements: + * + * - input must fit into 224 bits + */ + function toInt224(int256 value) internal pure returns (int224 downcasted) { + downcasted = int224(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(224, value); + } + } + + /** + * @dev Returns the downcasted int216 from int256, reverting on + * overflow (when the input is less than smallest int216 or + * greater than largest int216). + * + * Counterpart to Solidity's `int216` operator. + * + * Requirements: + * + * - input must fit into 216 bits + */ + function toInt216(int256 value) internal pure returns (int216 downcasted) { + downcasted = int216(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(216, value); + } + } + + /** + * @dev Returns the downcasted int208 from int256, reverting on + * overflow (when the input is less than smallest int208 or + * greater than largest int208). + * + * Counterpart to Solidity's `int208` operator. + * + * Requirements: + * + * - input must fit into 208 bits + */ + function toInt208(int256 value) internal pure returns (int208 downcasted) { + downcasted = int208(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(208, value); + } + } + + /** + * @dev Returns the downcasted int200 from int256, reverting on + * overflow (when the input is less than smallest int200 or + * greater than largest int200). + * + * Counterpart to Solidity's `int200` operator. + * + * Requirements: + * + * - input must fit into 200 bits + */ + function toInt200(int256 value) internal pure returns (int200 downcasted) { + downcasted = int200(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(200, value); + } + } + + /** + * @dev Returns the downcasted int192 from int256, reverting on + * overflow (when the input is less than smallest int192 or + * greater than largest int192). + * + * Counterpart to Solidity's `int192` operator. + * + * Requirements: + * + * - input must fit into 192 bits + */ + function toInt192(int256 value) internal pure returns (int192 downcasted) { + downcasted = int192(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(192, value); + } + } + + /** + * @dev Returns the downcasted int184 from int256, reverting on + * overflow (when the input is less than smallest int184 or + * greater than largest int184). + * + * Counterpart to Solidity's `int184` operator. + * + * Requirements: + * + * - input must fit into 184 bits + */ + function toInt184(int256 value) internal pure returns (int184 downcasted) { + downcasted = int184(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(184, value); + } + } + + /** + * @dev Returns the downcasted int176 from int256, reverting on + * overflow (when the input is less than smallest int176 or + * greater than largest int176). + * + * Counterpart to Solidity's `int176` operator. + * + * Requirements: + * + * - input must fit into 176 bits + */ + function toInt176(int256 value) internal pure returns (int176 downcasted) { + downcasted = int176(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(176, value); + } + } + + /** + * @dev Returns the downcasted int168 from int256, reverting on + * overflow (when the input is less than smallest int168 or + * greater than largest int168). + * + * Counterpart to Solidity's `int168` operator. + * + * Requirements: + * + * - input must fit into 168 bits + */ + function toInt168(int256 value) internal pure returns (int168 downcasted) { + downcasted = int168(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(168, value); + } + } + + /** + * @dev Returns the downcasted int160 from int256, reverting on + * overflow (when the input is less than smallest int160 or + * greater than largest int160). + * + * Counterpart to Solidity's `int160` operator. + * + * Requirements: + * + * - input must fit into 160 bits + */ + function toInt160(int256 value) internal pure returns (int160 downcasted) { + downcasted = int160(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(160, value); + } + } + + /** + * @dev Returns the downcasted int152 from int256, reverting on + * overflow (when the input is less than smallest int152 or + * greater than largest int152). + * + * Counterpart to Solidity's `int152` operator. + * + * Requirements: + * + * - input must fit into 152 bits + */ + function toInt152(int256 value) internal pure returns (int152 downcasted) { + downcasted = int152(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(152, value); + } + } + + /** + * @dev Returns the downcasted int144 from int256, reverting on + * overflow (when the input is less than smallest int144 or + * greater than largest int144). + * + * Counterpart to Solidity's `int144` operator. + * + * Requirements: + * + * - input must fit into 144 bits + */ + function toInt144(int256 value) internal pure returns (int144 downcasted) { + downcasted = int144(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(144, value); + } + } + + /** + * @dev Returns the downcasted int136 from int256, reverting on + * overflow (when the input is less than smallest int136 or + * greater than largest int136). + * + * Counterpart to Solidity's `int136` operator. + * + * Requirements: + * + * - input must fit into 136 bits + */ + function toInt136(int256 value) internal pure returns (int136 downcasted) { + downcasted = int136(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(136, value); + } + } + + /** + * @dev Returns the downcasted int128 from int256, reverting on + * overflow (when the input is less than smallest int128 or + * greater than largest int128). + * + * Counterpart to Solidity's `int128` operator. + * + * Requirements: + * + * - input must fit into 128 bits + */ + function toInt128(int256 value) internal pure returns (int128 downcasted) { + downcasted = int128(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(128, value); + } + } + + /** + * @dev Returns the downcasted int120 from int256, reverting on + * overflow (when the input is less than smallest int120 or + * greater than largest int120). + * + * Counterpart to Solidity's `int120` operator. + * + * Requirements: + * + * - input must fit into 120 bits + */ + function toInt120(int256 value) internal pure returns (int120 downcasted) { + downcasted = int120(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(120, value); + } + } + + /** + * @dev Returns the downcasted int112 from int256, reverting on + * overflow (when the input is less than smallest int112 or + * greater than largest int112). + * + * Counterpart to Solidity's `int112` operator. + * + * Requirements: + * + * - input must fit into 112 bits + */ + function toInt112(int256 value) internal pure returns (int112 downcasted) { + downcasted = int112(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(112, value); + } + } + + /** + * @dev Returns the downcasted int104 from int256, reverting on + * overflow (when the input is less than smallest int104 or + * greater than largest int104). + * + * Counterpart to Solidity's `int104` operator. + * + * Requirements: + * + * - input must fit into 104 bits + */ + function toInt104(int256 value) internal pure returns (int104 downcasted) { + downcasted = int104(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(104, value); + } + } + + /** + * @dev Returns the downcasted int96 from int256, reverting on + * overflow (when the input is less than smallest int96 or + * greater than largest int96). + * + * Counterpart to Solidity's `int96` operator. + * + * Requirements: + * + * - input must fit into 96 bits + */ + function toInt96(int256 value) internal pure returns (int96 downcasted) { + downcasted = int96(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(96, value); + } + } + + /** + * @dev Returns the downcasted int88 from int256, reverting on + * overflow (when the input is less than smallest int88 or + * greater than largest int88). + * + * Counterpart to Solidity's `int88` operator. + * + * Requirements: + * + * - input must fit into 88 bits + */ + function toInt88(int256 value) internal pure returns (int88 downcasted) { + downcasted = int88(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(88, value); + } + } + + /** + * @dev Returns the downcasted int80 from int256, reverting on + * overflow (when the input is less than smallest int80 or + * greater than largest int80). + * + * Counterpart to Solidity's `int80` operator. + * + * Requirements: + * + * - input must fit into 80 bits + */ + function toInt80(int256 value) internal pure returns (int80 downcasted) { + downcasted = int80(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(80, value); + } + } + + /** + * @dev Returns the downcasted int72 from int256, reverting on + * overflow (when the input is less than smallest int72 or + * greater than largest int72). + * + * Counterpart to Solidity's `int72` operator. + * + * Requirements: + * + * - input must fit into 72 bits + */ + function toInt72(int256 value) internal pure returns (int72 downcasted) { + downcasted = int72(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(72, value); + } + } + + /** + * @dev Returns the downcasted int64 from int256, reverting on + * overflow (when the input is less than smallest int64 or + * greater than largest int64). + * + * Counterpart to Solidity's `int64` operator. + * + * Requirements: + * + * - input must fit into 64 bits + */ + function toInt64(int256 value) internal pure returns (int64 downcasted) { + downcasted = int64(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(64, value); + } + } + + /** + * @dev Returns the downcasted int56 from int256, reverting on + * overflow (when the input is less than smallest int56 or + * greater than largest int56). + * + * Counterpart to Solidity's `int56` operator. + * + * Requirements: + * + * - input must fit into 56 bits + */ + function toInt56(int256 value) internal pure returns (int56 downcasted) { + downcasted = int56(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(56, value); + } + } + + /** + * @dev Returns the downcasted int48 from int256, reverting on + * overflow (when the input is less than smallest int48 or + * greater than largest int48). + * + * Counterpart to Solidity's `int48` operator. + * + * Requirements: + * + * - input must fit into 48 bits + */ + function toInt48(int256 value) internal pure returns (int48 downcasted) { + downcasted = int48(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(48, value); + } + } + + /** + * @dev Returns the downcasted int40 from int256, reverting on + * overflow (when the input is less than smallest int40 or + * greater than largest int40). + * + * Counterpart to Solidity's `int40` operator. + * + * Requirements: + * + * - input must fit into 40 bits + */ + function toInt40(int256 value) internal pure returns (int40 downcasted) { + downcasted = int40(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(40, value); + } + } + + /** + * @dev Returns the downcasted int32 from int256, reverting on + * overflow (when the input is less than smallest int32 or + * greater than largest int32). + * + * Counterpart to Solidity's `int32` operator. + * + * Requirements: + * + * - input must fit into 32 bits + */ + function toInt32(int256 value) internal pure returns (int32 downcasted) { + downcasted = int32(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(32, value); + } + } + + /** + * @dev Returns the downcasted int24 from int256, reverting on + * overflow (when the input is less than smallest int24 or + * greater than largest int24). + * + * Counterpart to Solidity's `int24` operator. + * + * Requirements: + * + * - input must fit into 24 bits + */ + function toInt24(int256 value) internal pure returns (int24 downcasted) { + downcasted = int24(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(24, value); + } + } + + /** + * @dev Returns the downcasted int16 from int256, reverting on + * overflow (when the input is less than smallest int16 or + * greater than largest int16). + * + * Counterpart to Solidity's `int16` operator. + * + * Requirements: + * + * - input must fit into 16 bits + */ + function toInt16(int256 value) internal pure returns (int16 downcasted) { + downcasted = int16(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(16, value); + } + } + + /** + * @dev Returns the downcasted int8 from int256, reverting on + * overflow (when the input is less than smallest int8 or + * greater than largest int8). + * + * Counterpart to Solidity's `int8` operator. + * + * Requirements: + * + * - input must fit into 8 bits + */ + function toInt8(int256 value) internal pure returns (int8 downcasted) { + downcasted = int8(value); + if (downcasted != value) { + revert SafeCastOverflowedIntDowncast(8, value); + } + } + + /** + * @dev Converts an unsigned uint256 into a signed int256. + * + * Requirements: + * + * - input must be less than or equal to maxInt256. + */ + function toInt256(uint256 value) internal pure returns (int256) { + // Note: Unsafe cast below is okay because `type(int256).max` is guaranteed to be positive + if (value > uint256(type(int256).max)) { + revert SafeCastOverflowedUintToInt(value); + } + return int256(value); + } + + /** + * @dev Cast a boolean (false or true) to a uint256 (0 or 1) with no jump. + */ + function toUint(bool b) internal pure returns (uint256 u) { + assembly ("memory-safe") { + u := iszero(iszero(b)) + } + } +} + +// File: @openzeppelin/contracts@5.4.0/utils/math/Math.sol + + +// OpenZeppelin Contracts (last updated v5.3.0) (utils/math/Math.sol) + +pragma solidity ^0.8.20; + + + +/** + * @dev Standard math utilities missing in the Solidity language. + */ +library Math { + enum Rounding { + Floor, // Toward negative infinity + Ceil, // Toward positive infinity + Trunc, // Toward zero + Expand // Away from zero + } + + /** + * @dev Return the 512-bit addition of two uint256. + * + * The result is stored in two 256 variables such that sum = high * 2²⁵⁶ + low. + */ + function add512(uint256 a, uint256 b) internal pure returns (uint256 high, uint256 low) { + assembly ("memory-safe") { + low := add(a, b) + high := lt(low, a) + } + } + + /** + * @dev Return the 512-bit multiplication of two uint256. + * + * The result is stored in two 256 variables such that product = high * 2²⁵⁶ + low. + */ + function mul512(uint256 a, uint256 b) internal pure returns (uint256 high, uint256 low) { + // 512-bit multiply [high low] = x * y. Compute the product mod 2²⁵⁶ and mod 2²⁵⁶ - 1, then use + // the Chinese Remainder Theorem to reconstruct the 512 bit result. The result is stored in two 256 + // variables such that product = high * 2²⁵⁶ + low. + assembly ("memory-safe") { + let mm := mulmod(a, b, not(0)) + low := mul(a, b) + high := sub(sub(mm, low), lt(mm, low)) + } + } + + /** + * @dev Returns the addition of two unsigned integers, with a success flag (no overflow). + */ + function tryAdd(uint256 a, uint256 b) internal pure returns (bool success, uint256 result) { + unchecked { + uint256 c = a + b; + success = c >= a; + result = c * SafeCast.toUint(success); + } + } + + /** + * @dev Returns the subtraction of two unsigned integers, with a success flag (no overflow). + */ + function trySub(uint256 a, uint256 b) internal pure returns (bool success, uint256 result) { + unchecked { + uint256 c = a - b; + success = c <= a; + result = c * SafeCast.toUint(success); + } + } + + /** + * @dev Returns the multiplication of two unsigned integers, with a success flag (no overflow). + */ + function tryMul(uint256 a, uint256 b) internal pure returns (bool success, uint256 result) { + unchecked { + uint256 c = a * b; + assembly ("memory-safe") { + // Only true when the multiplication doesn't overflow + // (c / a == b) || (a == 0) + success := or(eq(div(c, a), b), iszero(a)) + } + // equivalent to: success ? c : 0 + result = c * SafeCast.toUint(success); + } + } + + /** + * @dev Returns the division of two unsigned integers, with a success flag (no division by zero). + */ + function tryDiv(uint256 a, uint256 b) internal pure returns (bool success, uint256 result) { + unchecked { + success = b > 0; + assembly ("memory-safe") { + // The `DIV` opcode returns zero when the denominator is 0. + result := div(a, b) + } + } + } + + /** + * @dev Returns the remainder of dividing two unsigned integers, with a success flag (no division by zero). + */ + function tryMod(uint256 a, uint256 b) internal pure returns (bool success, uint256 result) { + unchecked { + success = b > 0; + assembly ("memory-safe") { + // The `MOD` opcode returns zero when the denominator is 0. + result := mod(a, b) + } + } + } + + /** + * @dev Unsigned saturating addition, bounds to `2²⁵⁶ - 1` instead of overflowing. + */ + function saturatingAdd(uint256 a, uint256 b) internal pure returns (uint256) { + (bool success, uint256 result) = tryAdd(a, b); + return ternary(success, result, type(uint256).max); + } + + /** + * @dev Unsigned saturating subtraction, bounds to zero instead of overflowing. + */ + function saturatingSub(uint256 a, uint256 b) internal pure returns (uint256) { + (, uint256 result) = trySub(a, b); + return result; + } + + /** + * @dev Unsigned saturating multiplication, bounds to `2²⁵⁶ - 1` instead of overflowing. + */ + function saturatingMul(uint256 a, uint256 b) internal pure returns (uint256) { + (bool success, uint256 result) = tryMul(a, b); + return ternary(success, result, type(uint256).max); + } + + /** + * @dev Branchless ternary evaluation for `a ? b : c`. Gas costs are constant. + * + * IMPORTANT: This function may reduce bytecode size and consume less gas when used standalone. + * However, the compiler may optimize Solidity ternary operations (i.e. `a ? b : c`) to only compute + * one branch when needed, making this function more expensive. + */ + function ternary(bool condition, uint256 a, uint256 b) internal pure returns (uint256) { + unchecked { + // branchless ternary works because: + // b ^ (a ^ b) == a + // b ^ 0 == b + return b ^ ((a ^ b) * SafeCast.toUint(condition)); + } + } + + /** + * @dev Returns the largest of two numbers. + */ + function max(uint256 a, uint256 b) internal pure returns (uint256) { + return ternary(a > b, a, b); + } + + /** + * @dev Returns the smallest of two numbers. + */ + function min(uint256 a, uint256 b) internal pure returns (uint256) { + return ternary(a < b, a, b); + } + + /** + * @dev Returns the average of two numbers. The result is rounded towards + * zero. + */ + function average(uint256 a, uint256 b) internal pure returns (uint256) { + // (a + b) / 2 can overflow. + return (a & b) + (a ^ b) / 2; + } + + /** + * @dev Returns the ceiling of the division of two numbers. + * + * This differs from standard division with `/` in that it rounds towards infinity instead + * of rounding towards zero. + */ + function ceilDiv(uint256 a, uint256 b) internal pure returns (uint256) { + if (b == 0) { + // Guarantee the same behavior as in a regular Solidity division. + Panic.panic(Panic.DIVISION_BY_ZERO); + } + + // The following calculation ensures accurate ceiling division without overflow. + // Since a is non-zero, (a - 1) / b will not overflow. + // The largest possible result occurs when (a - 1) / b is type(uint256).max, + // but the largest value we can obtain is type(uint256).max - 1, which happens + // when a = type(uint256).max and b = 1. + unchecked { + return SafeCast.toUint(a > 0) * ((a - 1) / b + 1); + } + } + + /** + * @dev Calculates floor(x * y / denominator) with full precision. Throws if result overflows a uint256 or + * denominator == 0. + * + * Original credit to Remco Bloemen under MIT license (https://xn--2-umb.com/21/muldiv) with further edits by + * Uniswap Labs also under MIT license. + */ + function mulDiv(uint256 x, uint256 y, uint256 denominator) internal pure returns (uint256 result) { + unchecked { + (uint256 high, uint256 low) = mul512(x, y); + + // Handle non-overflow cases, 256 by 256 division. + if (high == 0) { + // Solidity will revert if denominator == 0, unlike the div opcode on its own. + // The surrounding unchecked block does not change this fact. + // See https://docs.soliditylang.org/en/latest/control-structures.html#checked-or-unchecked-arithmetic. + return low / denominator; + } + + // Make sure the result is less than 2²⁵⁶. Also prevents denominator == 0. + if (denominator <= high) { + Panic.panic(ternary(denominator == 0, Panic.DIVISION_BY_ZERO, Panic.UNDER_OVERFLOW)); + } + + /////////////////////////////////////////////// + // 512 by 256 division. + /////////////////////////////////////////////// + + // Make division exact by subtracting the remainder from [high low]. + uint256 remainder; + assembly ("memory-safe") { + // Compute remainder using mulmod. + remainder := mulmod(x, y, denominator) + + // Subtract 256 bit number from 512 bit number. + high := sub(high, gt(remainder, low)) + low := sub(low, remainder) + } + + // Factor powers of two out of denominator and compute largest power of two divisor of denominator. + // Always >= 1. See https://cs.stackexchange.com/q/138556/92363. + + uint256 twos = denominator & (0 - denominator); + assembly ("memory-safe") { + // Divide denominator by twos. + denominator := div(denominator, twos) + + // Divide [high low] by twos. + low := div(low, twos) + + // Flip twos such that it is 2²⁵⁶ / twos. If twos is zero, then it becomes one. + twos := add(div(sub(0, twos), twos), 1) + } + + // Shift in bits from high into low. + low |= high * twos; + + // Invert denominator mod 2²⁵⁶. Now that denominator is an odd number, it has an inverse modulo 2²⁵⁶ such + // that denominator * inv ≡ 1 mod 2²⁵⁶. Compute the inverse by starting with a seed that is correct for + // four bits. That is, denominator * inv ≡ 1 mod 2⁴. + uint256 inverse = (3 * denominator) ^ 2; + + // Use the Newton-Raphson iteration to improve the precision. Thanks to Hensel's lifting lemma, this also + // works in modular arithmetic, doubling the correct bits in each step. + inverse *= 2 - denominator * inverse; // inverse mod 2⁸ + inverse *= 2 - denominator * inverse; // inverse mod 2¹⁶ + inverse *= 2 - denominator * inverse; // inverse mod 2³² + inverse *= 2 - denominator * inverse; // inverse mod 2⁶⁴ + inverse *= 2 - denominator * inverse; // inverse mod 2¹²⁸ + inverse *= 2 - denominator * inverse; // inverse mod 2²⁵⁶ + + // Because the division is now exact we can divide by multiplying with the modular inverse of denominator. + // This will give us the correct result modulo 2²⁵⁶. Since the preconditions guarantee that the outcome is + // less than 2²⁵⁶, this is the final result. We don't need to compute the high bits of the result and high + // is no longer required. + result = low * inverse; + return result; + } + } + + /** + * @dev Calculates x * y / denominator with full precision, following the selected rounding direction. + */ + function mulDiv(uint256 x, uint256 y, uint256 denominator, Rounding rounding) internal pure returns (uint256) { + return mulDiv(x, y, denominator) + SafeCast.toUint(unsignedRoundsUp(rounding) && mulmod(x, y, denominator) > 0); + } + + /** + * @dev Calculates floor(x * y >> n) with full precision. Throws if result overflows a uint256. + */ + function mulShr(uint256 x, uint256 y, uint8 n) internal pure returns (uint256 result) { + unchecked { + (uint256 high, uint256 low) = mul512(x, y); + if (high >= 1 << n) { + Panic.panic(Panic.UNDER_OVERFLOW); + } + return (high << (256 - n)) | (low >> n); + } + } + + /** + * @dev Calculates x * y >> n with full precision, following the selected rounding direction. + */ + function mulShr(uint256 x, uint256 y, uint8 n, Rounding rounding) internal pure returns (uint256) { + return mulShr(x, y, n) + SafeCast.toUint(unsignedRoundsUp(rounding) && mulmod(x, y, 1 << n) > 0); + } + + /** + * @dev Calculate the modular multiplicative inverse of a number in Z/nZ. + * + * If n is a prime, then Z/nZ is a field. In that case all elements are inversible, except 0. + * If n is not a prime, then Z/nZ is not a field, and some elements might not be inversible. + * + * If the input value is not inversible, 0 is returned. + * + * NOTE: If you know for sure that n is (big) a prime, it may be cheaper to use Fermat's little theorem and get the + * inverse using `Math.modExp(a, n - 2, n)`. See {invModPrime}. + */ + function invMod(uint256 a, uint256 n) internal pure returns (uint256) { + unchecked { + if (n == 0) return 0; + + // The inverse modulo is calculated using the Extended Euclidean Algorithm (iterative version) + // Used to compute integers x and y such that: ax + ny = gcd(a, n). + // When the gcd is 1, then the inverse of a modulo n exists and it's x. + // ax + ny = 1 + // ax = 1 + (-y)n + // ax ≡ 1 (mod n) # x is the inverse of a modulo n + + // If the remainder is 0 the gcd is n right away. + uint256 remainder = a % n; + uint256 gcd = n; + + // Therefore the initial coefficients are: + // ax + ny = gcd(a, n) = n + // 0a + 1n = n + int256 x = 0; + int256 y = 1; + + while (remainder != 0) { + uint256 quotient = gcd / remainder; + + (gcd, remainder) = ( + // The old remainder is the next gcd to try. + remainder, + // Compute the next remainder. + // Can't overflow given that (a % gcd) * (gcd // (a % gcd)) <= gcd + // where gcd is at most n (capped to type(uint256).max) + gcd - remainder * quotient + ); + + (x, y) = ( + // Increment the coefficient of a. + y, + // Decrement the coefficient of n. + // Can overflow, but the result is casted to uint256 so that the + // next value of y is "wrapped around" to a value between 0 and n - 1. + x - y * int256(quotient) + ); + } + + if (gcd != 1) return 0; // No inverse exists. + return ternary(x < 0, n - uint256(-x), uint256(x)); // Wrap the result if it's negative. + } + } + + /** + * @dev Variant of {invMod}. More efficient, but only works if `p` is known to be a prime greater than `2`. + * + * From https://en.wikipedia.org/wiki/Fermat%27s_little_theorem[Fermat's little theorem], we know that if p is + * prime, then `a**(p-1) ≡ 1 mod p`. As a consequence, we have `a * a**(p-2) ≡ 1 mod p`, which means that + * `a**(p-2)` is the modular multiplicative inverse of a in Fp. + * + * NOTE: this function does NOT check that `p` is a prime greater than `2`. + */ + function invModPrime(uint256 a, uint256 p) internal view returns (uint256) { + unchecked { + return Math.modExp(a, p - 2, p); + } + } + + /** + * @dev Returns the modular exponentiation of the specified base, exponent and modulus (b ** e % m) + * + * Requirements: + * - modulus can't be zero + * - underlying staticcall to precompile must succeed + * + * IMPORTANT: The result is only valid if the underlying call succeeds. When using this function, make + * sure the chain you're using it on supports the precompiled contract for modular exponentiation + * at address 0x05 as specified in https://eips.ethereum.org/EIPS/eip-198[EIP-198]. Otherwise, + * the underlying function will succeed given the lack of a revert, but the result may be incorrectly + * interpreted as 0. + */ + function modExp(uint256 b, uint256 e, uint256 m) internal view returns (uint256) { + (bool success, uint256 result) = tryModExp(b, e, m); + if (!success) { + Panic.panic(Panic.DIVISION_BY_ZERO); + } + return result; + } + + /** + * @dev Returns the modular exponentiation of the specified base, exponent and modulus (b ** e % m). + * It includes a success flag indicating if the operation succeeded. Operation will be marked as failed if trying + * to operate modulo 0 or if the underlying precompile reverted. + * + * IMPORTANT: The result is only valid if the success flag is true. When using this function, make sure the chain + * you're using it on supports the precompiled contract for modular exponentiation at address 0x05 as specified in + * https://eips.ethereum.org/EIPS/eip-198[EIP-198]. Otherwise, the underlying function will succeed given the lack + * of a revert, but the result may be incorrectly interpreted as 0. + */ + function tryModExp(uint256 b, uint256 e, uint256 m) internal view returns (bool success, uint256 result) { + if (m == 0) return (false, 0); + assembly ("memory-safe") { + let ptr := mload(0x40) + // | Offset | Content | Content (Hex) | + // |-----------|------------|--------------------------------------------------------------------| + // | 0x00:0x1f | size of b | 0x0000000000000000000000000000000000000000000000000000000000000020 | + // | 0x20:0x3f | size of e | 0x0000000000000000000000000000000000000000000000000000000000000020 | + // | 0x40:0x5f | size of m | 0x0000000000000000000000000000000000000000000000000000000000000020 | + // | 0x60:0x7f | value of b | 0x<.............................................................b> | + // | 0x80:0x9f | value of e | 0x<.............................................................e> | + // | 0xa0:0xbf | value of m | 0x<.............................................................m> | + mstore(ptr, 0x20) + mstore(add(ptr, 0x20), 0x20) + mstore(add(ptr, 0x40), 0x20) + mstore(add(ptr, 0x60), b) + mstore(add(ptr, 0x80), e) + mstore(add(ptr, 0xa0), m) + + // Given the result < m, it's guaranteed to fit in 32 bytes, + // so we can use the memory scratch space located at offset 0. + success := staticcall(gas(), 0x05, ptr, 0xc0, 0x00, 0x20) + result := mload(0x00) + } + } + + /** + * @dev Variant of {modExp} that supports inputs of arbitrary length. + */ + function modExp(bytes memory b, bytes memory e, bytes memory m) internal view returns (bytes memory) { + (bool success, bytes memory result) = tryModExp(b, e, m); + if (!success) { + Panic.panic(Panic.DIVISION_BY_ZERO); + } + return result; + } + + /** + * @dev Variant of {tryModExp} that supports inputs of arbitrary length. + */ + function tryModExp( + bytes memory b, + bytes memory e, + bytes memory m + ) internal view returns (bool success, bytes memory result) { + if (_zeroBytes(m)) return (false, new bytes(0)); + + uint256 mLen = m.length; + + // Encode call args in result and move the free memory pointer + result = abi.encodePacked(b.length, e.length, mLen, b, e, m); + + assembly ("memory-safe") { + let dataPtr := add(result, 0x20) + // Write result on top of args to avoid allocating extra memory. + success := staticcall(gas(), 0x05, dataPtr, mload(result), dataPtr, mLen) + // Overwrite the length. + // result.length > returndatasize() is guaranteed because returndatasize() == m.length + mstore(result, mLen) + // Set the memory pointer after the returned data. + mstore(0x40, add(dataPtr, mLen)) + } + } + + /** + * @dev Returns whether the provided byte array is zero. + */ + function _zeroBytes(bytes memory byteArray) private pure returns (bool) { + for (uint256 i = 0; i < byteArray.length; ++i) { + if (byteArray[i] != 0) { + return false; + } + } + return true; + } + + /** + * @dev Returns the square root of a number. If the number is not a perfect square, the value is rounded + * towards zero. + * + * This method is based on Newton's method for computing square roots; the algorithm is restricted to only + * using integer operations. + */ + function sqrt(uint256 a) internal pure returns (uint256) { + unchecked { + // Take care of easy edge cases when a == 0 or a == 1 + if (a <= 1) { + return a; + } + + // In this function, we use Newton's method to get a root of `f(x) := x² - a`. It involves building a + // sequence x_n that converges toward sqrt(a). For each iteration x_n, we also define the error between + // the current value as `ε_n = | x_n - sqrt(a) |`. + // + // For our first estimation, we consider `e` the smallest power of 2 which is bigger than the square root + // of the target. (i.e. `2**(e-1) ≤ sqrt(a) < 2**e`). We know that `e ≤ 128` because `(2¹²⁸)² = 2²⁵⁶` is + // bigger than any uint256. + // + // By noticing that + // `2**(e-1) ≤ sqrt(a) < 2**e → (2**(e-1))² ≤ a < (2**e)² → 2**(2*e-2) ≤ a < 2**(2*e)` + // we can deduce that `e - 1` is `log2(a) / 2`. We can thus compute `x_n = 2**(e-1)` using a method similar + // to the msb function. + uint256 aa = a; + uint256 xn = 1; + + if (aa >= (1 << 128)) { + aa >>= 128; + xn <<= 64; + } + if (aa >= (1 << 64)) { + aa >>= 64; + xn <<= 32; + } + if (aa >= (1 << 32)) { + aa >>= 32; + xn <<= 16; + } + if (aa >= (1 << 16)) { + aa >>= 16; + xn <<= 8; + } + if (aa >= (1 << 8)) { + aa >>= 8; + xn <<= 4; + } + if (aa >= (1 << 4)) { + aa >>= 4; + xn <<= 2; + } + if (aa >= (1 << 2)) { + xn <<= 1; + } + + // We now have x_n such that `x_n = 2**(e-1) ≤ sqrt(a) < 2**e = 2 * x_n`. This implies ε_n ≤ 2**(e-1). + // + // We can refine our estimation by noticing that the middle of that interval minimizes the error. + // If we move x_n to equal 2**(e-1) + 2**(e-2), then we reduce the error to ε_n ≤ 2**(e-2). + // This is going to be our x_0 (and ε_0) + xn = (3 * xn) >> 1; // ε_0 := | x_0 - sqrt(a) | ≤ 2**(e-2) + + // From here, Newton's method give us: + // x_{n+1} = (x_n + a / x_n) / 2 + // + // One should note that: + // x_{n+1}² - a = ((x_n + a / x_n) / 2)² - a + // = ((x_n² + a) / (2 * x_n))² - a + // = (x_n⁴ + 2 * a * x_n² + a²) / (4 * x_n²) - a + // = (x_n⁴ + 2 * a * x_n² + a² - 4 * a * x_n²) / (4 * x_n²) + // = (x_n⁴ - 2 * a * x_n² + a²) / (4 * x_n²) + // = (x_n² - a)² / (2 * x_n)² + // = ((x_n² - a) / (2 * x_n))² + // ≥ 0 + // Which proves that for all n ≥ 1, sqrt(a) ≤ x_n + // + // This gives us the proof of quadratic convergence of the sequence: + // ε_{n+1} = | x_{n+1} - sqrt(a) | + // = | (x_n + a / x_n) / 2 - sqrt(a) | + // = | (x_n² + a - 2*x_n*sqrt(a)) / (2 * x_n) | + // = | (x_n - sqrt(a))² / (2 * x_n) | + // = | ε_n² / (2 * x_n) | + // = ε_n² / | (2 * x_n) | + // + // For the first iteration, we have a special case where x_0 is known: + // ε_1 = ε_0² / | (2 * x_0) | + // ≤ (2**(e-2))² / (2 * (2**(e-1) + 2**(e-2))) + // ≤ 2**(2*e-4) / (3 * 2**(e-1)) + // ≤ 2**(e-3) / 3 + // ≤ 2**(e-3-log2(3)) + // ≤ 2**(e-4.5) + // + // For the following iterations, we use the fact that, 2**(e-1) ≤ sqrt(a) ≤ x_n: + // ε_{n+1} = ε_n² / | (2 * x_n) | + // ≤ (2**(e-k))² / (2 * 2**(e-1)) + // ≤ 2**(2*e-2*k) / 2**e + // ≤ 2**(e-2*k) + xn = (xn + a / xn) >> 1; // ε_1 := | x_1 - sqrt(a) | ≤ 2**(e-4.5) -- special case, see above + xn = (xn + a / xn) >> 1; // ε_2 := | x_2 - sqrt(a) | ≤ 2**(e-9) -- general case with k = 4.5 + xn = (xn + a / xn) >> 1; // ε_3 := | x_3 - sqrt(a) | ≤ 2**(e-18) -- general case with k = 9 + xn = (xn + a / xn) >> 1; // ε_4 := | x_4 - sqrt(a) | ≤ 2**(e-36) -- general case with k = 18 + xn = (xn + a / xn) >> 1; // ε_5 := | x_5 - sqrt(a) | ≤ 2**(e-72) -- general case with k = 36 + xn = (xn + a / xn) >> 1; // ε_6 := | x_6 - sqrt(a) | ≤ 2**(e-144) -- general case with k = 72 + + // Because e ≤ 128 (as discussed during the first estimation phase), we know have reached a precision + // ε_6 ≤ 2**(e-144) < 1. Given we're operating on integers, then we can ensure that xn is now either + // sqrt(a) or sqrt(a) + 1. + return xn - SafeCast.toUint(xn > a / xn); + } + } + + /** + * @dev Calculates sqrt(a), following the selected rounding direction. + */ + function sqrt(uint256 a, Rounding rounding) internal pure returns (uint256) { + unchecked { + uint256 result = sqrt(a); + return result + SafeCast.toUint(unsignedRoundsUp(rounding) && result * result < a); + } + } + + /** + * @dev Return the log in base 2 of a positive value rounded towards zero. + * Returns 0 if given 0. + */ + function log2(uint256 x) internal pure returns (uint256 r) { + // If value has upper 128 bits set, log2 result is at least 128 + r = SafeCast.toUint(x > 0xffffffffffffffffffffffffffffffff) << 7; + // If upper 64 bits of 128-bit half set, add 64 to result + r |= SafeCast.toUint((x >> r) > 0xffffffffffffffff) << 6; + // If upper 32 bits of 64-bit half set, add 32 to result + r |= SafeCast.toUint((x >> r) > 0xffffffff) << 5; + // If upper 16 bits of 32-bit half set, add 16 to result + r |= SafeCast.toUint((x >> r) > 0xffff) << 4; + // If upper 8 bits of 16-bit half set, add 8 to result + r |= SafeCast.toUint((x >> r) > 0xff) << 3; + // If upper 4 bits of 8-bit half set, add 4 to result + r |= SafeCast.toUint((x >> r) > 0xf) << 2; + + // Shifts value right by the current result and use it as an index into this lookup table: + // + // | x (4 bits) | index | table[index] = MSB position | + // |------------|---------|-----------------------------| + // | 0000 | 0 | table[0] = 0 | + // | 0001 | 1 | table[1] = 0 | + // | 0010 | 2 | table[2] = 1 | + // | 0011 | 3 | table[3] = 1 | + // | 0100 | 4 | table[4] = 2 | + // | 0101 | 5 | table[5] = 2 | + // | 0110 | 6 | table[6] = 2 | + // | 0111 | 7 | table[7] = 2 | + // | 1000 | 8 | table[8] = 3 | + // | 1001 | 9 | table[9] = 3 | + // | 1010 | 10 | table[10] = 3 | + // | 1011 | 11 | table[11] = 3 | + // | 1100 | 12 | table[12] = 3 | + // | 1101 | 13 | table[13] = 3 | + // | 1110 | 14 | table[14] = 3 | + // | 1111 | 15 | table[15] = 3 | + // + // The lookup table is represented as a 32-byte value with the MSB positions for 0-15 in the last 16 bytes. + assembly ("memory-safe") { + r := or(r, byte(shr(r, x), 0x0000010102020202030303030303030300000000000000000000000000000000)) + } + } + + /** + * @dev Return the log in base 2, following the selected rounding direction, of a positive value. + * Returns 0 if given 0. + */ + function log2(uint256 value, Rounding rounding) internal pure returns (uint256) { + unchecked { + uint256 result = log2(value); + return result + SafeCast.toUint(unsignedRoundsUp(rounding) && 1 << result < value); + } + } + + /** + * @dev Return the log in base 10 of a positive value rounded towards zero. + * Returns 0 if given 0. + */ + function log10(uint256 value) internal pure returns (uint256) { + uint256 result = 0; + unchecked { + if (value >= 10 ** 64) { + value /= 10 ** 64; + result += 64; + } + if (value >= 10 ** 32) { + value /= 10 ** 32; + result += 32; + } + if (value >= 10 ** 16) { + value /= 10 ** 16; + result += 16; + } + if (value >= 10 ** 8) { + value /= 10 ** 8; + result += 8; + } + if (value >= 10 ** 4) { + value /= 10 ** 4; + result += 4; + } + if (value >= 10 ** 2) { + value /= 10 ** 2; + result += 2; + } + if (value >= 10 ** 1) { + result += 1; + } + } + return result; + } + + /** + * @dev Return the log in base 10, following the selected rounding direction, of a positive value. + * Returns 0 if given 0. + */ + function log10(uint256 value, Rounding rounding) internal pure returns (uint256) { + unchecked { + uint256 result = log10(value); + return result + SafeCast.toUint(unsignedRoundsUp(rounding) && 10 ** result < value); + } + } + + /** + * @dev Return the log in base 256 of a positive value rounded towards zero. + * Returns 0 if given 0. + * + * Adding one to the result gives the number of pairs of hex symbols needed to represent `value` as a hex string. + */ + function log256(uint256 x) internal pure returns (uint256 r) { + // If value has upper 128 bits set, log2 result is at least 128 + r = SafeCast.toUint(x > 0xffffffffffffffffffffffffffffffff) << 7; + // If upper 64 bits of 128-bit half set, add 64 to result + r |= SafeCast.toUint((x >> r) > 0xffffffffffffffff) << 6; + // If upper 32 bits of 64-bit half set, add 32 to result + r |= SafeCast.toUint((x >> r) > 0xffffffff) << 5; + // If upper 16 bits of 32-bit half set, add 16 to result + r |= SafeCast.toUint((x >> r) > 0xffff) << 4; + // Add 1 if upper 8 bits of 16-bit half set, and divide accumulated result by 8 + return (r >> 3) | SafeCast.toUint((x >> r) > 0xff); + } + + /** + * @dev Return the log in base 256, following the selected rounding direction, of a positive value. + * Returns 0 if given 0. + */ + function log256(uint256 value, Rounding rounding) internal pure returns (uint256) { + unchecked { + uint256 result = log256(value); + return result + SafeCast.toUint(unsignedRoundsUp(rounding) && 1 << (result << 3) < value); + } + } + + /** + * @dev Returns whether a provided rounding mode is considered rounding up for unsigned integers. + */ + function unsignedRoundsUp(Rounding rounding) internal pure returns (bool) { + return uint8(rounding) % 2 == 1; + } +} + +// File: @openzeppelin/contracts@5.4.0/utils/math/SignedMath.sol + + +// OpenZeppelin Contracts (last updated v5.1.0) (utils/math/SignedMath.sol) + +pragma solidity ^0.8.20; + + +/** + * @dev Standard signed math utilities missing in the Solidity language. + */ +library SignedMath { + /** + * @dev Branchless ternary evaluation for `a ? b : c`. Gas costs are constant. + * + * IMPORTANT: This function may reduce bytecode size and consume less gas when used standalone. + * However, the compiler may optimize Solidity ternary operations (i.e. `a ? b : c`) to only compute + * one branch when needed, making this function more expensive. + */ + function ternary(bool condition, int256 a, int256 b) internal pure returns (int256) { + unchecked { + // branchless ternary works because: + // b ^ (a ^ b) == a + // b ^ 0 == b + return b ^ ((a ^ b) * int256(SafeCast.toUint(condition))); + } + } + + /** + * @dev Returns the largest of two signed numbers. + */ + function max(int256 a, int256 b) internal pure returns (int256) { + return ternary(a > b, a, b); + } + + /** + * @dev Returns the smallest of two signed numbers. + */ + function min(int256 a, int256 b) internal pure returns (int256) { + return ternary(a < b, a, b); + } + + /** + * @dev Returns the average of two signed numbers without overflow. + * The result is rounded towards zero. + */ + function average(int256 a, int256 b) internal pure returns (int256) { + // Formula from the book "Hacker's Delight" + int256 x = (a & b) + ((a ^ b) >> 1); + return x + (int256(uint256(x) >> 255) & (a ^ b)); + } + + /** + * @dev Returns the absolute unsigned value of a signed value. + */ + function abs(int256 n) internal pure returns (uint256) { + unchecked { + // Formula from the "Bit Twiddling Hacks" by Sean Eron Anderson. + // Since `n` is a signed integer, the generated bytecode will use the SAR opcode to perform the right shift, + // taking advantage of the most significant (or "sign" bit) in two's complement representation. + // This opcode adds new most significant bits set to the value of the previous most significant bit. As a result, + // the mask will either be `bytes32(0)` (if n is positive) or `~bytes32(0)` (if n is negative). + int256 mask = n >> 255; + + // A `bytes32(0)` mask leaves the input unchanged, while a `~bytes32(0)` mask complements it. + return uint256((n + mask) ^ mask); + } + } +} + +// File: @openzeppelin/contracts@5.4.0/utils/Strings.sol + + +// OpenZeppelin Contracts (last updated v5.4.0) (utils/Strings.sol) + +pragma solidity ^0.8.20; + + + + +/** + * @dev String operations. + */ +library Strings { + using SafeCast for *; + + bytes16 private constant HEX_DIGITS = "0123456789abcdef"; + uint8 private constant ADDRESS_LENGTH = 20; + uint256 private constant SPECIAL_CHARS_LOOKUP = + (1 << 0x08) | // backspace + (1 << 0x09) | // tab + (1 << 0x0a) | // newline + (1 << 0x0c) | // form feed + (1 << 0x0d) | // carriage return + (1 << 0x22) | // double quote + (1 << 0x5c); // backslash + + /** + * @dev The `value` string doesn't fit in the specified `length`. + */ + error StringsInsufficientHexLength(uint256 value, uint256 length); + + /** + * @dev The string being parsed contains characters that are not in scope of the given base. + */ + error StringsInvalidChar(); + + /** + * @dev The string being parsed is not a properly formatted address. + */ + error StringsInvalidAddressFormat(); + + /** + * @dev Converts a `uint256` to its ASCII `string` decimal representation. + */ + function toString(uint256 value) internal pure returns (string memory) { + unchecked { + uint256 length = Math.log10(value) + 1; + string memory buffer = new string(length); + uint256 ptr; + assembly ("memory-safe") { + ptr := add(add(buffer, 0x20), length) + } + while (true) { + ptr--; + assembly ("memory-safe") { + mstore8(ptr, byte(mod(value, 10), HEX_DIGITS)) + } + value /= 10; + if (value == 0) break; + } + return buffer; + } + } + + /** + * @dev Converts a `int256` to its ASCII `string` decimal representation. + */ + function toStringSigned(int256 value) internal pure returns (string memory) { + return string.concat(value < 0 ? "-" : "", toString(SignedMath.abs(value))); + } + + /** + * @dev Converts a `uint256` to its ASCII `string` hexadecimal representation. + */ + function toHexString(uint256 value) internal pure returns (string memory) { + unchecked { + return toHexString(value, Math.log256(value) + 1); + } + } + + /** + * @dev Converts a `uint256` to its ASCII `string` hexadecimal representation with fixed length. + */ + function toHexString(uint256 value, uint256 length) internal pure returns (string memory) { + uint256 localValue = value; + bytes memory buffer = new bytes(2 * length + 2); + buffer[0] = "0"; + buffer[1] = "x"; + for (uint256 i = 2 * length + 1; i > 1; --i) { + buffer[i] = HEX_DIGITS[localValue & 0xf]; + localValue >>= 4; + } + if (localValue != 0) { + revert StringsInsufficientHexLength(value, length); + } + return string(buffer); + } + + /** + * @dev Converts an `address` with fixed length of 20 bytes to its not checksummed ASCII `string` hexadecimal + * representation. + */ + function toHexString(address addr) internal pure returns (string memory) { + return toHexString(uint256(uint160(addr)), ADDRESS_LENGTH); + } + + /** + * @dev Converts an `address` with fixed length of 20 bytes to its checksummed ASCII `string` hexadecimal + * representation, according to EIP-55. + */ + function toChecksumHexString(address addr) internal pure returns (string memory) { + bytes memory buffer = bytes(toHexString(addr)); + + // hash the hex part of buffer (skip length + 2 bytes, length 40) + uint256 hashValue; + assembly ("memory-safe") { + hashValue := shr(96, keccak256(add(buffer, 0x22), 40)) + } + + for (uint256 i = 41; i > 1; --i) { + // possible values for buffer[i] are 48 (0) to 57 (9) and 97 (a) to 102 (f) + if (hashValue & 0xf > 7 && uint8(buffer[i]) > 96) { + // case shift by xoring with 0x20 + buffer[i] ^= 0x20; + } + hashValue >>= 4; + } + return string(buffer); + } + + /** + * @dev Returns true if the two strings are equal. + */ + function equal(string memory a, string memory b) internal pure returns (bool) { + return bytes(a).length == bytes(b).length && keccak256(bytes(a)) == keccak256(bytes(b)); + } + + /** + * @dev Parse a decimal string and returns the value as a `uint256`. + * + * Requirements: + * - The string must be formatted as `[0-9]*` + * - The result must fit into an `uint256` type + */ + function parseUint(string memory input) internal pure returns (uint256) { + return parseUint(input, 0, bytes(input).length); + } + + /** + * @dev Variant of {parseUint-string} that parses a substring of `input` located between position `begin` (included) and + * `end` (excluded). + * + * Requirements: + * - The substring must be formatted as `[0-9]*` + * - The result must fit into an `uint256` type + */ + function parseUint(string memory input, uint256 begin, uint256 end) internal pure returns (uint256) { + (bool success, uint256 value) = tryParseUint(input, begin, end); + if (!success) revert StringsInvalidChar(); + return value; + } + + /** + * @dev Variant of {parseUint-string} that returns false if the parsing fails because of an invalid character. + * + * NOTE: This function will revert if the result does not fit in a `uint256`. + */ + function tryParseUint(string memory input) internal pure returns (bool success, uint256 value) { + return _tryParseUintUncheckedBounds(input, 0, bytes(input).length); + } + + /** + * @dev Variant of {parseUint-string-uint256-uint256} that returns false if the parsing fails because of an invalid + * character. + * + * NOTE: This function will revert if the result does not fit in a `uint256`. + */ + function tryParseUint( + string memory input, + uint256 begin, + uint256 end + ) internal pure returns (bool success, uint256 value) { + if (end > bytes(input).length || begin > end) return (false, 0); + return _tryParseUintUncheckedBounds(input, begin, end); + } + + /** + * @dev Implementation of {tryParseUint-string-uint256-uint256} that does not check bounds. Caller should make sure that + * `begin <= end <= input.length`. Other inputs would result in undefined behavior. + */ + function _tryParseUintUncheckedBounds( + string memory input, + uint256 begin, + uint256 end + ) private pure returns (bool success, uint256 value) { + bytes memory buffer = bytes(input); + + uint256 result = 0; + for (uint256 i = begin; i < end; ++i) { + uint8 chr = _tryParseChr(bytes1(_unsafeReadBytesOffset(buffer, i))); + if (chr > 9) return (false, 0); + result *= 10; + result += chr; + } + return (true, result); + } + + /** + * @dev Parse a decimal string and returns the value as a `int256`. + * + * Requirements: + * - The string must be formatted as `[-+]?[0-9]*` + * - The result must fit in an `int256` type. + */ + function parseInt(string memory input) internal pure returns (int256) { + return parseInt(input, 0, bytes(input).length); + } + + /** + * @dev Variant of {parseInt-string} that parses a substring of `input` located between position `begin` (included) and + * `end` (excluded). + * + * Requirements: + * - The substring must be formatted as `[-+]?[0-9]*` + * - The result must fit in an `int256` type. + */ + function parseInt(string memory input, uint256 begin, uint256 end) internal pure returns (int256) { + (bool success, int256 value) = tryParseInt(input, begin, end); + if (!success) revert StringsInvalidChar(); + return value; + } + + /** + * @dev Variant of {parseInt-string} that returns false if the parsing fails because of an invalid character or if + * the result does not fit in a `int256`. + * + * NOTE: This function will revert if the absolute value of the result does not fit in a `uint256`. + */ + function tryParseInt(string memory input) internal pure returns (bool success, int256 value) { + return _tryParseIntUncheckedBounds(input, 0, bytes(input).length); + } + + uint256 private constant ABS_MIN_INT256 = 2 ** 255; + + /** + * @dev Variant of {parseInt-string-uint256-uint256} that returns false if the parsing fails because of an invalid + * character or if the result does not fit in a `int256`. + * + * NOTE: This function will revert if the absolute value of the result does not fit in a `uint256`. + */ + function tryParseInt( + string memory input, + uint256 begin, + uint256 end + ) internal pure returns (bool success, int256 value) { + if (end > bytes(input).length || begin > end) return (false, 0); + return _tryParseIntUncheckedBounds(input, begin, end); + } + + /** + * @dev Implementation of {tryParseInt-string-uint256-uint256} that does not check bounds. Caller should make sure that + * `begin <= end <= input.length`. Other inputs would result in undefined behavior. + */ + function _tryParseIntUncheckedBounds( + string memory input, + uint256 begin, + uint256 end + ) private pure returns (bool success, int256 value) { + bytes memory buffer = bytes(input); + + // Check presence of a negative sign. + bytes1 sign = begin == end ? bytes1(0) : bytes1(_unsafeReadBytesOffset(buffer, begin)); // don't do out-of-bound (possibly unsafe) read if sub-string is empty + bool positiveSign = sign == bytes1("+"); + bool negativeSign = sign == bytes1("-"); + uint256 offset = (positiveSign || negativeSign).toUint(); + + (bool absSuccess, uint256 absValue) = tryParseUint(input, begin + offset, end); + + if (absSuccess && absValue < ABS_MIN_INT256) { + return (true, negativeSign ? -int256(absValue) : int256(absValue)); + } else if (absSuccess && negativeSign && absValue == ABS_MIN_INT256) { + return (true, type(int256).min); + } else return (false, 0); + } + + /** + * @dev Parse a hexadecimal string (with or without "0x" prefix), and returns the value as a `uint256`. + * + * Requirements: + * - The string must be formatted as `(0x)?[0-9a-fA-F]*` + * - The result must fit in an `uint256` type. + */ + function parseHexUint(string memory input) internal pure returns (uint256) { + return parseHexUint(input, 0, bytes(input).length); + } + + /** + * @dev Variant of {parseHexUint-string} that parses a substring of `input` located between position `begin` (included) and + * `end` (excluded). + * + * Requirements: + * - The substring must be formatted as `(0x)?[0-9a-fA-F]*` + * - The result must fit in an `uint256` type. + */ + function parseHexUint(string memory input, uint256 begin, uint256 end) internal pure returns (uint256) { + (bool success, uint256 value) = tryParseHexUint(input, begin, end); + if (!success) revert StringsInvalidChar(); + return value; + } + + /** + * @dev Variant of {parseHexUint-string} that returns false if the parsing fails because of an invalid character. + * + * NOTE: This function will revert if the result does not fit in a `uint256`. + */ + function tryParseHexUint(string memory input) internal pure returns (bool success, uint256 value) { + return _tryParseHexUintUncheckedBounds(input, 0, bytes(input).length); + } + + /** + * @dev Variant of {parseHexUint-string-uint256-uint256} that returns false if the parsing fails because of an + * invalid character. + * + * NOTE: This function will revert if the result does not fit in a `uint256`. + */ + function tryParseHexUint( + string memory input, + uint256 begin, + uint256 end + ) internal pure returns (bool success, uint256 value) { + if (end > bytes(input).length || begin > end) return (false, 0); + return _tryParseHexUintUncheckedBounds(input, begin, end); + } + + /** + * @dev Implementation of {tryParseHexUint-string-uint256-uint256} that does not check bounds. Caller should make sure that + * `begin <= end <= input.length`. Other inputs would result in undefined behavior. + */ + function _tryParseHexUintUncheckedBounds( + string memory input, + uint256 begin, + uint256 end + ) private pure returns (bool success, uint256 value) { + bytes memory buffer = bytes(input); + + // skip 0x prefix if present + bool hasPrefix = (end > begin + 1) && bytes2(_unsafeReadBytesOffset(buffer, begin)) == bytes2("0x"); // don't do out-of-bound (possibly unsafe) read if sub-string is empty + uint256 offset = hasPrefix.toUint() * 2; + + uint256 result = 0; + for (uint256 i = begin + offset; i < end; ++i) { + uint8 chr = _tryParseChr(bytes1(_unsafeReadBytesOffset(buffer, i))); + if (chr > 15) return (false, 0); + result *= 16; + unchecked { + // Multiplying by 16 is equivalent to a shift of 4 bits (with additional overflow check). + // This guarantees that adding a value < 16 will not cause an overflow, hence the unchecked. + result += chr; + } + } + return (true, result); + } + + /** + * @dev Parse a hexadecimal string (with or without "0x" prefix), and returns the value as an `address`. + * + * Requirements: + * - The string must be formatted as `(0x)?[0-9a-fA-F]{40}` + */ + function parseAddress(string memory input) internal pure returns (address) { + return parseAddress(input, 0, bytes(input).length); + } + + /** + * @dev Variant of {parseAddress-string} that parses a substring of `input` located between position `begin` (included) and + * `end` (excluded). + * + * Requirements: + * - The substring must be formatted as `(0x)?[0-9a-fA-F]{40}` + */ + function parseAddress(string memory input, uint256 begin, uint256 end) internal pure returns (address) { + (bool success, address value) = tryParseAddress(input, begin, end); + if (!success) revert StringsInvalidAddressFormat(); + return value; + } + + /** + * @dev Variant of {parseAddress-string} that returns false if the parsing fails because the input is not a properly + * formatted address. See {parseAddress-string} requirements. + */ + function tryParseAddress(string memory input) internal pure returns (bool success, address value) { + return tryParseAddress(input, 0, bytes(input).length); + } + + /** + * @dev Variant of {parseAddress-string-uint256-uint256} that returns false if the parsing fails because input is not a properly + * formatted address. See {parseAddress-string-uint256-uint256} requirements. + */ + function tryParseAddress( + string memory input, + uint256 begin, + uint256 end + ) internal pure returns (bool success, address value) { + if (end > bytes(input).length || begin > end) return (false, address(0)); + + bool hasPrefix = (end > begin + 1) && bytes2(_unsafeReadBytesOffset(bytes(input), begin)) == bytes2("0x"); // don't do out-of-bound (possibly unsafe) read if sub-string is empty + uint256 expectedLength = 40 + hasPrefix.toUint() * 2; + + // check that input is the correct length + if (end - begin == expectedLength) { + // length guarantees that this does not overflow, and value is at most type(uint160).max + (bool s, uint256 v) = _tryParseHexUintUncheckedBounds(input, begin, end); + return (s, address(uint160(v))); + } else { + return (false, address(0)); + } + } + + function _tryParseChr(bytes1 chr) private pure returns (uint8) { + uint8 value = uint8(chr); + + // Try to parse `chr`: + // - Case 1: [0-9] + // - Case 2: [a-f] + // - Case 3: [A-F] + // - otherwise not supported + unchecked { + if (value > 47 && value < 58) value -= 48; + else if (value > 96 && value < 103) value -= 87; + else if (value > 64 && value < 71) value -= 55; + else return type(uint8).max; + } + + return value; + } + + /** + * @dev Escape special characters in JSON strings. This can be useful to prevent JSON injection in NFT metadata. + * + * WARNING: This function should only be used in double quoted JSON strings. Single quotes are not escaped. + * + * NOTE: This function escapes all unicode characters, and not just the ones in ranges defined in section 2.5 of + * RFC-4627 (U+0000 to U+001F, U+0022 and U+005C). ECMAScript's `JSON.parse` does recover escaped unicode + * characters that are not in this range, but other tooling may provide different results. + */ + function escapeJSON(string memory input) internal pure returns (string memory) { + bytes memory buffer = bytes(input); + bytes memory output = new bytes(2 * buffer.length); // worst case scenario + uint256 outputLength = 0; + + for (uint256 i; i < buffer.length; ++i) { + bytes1 char = bytes1(_unsafeReadBytesOffset(buffer, i)); + if (((SPECIAL_CHARS_LOOKUP & (1 << uint8(char))) != 0)) { + output[outputLength++] = "\\"; + if (char == 0x08) output[outputLength++] = "b"; + else if (char == 0x09) output[outputLength++] = "t"; + else if (char == 0x0a) output[outputLength++] = "n"; + else if (char == 0x0c) output[outputLength++] = "f"; + else if (char == 0x0d) output[outputLength++] = "r"; + else if (char == 0x5c) output[outputLength++] = "\\"; + else if (char == 0x22) { + // solhint-disable-next-line quotes + output[outputLength++] = '"'; + } + } else { + output[outputLength++] = char; + } + } + // write the actual length and deallocate unused memory + assembly ("memory-safe") { + mstore(output, outputLength) + mstore(0x40, add(output, shl(5, shr(5, add(outputLength, 63))))) + } + + return string(output); + } + + /** + * @dev Reads a bytes32 from a bytes array without bounds checking. + * + * NOTE: making this function internal would mean it could be used with memory unsafe offset, and marking the + * assembly block as such would prevent some optimizations. + */ + function _unsafeReadBytesOffset(bytes memory buffer, uint256 offset) private pure returns (bytes32 value) { + // This is not memory safe in the general case, but all calls to this private function are within bounds. + assembly ("memory-safe") { + value := mload(add(add(buffer, 0x20), offset)) + } + } +} + +// File: @openzeppelin/contracts@5.4.0/utils/introspection/ERC165.sol + + +// OpenZeppelin Contracts (last updated v5.4.0) (utils/introspection/ERC165.sol) + +pragma solidity ^0.8.20; + + +/** + * @dev Implementation of the {IERC165} interface. + * + * Contracts that want to implement ERC-165 should inherit from this contract and override {supportsInterface} to check + * for the additional interface id that will be supported. For example: + * + * ```solidity + * function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + * return interfaceId == type(MyInterface).interfaceId || super.supportsInterface(interfaceId); + * } + * ``` + */ +abstract contract ERC165 is IERC165 { + /// @inheritdoc IERC165 + function supportsInterface(bytes4 interfaceId) public view virtual returns (bool) { + return interfaceId == type(IERC165).interfaceId; + } +} + +// File: @openzeppelin/contracts@5.4.0/token/ERC721/ERC721.sol + + +// OpenZeppelin Contracts (last updated v5.4.0) (token/ERC721/ERC721.sol) + +pragma solidity ^0.8.20; + + + + + + + + +/** + * @dev Implementation of https://eips.ethereum.org/EIPS/eip-721[ERC-721] Non-Fungible Token Standard, including + * the Metadata extension, but not including the Enumerable extension, which is available separately as + * {ERC721Enumerable}. + */ +abstract contract ERC721 is Context, ERC165, IERC721, IERC721Metadata, IERC721Errors { + using Strings for uint256; + + // Token name + string private _name; + + // Token symbol + string private _symbol; + + mapping(uint256 tokenId => address) private _owners; + + mapping(address owner => uint256) private _balances; + + mapping(uint256 tokenId => address) private _tokenApprovals; + + mapping(address owner => mapping(address operator => bool)) private _operatorApprovals; + + /** + * @dev Initializes the contract by setting a `name` and a `symbol` to the token collection. + */ + constructor(string memory name_, string memory symbol_) { + _name = name_; + _symbol = symbol_; + } + + /// @inheritdoc IERC165 + function supportsInterface(bytes4 interfaceId) public view virtual override(ERC165, IERC165) returns (bool) { + return + interfaceId == type(IERC721).interfaceId || + interfaceId == type(IERC721Metadata).interfaceId || + super.supportsInterface(interfaceId); + } + + /// @inheritdoc IERC721 + function balanceOf(address owner) public view virtual returns (uint256) { + if (owner == address(0)) { + revert ERC721InvalidOwner(address(0)); + } + return _balances[owner]; + } + + /// @inheritdoc IERC721 + function ownerOf(uint256 tokenId) public view virtual returns (address) { + return _requireOwned(tokenId); + } + + /// @inheritdoc IERC721Metadata + function name() public view virtual returns (string memory) { + return _name; + } + + /// @inheritdoc IERC721Metadata + function symbol() public view virtual returns (string memory) { + return _symbol; + } + + /// @inheritdoc IERC721Metadata + function tokenURI(uint256 tokenId) public view virtual returns (string memory) { + _requireOwned(tokenId); + + string memory baseURI = _baseURI(); + return bytes(baseURI).length > 0 ? string.concat(baseURI, tokenId.toString()) : ""; + } + + /** + * @dev Base URI for computing {tokenURI}. If set, the resulting URI for each + * token will be the concatenation of the `baseURI` and the `tokenId`. Empty + * by default, can be overridden in child contracts. + */ + function _baseURI() internal view virtual returns (string memory) { + return ""; + } + + /// @inheritdoc IERC721 + function approve(address to, uint256 tokenId) public virtual { + _approve(to, tokenId, _msgSender()); + } + + /// @inheritdoc IERC721 + function getApproved(uint256 tokenId) public view virtual returns (address) { + _requireOwned(tokenId); + + return _getApproved(tokenId); + } + + /// @inheritdoc IERC721 + function setApprovalForAll(address operator, bool approved) public virtual { + _setApprovalForAll(_msgSender(), operator, approved); + } + + /// @inheritdoc IERC721 + function isApprovedForAll(address owner, address operator) public view virtual returns (bool) { + return _operatorApprovals[owner][operator]; + } + + /// @inheritdoc IERC721 + function transferFrom(address from, address to, uint256 tokenId) public virtual { + if (to == address(0)) { + revert ERC721InvalidReceiver(address(0)); + } + // Setting an "auth" arguments enables the `_isAuthorized` check which verifies that the token exists + // (from != 0). Therefore, it is not needed to verify that the return value is not 0 here. + address previousOwner = _update(to, tokenId, _msgSender()); + if (previousOwner != from) { + revert ERC721IncorrectOwner(from, tokenId, previousOwner); + } + } + + /// @inheritdoc IERC721 + function safeTransferFrom(address from, address to, uint256 tokenId) public { + safeTransferFrom(from, to, tokenId, ""); + } + + /// @inheritdoc IERC721 + function safeTransferFrom(address from, address to, uint256 tokenId, bytes memory data) public virtual { + transferFrom(from, to, tokenId); + ERC721Utils.checkOnERC721Received(_msgSender(), from, to, tokenId, data); + } + + /** + * @dev Returns the owner of the `tokenId`. Does NOT revert if token doesn't exist + * + * IMPORTANT: Any overrides to this function that add ownership of tokens not tracked by the + * core ERC-721 logic MUST be matched with the use of {_increaseBalance} to keep balances + * consistent with ownership. The invariant to preserve is that for any address `a` the value returned by + * `balanceOf(a)` must be equal to the number of tokens such that `_ownerOf(tokenId)` is `a`. + */ + function _ownerOf(uint256 tokenId) internal view virtual returns (address) { + return _owners[tokenId]; + } + + /** + * @dev Returns the approved address for `tokenId`. Returns 0 if `tokenId` is not minted. + */ + function _getApproved(uint256 tokenId) internal view virtual returns (address) { + return _tokenApprovals[tokenId]; + } + + /** + * @dev Returns whether `spender` is allowed to manage `owner`'s tokens, or `tokenId` in + * particular (ignoring whether it is owned by `owner`). + * + * WARNING: This function assumes that `owner` is the actual owner of `tokenId` and does not verify this + * assumption. + */ + function _isAuthorized(address owner, address spender, uint256 tokenId) internal view virtual returns (bool) { + return + spender != address(0) && + (owner == spender || isApprovedForAll(owner, spender) || _getApproved(tokenId) == spender); + } + + /** + * @dev Checks if `spender` can operate on `tokenId`, assuming the provided `owner` is the actual owner. + * Reverts if: + * - `spender` does not have approval from `owner` for `tokenId`. + * - `spender` does not have approval to manage all of `owner`'s assets. + * + * WARNING: This function assumes that `owner` is the actual owner of `tokenId` and does not verify this + * assumption. + */ + function _checkAuthorized(address owner, address spender, uint256 tokenId) internal view virtual { + if (!_isAuthorized(owner, spender, tokenId)) { + if (owner == address(0)) { + revert ERC721NonexistentToken(tokenId); + } else { + revert ERC721InsufficientApproval(spender, tokenId); + } + } + } + + /** + * @dev Unsafe write access to the balances, used by extensions that "mint" tokens using an {ownerOf} override. + * + * NOTE: the value is limited to type(uint128).max. This protect against _balance overflow. It is unrealistic that + * a uint256 would ever overflow from increments when these increments are bounded to uint128 values. + * + * WARNING: Increasing an account's balance using this function tends to be paired with an override of the + * {_ownerOf} function to resolve the ownership of the corresponding tokens so that balances and ownership + * remain consistent with one another. + */ + function _increaseBalance(address account, uint128 value) internal virtual { + unchecked { + _balances[account] += value; + } + } + + /** + * @dev Transfers `tokenId` from its current owner to `to`, or alternatively mints (or burns) if the current owner + * (or `to`) is the zero address. Returns the owner of the `tokenId` before the update. + * + * The `auth` argument is optional. If the value passed is non 0, then this function will check that + * `auth` is either the owner of the token, or approved to operate on the token (by the owner). + * + * Emits a {Transfer} event. + * + * NOTE: If overriding this function in a way that tracks balances, see also {_increaseBalance}. + */ + function _update(address to, uint256 tokenId, address auth) internal virtual returns (address) { + address from = _ownerOf(tokenId); + + // Perform (optional) operator check + if (auth != address(0)) { + _checkAuthorized(from, auth, tokenId); + } + + // Execute the update + if (from != address(0)) { + // Clear approval. No need to re-authorize or emit the Approval event + _approve(address(0), tokenId, address(0), false); + + unchecked { + _balances[from] -= 1; + } + } + + if (to != address(0)) { + unchecked { + _balances[to] += 1; + } + } + + _owners[tokenId] = to; + + emit Transfer(from, to, tokenId); + + return from; + } + + /** + * @dev Mints `tokenId` and transfers it to `to`. + * + * WARNING: Usage of this method is discouraged, use {_safeMint} whenever possible + * + * Requirements: + * + * - `tokenId` must not exist. + * - `to` cannot be the zero address. + * + * Emits a {Transfer} event. + */ + function _mint(address to, uint256 tokenId) internal { + if (to == address(0)) { + revert ERC721InvalidReceiver(address(0)); + } + address previousOwner = _update(to, tokenId, address(0)); + if (previousOwner != address(0)) { + revert ERC721InvalidSender(address(0)); + } + } + + /** + * @dev Mints `tokenId`, transfers it to `to` and checks for `to` acceptance. + * + * Requirements: + * + * - `tokenId` must not exist. + * - If `to` refers to a smart contract, it must implement {IERC721Receiver-onERC721Received}, which is called upon a safe transfer. + * + * Emits a {Transfer} event. + */ + function _safeMint(address to, uint256 tokenId) internal { + _safeMint(to, tokenId, ""); + } + + /** + * @dev Same as {xref-ERC721-_safeMint-address-uint256-}[`_safeMint`], with an additional `data` parameter which is + * forwarded in {IERC721Receiver-onERC721Received} to contract recipients. + */ + function _safeMint(address to, uint256 tokenId, bytes memory data) internal virtual { + _mint(to, tokenId); + ERC721Utils.checkOnERC721Received(_msgSender(), address(0), to, tokenId, data); + } + + /** + * @dev Destroys `tokenId`. + * The approval is cleared when the token is burned. + * This is an internal function that does not check if the sender is authorized to operate on the token. + * + * Requirements: + * + * - `tokenId` must exist. + * + * Emits a {Transfer} event. + */ + function _burn(uint256 tokenId) internal { + address previousOwner = _update(address(0), tokenId, address(0)); + if (previousOwner == address(0)) { + revert ERC721NonexistentToken(tokenId); + } + } + + /** + * @dev Transfers `tokenId` from `from` to `to`. + * As opposed to {transferFrom}, this imposes no restrictions on msg.sender. + * + * Requirements: + * + * - `to` cannot be the zero address. + * - `tokenId` token must be owned by `from`. + * + * Emits a {Transfer} event. + */ + function _transfer(address from, address to, uint256 tokenId) internal { + if (to == address(0)) { + revert ERC721InvalidReceiver(address(0)); + } + address previousOwner = _update(to, tokenId, address(0)); + if (previousOwner == address(0)) { + revert ERC721NonexistentToken(tokenId); + } else if (previousOwner != from) { + revert ERC721IncorrectOwner(from, tokenId, previousOwner); + } + } + + /** + * @dev Safely transfers `tokenId` token from `from` to `to`, checking that contract recipients + * are aware of the ERC-721 standard to prevent tokens from being forever locked. + * + * `data` is additional data, it has no specified format and it is sent in call to `to`. + * + * This internal function is like {safeTransferFrom} in the sense that it invokes + * {IERC721Receiver-onERC721Received} on the receiver, and can be used to e.g. + * implement alternative mechanisms to perform token transfer, such as signature-based. + * + * Requirements: + * + * - `tokenId` token must exist and be owned by `from`. + * - `to` cannot be the zero address. + * - `from` cannot be the zero address. + * - If `to` refers to a smart contract, it must implement {IERC721Receiver-onERC721Received}, which is called upon a safe transfer. + * + * Emits a {Transfer} event. + */ + function _safeTransfer(address from, address to, uint256 tokenId) internal { + _safeTransfer(from, to, tokenId, ""); + } + + /** + * @dev Same as {xref-ERC721-_safeTransfer-address-address-uint256-}[`_safeTransfer`], with an additional `data` parameter which is + * forwarded in {IERC721Receiver-onERC721Received} to contract recipients. + */ + function _safeTransfer(address from, address to, uint256 tokenId, bytes memory data) internal virtual { + _transfer(from, to, tokenId); + ERC721Utils.checkOnERC721Received(_msgSender(), from, to, tokenId, data); + } + + /** + * @dev Approve `to` to operate on `tokenId` + * + * The `auth` argument is optional. If the value passed is non 0, then this function will check that `auth` is + * either the owner of the token, or approved to operate on all tokens held by this owner. + * + * Emits an {Approval} event. + * + * Overrides to this logic should be done to the variant with an additional `bool emitEvent` argument. + */ + function _approve(address to, uint256 tokenId, address auth) internal { + _approve(to, tokenId, auth, true); + } + + /** + * @dev Variant of `_approve` with an optional flag to enable or disable the {Approval} event. The event is not + * emitted in the context of transfers. + */ + function _approve(address to, uint256 tokenId, address auth, bool emitEvent) internal virtual { + // Avoid reading the owner unless necessary + if (emitEvent || auth != address(0)) { + address owner = _requireOwned(tokenId); + + // We do not use _isAuthorized because single-token approvals should not be able to call approve + if (auth != address(0) && owner != auth && !isApprovedForAll(owner, auth)) { + revert ERC721InvalidApprover(auth); + } + + if (emitEvent) { + emit Approval(owner, to, tokenId); + } + } + + _tokenApprovals[tokenId] = to; + } + + /** + * @dev Approve `operator` to operate on all of `owner` tokens + * + * Requirements: + * - operator can't be the address zero. + * + * Emits an {ApprovalForAll} event. + */ + function _setApprovalForAll(address owner, address operator, bool approved) internal virtual { + if (operator == address(0)) { + revert ERC721InvalidOperator(operator); + } + _operatorApprovals[owner][operator] = approved; + emit ApprovalForAll(owner, operator, approved); + } + + /** + * @dev Reverts if the `tokenId` doesn't have a current owner (it hasn't been minted, or it has been burned). + * Returns the owner. + * + * Overrides to ownership logic should be done to {_ownerOf}. + */ + function _requireOwned(uint256 tokenId) internal view returns (address) { + address owner = _ownerOf(tokenId); + if (owner == address(0)) { + revert ERC721NonexistentToken(tokenId); + } + return owner; + } +} + +// File: @openzeppelin/contracts@5.4.0/token/ERC721/extensions/IERC721Enumerable.sol + + +// OpenZeppelin Contracts (last updated v5.4.0) (token/ERC721/extensions/IERC721Enumerable.sol) + +pragma solidity >=0.6.2; + + +/** + * @title ERC-721 Non-Fungible Token Standard, optional enumeration extension + * @dev See https://eips.ethereum.org/EIPS/eip-721 + */ +interface IERC721Enumerable is IERC721 { + /** + * @dev Returns the total amount of tokens stored by the contract. + */ + function totalSupply() external view returns (uint256); + + /** + * @dev Returns a token ID owned by `owner` at a given `index` of its token list. + * Use along with {balanceOf} to enumerate all of ``owner``'s tokens. + */ + function tokenOfOwnerByIndex(address owner, uint256 index) external view returns (uint256); + + /** + * @dev Returns a token ID at a given `index` of all the tokens stored by the contract. + * Use along with {totalSupply} to enumerate all tokens. + */ + function tokenByIndex(uint256 index) external view returns (uint256); +} + +// File: @openzeppelin/contracts@5.4.0/token/ERC721/extensions/ERC721Enumerable.sol + + +// OpenZeppelin Contracts (last updated v5.4.0) (token/ERC721/extensions/ERC721Enumerable.sol) + +pragma solidity ^0.8.20; + + + + +/** + * @dev This implements an optional extension of {ERC721} defined in the ERC that adds enumerability + * of all the token ids in the contract as well as all token ids owned by each account. + * + * CAUTION: {ERC721} extensions that implement custom `balanceOf` logic, such as {ERC721Consecutive}, + * interfere with enumerability and should not be used together with {ERC721Enumerable}. + */ +abstract contract ERC721Enumerable is ERC721, IERC721Enumerable { + mapping(address owner => mapping(uint256 index => uint256)) private _ownedTokens; + mapping(uint256 tokenId => uint256) private _ownedTokensIndex; + + uint256[] private _allTokens; + mapping(uint256 tokenId => uint256) private _allTokensIndex; + + /** + * @dev An `owner`'s token query was out of bounds for `index`. + * + * NOTE: The owner being `address(0)` indicates a global out of bounds index. + */ + error ERC721OutOfBoundsIndex(address owner, uint256 index); + + /** + * @dev Batch mint is not allowed. + */ + error ERC721EnumerableForbiddenBatchMint(); + + /// @inheritdoc IERC165 + function supportsInterface(bytes4 interfaceId) public view virtual override(IERC165, ERC721) returns (bool) { + return interfaceId == type(IERC721Enumerable).interfaceId || super.supportsInterface(interfaceId); + } + + /// @inheritdoc IERC721Enumerable + function tokenOfOwnerByIndex(address owner, uint256 index) public view virtual returns (uint256) { + if (index >= balanceOf(owner)) { + revert ERC721OutOfBoundsIndex(owner, index); + } + return _ownedTokens[owner][index]; + } + + /// @inheritdoc IERC721Enumerable + function totalSupply() public view virtual returns (uint256) { + return _allTokens.length; + } + + /// @inheritdoc IERC721Enumerable + function tokenByIndex(uint256 index) public view virtual returns (uint256) { + if (index >= totalSupply()) { + revert ERC721OutOfBoundsIndex(address(0), index); + } + return _allTokens[index]; + } + + /// @inheritdoc ERC721 + function _update(address to, uint256 tokenId, address auth) internal virtual override returns (address) { + address previousOwner = super._update(to, tokenId, auth); + + if (previousOwner == address(0)) { + _addTokenToAllTokensEnumeration(tokenId); + } else if (previousOwner != to) { + _removeTokenFromOwnerEnumeration(previousOwner, tokenId); + } + if (to == address(0)) { + _removeTokenFromAllTokensEnumeration(tokenId); + } else if (previousOwner != to) { + _addTokenToOwnerEnumeration(to, tokenId); + } + + return previousOwner; + } + + /** + * @dev Private function to add a token to this extension's ownership-tracking data structures. + * @param to address representing the new owner of the given token ID + * @param tokenId uint256 ID of the token to be added to the tokens list of the given address + */ + function _addTokenToOwnerEnumeration(address to, uint256 tokenId) private { + uint256 length = balanceOf(to) - 1; + _ownedTokens[to][length] = tokenId; + _ownedTokensIndex[tokenId] = length; + } + + /** + * @dev Private function to add a token to this extension's token tracking data structures. + * @param tokenId uint256 ID of the token to be added to the tokens list + */ + function _addTokenToAllTokensEnumeration(uint256 tokenId) private { + _allTokensIndex[tokenId] = _allTokens.length; + _allTokens.push(tokenId); + } + + /** + * @dev Private function to remove a token from this extension's ownership-tracking data structures. Note that + * while the token is not assigned a new owner, the `_ownedTokensIndex` mapping is _not_ updated: this allows for + * gas optimizations e.g. when performing a transfer operation (avoiding double writes). + * This has O(1) time complexity, but alters the order of the _ownedTokens array. + * @param from address representing the previous owner of the given token ID + * @param tokenId uint256 ID of the token to be removed from the tokens list of the given address + */ + function _removeTokenFromOwnerEnumeration(address from, uint256 tokenId) private { + // To prevent a gap in from's tokens array, we store the last token in the index of the token to delete, and + // then delete the last slot (swap and pop). + + uint256 lastTokenIndex = balanceOf(from); + uint256 tokenIndex = _ownedTokensIndex[tokenId]; + + mapping(uint256 index => uint256) storage _ownedTokensByOwner = _ownedTokens[from]; + + // When the token to delete is the last token, the swap operation is unnecessary + if (tokenIndex != lastTokenIndex) { + uint256 lastTokenId = _ownedTokensByOwner[lastTokenIndex]; + + _ownedTokensByOwner[tokenIndex] = lastTokenId; // Move the last token to the slot of the to-delete token + _ownedTokensIndex[lastTokenId] = tokenIndex; // Update the moved token's index + } + + // This also deletes the contents at the last position of the array + delete _ownedTokensIndex[tokenId]; + delete _ownedTokensByOwner[lastTokenIndex]; + } + + /** + * @dev Private function to remove a token from this extension's token tracking data structures. + * This has O(1) time complexity, but alters the order of the _allTokens array. + * @param tokenId uint256 ID of the token to be removed from the tokens list + */ + function _removeTokenFromAllTokensEnumeration(uint256 tokenId) private { + // To prevent a gap in the tokens array, we store the last token in the index of the token to delete, and + // then delete the last slot (swap and pop). + + uint256 lastTokenIndex = _allTokens.length - 1; + uint256 tokenIndex = _allTokensIndex[tokenId]; + + // When the token to delete is the last token, the swap operation is unnecessary. However, since this occurs so + // rarely (when the last minted token is burnt) that we still do the swap here to avoid the gas cost of adding + // an 'if' statement (like in _removeTokenFromOwnerEnumeration) + uint256 lastTokenId = _allTokens[lastTokenIndex]; + + _allTokens[tokenIndex] = lastTokenId; // Move the last token to the slot of the to-delete token + _allTokensIndex[lastTokenId] = tokenIndex; // Update the moved token's index + + // This also deletes the contents at the last position of the array + delete _allTokensIndex[tokenId]; + _allTokens.pop(); + } + + /** + * See {ERC721-_increaseBalance}. We need that to account tokens that were minted in batch + */ + function _increaseBalance(address account, uint128 amount) internal virtual override { + if (amount > 0) { + revert ERC721EnumerableForbiddenBatchMint(); + } + super._increaseBalance(account, amount); + } +} + +// File: @openzeppelin/contracts@5.4.0/access/Ownable.sol + + +// OpenZeppelin Contracts (last updated v5.0.0) (access/Ownable.sol) + +pragma solidity ^0.8.20; + + +/** + * @dev Contract module which provides a basic access control mechanism, where + * there is an account (an owner) that can be granted exclusive access to + * specific functions. + * + * The initial owner is set to the address provided by the deployer. This can + * later be changed with {transferOwnership}. + * + * This module is used through inheritance. It will make available the modifier + * `onlyOwner`, which can be applied to your functions to restrict their use to + * the owner. + */ +abstract contract Ownable is Context { + address private _owner; + + /** + * @dev The caller account is not authorized to perform an operation. + */ + error OwnableUnauthorizedAccount(address account); + + /** + * @dev The owner is not a valid owner account. (eg. `address(0)`) + */ + error OwnableInvalidOwner(address owner); + + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + + /** + * @dev Initializes the contract setting the address provided by the deployer as the initial owner. + */ + constructor(address initialOwner) { + if (initialOwner == address(0)) { + revert OwnableInvalidOwner(address(0)); + } + _transferOwnership(initialOwner); + } + + /** + * @dev Throws if called by any account other than the owner. + */ + modifier onlyOwner() { + _checkOwner(); + _; + } + + /** + * @dev Returns the address of the current owner. + */ + function owner() public view virtual returns (address) { + return _owner; + } + + /** + * @dev Throws if the sender is not the owner. + */ + function _checkOwner() internal view virtual { + if (owner() != _msgSender()) { + revert OwnableUnauthorizedAccount(_msgSender()); + } + } + + /** + * @dev Leaves the contract without owner. It will not be possible to call + * `onlyOwner` functions. Can only be called by the current owner. + * + * NOTE: Renouncing ownership will leave the contract without an owner, + * thereby disabling any functionality that is only available to the owner. + */ + function renounceOwnership() public virtual onlyOwner { + _transferOwnership(address(0)); + } + + /** + * @dev Transfers ownership of the contract to a new account (`newOwner`). + * Can only be called by the current owner. + */ + function transferOwnership(address newOwner) public virtual onlyOwner { + if (newOwner == address(0)) { + revert OwnableInvalidOwner(address(0)); + } + _transferOwnership(newOwner); + } + + /** + * @dev Transfers ownership of the contract to a new account (`newOwner`). + * Internal function without access restriction. + */ + function _transferOwnership(address newOwner) internal virtual { + address oldOwner = _owner; + _owner = newOwner; + emit OwnershipTransferred(oldOwner, newOwner); + } +} + +// File: contracts/NFTNumbered.sol + + +pragma solidity ^0.8.27; + + + + +/// @title NFTNumbered +/// @notice ERC721 contract with sequential variants for immutable metadata for minted tokens, allowing to change metadata for future tokens. +/// @dev Non-upgradeable. Minter address is settable by owner. Global sequential token ID. +contract NFTNumbered is ERC721Enumerable, Ownable { + address public minter; + bool public mintingLocked; + TokenInfo[] private tokenInfos; + uint public nextTokenId = 1; + + struct TokenInfo { + uint tokenId; + string tokenUri; + } + + constructor( + string memory _name, + string memory _symbol, + string memory _tokenUri + ) ERC721(_name, _symbol) Ownable(msg.sender) { + minter = msg.sender; + mintingLocked = false; + setNextTokenURI(_tokenUri); + } + + event MinterUpdated(address indexed newMinter); + event MintingLocked(); + event NextTokenURI(uint nextTokenId, string nextTokenUri); + + /// @notice Updates the minter address. + function setMinter(address newMinter) external onlyOwner whenNotLocked { + minter = newMinter; + emit MinterUpdated(newMinter); + } + + /// @notice Permanently disable minting and changes. + function lockMintingPermanently() external onlyOwner { + mintingLocked = true; + emit MintingLocked(); + } + + modifier whenNotLocked() { + require(!mintingLocked, "Contract permanently locked for changes and minting"); + _; + } + + /// @notice Sets tokenUri for future mints only. + function setNextTokenURI(string memory newTokenUri) public onlyOwner whenNotLocked { + uint len = tokenInfos.length; + TokenInfo memory newInfo = TokenInfo({tokenId: nextTokenId, tokenUri: newTokenUri}); + if (len == 0 || tokenInfos[len - 1].tokenId < nextTokenId) { // add + tokenInfos.push(newInfo); + } else { // replace + tokenInfos[len - 1] = newInfo; + } + emit NextTokenURI(nextTokenId, newTokenUri); + } + + /// @notice metadata URI of the token that will be minted next + function nextTokenURI() external view returns (string memory) { + return tokenInfos[tokenInfos.length - 1].tokenUri; + } + + /// @notice Mints a new token. + /// @param to The recipient of the token. + function mint(address to) external whenNotLocked { + require(msg.sender == owner() || msg.sender == minter, "Caller must be the owner or minter"); + require(to != address(0), "Cannot mint to zero address"); + uint tokenId = nextTokenId++; + _safeMint(to, tokenId); + } + + /// @notice Burn owned token. + function burn(uint tokenId) external { + _burn(tokenId); + } + + /// @notice prohibit approvals + // address to, uint256 tokenId + function approve(address, uint256) public virtual override (ERC721, IERC721) { + revert("Soulbound token: approvals prohibited"); + } + + /// @notice prohibit approvals + // address operator, bool approved + function setApprovalForAll(address, bool) public virtual override (ERC721, IERC721) { + revert("Soulbound token: approvals prohibited"); + } + + /// @notice Limits ownership to 1 token. + function _update(address to, uint tokenId, address auth) internal virtual override returns (address) { + require(to == address(0) || balanceOf(to) == 0, "Soulbound token: only 1 per address"); + require(to == address(0) || _ownerOf(tokenId) == address(0), "Soulbound token: transfers prohibited"); + return super._update(to, tokenId, auth); + } + + /// @notice Returns embedded JSON metadata URI. + /// @param id Token ID. + function tokenURI(uint id) public view virtual override returns (string memory) { + _requireOwned(id); + uint len = tokenInfos.length; + for (uint i = len; i > 0; ) { + unchecked { i--; } + if (tokenInfos[i].tokenId > id) continue; + return tokenInfos[i].tokenUri; + } + revert("Unknown token ID"); + } + + /// @notice Withdraw any accidental ETH. + function withdraw() external onlyOwner { + (bool success, ) = payable(owner()).call{value: address(this).balance}(""); + require(success, "Withdraw failed"); + } +} diff --git a/eth/nft/scripts/deploy_with_ethers.ts b/eth/nft/scripts/deploy_with_ethers.ts new file mode 100644 index 0000000000..63533fd6f5 --- /dev/null +++ b/eth/nft/scripts/deploy_with_ethers.ts @@ -0,0 +1,10 @@ +import { deploy } from './ethers-lib' + +(async () => { + try { + const result = await deploy('MyToken', []) + console.log(`address: ${result.address}`) + } catch (e) { + console.log(e.message) + } +})() \ No newline at end of file diff --git a/eth/nft/tests/MultiERC1155_test.sol b/eth/nft/tests/MultiERC1155_test.sol new file mode 100644 index 0000000000..f0152ad657 --- /dev/null +++ b/eth/nft/tests/MultiERC1155_test.sol @@ -0,0 +1,336 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import "remix_tests.sol"; + +import "../contracts/MultiERC1155.sol"; + +contract MultiERC1155Test { + MultiERC1155 public ct; + address public owner = address(this); + address public admin = address(0x1); + address public minter = address(0x2); + address public user = address(0x3); + address public recipient = address(0x4); + + MultiERC1155.TokenInfo defaultInfo = MultiERC1155.TokenInfo({ + tokenUri: "https://example.com/token.json", + totalSupply: 0, + enabled: true + }); + + function beforeAll() public { + ct = new MultiERC1155(); + MultiERC1155.TokenInfo memory newInfo = defaultInfo; + (bool success, ) = address(ct).call(abi.encodeWithSignature("addToken((string,bool,string,string,string,uint256))", newInfo)); + Assert.equal(success, true, "addToken success"); + } + + // Constructor Test + function testConstructor() public { + Assert.equal(ct.owner(), owner, "Owner should be deployer"); + Assert.equal(ct.admin(), owner, "Admin should be deployer"); + Assert.equal(ct.minter(), owner, "Minter should be deployer"); + Assert.equal(ct.mintingEnabled(), true, "Minting should be enabled"); + Assert.equal(ct.contractLocked(), false, "Contract should not be locked"); + uint[] memory ids = ct.getTokenIds(); + Assert.equal(ids.length, uint(1), "One token ID added"); + Assert.equal(ids[0], uint(1), "First token ID is 1"); + (, uint currentSupply, bool exists, bool locked) = ct.tokens(1); + Assert.equal(exists, true, "Token 1 exists"); + Assert.equal(locked, false, "Token 1 not locked"); + Assert.equal(currentSupply, uint(0), "Token 1 supply 0"); + } + + // setAdmin Test + function testSetAdmin() public { + ct.setAdmin(admin); + Assert.equal(ct.admin(), admin, "Admin updated"); + } + + function testSetAdminOnlyOwner() public { + (bool success, ) = address(ct).call(abi.encodeWithSignature("setAdmin(address)", admin)); + Assert.equal(success, true, "Set admin from owner succeeds"); // Since this is owner + + // To test revert, Remix plugin runs as this, so for non-owner, manual simulation not easy; note as TODO or skip + } + + // setMinter Test + function testSetMinter() public { + ct.setMinter(minter); + Assert.equal(ct.minter(), minter, "Minter updated"); + } + + function testSetMinterOnlyOwner() public { + (bool success, ) = address(ct).call(abi.encodeWithSignature("setMinter(address)", minter)); + Assert.equal(success, true, "Set minter from owner succeeds"); + } + + // toggleMinting Test + function testToggleMinting() public { + ct.toggleMinting(false); + Assert.equal(ct.mintingEnabled(), false, "Minting disabled"); + + ct.toggleMinting(true); + Assert.equal(ct.mintingEnabled(), true, "Minting enabled"); + } + + function testToggleMintingNoChange() public { + ct.toggleMinting(true); // Already true + Assert.equal(ct.mintingEnabled(), true, "No change if same"); + } + + function testToggleMintingOnlyOwner() public { + (bool success, ) = address(ct).call(abi.encodeWithSignature("toggleMinting(bool)", false)); + Assert.equal(success, true, "Toggle from owner succeeds"); + } + + // lockContract Test + function testLockContract() public { + ct.lockContract(); + Assert.equal(ct.contractLocked(), true, "Contract locked"); + Assert.equal(ct.mintingEnabled(), false, "Minting disabled"); + } + + function testLockContractOnlyOwner() public { + (bool success, ) = address(ct).call(abi.encodeWithSignature("lockContract()")); + Assert.equal(success, true, "Lock from owner succeeds"); + } + + // addToken Test + function testAddToken() public { + MultiERC1155.TokenInfo memory newInfo = MultiERC1155.TokenInfo({ + tokenUri: "https://example.com/new_token.json", + totalSupply: 100, + enabled: true + }); + + ct.addToken(newInfo); + uint[] memory ids = ct.getTokenIds(); + Assert.equal(ids.length, uint(2), "Two token IDs"); + Assert.equal(ids[1], uint(2), "Second token ID 2"); + (MultiERC1155.TokenInfo memory tokenInfo, uint currentSupply, bool exists, bool locked) = ct.tokens(2); + Assert.equal(exists, true, "Token 2 exists"); + Assert.equal(locked, false, "Token 2 not locked"); + Assert.equal(currentSupply, uint(0), "Token 2 supply 0"); + Assert.equal(tokenInfo.totalSupply, uint(100), "Token 2 totalSupply 100"); + // Validate info fields as needed + } + + function testAddTokenByAdmin() public { + ct.setAdmin(admin); + MultiERC1155.TokenInfo memory newInfo = defaultInfo; + (bool success, ) = address(ct).call(abi.encodeWithSignature("addToken((string,bool,string,string,string,uint256))", newInfo)); + Assert.equal(success, true, "Add by admin succeeds"); + uint[] memory ids = ct.getTokenIds(); + Assert.equal(ids.length, uint(2), "Two token IDs"); + } + + function testAddTokenRevertInvalidInfo() public { + MultiERC1155.TokenInfo memory invalidInfo = MultiERC1155.TokenInfo({ + tokenUri: "", + totalSupply: 0, + enabled: true + }); + (bool success, ) = address(ct).call(abi.encodeWithSignature("addToken((string,bool,string,string,string,uint256))", invalidInfo)); + Assert.equal(success, false, "Invalid info reverts"); + } + + function testAddTokenOnlyAdminOrOwner() public { + MultiERC1155.TokenInfo memory info = defaultInfo; + (bool success, ) = address(ct).call(abi.encodeWithSignature("addToken((string,bool,string,string,string,uint256))", info)); + Assert.equal(success, true, "Add from owner succeeds"); + } + + // removeToken Test + function testRemoveToken() public { + MultiERC1155.TokenInfo memory newInfo = defaultInfo; + ct.addToken(newInfo); + uint[] memory ids = ct.getTokenIds(); + Assert.equal(ids.length, uint(2), "Two token IDs"); + + ct.removeToken(2); + ids = ct.getTokenIds(); + Assert.equal(ids.length, uint(1), "One token ID after removal"); + (,,bool exists,) = ct.tokens(2); + Assert.equal(exists, false, "Token 2 removed"); + } + + function testRemoveTokenRevertNonExist() public { + (bool success, ) = address(ct).call(abi.encodeWithSignature("removeToken(uint256)", 99)); + Assert.equal(success, false, "Remove non-exist reverts"); + } + + function testRemoveTokenRevertHasSupply() public { + ct.mint(user, 1, 10, ""); + (bool success,) = address(ct).call(abi.encodeWithSignature("removeToken(uint256)", 1)); + Assert.equal(success, false, "Remove with supply reverts"); + } + + function testRemoveTokenRevertLast() public { + (bool success, ) = address(ct).call(abi.encodeWithSignature("removeToken(uint256)", 1)); + Assert.equal(success, false, "Remove last reverts"); + } + + function testRemoveTokenOnlyAdminOrOwner() public { + (bool success, ) = address(ct).call(abi.encodeWithSignature("removeToken(uint256)", 1)); + Assert.equal(success, false, "Remove last fails"); // But for non-owner, need simulation + } + + // updateToken Test + function testUpdateToken() public { + ct.updateToken(1, false, 100); + (MultiERC1155.TokenInfo memory tokenInfo,,,) = ct.tokens(1); + Assert.equal(tokenInfo.enabled, false, "Enabled updated"); + Assert.equal(tokenInfo.totalSupply, uint(100), "Total supply updated"); + } + + function testUpdateTokenNoChange() public { + (MultiERC1155.TokenInfo memory tokenInfo,,,) = ct.tokens(1); + ct.updateToken(1, tokenInfo.enabled, tokenInfo.totalSupply); + // No assert, as no change + } + + function testUpdateTokenRevertNonExist() public { + (bool success, ) = address(ct).call(abi.encodeWithSignature("updateToken(uint256,bool,uint256)", 99, true, 0)); + Assert.equal(success, false, "Update non-exist reverts"); + } + + function testUpdateTokenRevertLocked() public { + ct.mint(user, 1, 10, ""); + ct.lockToken(1); + (bool success, ) = address(ct).call(abi.encodeWithSignature("updateToken(uint256,bool,uint256)", 1, true, 0)); + Assert.equal(success, false, "Update locked reverts"); + } + + function testUpdateTokenRevertInvalidSupply() public { + ct.mint(user, 1, 10, ""); + (bool success, ) = address(ct).call(abi.encodeWithSignature("updateToken(uint256,bool,uint256)", 1, true, 5)); + Assert.equal(success, false, "Invalid supply reverts"); + } + + function testUpdateTokenOnlyAdminOrOwner() public { + (bool success, ) = address(ct).call(abi.encodeWithSignature("updateToken(uint256,bool,uint256)", 1, true, 0)); + Assert.equal(success, true, "Update from owner succeeds"); + } + + // lockToken Test + function testLockToken() public { + ct.mint(user, 1, 10, ""); + ct.lockToken(1); + (MultiERC1155.TokenInfo memory tokenInfo,,, bool locked) = ct.tokens(1); + Assert.equal(locked, true, "Token locked"); + Assert.equal(tokenInfo.enabled, false, "Enabled false"); + } + + function testLockTokenRevertNonExist() public { + (bool success, ) = address(ct).call(abi.encodeWithSignature("lockToken(uint256)", 99)); + Assert.equal(success, false, "Lock non-exist reverts"); + } + + function testLockTokenRevertAlreadyLocked() public { + ct.mint(user, 1, 10, ""); + ct.lockToken(1); + (bool success, ) = address(ct).call(abi.encodeWithSignature("lockToken(uint256)", 1)); + Assert.equal(success, false, "Lock already locked reverts"); + } + + function testLockTokenRevertNoSupply() public { + (bool success, ) = address(ct).call(abi.encodeWithSignature("lockToken(uint256)", 1)); + Assert.equal(success, false, "Lock no supply reverts"); + } + + function testLockTokenOnlyOwner() public { + (bool success, ) = address(ct).call(abi.encodeWithSignature("lockToken(uint256)", 1)); + Assert.equal(success, false, "Lock no supply fails"); // Test with supply + } + + // mint Test + function testMint() public { + ct.mint(user, 1, 10, ""); + Assert.equal(ct.balanceOf(user, 1), 10, "User balance 10"); + (,uint currentSupply,,) = ct.tokens(1); + Assert.equal(currentSupply, 10, "Token supply 10"); + } + + function testMintRevertLockedContract() public { + ct.lockContract(); + (bool success, ) = address(ct).call(abi.encodeWithSignature("mint(address,uint256,uint256,bytes)", user, 1, 10, "")); + Assert.equal(success, false, "Mint locked contract reverts"); + } + + function testMintRevertMintingDisabled() public { + ct.toggleMinting(false); + (bool success, ) = address(ct).call(abi.encodeWithSignature("mint(address,uint256,uint256,bytes)", user, 1, 10, "")); + Assert.equal(success, false, "Mint disabled reverts"); + } + + function testMintRevertInvalidCaller() public { + (bool success, ) = address(ct).call(abi.encodeWithSignature("mint(address,uint256,uint256,bytes)", user, 1, 10, "")); + Assert.equal(success, true, "Mint from owner succeeds"); + } + + function testMintRevertNonExist() public { + (bool success, ) = address(ct).call(abi.encodeWithSignature("mint(address,uint256,uint256,bytes)", user, 99, 10, "")); + Assert.equal(success, false, "Mint non-exist reverts"); + } + + function testMintRevertLockedToken() public { + ct.mint(user, 1, 10, ""); + ct.lockToken(1); + (bool success, ) = address(ct).call(abi.encodeWithSignature("mint(address,uint256,uint256,bytes)", user, 1, 5, "")); + Assert.equal(success, false, "Mint locked token reverts"); + } + + function testMintRevertDisabledToken() public { + ct.updateToken(1, false, 0); + (bool success, ) = address(ct).call(abi.encodeWithSignature("mint(address,uint256,uint256,bytes)", user, 1, 10, "")); + Assert.equal(success, false, "Mint disabled token reverts"); + } + + function testMintRevertSupplyExceeded() public { + ct.updateToken(1, true, 5); + (bool success, ) = address(ct).call(abi.encodeWithSignature("mint(address,uint256,uint256,bytes)", user, 1, 10, "")); + Assert.equal(success, false, "Mint supply exceeded reverts"); + } + + function testMintRevertZeroAmount() public { + (bool success, ) = address(ct).call(abi.encodeWithSignature("mint(address,uint256,uint256,bytes)", user, 1, 0, "")); + Assert.equal(success, false, "Mint zero amount reverts"); + } + + // _update Test (via transfer/burn) + function testUpdateMint() public { + ct.mint(user, 1, 10, ""); + (,uint currentSupply,,) = ct.tokens(1); + Assert.equal(currentSupply, 10, "Supply after mint"); + } + + function testUpdateBurn() public { + ct.mint(user, 1, 10, ""); + (bool success, ) = address(ct).call(abi.encodeWithSignature("safeTransferFrom(address,address,uint256,uint256,bytes)", user, address(0), 1, 5, "")); + Assert.equal(success, true, "Burn succeeds"); + (,uint currentSupply,,) = ct.tokens(1); + Assert.equal(currentSupply, 5, "Supply after burn"); + } + + function testUpdateTransfer() public { + ct.mint(user, 1, 10, ""); + (bool success, ) = address(ct).call(abi.encodeWithSignature("safeTransferFrom(address,address,uint256,uint256,bytes)", user, recipient, 1, 5, "")); + Assert.equal(success, true, "Transfer succeeds"); + (,uint currentSupply,,) = ct.tokens(1); + Assert.equal(currentSupply, 10, "Supply unchanged on transfer"); + } + + // uri Test + function testUri() public { + string memory json = Base64.encode(bytes('{"name":"Test Token","description":"Test Description","image":"https://test.com/image.png","properties":{}}')); + string memory expected = string.concat("data:application/json;base64,", json); + Assert.equal(ct.uri(1), expected, "URI matches"); + } + + function testUriRevertNonExist() public { + (bool success, ) = address(ct).call(abi.encodeWithSignature("uri(uint256)", 99)); + Assert.equal(success, false, "URI non-exist reverts"); + } +} diff --git a/eth/nft/tests/NFTMinter_test.sol b/eth/nft/tests/NFTMinter_test.sol new file mode 100644 index 0000000000..c6393f5f2b --- /dev/null +++ b/eth/nft/tests/NFTMinter_test.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity >=0.4.22 <0.9.0; + +// This import is automatically injected by Remix +import "remix_tests.sol"; + +// This import is required to use custom transaction context +// Although it may fail compilation in 'Solidity Compiler' plugin +// But it will work fine in 'Solidity Unit Testing' plugin +import "remix_accounts.sol"; +import "../contracts/NFTMinter.sol"; +import "../contracts/NFTNumbered.sol"; + +// File name has to end with '_test.sol', this file can contain more than one testSuite contracts +contract NFTMinterTest { + NFTNumbered s; + NFTMinter m; + address public owner = address(this); + + function beforeAll() public { + s = new NFTNumbered( + "SimpleX NFT: SMPX testnet access", + "SIMPLEXNFT", + "https://ipfs.io/ipfs/abcd" + ); + m = new NFTMinter(address(s), 0, 0, false); + } + + function testCreateMinter() public { + Assert.equal(address(m.nft()), address(s), "bad nft contract"); + Assert.equal(m.mintEndTime(), 0, "bad time"); + Assert.equal(m.owner(), owner, "bad owner"); + } + + function testMinting() public { + m.setMintStartTime(block.timestamp + 86400); + try m.mint() { + Assert.ok(false, "expected revert"); + } catch Error(string memory reason) { + Assert.equal(reason, "Minting not started", "bad reason"); + } catch (bytes memory) { + Assert.ok(false, "unexpected error"); + } + m.setMintStartTime(0); + } +} diff --git a/eth/nft/tests/NFTNumbered_test.sol b/eth/nft/tests/NFTNumbered_test.sol new file mode 100644 index 0000000000..8c2f8fb51f --- /dev/null +++ b/eth/nft/tests/NFTNumbered_test.sol @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity >=0.7.0 <0.9.0; +import "remix_tests.sol"; +import "remix_accounts.sol"; +import "../contracts/NFTNumbered.sol"; + +contract NFTNumberedTest { + NFTNumbered s; + address public owner = address(this); + address public user1 = address(0x1); + address public user2 = address(0x2); + address public user3 = address(0x3); + address public user4 = TestsAccounts.getAccount(1); + address public minter = address(0x5); + + function beforeAll () public { + s = new NFTNumbered( + "SimpleX NFT: SMPX testnet access", + "SIMPLEXNFT", + "https://ipfs.io/ipfs/abcd" + ); + } + + function testCreateToken () public { + Assert.equal(s.name(), "SimpleX NFT: SMPX testnet access", "bad name"); + Assert.equal(s.symbol(), "SIMPLEXNFT", "bad symbol"); + Assert.equal(s.nextTokenId(), 1, "bad next token ID"); + Assert.equal(s.minter(), s.owner(), "minter different from owner"); + Assert.equal(s.mintingLocked(), false, "minting locked"); + Assert.equal(s.owner(), owner, "bad owner"); + } + + function testTransferToken() public { + Assert.equal(s.balanceOf(user3), 0, "bad balance"); + s.mint(user4); + Assert.equal(s.balanceOf(user4), 1, "bad balance"); + /// #sender: account-1 + // try s.safeTransferFrom(user4, user3, 1) { + // Assert.ok(false, "expected revert"); + // } catch Error(string memory reason) { + // Assert.equal(reason, "Token is soulbound: transfers are prohibited", "bad reason"); + // } catch (bytes memory data) { + // Assert.ok(false, "unexpected error 2"); + // } + } + + function testMinting () public { + s.mint(user1); + Assert.equal(s.balanceOf(user1), 1, "bad balance"); + Assert.equal(s.tokenURI(2), "https://ipfs.io/ipfs/abcd", "bad URI"); + try s.mint(user1) { + Assert.ok(false, "expected revert"); + } catch Error(string memory reason) { + Assert.equal(reason, "Soulbound token: only 1 per address", "bad reason"); + } catch (bytes memory) { + Assert.ok(false, "unexpected error"); + } + Assert.equal(s.nextTokenId(), 3, "bad next token ID"); + s.mint(user2); + Assert.equal(s.balanceOf(user2), 1, "bad balance"); + Assert.equal(s.tokenURI(3), "https://ipfs.io/ipfs/abcd", "bad URI"); + Assert.equal(s.nextTokenURI(), "https://ipfs.io/ipfs/abcd", "bad URI"); + s.setNextTokenURI("https://ipfs.io/ipfs/efgh"); + Assert.equal(s.nextTokenURI(), "https://ipfs.io/ipfs/efgh", "bad URI"); + Assert.equal(s.balanceOf(user3), 0, "bad balance"); + s.mint(user3); + Assert.equal(s.balanceOf(user3), 1, "bad balance"); + Assert.equal(s.tokenURI(4), "https://ipfs.io/ipfs/efgh", "bad URI"); + } + + function testMintingLock () public { + s.lockMintingPermanently(); + try s.mint(user2) { + Assert.ok(false, "expected revert"); + } catch Error(string memory reason) { + Assert.equal(reason, "Contract permanently locked for changes and minting", "bad reason"); + } catch (bytes memory) { + Assert.ok(false, "unexpected error"); + } + } +} diff --git a/flake.nix b/flake.nix index 1520fb2ee3..a68b42e2f1 100644 --- a/flake.nix +++ b/flake.nix @@ -309,6 +309,7 @@ aarch64-unknown-linux-android-ghc -shared -o libsupport.so \ -optl-Wl,-u,setLineBuffering \ -optl-Wl,-u,pipe_std_to_socket \ + -optl-Wl,-z,max-page-size=16384 \ dist/build/*.a ''; @@ -475,6 +476,7 @@ "-threaded" # "-debug" "-optl-lffi" + "-optl-Wl,-z,max-page-size=16384" ] # This is fairly idiotic. LLD will strip out foreign exported # symbols (a GHC bug? Codegen bug?). So we need to pass `-u ` diff --git a/install.sh b/install.sh index 68acbccb14..237fbb666c 100755 --- a/install.sh +++ b/install.sh @@ -20,7 +20,7 @@ fi if [ $PLATFORM == "Darwin" ]; then PLATFORM="macos-x86-64" elif [ $PLATFORM == "Linux" ]; then - PLATFORM="ubuntu-22_04-x86-64" + PLATFORM="ubuntu-22_04-x86_64" else echo "Scripted installation on your platform is not supported." echo "See compiled binaries in the ${1:-latest} release: https://github.com/$APP_NAME/$APP_NAME/releases/$RELEASE" diff --git a/packages/simplex-chat-client/types/typescript/.gitignore b/packages/simplex-chat-client/types/typescript/.gitignore new file mode 100644 index 0000000000..7481e30d5a --- /dev/null +++ b/packages/simplex-chat-client/types/typescript/.gitignore @@ -0,0 +1,4 @@ +node_modules +package-lock.json +dist +coverage diff --git a/packages/simplex-chat-client/types/typescript/LICENSE b/packages/simplex-chat-client/types/typescript/LICENSE new file mode 100644 index 0000000000..0ad25db4bd --- /dev/null +++ b/packages/simplex-chat-client/types/typescript/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/packages/simplex-chat-client/types/typescript/README.md b/packages/simplex-chat-client/types/typescript/README.md new file mode 100644 index 0000000000..ad13a5d76e --- /dev/null +++ b/packages/simplex-chat-client/types/typescript/README.md @@ -0,0 +1,11 @@ +# SimpleX Chat bots API: typeScript types + +This TypeScript library provides auto-generated types for bots API: commands and responses, events and all types they use. + +It is used in [simplex-chat](https://www.npmjs.com/package/simplex-chat) library that uses WebSockets interface of SimpleX Chat CLI. + +[API reference](https://github.com/simplex-chat/simplex-chat/tree/stable/bots). + +## License + +[AGPL v3](./LICENSE) diff --git a/packages/simplex-chat-client/types/typescript/package.json b/packages/simplex-chat-client/types/typescript/package.json new file mode 100644 index 0000000000..1bf593b483 --- /dev/null +++ b/packages/simplex-chat-client/types/typescript/package.json @@ -0,0 +1,42 @@ +{ + "name": "@simplex-chat/types", + "version": "0.1.0", + "description": "TypeScript types for SimpleX Chat bot libraries", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "build": "tsc", + "prepublishOnly": "tsc" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/simplex-chat/simplex-chat.git", + "directory": "/packages/simplex-chat-client/types/typescript" + }, + "keywords": [ + "simplex", + "messenger", + "chat", + "privacy", + "security", + "bots", + "types" + ], + "author": "SimpleX Chat", + "license": "AGPL-3.0", + "bugs": { + "url": "https://github.com/simplex-chat/simplex-chat/issues" + }, + "homepage": "https://github.com/simplex-chat/simplex-chat#readme", + "dependencies": { + "typescript": "^5.9.2" + } +} diff --git a/packages/simplex-chat-client/types/typescript/src/commands.ts b/packages/simplex-chat-client/types/typescript/src/commands.ts new file mode 100644 index 0000000000..de6a7a7ce1 --- /dev/null +++ b/packages/simplex-chat-client/types/typescript/src/commands.ts @@ -0,0 +1,662 @@ +// API Commands +// This file is generated automatically. + +import * as T from "./types" + +import {CR} from "./responses" + +// Address commands +// Bots can use these commands to automatically check and create address when initialized + +// Create bot address. +// Network usage: interactive. +export interface APICreateMyAddress { + userId: number // int64 +} + +export namespace APICreateMyAddress { + export type Response = CR.UserContactLinkCreated | CR.ChatCmdError + + export function cmdString(self: APICreateMyAddress): string { + return '/_address ' + self.userId + } +} + +// Delete bot address. +// Network usage: background. +export interface APIDeleteMyAddress { + userId: number // int64 +} + +export namespace APIDeleteMyAddress { + export type Response = CR.UserContactLinkDeleted | CR.ChatCmdError + + export function cmdString(self: APIDeleteMyAddress): string { + return '/_delete_address ' + self.userId + } +} + +// Get bot address and settings. +// Network usage: no. +export interface APIShowMyAddress { + userId: number // int64 +} + +export namespace APIShowMyAddress { + export type Response = CR.UserContactLink | CR.ChatCmdError + + export function cmdString(self: APIShowMyAddress): string { + return '/_show_address ' + self.userId + } +} + +// Add address to bot profile. +// Network usage: interactive. +export interface APISetProfileAddress { + userId: number // int64 + enable: boolean +} + +export namespace APISetProfileAddress { + export type Response = CR.UserProfileUpdated | CR.ChatCmdError + + export function cmdString(self: APISetProfileAddress): string { + return '/_profile_address ' + self.userId + ' ' + (self.enable ? 'on' : 'off') + } +} + +// Set bot address settings. +// Network usage: interactive. +export interface APISetAddressSettings { + userId: number // int64 + settings: T.AddressSettings +} + +export namespace APISetAddressSettings { + export type Response = CR.UserContactLinkUpdated | CR.ChatCmdError + + export function cmdString(self: APISetAddressSettings): string { + return '/_address_settings ' + self.userId + ' ' + JSON.stringify(self.settings) + } +} + +// Message commands +// Commands to send, update, delete, moderate messages and set message reactions + +// Send messages. +// Network usage: background. +export interface APISendMessages { + sendRef: T.ChatRef + liveMessage: boolean + ttl?: number // int + composedMessages: T.ComposedMessage[] // non-empty +} + +export namespace APISendMessages { + export type Response = CR.NewChatItems | CR.ChatCmdError + + export function cmdString(self: APISendMessages): string { + return '/_send ' + self.sendRef.toString() + (self.liveMessage ? ' live=on' : '') + (self.ttl ? ' ttl=' + self.ttl : '') + ' json ' + JSON.stringify(self.composedMessages) + } +} + +// Update message. +// Network usage: background. +export interface APIUpdateChatItem { + chatRef: T.ChatRef + chatItemId: number // int64 + liveMessage: boolean + updatedMessage: T.UpdatedMessage +} + +export namespace APIUpdateChatItem { + export type Response = CR.ChatItemUpdated | CR.ChatItemNotChanged | CR.ChatCmdError + + export function cmdString(self: APIUpdateChatItem): string { + return '/_update item ' + self.chatRef.toString() + ' ' + self.chatItemId + (self.liveMessage ? ' live=on' : '') + ' json ' + JSON.stringify(self.updatedMessage) + } +} + +// Delete message. +// Network usage: background. +export interface APIDeleteChatItem { + chatRef: T.ChatRef + chatItemIds: number[] // int64, non-empty + deleteMode: T.CIDeleteMode +} + +export namespace APIDeleteChatItem { + export type Response = CR.ChatItemsDeleted | CR.ChatCmdError + + export function cmdString(self: APIDeleteChatItem): string { + return '/_delete item ' + self.chatRef.toString() + ' ' + self.chatItemIds.join(',') + ' ' + self.deleteMode + } +} + +// Moderate message. Requires Moderator role (and higher than message author's). +// Network usage: background. +export interface APIDeleteMemberChatItem { + groupId: number // int64 + chatItemIds: number[] // int64, non-empty +} + +export namespace APIDeleteMemberChatItem { + export type Response = CR.ChatItemsDeleted | CR.ChatCmdError + + export function cmdString(self: APIDeleteMemberChatItem): string { + return '/_delete member item #' + self.groupId + ' ' + self.chatItemIds.join(',') + } +} + +// Add/remove message reaction. +// Network usage: background. +export interface APIChatItemReaction { + chatRef: T.ChatRef + chatItemId: number // int64 + add: boolean + reaction: T.MsgReaction +} + +export namespace APIChatItemReaction { + export type Response = CR.ChatItemReaction | CR.ChatCmdError + + export function cmdString(self: APIChatItemReaction): string { + return '/_reaction ' + self.chatRef.toString() + ' ' + self.chatItemId + ' ' + (self.add ? 'on' : 'off') + ' ' + JSON.stringify(self.reaction) + } +} + +// File commands +// Commands to receive and to cancel files. Files are sent as part of the message, there are no separate commands to send files. + +// Receive file. +// Network usage: no. +export interface ReceiveFile { + fileId: number // int64 + userApprovedRelays: boolean + storeEncrypted?: boolean + fileInline?: boolean + filePath?: string +} + +export namespace ReceiveFile { + export type Response = CR.RcvFileAccepted | CR.RcvFileAcceptedSndCancelled | CR.ChatCmdError + + export function cmdString(self: ReceiveFile): string { + return '/freceive ' + self.fileId + (self.userApprovedRelays ? ' approved_relays=on' : '') + (typeof self.storeEncrypted == 'boolean' ? ' encrypt=' + (self.storeEncrypted ? 'on' : 'off') : '') + (typeof self.fileInline == 'boolean' ? ' inline=' + (self.fileInline ? 'on' : 'off') : '') + (self.filePath ? ' ' + self.filePath : '') + } +} + +// Cancel file. +// Network usage: background. +export interface CancelFile { + fileId: number // int64 +} + +export namespace CancelFile { + export type Response = CR.SndFileCancelled | CR.RcvFileCancelled | CR.ChatCmdError + + export function cmdString(self: CancelFile): string { + return '/fcancel ' + self.fileId + } +} + +// Group commands +// Commands to manage and moderate groups. These commands can be used with business chats as well - they are groups. E.g., a common scenario would be to add human agents to business chat with the customer who connected via business address. + +// Add contact to group. Requires bot to have Admin role. +// Network usage: interactive. +export interface APIAddMember { + groupId: number // int64 + contactId: number // int64 + memberRole: T.GroupMemberRole +} + +export namespace APIAddMember { + export type Response = CR.SentGroupInvitation | CR.ChatCmdError + + export function cmdString(self: APIAddMember): string { + return '/_add #' + self.groupId + ' ' + self.contactId + ' ' + self.memberRole + } +} + +// Join group. +// Network usage: interactive. +export interface APIJoinGroup { + groupId: number // int64 +} + +export namespace APIJoinGroup { + export type Response = CR.UserAcceptedGroupSent | CR.ChatCmdError + + export function cmdString(self: APIJoinGroup): string { + return '/_join #' + self.groupId + } +} + +// Accept group member. Requires Admin role. +// Network usage: background. +export interface APIAcceptMember { + groupId: number // int64 + groupMemberId: number // int64 + memberRole: T.GroupMemberRole +} + +export namespace APIAcceptMember { + export type Response = CR.MemberAccepted | CR.ChatCmdError + + export function cmdString(self: APIAcceptMember): string { + return '/_accept member #' + self.groupId + ' ' + self.groupMemberId + ' ' + self.memberRole + } +} + +// Set members role. Requires Admin role. +// Network usage: background. +export interface APIMembersRole { + groupId: number // int64 + groupMemberIds: number[] // int64, non-empty + memberRole: T.GroupMemberRole +} + +export namespace APIMembersRole { + export type Response = CR.MembersRoleUser | CR.ChatCmdError + + export function cmdString(self: APIMembersRole): string { + return '/_member role #' + self.groupId + ' ' + self.groupMemberIds.join(',') + ' ' + self.memberRole + } +} + +// Block members. Requires Moderator role. +// Network usage: background. +export interface APIBlockMembersForAll { + groupId: number // int64 + groupMemberIds: number[] // int64, non-empty + blocked: boolean +} + +export namespace APIBlockMembersForAll { + export type Response = CR.MembersBlockedForAllUser | CR.ChatCmdError + + export function cmdString(self: APIBlockMembersForAll): string { + return '/_block #' + self.groupId + ' ' + self.groupMemberIds.join(',') + ' blocked=' + (self.blocked ? 'on' : 'off') + } +} + +// Remove members. Requires Admin role. +// Network usage: background. +export interface APIRemoveMembers { + groupId: number // int64 + groupMemberIds: number[] // int64, non-empty + withMessages: boolean +} + +export namespace APIRemoveMembers { + export type Response = CR.UserDeletedMembers | CR.ChatCmdError + + export function cmdString(self: APIRemoveMembers): string { + return '/_remove #' + self.groupId + ' ' + self.groupMemberIds.join(',') + (self.withMessages ? ' messages=on' : '') + } +} + +// Leave group. +// Network usage: background. +export interface APILeaveGroup { + groupId: number // int64 +} + +export namespace APILeaveGroup { + export type Response = CR.LeftMemberUser | CR.ChatCmdError + + export function cmdString(self: APILeaveGroup): string { + return '/_leave #' + self.groupId + } +} + +// Get group members. +// Network usage: no. +export interface APIListMembers { + groupId: number // int64 +} + +export namespace APIListMembers { + export type Response = CR.GroupMembers | CR.ChatCmdError + + export function cmdString(self: APIListMembers): string { + return '/_members #' + self.groupId + } +} + +// Create group. +// Network usage: no. +export interface APINewGroup { + userId: number // int64 + incognito: boolean + groupProfile: T.GroupProfile +} + +export namespace APINewGroup { + export type Response = CR.GroupCreated | CR.ChatCmdError + + export function cmdString(self: APINewGroup): string { + return '/_group ' + self.userId + (self.incognito ? ' incognito=on' : '') + ' ' + JSON.stringify(self.groupProfile) + } +} + +// Update group profile. +// Network usage: background. +export interface APIUpdateGroupProfile { + groupId: number // int64 + groupProfile: T.GroupProfile +} + +export namespace APIUpdateGroupProfile { + export type Response = CR.GroupUpdated | CR.ChatCmdError + + export function cmdString(self: APIUpdateGroupProfile): string { + return '/_group_profile #' + self.groupId + ' ' + JSON.stringify(self.groupProfile) + } +} + +// Group link commands +// These commands can be used by bots that manage multiple public groups + +// Create group link. +// Network usage: interactive. +export interface APICreateGroupLink { + groupId: number // int64 + memberRole: T.GroupMemberRole +} + +export namespace APICreateGroupLink { + export type Response = CR.GroupLinkCreated | CR.ChatCmdError + + export function cmdString(self: APICreateGroupLink): string { + return '/_create link #' + self.groupId + ' ' + self.memberRole + } +} + +// Set member role for group link. +// Network usage: no. +export interface APIGroupLinkMemberRole { + groupId: number // int64 + memberRole: T.GroupMemberRole +} + +export namespace APIGroupLinkMemberRole { + export type Response = CR.GroupLink | CR.ChatCmdError + + export function cmdString(self: APIGroupLinkMemberRole): string { + return '/_set link role #' + self.groupId + ' ' + self.memberRole + } +} + +// Delete group link. +// Network usage: background. +export interface APIDeleteGroupLink { + groupId: number // int64 +} + +export namespace APIDeleteGroupLink { + export type Response = CR.GroupLinkDeleted | CR.ChatCmdError + + export function cmdString(self: APIDeleteGroupLink): string { + return '/_delete link #' + self.groupId + } +} + +// Get group link. +// Network usage: no. +export interface APIGetGroupLink { + groupId: number // int64 +} + +export namespace APIGetGroupLink { + export type Response = CR.GroupLink | CR.ChatCmdError + + export function cmdString(self: APIGetGroupLink): string { + return '/_get link #' + self.groupId + } +} + +// Connection commands +// These commands may be used to create connections. Most bots do not need to use them - bot users will connect via bot address with auto-accept enabled. + +// Create 1-time invitation link. +// Network usage: interactive. +export interface APIAddContact { + userId: number // int64 + incognito: boolean +} + +export namespace APIAddContact { + export type Response = CR.Invitation | CR.ChatCmdError + + export function cmdString(self: APIAddContact): string { + return '/_connect ' + self.userId + (self.incognito ? ' incognito=on' : '') + } +} + +// Determine SimpleX link type and if the bot is already connected via this link. +// Network usage: interactive. +export interface APIConnectPlan { + userId: number // int64 + connectionLink?: string +} + +export namespace APIConnectPlan { + export type Response = CR.ConnectionPlan | CR.ChatCmdError + + export function cmdString(self: APIConnectPlan): string { + return '/_connect plan ' + self.userId + ' ' + self.connectionLink + } +} + +// Connect via prepared SimpleX link. The link can be 1-time invitation link, contact address or group link +// Network usage: interactive. +export interface APIConnect { + userId: number // int64 + incognito: boolean + preparedLink_?: T.CreatedConnLink +} + +export namespace APIConnect { + export type Response = CR.SentConfirmation | CR.ContactAlreadyExists | CR.SentInvitation | CR.ChatCmdError + + export function cmdString(self: APIConnect): string { + return '/_connect ' + self.userId + (self.preparedLink_ ? ' ' + self.preparedLink_.toString() : '') + } +} + +// Connect via SimpleX link as string in the active user profile. +// Network usage: interactive. +export interface Connect { + incognito: boolean + connLink_?: string +} + +export namespace Connect { + export type Response = CR.SentConfirmation | CR.ContactAlreadyExists | CR.SentInvitation | CR.ChatCmdError + + export function cmdString(self: Connect): string { + return '/connect' + (self.connLink_ ? ' ' + self.connLink_ : '') + } +} + +// Accept contact request. +// Network usage: interactive. +export interface APIAcceptContact { + contactReqId: number // int64 +} + +export namespace APIAcceptContact { + export type Response = CR.AcceptingContactRequest | CR.ChatCmdError + + export function cmdString(self: APIAcceptContact): string { + return '/_accept ' + self.contactReqId + } +} + +// Reject contact request. The user who sent the request is **not notified**. +// Network usage: no. +export interface APIRejectContact { + contactReqId: number // int64 +} + +export namespace APIRejectContact { + export type Response = CR.ContactRequestRejected | CR.ChatCmdError + + export function cmdString(self: APIRejectContact): string { + return '/_reject ' + self.contactReqId + } +} + +// Chat commands +// Commands to list and delete conversations. + +// Get contacts. +// Network usage: no. +export interface APIListContacts { + userId: number // int64 +} + +export namespace APIListContacts { + export type Response = CR.ContactsList | CR.ChatCmdError + + export function cmdString(self: APIListContacts): string { + return '/_contacts ' + self.userId + } +} + +// Get groups. +// Network usage: no. +export interface APIListGroups { + userId: number // int64 + contactId_?: number // int64 + search?: string +} + +export namespace APIListGroups { + export type Response = CR.GroupsList | CR.ChatCmdError + + export function cmdString(self: APIListGroups): string { + return '/_groups ' + self.userId + (self.contactId_ ? ' @' + self.contactId_ : '') + (self.search ? ' ' + self.search : '') + } +} + +// Delete chat. +// Network usage: background. +export interface APIDeleteChat { + chatRef: T.ChatRef + chatDeleteMode: T.ChatDeleteMode +} + +export namespace APIDeleteChat { + export type Response = CR.ContactDeleted | CR.ContactConnectionDeleted | CR.GroupDeletedUser | CR.ChatCmdError + + export function cmdString(self: APIDeleteChat): string { + return '/_delete ' + self.chatRef.toString() + ' ' + self.chatDeleteMode.toString() + } +} + +// User profile commands +// Most bots don't need to use these commands, as bot profile can be configured manually via CLI or desktop client. These commands can be used by bots that need to manage multiple user profiles (e.g., the profiles of support agents). + +// Get active user profile +// Network usage: no. +export interface ShowActiveUser { +} + +export namespace ShowActiveUser { + export type Response = CR.ActiveUser | CR.ChatCmdError + + export function cmdString(_self: ShowActiveUser): string { + return '/user' + } +} + +// Create new user profile +// Network usage: no. +export interface CreateActiveUser { + newUser: T.NewUser +} + +export namespace CreateActiveUser { + export type Response = CR.ActiveUser | CR.ChatCmdError + + export function cmdString(self: CreateActiveUser): string { + return '/_create user ' + JSON.stringify(self.newUser) + } +} + +// Get all user profiles +// Network usage: no. +export interface ListUsers { +} + +export namespace ListUsers { + export type Response = CR.UsersList | CR.ChatCmdError + + export function cmdString(_self: ListUsers): string { + return '/users' + } +} + +// Set active user profile +// Network usage: no. +export interface APISetActiveUser { + userId: number // int64 + viewPwd?: string +} + +export namespace APISetActiveUser { + export type Response = CR.ActiveUser | CR.ChatCmdError + + export function cmdString(self: APISetActiveUser): string { + return '/_user ' + self.userId + (self.viewPwd ? ' ' + JSON.stringify(self.viewPwd) : '') + } +} + +// Delete user profile. +// Network usage: background. +export interface APIDeleteUser { + userId: number // int64 + delSMPQueues: boolean + viewPwd?: string +} + +export namespace APIDeleteUser { + export type Response = CR.CmdOk | CR.ChatCmdError + + export function cmdString(self: APIDeleteUser): string { + return '/_delete user ' + self.userId + ' del_smp=' + (self.delSMPQueues ? 'on' : 'off') + (self.viewPwd ? ' ' + JSON.stringify(self.viewPwd) : '') + } +} + +// Update user profile. +// Network usage: background. +export interface APIUpdateProfile { + userId: number // int64 + profile: T.Profile +} + +export namespace APIUpdateProfile { + export type Response = CR.UserProfileUpdated | CR.UserProfileNoChange | CR.ChatCmdError + + export function cmdString(self: APIUpdateProfile): string { + return '/_profile ' + self.userId + ' ' + JSON.stringify(self.profile) + } +} + +// Configure chat preference overrides for the contact. +// Network usage: background. +export interface APISetContactPrefs { + contactId: number // int64 + preferences: T.Preferences +} + +export namespace APISetContactPrefs { + export type Response = CR.ContactPrefsUpdated | CR.ChatCmdError + + export function cmdString(self: APISetContactPrefs): string { + return '/_set prefs @' + self.contactId + ' ' + JSON.stringify(self.preferences) + } +} diff --git a/packages/simplex-chat-client/types/typescript/src/events.ts b/packages/simplex-chat-client/types/typescript/src/events.ts new file mode 100644 index 0000000000..d7a0419bbe --- /dev/null +++ b/packages/simplex-chat-client/types/typescript/src/events.ts @@ -0,0 +1,430 @@ +// API Events +// This file is generated automatically. + +import * as T from "./types" + +export type ChatEvent = + | CEvt.ContactConnected + | CEvt.ContactUpdated + | CEvt.ContactDeletedByContact + | CEvt.ReceivedContactRequest + | CEvt.NewMemberContactReceivedInv + | CEvt.ContactSndReady + | CEvt.NewChatItems + | CEvt.ChatItemReaction + | CEvt.ChatItemsDeleted + | CEvt.ChatItemUpdated + | CEvt.GroupChatItemsDeleted + | CEvt.ChatItemsStatusesUpdated + | CEvt.ReceivedGroupInvitation + | CEvt.UserJoinedGroup + | CEvt.GroupUpdated + | CEvt.JoinedGroupMember + | CEvt.MemberRole + | CEvt.DeletedMember + | CEvt.LeftMember + | CEvt.DeletedMemberUser + | CEvt.GroupDeleted + | CEvt.ConnectedToGroupMember + | CEvt.MemberAcceptedByOther + | CEvt.MemberBlockedForAll + | CEvt.GroupMemberUpdated + | CEvt.RcvFileDescrReady + | CEvt.RcvFileComplete + | CEvt.SndFileCompleteXFTP + | CEvt.RcvFileStart + | CEvt.RcvFileSndCancelled + | CEvt.RcvFileAccepted + | CEvt.RcvFileError + | CEvt.RcvFileWarning + | CEvt.SndFileError + | CEvt.SndFileWarning + | CEvt.AcceptingContactRequest + | CEvt.AcceptingBusinessRequest + | CEvt.ContactConnecting + | CEvt.BusinessLinkConnecting + | CEvt.JoinedGroupMemberConnecting + | CEvt.SentGroupInvitation + | CEvt.GroupLinkConnecting + | CEvt.MessageError + | CEvt.ChatError + | CEvt.ChatErrors + +export namespace CEvt { + export type Tag = + | "contactConnected" + | "contactUpdated" + | "contactDeletedByContact" + | "receivedContactRequest" + | "newMemberContactReceivedInv" + | "contactSndReady" + | "newChatItems" + | "chatItemReaction" + | "chatItemsDeleted" + | "chatItemUpdated" + | "groupChatItemsDeleted" + | "chatItemsStatusesUpdated" + | "receivedGroupInvitation" + | "userJoinedGroup" + | "groupUpdated" + | "joinedGroupMember" + | "memberRole" + | "deletedMember" + | "leftMember" + | "deletedMemberUser" + | "groupDeleted" + | "connectedToGroupMember" + | "memberAcceptedByOther" + | "memberBlockedForAll" + | "groupMemberUpdated" + | "rcvFileDescrReady" + | "rcvFileComplete" + | "sndFileCompleteXFTP" + | "rcvFileStart" + | "rcvFileSndCancelled" + | "rcvFileAccepted" + | "rcvFileError" + | "rcvFileWarning" + | "sndFileError" + | "sndFileWarning" + | "acceptingContactRequest" + | "acceptingBusinessRequest" + | "contactConnecting" + | "businessLinkConnecting" + | "joinedGroupMemberConnecting" + | "sentGroupInvitation" + | "groupLinkConnecting" + | "messageError" + | "chatError" + | "chatErrors" + + interface Interface { + type: Tag + } + + export interface ContactConnected extends Interface { + type: "contactConnected" + user: T.User + contact: T.Contact + userCustomProfile?: T.Profile + } + + export interface ContactUpdated extends Interface { + type: "contactUpdated" + user: T.User + fromContact: T.Contact + toContact: T.Contact + } + + export interface ContactDeletedByContact extends Interface { + type: "contactDeletedByContact" + user: T.User + contact: T.Contact + } + + export interface ReceivedContactRequest extends Interface { + type: "receivedContactRequest" + user: T.User + contactRequest: T.UserContactRequest + chat_?: T.AChat + } + + export interface NewMemberContactReceivedInv extends Interface { + type: "newMemberContactReceivedInv" + user: T.User + contact: T.Contact + groupInfo: T.GroupInfo + member: T.GroupMember + } + + export interface ContactSndReady extends Interface { + type: "contactSndReady" + user: T.User + contact: T.Contact + } + + export interface NewChatItems extends Interface { + type: "newChatItems" + user: T.User + chatItems: T.AChatItem[] + } + + export interface ChatItemReaction extends Interface { + type: "chatItemReaction" + user: T.User + added: boolean + reaction: T.ACIReaction + } + + export interface ChatItemsDeleted extends Interface { + type: "chatItemsDeleted" + user: T.User + chatItemDeletions: T.ChatItemDeletion[] + byUser: boolean + timed: boolean + } + + export interface ChatItemUpdated extends Interface { + type: "chatItemUpdated" + user: T.User + chatItem: T.AChatItem + } + + export interface GroupChatItemsDeleted extends Interface { + type: "groupChatItemsDeleted" + user: T.User + groupInfo: T.GroupInfo + chatItemIDs: number[] // int64 + byUser: boolean + member_?: T.GroupMember + } + + export interface ChatItemsStatusesUpdated extends Interface { + type: "chatItemsStatusesUpdated" + user: T.User + chatItems: T.AChatItem[] + } + + export interface ReceivedGroupInvitation extends Interface { + type: "receivedGroupInvitation" + user: T.User + groupInfo: T.GroupInfo + contact: T.Contact + fromMemberRole: T.GroupMemberRole + memberRole: T.GroupMemberRole + } + + export interface UserJoinedGroup extends Interface { + type: "userJoinedGroup" + user: T.User + groupInfo: T.GroupInfo + hostMember: T.GroupMember + } + + export interface GroupUpdated extends Interface { + type: "groupUpdated" + user: T.User + fromGroup: T.GroupInfo + toGroup: T.GroupInfo + member_?: T.GroupMember + } + + export interface JoinedGroupMember extends Interface { + type: "joinedGroupMember" + user: T.User + groupInfo: T.GroupInfo + member: T.GroupMember + } + + export interface MemberRole extends Interface { + type: "memberRole" + user: T.User + groupInfo: T.GroupInfo + byMember: T.GroupMember + member: T.GroupMember + fromRole: T.GroupMemberRole + toRole: T.GroupMemberRole + } + + export interface DeletedMember extends Interface { + type: "deletedMember" + user: T.User + groupInfo: T.GroupInfo + byMember: T.GroupMember + deletedMember: T.GroupMember + withMessages: boolean + } + + export interface LeftMember extends Interface { + type: "leftMember" + user: T.User + groupInfo: T.GroupInfo + member: T.GroupMember + } + + export interface DeletedMemberUser extends Interface { + type: "deletedMemberUser" + user: T.User + groupInfo: T.GroupInfo + member: T.GroupMember + withMessages: boolean + } + + export interface GroupDeleted extends Interface { + type: "groupDeleted" + user: T.User + groupInfo: T.GroupInfo + member: T.GroupMember + } + + export interface ConnectedToGroupMember extends Interface { + type: "connectedToGroupMember" + user: T.User + groupInfo: T.GroupInfo + member: T.GroupMember + memberContact?: T.Contact + } + + export interface MemberAcceptedByOther extends Interface { + type: "memberAcceptedByOther" + user: T.User + groupInfo: T.GroupInfo + acceptingMember: T.GroupMember + member: T.GroupMember + } + + export interface MemberBlockedForAll extends Interface { + type: "memberBlockedForAll" + user: T.User + groupInfo: T.GroupInfo + byMember: T.GroupMember + member: T.GroupMember + blocked: boolean + } + + export interface GroupMemberUpdated extends Interface { + type: "groupMemberUpdated" + user: T.User + groupInfo: T.GroupInfo + fromMember: T.GroupMember + toMember: T.GroupMember + } + + export interface RcvFileDescrReady extends Interface { + type: "rcvFileDescrReady" + user: T.User + chatItem: T.AChatItem + rcvFileTransfer: T.RcvFileTransfer + rcvFileDescr: T.RcvFileDescr + } + + export interface RcvFileComplete extends Interface { + type: "rcvFileComplete" + user: T.User + chatItem: T.AChatItem + } + + export interface SndFileCompleteXFTP extends Interface { + type: "sndFileCompleteXFTP" + user: T.User + chatItem: T.AChatItem + fileTransferMeta: T.FileTransferMeta + } + + export interface RcvFileStart extends Interface { + type: "rcvFileStart" + user: T.User + chatItem: T.AChatItem + } + + export interface RcvFileSndCancelled extends Interface { + type: "rcvFileSndCancelled" + user: T.User + chatItem: T.AChatItem + rcvFileTransfer: T.RcvFileTransfer + } + + export interface RcvFileAccepted extends Interface { + type: "rcvFileAccepted" + user: T.User + chatItem: T.AChatItem + } + + export interface RcvFileError extends Interface { + type: "rcvFileError" + user: T.User + chatItem_?: T.AChatItem + agentError: T.AgentErrorType + rcvFileTransfer: T.RcvFileTransfer + } + + export interface RcvFileWarning extends Interface { + type: "rcvFileWarning" + user: T.User + chatItem_?: T.AChatItem + agentError: T.AgentErrorType + rcvFileTransfer: T.RcvFileTransfer + } + + export interface SndFileError extends Interface { + type: "sndFileError" + user: T.User + chatItem_?: T.AChatItem + fileTransferMeta: T.FileTransferMeta + errorMessage: string + } + + export interface SndFileWarning extends Interface { + type: "sndFileWarning" + user: T.User + chatItem_?: T.AChatItem + fileTransferMeta: T.FileTransferMeta + errorMessage: string + } + + export interface AcceptingContactRequest extends Interface { + type: "acceptingContactRequest" + user: T.User + contact: T.Contact + } + + export interface AcceptingBusinessRequest extends Interface { + type: "acceptingBusinessRequest" + user: T.User + groupInfo: T.GroupInfo + } + + export interface ContactConnecting extends Interface { + type: "contactConnecting" + user: T.User + contact: T.Contact + } + + export interface BusinessLinkConnecting extends Interface { + type: "businessLinkConnecting" + user: T.User + groupInfo: T.GroupInfo + hostMember: T.GroupMember + fromContact: T.Contact + } + + export interface JoinedGroupMemberConnecting extends Interface { + type: "joinedGroupMemberConnecting" + user: T.User + groupInfo: T.GroupInfo + hostMember: T.GroupMember + member: T.GroupMember + } + + export interface SentGroupInvitation extends Interface { + type: "sentGroupInvitation" + user: T.User + groupInfo: T.GroupInfo + contact: T.Contact + member: T.GroupMember + } + + export interface GroupLinkConnecting extends Interface { + type: "groupLinkConnecting" + user: T.User + groupInfo: T.GroupInfo + hostMember: T.GroupMember + } + + export interface MessageError extends Interface { + type: "messageError" + user: T.User + severity: string + errorMessage: string + } + + export interface ChatError extends Interface { + type: "chatError" + chatError: T.ChatError + } + + export interface ChatErrors extends Interface { + type: "chatErrors" + chatErrors: T.ChatError[] + } +} diff --git a/packages/simplex-chat-client/types/typescript/src/index.ts b/packages/simplex-chat-client/types/typescript/src/index.ts new file mode 100644 index 0000000000..9a92af29a0 --- /dev/null +++ b/packages/simplex-chat-client/types/typescript/src/index.ts @@ -0,0 +1,4 @@ +export * as CC from "./commands" +export {ChatResponse, CR} from "./responses" +export {ChatEvent, CEvt} from "./events" +export * as T from "./types" diff --git a/packages/simplex-chat-client/types/typescript/src/responses.ts b/packages/simplex-chat-client/types/typescript/src/responses.ts new file mode 100644 index 0000000000..ea49478b15 --- /dev/null +++ b/packages/simplex-chat-client/types/typescript/src/responses.ts @@ -0,0 +1,396 @@ +// API Responses +// This file is generated automatically. + +import * as T from "./types" + +export type ChatResponse = + | CR.AcceptingContactRequest + | CR.ActiveUser + | CR.ChatItemNotChanged + | CR.ChatItemReaction + | CR.ChatItemUpdated + | CR.ChatItemsDeleted + | CR.CmdOk + | CR.ChatCmdError + | CR.ConnectionPlan + | CR.ContactAlreadyExists + | CR.ContactConnectionDeleted + | CR.ContactDeleted + | CR.ContactPrefsUpdated + | CR.ContactRequestRejected + | CR.ContactsList + | CR.GroupDeletedUser + | CR.GroupLink + | CR.GroupLinkCreated + | CR.GroupLinkDeleted + | CR.GroupCreated + | CR.GroupMembers + | CR.GroupUpdated + | CR.GroupsList + | CR.Invitation + | CR.LeftMemberUser + | CR.MemberAccepted + | CR.MembersBlockedForAllUser + | CR.MembersRoleUser + | CR.NewChatItems + | CR.RcvFileAccepted + | CR.RcvFileAcceptedSndCancelled + | CR.RcvFileCancelled + | CR.SentConfirmation + | CR.SentGroupInvitation + | CR.SentInvitation + | CR.SndFileCancelled + | CR.UserAcceptedGroupSent + | CR.UserContactLink + | CR.UserContactLinkCreated + | CR.UserContactLinkDeleted + | CR.UserContactLinkUpdated + | CR.UserDeletedMembers + | CR.UserProfileUpdated + | CR.UserProfileNoChange + | CR.UsersList + +export namespace CR { + export type Tag = + | "acceptingContactRequest" + | "activeUser" + | "chatItemNotChanged" + | "chatItemReaction" + | "chatItemUpdated" + | "chatItemsDeleted" + | "cmdOk" + | "chatCmdError" + | "connectionPlan" + | "contactAlreadyExists" + | "contactConnectionDeleted" + | "contactDeleted" + | "contactPrefsUpdated" + | "contactRequestRejected" + | "contactsList" + | "groupDeletedUser" + | "groupLink" + | "groupLinkCreated" + | "groupLinkDeleted" + | "groupCreated" + | "groupMembers" + | "groupUpdated" + | "groupsList" + | "invitation" + | "leftMemberUser" + | "memberAccepted" + | "membersBlockedForAllUser" + | "membersRoleUser" + | "newChatItems" + | "rcvFileAccepted" + | "rcvFileAcceptedSndCancelled" + | "rcvFileCancelled" + | "sentConfirmation" + | "sentGroupInvitation" + | "sentInvitation" + | "sndFileCancelled" + | "userAcceptedGroupSent" + | "userContactLink" + | "userContactLinkCreated" + | "userContactLinkDeleted" + | "userContactLinkUpdated" + | "userDeletedMembers" + | "userProfileUpdated" + | "userProfileNoChange" + | "usersList" + + interface Interface { + type: Tag + } + + export interface AcceptingContactRequest extends Interface { + type: "acceptingContactRequest" + user: T.User + contact: T.Contact + } + + export interface ActiveUser extends Interface { + type: "activeUser" + user: T.User + } + + export interface ChatItemNotChanged extends Interface { + type: "chatItemNotChanged" + user: T.User + chatItem: T.AChatItem + } + + export interface ChatItemReaction extends Interface { + type: "chatItemReaction" + user: T.User + added: boolean + reaction: T.ACIReaction + } + + export interface ChatItemUpdated extends Interface { + type: "chatItemUpdated" + user: T.User + chatItem: T.AChatItem + } + + export interface ChatItemsDeleted extends Interface { + type: "chatItemsDeleted" + user: T.User + chatItemDeletions: T.ChatItemDeletion[] + byUser: boolean + timed: boolean + } + + export interface CmdOk extends Interface { + type: "cmdOk" + user_?: T.User + } + + export interface ChatCmdError extends Interface { + type: "chatCmdError" + chatError: T.ChatError + } + + export interface ConnectionPlan extends Interface { + type: "connectionPlan" + user: T.User + connLink: T.CreatedConnLink + connectionPlan: T.ConnectionPlan + } + + export interface ContactAlreadyExists extends Interface { + type: "contactAlreadyExists" + user: T.User + contact: T.Contact + } + + export interface ContactConnectionDeleted extends Interface { + type: "contactConnectionDeleted" + user: T.User + connection: T.PendingContactConnection + } + + export interface ContactDeleted extends Interface { + type: "contactDeleted" + user: T.User + contact: T.Contact + } + + export interface ContactPrefsUpdated extends Interface { + type: "contactPrefsUpdated" + user: T.User + fromContact: T.Contact + toContact: T.Contact + } + + export interface ContactRequestRejected extends Interface { + type: "contactRequestRejected" + user: T.User + contactRequest: T.UserContactRequest + contact_?: T.Contact + } + + export interface ContactsList extends Interface { + type: "contactsList" + user: T.User + contacts: T.Contact[] + } + + export interface GroupDeletedUser extends Interface { + type: "groupDeletedUser" + user: T.User + groupInfo: T.GroupInfo + } + + export interface GroupLink extends Interface { + type: "groupLink" + user: T.User + groupInfo: T.GroupInfo + groupLink: T.GroupLink + } + + export interface GroupLinkCreated extends Interface { + type: "groupLinkCreated" + user: T.User + groupInfo: T.GroupInfo + groupLink: T.GroupLink + } + + export interface GroupLinkDeleted extends Interface { + type: "groupLinkDeleted" + user: T.User + groupInfo: T.GroupInfo + } + + export interface GroupCreated extends Interface { + type: "groupCreated" + user: T.User + groupInfo: T.GroupInfo + } + + export interface GroupMembers extends Interface { + type: "groupMembers" + user: T.User + group: T.Group + } + + export interface GroupUpdated extends Interface { + type: "groupUpdated" + user: T.User + fromGroup: T.GroupInfo + toGroup: T.GroupInfo + member_?: T.GroupMember + } + + export interface GroupsList extends Interface { + type: "groupsList" + user: T.User + groups: T.GroupInfo[] + } + + export interface Invitation extends Interface { + type: "invitation" + user: T.User + connLinkInvitation: T.CreatedConnLink + connection: T.PendingContactConnection + } + + export interface LeftMemberUser extends Interface { + type: "leftMemberUser" + user: T.User + groupInfo: T.GroupInfo + } + + export interface MemberAccepted extends Interface { + type: "memberAccepted" + user: T.User + groupInfo: T.GroupInfo + member: T.GroupMember + } + + export interface MembersBlockedForAllUser extends Interface { + type: "membersBlockedForAllUser" + user: T.User + groupInfo: T.GroupInfo + members: T.GroupMember[] + blocked: boolean + } + + export interface MembersRoleUser extends Interface { + type: "membersRoleUser" + user: T.User + groupInfo: T.GroupInfo + members: T.GroupMember[] + toRole: T.GroupMemberRole + } + + export interface NewChatItems extends Interface { + type: "newChatItems" + user: T.User + chatItems: T.AChatItem[] + } + + export interface RcvFileAccepted extends Interface { + type: "rcvFileAccepted" + user: T.User + chatItem: T.AChatItem + } + + export interface RcvFileAcceptedSndCancelled extends Interface { + type: "rcvFileAcceptedSndCancelled" + user: T.User + rcvFileTransfer: T.RcvFileTransfer + } + + export interface RcvFileCancelled extends Interface { + type: "rcvFileCancelled" + user: T.User + chatItem_?: T.AChatItem + rcvFileTransfer: T.RcvFileTransfer + } + + export interface SentConfirmation extends Interface { + type: "sentConfirmation" + user: T.User + connection: T.PendingContactConnection + customUserProfile?: T.Profile + } + + export interface SentGroupInvitation extends Interface { + type: "sentGroupInvitation" + user: T.User + groupInfo: T.GroupInfo + contact: T.Contact + member: T.GroupMember + } + + export interface SentInvitation extends Interface { + type: "sentInvitation" + user: T.User + connection: T.PendingContactConnection + customUserProfile?: T.Profile + } + + export interface SndFileCancelled extends Interface { + type: "sndFileCancelled" + user: T.User + chatItem_?: T.AChatItem + fileTransferMeta: T.FileTransferMeta + sndFileTransfers: T.SndFileTransfer[] + } + + export interface UserAcceptedGroupSent extends Interface { + type: "userAcceptedGroupSent" + user: T.User + groupInfo: T.GroupInfo + hostContact?: T.Contact + } + + export interface UserContactLink extends Interface { + type: "userContactLink" + user: T.User + contactLink: T.UserContactLink + } + + export interface UserContactLinkCreated extends Interface { + type: "userContactLinkCreated" + user: T.User + connLinkContact: T.CreatedConnLink + } + + export interface UserContactLinkDeleted extends Interface { + type: "userContactLinkDeleted" + user: T.User + } + + export interface UserContactLinkUpdated extends Interface { + type: "userContactLinkUpdated" + user: T.User + contactLink: T.UserContactLink + } + + export interface UserDeletedMembers extends Interface { + type: "userDeletedMembers" + user: T.User + groupInfo: T.GroupInfo + members: T.GroupMember[] + withMessages: boolean + } + + export interface UserProfileUpdated extends Interface { + type: "userProfileUpdated" + user: T.User + fromProfile: T.Profile + toProfile: T.Profile + updateSummary: T.UserProfileUpdateSummary + } + + export interface UserProfileNoChange extends Interface { + type: "userProfileNoChange" + user: T.User + } + + export interface UsersList extends Interface { + type: "usersList" + users: T.UserInfo[] + } +} diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts new file mode 100644 index 0000000000..b8095affdf --- /dev/null +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -0,0 +1,4572 @@ +// API Types +// This file is generated automatically. + +export interface ACIReaction { + chatInfo: ChatInfo + chatReaction: CIReaction +} + +export interface AChat { + chatInfo: ChatInfo + chatItems: ChatItem[] + chatStats: ChatStats +} + +export interface AChatItem { + chatInfo: ChatInfo + chatItem: ChatItem +} + +export interface AddressSettings { + businessAddress: boolean + autoAccept?: AutoAccept + autoReply?: MsgContent +} + +export type AgentCryptoError = + | AgentCryptoError.DECRYPT_AES + | AgentCryptoError.DECRYPT_CB + | AgentCryptoError.RATCHET_HEADER + | AgentCryptoError.RATCHET_SYNC + +export namespace AgentCryptoError { + export type Tag = "DECRYPT_AES" | "DECRYPT_CB" | "RATCHET_HEADER" | "RATCHET_SYNC" + + interface Interface { + type: Tag + } + + export interface DECRYPT_AES extends Interface { + type: "DECRYPT_AES" + } + + export interface DECRYPT_CB extends Interface { + type: "DECRYPT_CB" + } + + export interface RATCHET_HEADER extends Interface { + type: "RATCHET_HEADER" + } + + export interface RATCHET_SYNC extends Interface { + type: "RATCHET_SYNC" + } +} + +export type AgentErrorType = + | AgentErrorType.CMD + | AgentErrorType.CONN + | AgentErrorType.NO_USER + | AgentErrorType.SMP + | AgentErrorType.NTF + | AgentErrorType.XFTP + | AgentErrorType.FILE + | AgentErrorType.PROXY + | AgentErrorType.RCP + | AgentErrorType.BROKER + | AgentErrorType.AGENT + | AgentErrorType.NOTICE + | AgentErrorType.INTERNAL + | AgentErrorType.CRITICAL + | AgentErrorType.INACTIVE + +export namespace AgentErrorType { + export type Tag = + | "CMD" + | "CONN" + | "NO_USER" + | "SMP" + | "NTF" + | "XFTP" + | "FILE" + | "PROXY" + | "RCP" + | "BROKER" + | "AGENT" + | "NOTICE" + | "INTERNAL" + | "CRITICAL" + | "INACTIVE" + + interface Interface { + type: Tag + } + + export interface CMD extends Interface { + type: "CMD" + cmdErr: CommandErrorType + errContext: string + } + + export interface CONN extends Interface { + type: "CONN" + connErr: ConnectionErrorType + errContext: string + } + + export interface NO_USER extends Interface { + type: "NO_USER" + } + + export interface SMP extends Interface { + type: "SMP" + serverAddress: string + smpErr: ErrorType + } + + export interface NTF extends Interface { + type: "NTF" + serverAddress: string + ntfErr: ErrorType + } + + export interface XFTP extends Interface { + type: "XFTP" + serverAddress: string + xftpErr: XFTPErrorType + } + + export interface FILE extends Interface { + type: "FILE" + fileErr: FileErrorType + } + + export interface PROXY extends Interface { + type: "PROXY" + proxyServer: string + relayServer: string + proxyErr: ProxyClientError + } + + export interface RCP extends Interface { + type: "RCP" + rcpErr: RCErrorType + } + + export interface BROKER extends Interface { + type: "BROKER" + brokerAddress: string + brokerErr: BrokerErrorType + } + + export interface AGENT extends Interface { + type: "AGENT" + agentErr: SMPAgentError + } + + export interface NOTICE extends Interface { + type: "NOTICE" + server: string + preset: boolean + expiresAt?: string // ISO-8601 timestamp + } + + export interface INTERNAL extends Interface { + type: "INTERNAL" + internalErr: string + } + + export interface CRITICAL extends Interface { + type: "CRITICAL" + offerRestart: boolean + criticalErr: string + } + + export interface INACTIVE extends Interface { + type: "INACTIVE" + } +} + +export interface AutoAccept { + acceptIncognito: boolean +} + +export interface BlockingInfo { + reason: BlockingReason + notice?: ClientNotice +} + +export enum BlockingReason { + Spam = "spam", + Content = "content", +} + +export type BrokerErrorType = + | BrokerErrorType.RESPONSE + | BrokerErrorType.UNEXPECTED + | BrokerErrorType.NETWORK + | BrokerErrorType.HOST + | BrokerErrorType.NO_SERVICE + | BrokerErrorType.TRANSPORT + | BrokerErrorType.TIMEOUT + +export namespace BrokerErrorType { + export type Tag = + | "RESPONSE" + | "UNEXPECTED" + | "NETWORK" + | "HOST" + | "NO_SERVICE" + | "TRANSPORT" + | "TIMEOUT" + + interface Interface { + type: Tag + } + + export interface RESPONSE extends Interface { + type: "RESPONSE" + respErr: string + } + + export interface UNEXPECTED extends Interface { + type: "UNEXPECTED" + respErr: string + } + + export interface NETWORK extends Interface { + type: "NETWORK" + networkError: NetworkError + } + + export interface HOST extends Interface { + type: "HOST" + } + + export interface NO_SERVICE extends Interface { + type: "NO_SERVICE" + } + + export interface TRANSPORT extends Interface { + type: "TRANSPORT" + transportErr: TransportError + } + + export interface TIMEOUT extends Interface { + type: "TIMEOUT" + } +} + +export interface BusinessChatInfo { + chatType: BusinessChatType + businessId: string + customerId: string +} + +export enum BusinessChatType { + Business = "business", + Customer = "customer", +} + +export enum CICallStatus { + Pending = "pending", + Missed = "missed", + Rejected = "rejected", + Accepted = "accepted", + Negotiated = "negotiated", + Progress = "progress", + Ended = "ended", + Error = "error", +} + +export type CIContent = + | CIContent.SndMsgContent + | CIContent.RcvMsgContent + | CIContent.SndDeleted + | CIContent.RcvDeleted + | CIContent.SndCall + | CIContent.RcvCall + | CIContent.RcvIntegrityError + | CIContent.RcvDecryptionError + | CIContent.RcvGroupInvitation + | CIContent.SndGroupInvitation + | CIContent.RcvDirectEvent + | CIContent.RcvGroupEvent + | CIContent.SndGroupEvent + | CIContent.RcvConnEvent + | CIContent.SndConnEvent + | CIContent.RcvChatFeature + | CIContent.SndChatFeature + | CIContent.RcvChatPreference + | CIContent.SndChatPreference + | CIContent.RcvGroupFeature + | CIContent.SndGroupFeature + | CIContent.RcvChatFeatureRejected + | CIContent.RcvGroupFeatureRejected + | CIContent.SndModerated + | CIContent.RcvModerated + | CIContent.RcvBlocked + | CIContent.SndDirectE2EEInfo + | CIContent.RcvDirectE2EEInfo + | CIContent.SndGroupE2EEInfo + | CIContent.RcvGroupE2EEInfo + | CIContent.ChatBanner + +export namespace CIContent { + export type Tag = + | "sndMsgContent" + | "rcvMsgContent" + | "sndDeleted" + | "rcvDeleted" + | "sndCall" + | "rcvCall" + | "rcvIntegrityError" + | "rcvDecryptionError" + | "rcvGroupInvitation" + | "sndGroupInvitation" + | "rcvDirectEvent" + | "rcvGroupEvent" + | "sndGroupEvent" + | "rcvConnEvent" + | "sndConnEvent" + | "rcvChatFeature" + | "sndChatFeature" + | "rcvChatPreference" + | "sndChatPreference" + | "rcvGroupFeature" + | "sndGroupFeature" + | "rcvChatFeatureRejected" + | "rcvGroupFeatureRejected" + | "sndModerated" + | "rcvModerated" + | "rcvBlocked" + | "sndDirectE2EEInfo" + | "rcvDirectE2EEInfo" + | "sndGroupE2EEInfo" + | "rcvGroupE2EEInfo" + | "chatBanner" + + interface Interface { + type: Tag + } + + export interface SndMsgContent extends Interface { + type: "sndMsgContent" + msgContent: MsgContent + } + + export interface RcvMsgContent extends Interface { + type: "rcvMsgContent" + msgContent: MsgContent + } + + export interface SndDeleted extends Interface { + type: "sndDeleted" + deleteMode: CIDeleteMode + } + + export interface RcvDeleted extends Interface { + type: "rcvDeleted" + deleteMode: CIDeleteMode + } + + export interface SndCall extends Interface { + type: "sndCall" + status: CICallStatus + duration: number // int + } + + export interface RcvCall extends Interface { + type: "rcvCall" + status: CICallStatus + duration: number // int + } + + export interface RcvIntegrityError extends Interface { + type: "rcvIntegrityError" + msgError: MsgErrorType + } + + export interface RcvDecryptionError extends Interface { + type: "rcvDecryptionError" + msgDecryptError: MsgDecryptError + msgCount: number // word32 + } + + export interface RcvGroupInvitation extends Interface { + type: "rcvGroupInvitation" + groupInvitation: CIGroupInvitation + memberRole: GroupMemberRole + } + + export interface SndGroupInvitation extends Interface { + type: "sndGroupInvitation" + groupInvitation: CIGroupInvitation + memberRole: GroupMemberRole + } + + export interface RcvDirectEvent extends Interface { + type: "rcvDirectEvent" + rcvDirectEvent: RcvDirectEvent + } + + export interface RcvGroupEvent extends Interface { + type: "rcvGroupEvent" + rcvGroupEvent: RcvGroupEvent + } + + export interface SndGroupEvent extends Interface { + type: "sndGroupEvent" + sndGroupEvent: SndGroupEvent + } + + export interface RcvConnEvent extends Interface { + type: "rcvConnEvent" + rcvConnEvent: RcvConnEvent + } + + export interface SndConnEvent extends Interface { + type: "sndConnEvent" + sndConnEvent: SndConnEvent + } + + export interface RcvChatFeature extends Interface { + type: "rcvChatFeature" + feature: ChatFeature + enabled: PrefEnabled + param?: number // int + } + + export interface SndChatFeature extends Interface { + type: "sndChatFeature" + feature: ChatFeature + enabled: PrefEnabled + param?: number // int + } + + export interface RcvChatPreference extends Interface { + type: "rcvChatPreference" + feature: ChatFeature + allowed: FeatureAllowed + param?: number // int + } + + export interface SndChatPreference extends Interface { + type: "sndChatPreference" + feature: ChatFeature + allowed: FeatureAllowed + param?: number // int + } + + export interface RcvGroupFeature extends Interface { + type: "rcvGroupFeature" + groupFeature: GroupFeature + preference: GroupPreference + param?: number // int + memberRole_?: GroupMemberRole + } + + export interface SndGroupFeature extends Interface { + type: "sndGroupFeature" + groupFeature: GroupFeature + preference: GroupPreference + param?: number // int + memberRole_?: GroupMemberRole + } + + export interface RcvChatFeatureRejected extends Interface { + type: "rcvChatFeatureRejected" + feature: ChatFeature + } + + export interface RcvGroupFeatureRejected extends Interface { + type: "rcvGroupFeatureRejected" + groupFeature: GroupFeature + } + + export interface SndModerated extends Interface { + type: "sndModerated" + } + + export interface RcvModerated extends Interface { + type: "rcvModerated" + } + + export interface RcvBlocked extends Interface { + type: "rcvBlocked" + } + + export interface SndDirectE2EEInfo extends Interface { + type: "sndDirectE2EEInfo" + e2eeInfo: E2EInfo + } + + export interface RcvDirectE2EEInfo extends Interface { + type: "rcvDirectE2EEInfo" + e2eeInfo: E2EInfo + } + + export interface SndGroupE2EEInfo extends Interface { + type: "sndGroupE2EEInfo" + e2eeInfo: E2EInfo + } + + export interface RcvGroupE2EEInfo extends Interface { + type: "rcvGroupE2EEInfo" + e2eeInfo: E2EInfo + } + + export interface ChatBanner extends Interface { + type: "chatBanner" + } +} + +export enum CIDeleteMode { + Broadcast = "broadcast", + Internal = "internal", + InternalMark = "internalMark", +} + +export type CIDeleted = CIDeleted.Deleted | CIDeleted.Blocked | CIDeleted.BlockedByAdmin | CIDeleted.Moderated + +export namespace CIDeleted { + export type Tag = "deleted" | "blocked" | "blockedByAdmin" | "moderated" + + interface Interface { + type: Tag + } + + export interface Deleted extends Interface { + type: "deleted" + deletedTs?: string // ISO-8601 timestamp + chatType: ChatType + } + + export interface Blocked extends Interface { + type: "blocked" + deletedTs?: string // ISO-8601 timestamp + } + + export interface BlockedByAdmin extends Interface { + type: "blockedByAdmin" + deletedTs?: string // ISO-8601 timestamp + } + + export interface Moderated extends Interface { + type: "moderated" + deletedTs?: string // ISO-8601 timestamp + byGroupMember: GroupMember + } +} + +export type CIDirection = + | CIDirection.DirectSnd + | CIDirection.DirectRcv + | CIDirection.GroupSnd + | CIDirection.GroupRcv + | CIDirection.LocalSnd + | CIDirection.LocalRcv + +export namespace CIDirection { + export type Tag = "directSnd" | "directRcv" | "groupSnd" | "groupRcv" | "localSnd" | "localRcv" + + interface Interface { + type: Tag + } + + export interface DirectSnd extends Interface { + type: "directSnd" + } + + export interface DirectRcv extends Interface { + type: "directRcv" + } + + export interface GroupSnd extends Interface { + type: "groupSnd" + } + + export interface GroupRcv extends Interface { + type: "groupRcv" + groupMember: GroupMember + } + + export interface LocalSnd extends Interface { + type: "localSnd" + } + + export interface LocalRcv extends Interface { + type: "localRcv" + } +} + +export interface CIFile { + fileId: number // int64 + fileName: string + fileSize: number // int64 + fileSource?: CryptoFile + fileStatus: CIFileStatus + fileProtocol: FileProtocol +} + +export type CIFileStatus = + | CIFileStatus.SndStored + | CIFileStatus.SndTransfer + | CIFileStatus.SndCancelled + | CIFileStatus.SndComplete + | CIFileStatus.SndError + | CIFileStatus.SndWarning + | CIFileStatus.RcvInvitation + | CIFileStatus.RcvAccepted + | CIFileStatus.RcvTransfer + | CIFileStatus.RcvAborted + | CIFileStatus.RcvComplete + | CIFileStatus.RcvCancelled + | CIFileStatus.RcvError + | CIFileStatus.RcvWarning + | CIFileStatus.Invalid + +export namespace CIFileStatus { + export type Tag = + | "sndStored" + | "sndTransfer" + | "sndCancelled" + | "sndComplete" + | "sndError" + | "sndWarning" + | "rcvInvitation" + | "rcvAccepted" + | "rcvTransfer" + | "rcvAborted" + | "rcvComplete" + | "rcvCancelled" + | "rcvError" + | "rcvWarning" + | "invalid" + + interface Interface { + type: Tag + } + + export interface SndStored extends Interface { + type: "sndStored" + } + + export interface SndTransfer extends Interface { + type: "sndTransfer" + sndProgress: number // int64 + sndTotal: number // int64 + } + + export interface SndCancelled extends Interface { + type: "sndCancelled" + } + + export interface SndComplete extends Interface { + type: "sndComplete" + } + + export interface SndError extends Interface { + type: "sndError" + sndFileError: FileError + } + + export interface SndWarning extends Interface { + type: "sndWarning" + sndFileError: FileError + } + + export interface RcvInvitation extends Interface { + type: "rcvInvitation" + } + + export interface RcvAccepted extends Interface { + type: "rcvAccepted" + } + + export interface RcvTransfer extends Interface { + type: "rcvTransfer" + rcvProgress: number // int64 + rcvTotal: number // int64 + } + + export interface RcvAborted extends Interface { + type: "rcvAborted" + } + + export interface RcvComplete extends Interface { + type: "rcvComplete" + } + + export interface RcvCancelled extends Interface { + type: "rcvCancelled" + } + + export interface RcvError extends Interface { + type: "rcvError" + rcvFileError: FileError + } + + export interface RcvWarning extends Interface { + type: "rcvWarning" + rcvFileError: FileError + } + + export interface Invalid extends Interface { + type: "invalid" + text: string + } +} + +export type CIForwardedFrom = CIForwardedFrom.Unknown | CIForwardedFrom.Contact | CIForwardedFrom.Group + +export namespace CIForwardedFrom { + export type Tag = "unknown" | "contact" | "group" + + interface Interface { + type: Tag + } + + export interface Unknown extends Interface { + type: "unknown" + } + + export interface Contact extends Interface { + type: "contact" + chatName: string + msgDir: MsgDirection + contactId?: number // int64 + chatItemId?: number // int64 + } + + export interface Group extends Interface { + type: "group" + chatName: string + msgDir: MsgDirection + groupId?: number // int64 + chatItemId?: number // int64 + } +} + +export interface CIGroupInvitation { + groupId: number // int64 + groupMemberId: number // int64 + localDisplayName: string + groupProfile: GroupProfile + status: CIGroupInvitationStatus +} + +export enum CIGroupInvitationStatus { + Pending = "pending", + Accepted = "accepted", + Rejected = "rejected", + Expired = "expired", +} + +export interface CIMention { + memberId: string + memberRef?: CIMentionMember +} + +export interface CIMentionMember { + groupMemberId: number // int64 + displayName: string + localAlias?: string + memberRole: GroupMemberRole +} + +export interface CIMeta { + itemId: number // int64 + itemTs: string // ISO-8601 timestamp + itemText: string + itemStatus: CIStatus + sentViaProxy?: boolean + itemSharedMsgId?: string + itemForwarded?: CIForwardedFrom + itemDeleted?: CIDeleted + itemEdited: boolean + itemTimed?: CITimed + itemLive?: boolean + userMention: boolean + deletable: boolean + editable: boolean + forwardedByMember?: number // int64 + showGroupAsSender: boolean + createdAt: string // ISO-8601 timestamp + updatedAt: string // ISO-8601 timestamp +} + +export interface CIQuote { + chatDir?: CIDirection + itemId?: number // int64 + sharedMsgId?: string + sentAt: string // ISO-8601 timestamp + content: MsgContent + formattedText?: FormattedText[] +} + +export interface CIReaction { + chatDir: CIDirection + chatItem: ChatItem + sentAt: string // ISO-8601 timestamp + reaction: MsgReaction +} + +export interface CIReactionCount { + reaction: MsgReaction + userReacted: boolean + totalReacted: number // int +} + +export type CIStatus = + | CIStatus.SndNew + | CIStatus.SndSent + | CIStatus.SndRcvd + | CIStatus.SndErrorAuth + | CIStatus.SndError + | CIStatus.SndWarning + | CIStatus.RcvNew + | CIStatus.RcvRead + | CIStatus.Invalid + +export namespace CIStatus { + export type Tag = + | "sndNew" + | "sndSent" + | "sndRcvd" + | "sndErrorAuth" + | "sndError" + | "sndWarning" + | "rcvNew" + | "rcvRead" + | "invalid" + + interface Interface { + type: Tag + } + + export interface SndNew extends Interface { + type: "sndNew" + } + + export interface SndSent extends Interface { + type: "sndSent" + sndProgress: SndCIStatusProgress + } + + export interface SndRcvd extends Interface { + type: "sndRcvd" + msgRcptStatus: MsgReceiptStatus + sndProgress: SndCIStatusProgress + } + + export interface SndErrorAuth extends Interface { + type: "sndErrorAuth" + } + + export interface SndError extends Interface { + type: "sndError" + agentError: SndError + } + + export interface SndWarning extends Interface { + type: "sndWarning" + agentError: SndError + } + + export interface RcvNew extends Interface { + type: "rcvNew" + } + + export interface RcvRead extends Interface { + type: "rcvRead" + } + + export interface Invalid extends Interface { + type: "invalid" + text: string + } +} + +export interface CITimed { + ttl: number // int + deleteAt?: string // ISO-8601 timestamp +} + +export type ChatBotCommand = ChatBotCommand.Command | ChatBotCommand.Menu + +export namespace ChatBotCommand { + export type Tag = "command" | "menu" + + interface Interface { + type: Tag + } + + export interface Command extends Interface { + type: "command" + keyword: string + label: string + params?: string + } + + export interface Menu extends Interface { + type: "menu" + label: string + commands: ChatBotCommand[] + } +} + +export type ChatDeleteMode = ChatDeleteMode.Full | ChatDeleteMode.Entity | ChatDeleteMode.Messages + +export namespace ChatDeleteMode { + export type Tag = "full" | "entity" | "messages" + + interface Interface { + type: Tag + } + + export interface Full extends Interface { + type: "full" + notify: boolean + } + + export interface Entity extends Interface { + type: "entity" + notify: boolean + } + + export interface Messages extends Interface { + type: "messages" + } + + export function cmdString(self: ChatDeleteMode): string { + return self.type + (self.type == 'messages' ? '' : (!self.notify ? ' notify=off' : '')) + } +} + +export type ChatError = ChatError.Error | ChatError.ErrorAgent | ChatError.ErrorStore + +export namespace ChatError { + export type Tag = "error" | "errorAgent" | "errorStore" + + interface Interface { + type: Tag + } + + export interface Error extends Interface { + type: "error" + errorType: ChatErrorType + } + + export interface ErrorAgent extends Interface { + type: "errorAgent" + agentError: AgentErrorType + agentConnId: string + connectionEntity_?: ConnectionEntity + } + + export interface ErrorStore extends Interface { + type: "errorStore" + storeError: StoreError + } +} + +export type ChatErrorType = + | ChatErrorType.NoActiveUser + | ChatErrorType.NoConnectionUser + | ChatErrorType.NoSndFileUser + | ChatErrorType.NoRcvFileUser + | ChatErrorType.UserUnknown + | ChatErrorType.ActiveUserExists + | ChatErrorType.UserExists + | ChatErrorType.DifferentActiveUser + | ChatErrorType.CantDeleteActiveUser + | ChatErrorType.CantDeleteLastUser + | ChatErrorType.CantHideLastUser + | ChatErrorType.HiddenUserAlwaysMuted + | ChatErrorType.EmptyUserPassword + | ChatErrorType.UserAlreadyHidden + | ChatErrorType.UserNotHidden + | ChatErrorType.InvalidDisplayName + | ChatErrorType.ChatNotStarted + | ChatErrorType.ChatNotStopped + | ChatErrorType.ChatStoreChanged + | ChatErrorType.InvalidConnReq + | ChatErrorType.UnsupportedConnReq + | ChatErrorType.ConnReqMessageProhibited + | ChatErrorType.ContactNotReady + | ChatErrorType.ContactNotActive + | ChatErrorType.ContactDisabled + | ChatErrorType.ConnectionDisabled + | ChatErrorType.GroupUserRole + | ChatErrorType.GroupMemberInitialRole + | ChatErrorType.ContactIncognitoCantInvite + | ChatErrorType.GroupIncognitoCantInvite + | ChatErrorType.GroupContactRole + | ChatErrorType.GroupDuplicateMember + | ChatErrorType.GroupDuplicateMemberId + | ChatErrorType.GroupNotJoined + | ChatErrorType.GroupMemberNotActive + | ChatErrorType.CantBlockMemberForSelf + | ChatErrorType.GroupMemberUserRemoved + | ChatErrorType.GroupMemberNotFound + | ChatErrorType.GroupCantResendInvitation + | ChatErrorType.GroupInternal + | ChatErrorType.FileNotFound + | ChatErrorType.FileSize + | ChatErrorType.FileAlreadyReceiving + | ChatErrorType.FileCancelled + | ChatErrorType.FileCancel + | ChatErrorType.FileAlreadyExists + | ChatErrorType.FileWrite + | ChatErrorType.FileSend + | ChatErrorType.FileRcvChunk + | ChatErrorType.FileInternal + | ChatErrorType.FileImageType + | ChatErrorType.FileImageSize + | ChatErrorType.FileNotReceived + | ChatErrorType.FileNotApproved + | ChatErrorType.FallbackToSMPProhibited + | ChatErrorType.InlineFileProhibited + | ChatErrorType.InvalidForward + | ChatErrorType.InvalidChatItemUpdate + | ChatErrorType.InvalidChatItemDelete + | ChatErrorType.HasCurrentCall + | ChatErrorType.NoCurrentCall + | ChatErrorType.CallContact + | ChatErrorType.DirectMessagesProhibited + | ChatErrorType.AgentVersion + | ChatErrorType.AgentNoSubResult + | ChatErrorType.CommandError + | ChatErrorType.AgentCommandError + | ChatErrorType.InvalidFileDescription + | ChatErrorType.ConnectionIncognitoChangeProhibited + | ChatErrorType.ConnectionUserChangeProhibited + | ChatErrorType.PeerChatVRangeIncompatible + | ChatErrorType.InternalError + | ChatErrorType.Exception + +export namespace ChatErrorType { + export type Tag = + | "noActiveUser" + | "noConnectionUser" + | "noSndFileUser" + | "noRcvFileUser" + | "userUnknown" + | "activeUserExists" + | "userExists" + | "differentActiveUser" + | "cantDeleteActiveUser" + | "cantDeleteLastUser" + | "cantHideLastUser" + | "hiddenUserAlwaysMuted" + | "emptyUserPassword" + | "userAlreadyHidden" + | "userNotHidden" + | "invalidDisplayName" + | "chatNotStarted" + | "chatNotStopped" + | "chatStoreChanged" + | "invalidConnReq" + | "unsupportedConnReq" + | "connReqMessageProhibited" + | "contactNotReady" + | "contactNotActive" + | "contactDisabled" + | "connectionDisabled" + | "groupUserRole" + | "groupMemberInitialRole" + | "contactIncognitoCantInvite" + | "groupIncognitoCantInvite" + | "groupContactRole" + | "groupDuplicateMember" + | "groupDuplicateMemberId" + | "groupNotJoined" + | "groupMemberNotActive" + | "cantBlockMemberForSelf" + | "groupMemberUserRemoved" + | "groupMemberNotFound" + | "groupCantResendInvitation" + | "groupInternal" + | "fileNotFound" + | "fileSize" + | "fileAlreadyReceiving" + | "fileCancelled" + | "fileCancel" + | "fileAlreadyExists" + | "fileWrite" + | "fileSend" + | "fileRcvChunk" + | "fileInternal" + | "fileImageType" + | "fileImageSize" + | "fileNotReceived" + | "fileNotApproved" + | "fallbackToSMPProhibited" + | "inlineFileProhibited" + | "invalidForward" + | "invalidChatItemUpdate" + | "invalidChatItemDelete" + | "hasCurrentCall" + | "noCurrentCall" + | "callContact" + | "directMessagesProhibited" + | "agentVersion" + | "agentNoSubResult" + | "commandError" + | "agentCommandError" + | "invalidFileDescription" + | "connectionIncognitoChangeProhibited" + | "connectionUserChangeProhibited" + | "peerChatVRangeIncompatible" + | "internalError" + | "exception" + + interface Interface { + type: Tag + } + + export interface NoActiveUser extends Interface { + type: "noActiveUser" + } + + export interface NoConnectionUser extends Interface { + type: "noConnectionUser" + agentConnId: string + } + + export interface NoSndFileUser extends Interface { + type: "noSndFileUser" + agentSndFileId: string + } + + export interface NoRcvFileUser extends Interface { + type: "noRcvFileUser" + agentRcvFileId: string + } + + export interface UserUnknown extends Interface { + type: "userUnknown" + } + + export interface ActiveUserExists extends Interface { + type: "activeUserExists" + } + + export interface UserExists extends Interface { + type: "userExists" + contactName: string + } + + export interface DifferentActiveUser extends Interface { + type: "differentActiveUser" + commandUserId: number // int64 + activeUserId: number // int64 + } + + export interface CantDeleteActiveUser extends Interface { + type: "cantDeleteActiveUser" + userId: number // int64 + } + + export interface CantDeleteLastUser extends Interface { + type: "cantDeleteLastUser" + userId: number // int64 + } + + export interface CantHideLastUser extends Interface { + type: "cantHideLastUser" + userId: number // int64 + } + + export interface HiddenUserAlwaysMuted extends Interface { + type: "hiddenUserAlwaysMuted" + userId: number // int64 + } + + export interface EmptyUserPassword extends Interface { + type: "emptyUserPassword" + userId: number // int64 + } + + export interface UserAlreadyHidden extends Interface { + type: "userAlreadyHidden" + userId: number // int64 + } + + export interface UserNotHidden extends Interface { + type: "userNotHidden" + userId: number // int64 + } + + export interface InvalidDisplayName extends Interface { + type: "invalidDisplayName" + displayName: string + validName: string + } + + export interface ChatNotStarted extends Interface { + type: "chatNotStarted" + } + + export interface ChatNotStopped extends Interface { + type: "chatNotStopped" + } + + export interface ChatStoreChanged extends Interface { + type: "chatStoreChanged" + } + + export interface InvalidConnReq extends Interface { + type: "invalidConnReq" + } + + export interface UnsupportedConnReq extends Interface { + type: "unsupportedConnReq" + } + + export interface ConnReqMessageProhibited extends Interface { + type: "connReqMessageProhibited" + } + + export interface ContactNotReady extends Interface { + type: "contactNotReady" + contact: Contact + } + + export interface ContactNotActive extends Interface { + type: "contactNotActive" + contact: Contact + } + + export interface ContactDisabled extends Interface { + type: "contactDisabled" + contact: Contact + } + + export interface ConnectionDisabled extends Interface { + type: "connectionDisabled" + connection: Connection + } + + export interface GroupUserRole extends Interface { + type: "groupUserRole" + groupInfo: GroupInfo + requiredRole: GroupMemberRole + } + + export interface GroupMemberInitialRole extends Interface { + type: "groupMemberInitialRole" + groupInfo: GroupInfo + initialRole: GroupMemberRole + } + + export interface ContactIncognitoCantInvite extends Interface { + type: "contactIncognitoCantInvite" + } + + export interface GroupIncognitoCantInvite extends Interface { + type: "groupIncognitoCantInvite" + } + + export interface GroupContactRole extends Interface { + type: "groupContactRole" + contactName: string + } + + export interface GroupDuplicateMember extends Interface { + type: "groupDuplicateMember" + contactName: string + } + + export interface GroupDuplicateMemberId extends Interface { + type: "groupDuplicateMemberId" + } + + export interface GroupNotJoined extends Interface { + type: "groupNotJoined" + groupInfo: GroupInfo + } + + export interface GroupMemberNotActive extends Interface { + type: "groupMemberNotActive" + } + + export interface CantBlockMemberForSelf extends Interface { + type: "cantBlockMemberForSelf" + groupInfo: GroupInfo + member: GroupMember + setShowMessages: boolean + } + + export interface GroupMemberUserRemoved extends Interface { + type: "groupMemberUserRemoved" + } + + export interface GroupMemberNotFound extends Interface { + type: "groupMemberNotFound" + } + + export interface GroupCantResendInvitation extends Interface { + type: "groupCantResendInvitation" + groupInfo: GroupInfo + contactName: string + } + + export interface GroupInternal extends Interface { + type: "groupInternal" + message: string + } + + export interface FileNotFound extends Interface { + type: "fileNotFound" + message: string + } + + export interface FileSize extends Interface { + type: "fileSize" + filePath: string + } + + export interface FileAlreadyReceiving extends Interface { + type: "fileAlreadyReceiving" + message: string + } + + export interface FileCancelled extends Interface { + type: "fileCancelled" + message: string + } + + export interface FileCancel extends Interface { + type: "fileCancel" + fileId: number // int64 + message: string + } + + export interface FileAlreadyExists extends Interface { + type: "fileAlreadyExists" + filePath: string + } + + export interface FileWrite extends Interface { + type: "fileWrite" + filePath: string + message: string + } + + export interface FileSend extends Interface { + type: "fileSend" + fileId: number // int64 + agentError: AgentErrorType + } + + export interface FileRcvChunk extends Interface { + type: "fileRcvChunk" + message: string + } + + export interface FileInternal extends Interface { + type: "fileInternal" + message: string + } + + export interface FileImageType extends Interface { + type: "fileImageType" + filePath: string + } + + export interface FileImageSize extends Interface { + type: "fileImageSize" + filePath: string + } + + export interface FileNotReceived extends Interface { + type: "fileNotReceived" + fileId: number // int64 + } + + export interface FileNotApproved extends Interface { + type: "fileNotApproved" + fileId: number // int64 + unknownServers: string[] + } + + export interface FallbackToSMPProhibited extends Interface { + type: "fallbackToSMPProhibited" + fileId: number // int64 + } + + export interface InlineFileProhibited extends Interface { + type: "inlineFileProhibited" + fileId: number // int64 + } + + export interface InvalidForward extends Interface { + type: "invalidForward" + } + + export interface InvalidChatItemUpdate extends Interface { + type: "invalidChatItemUpdate" + } + + export interface InvalidChatItemDelete extends Interface { + type: "invalidChatItemDelete" + } + + export interface HasCurrentCall extends Interface { + type: "hasCurrentCall" + } + + export interface NoCurrentCall extends Interface { + type: "noCurrentCall" + } + + export interface CallContact extends Interface { + type: "callContact" + contactId: number // int64 + } + + export interface DirectMessagesProhibited extends Interface { + type: "directMessagesProhibited" + direction: MsgDirection + contact: Contact + } + + export interface AgentVersion extends Interface { + type: "agentVersion" + } + + export interface AgentNoSubResult extends Interface { + type: "agentNoSubResult" + agentConnId: string + } + + export interface CommandError extends Interface { + type: "commandError" + message: string + } + + export interface AgentCommandError extends Interface { + type: "agentCommandError" + message: string + } + + export interface InvalidFileDescription extends Interface { + type: "invalidFileDescription" + message: string + } + + export interface ConnectionIncognitoChangeProhibited extends Interface { + type: "connectionIncognitoChangeProhibited" + } + + export interface ConnectionUserChangeProhibited extends Interface { + type: "connectionUserChangeProhibited" + } + + export interface PeerChatVRangeIncompatible extends Interface { + type: "peerChatVRangeIncompatible" + } + + export interface InternalError extends Interface { + type: "internalError" + message: string + } + + export interface Exception extends Interface { + type: "exception" + message: string + } +} + +export enum ChatFeature { + TimedMessages = "timedMessages", + FullDelete = "fullDelete", + Reactions = "reactions", + Voice = "voice", + Files = "files", + Calls = "calls", + Sessions = "sessions", +} + +export type ChatInfo = + | ChatInfo.Direct + | ChatInfo.Group + | ChatInfo.Local + | ChatInfo.ContactRequest + | ChatInfo.ContactConnection + +export namespace ChatInfo { + export type Tag = "direct" | "group" | "local" | "contactRequest" | "contactConnection" + + interface Interface { + type: Tag + } + + export interface Direct extends Interface { + type: "direct" + contact: Contact + } + + export interface Group extends Interface { + type: "group" + groupInfo: GroupInfo + groupChatScope?: GroupChatScopeInfo + } + + export interface Local extends Interface { + type: "local" + noteFolder: NoteFolder + } + + export interface ContactRequest extends Interface { + type: "contactRequest" + contactRequest: UserContactRequest + } + + export interface ContactConnection extends Interface { + type: "contactConnection" + contactConnection: PendingContactConnection + } +} + +export interface ChatItem { + chatDir: CIDirection + meta: CIMeta + content: CIContent + mentions: {[key: string]: CIMention} + formattedText?: FormattedText[] + quotedItem?: CIQuote + reactions: CIReactionCount[] + file?: CIFile +} +// Message deletion result. + +export interface ChatItemDeletion { + deletedChatItem: AChatItem + toChatItem?: AChatItem +} + +export enum ChatPeerType { + Human = "human", + Bot = "bot", +} +// Used in API commands. Chat scope can only be passed with groups. + +export interface ChatRef { + chatType: ChatType + chatId: number // int64 + chatScope?: GroupChatScope +} + +export namespace ChatRef { + export function cmdString(self: ChatRef): string { + return self.chatType.toString() + self.chatId + (self.chatScope ? self.chatScope.toString() : '') + } +} + +export interface ChatSettings { + enableNtfs: MsgFilter + sendRcpts?: boolean + favorite: boolean +} + +export interface ChatStats { + unreadCount: number // int + unreadMentions: number // int + reportsCount: number // int + minUnreadItemId: number // int64 + unreadChat: boolean +} + +export enum ChatType { + Direct = "direct", + Group = "group", + Local = "local", +} + +export namespace ChatType { + export function cmdString(self: ChatType): string { + return self == 'direct' ? '@' : self == 'group' ? '#' : self == 'local' ? '*' : '' + } +} + +export interface ChatWallpaper { + preset?: string + imageFile?: string + background?: string + tint?: string + scaleType?: ChatWallpaperScale + scale?: number // double +} + +export enum ChatWallpaperScale { + Fill = "fill", + Fit = "fit", + Repeat = "repeat", +} + +export interface ClientNotice { + ttl?: number // int64 +} + +export enum Color { + Black = "black", + Red = "red", + Green = "green", + Yellow = "yellow", + Blue = "blue", + Magenta = "magenta", + Cyan = "cyan", + White = "white", +} + +export type CommandError = + | CommandError.UNKNOWN + | CommandError.SYNTAX + | CommandError.PROHIBITED + | CommandError.NO_AUTH + | CommandError.HAS_AUTH + | CommandError.NO_ENTITY + +export namespace CommandError { + export type Tag = "UNKNOWN" | "SYNTAX" | "PROHIBITED" | "NO_AUTH" | "HAS_AUTH" | "NO_ENTITY" + + interface Interface { + type: Tag + } + + export interface UNKNOWN extends Interface { + type: "UNKNOWN" + } + + export interface SYNTAX extends Interface { + type: "SYNTAX" + } + + export interface PROHIBITED extends Interface { + type: "PROHIBITED" + } + + export interface NO_AUTH extends Interface { + type: "NO_AUTH" + } + + export interface HAS_AUTH extends Interface { + type: "HAS_AUTH" + } + + export interface NO_ENTITY extends Interface { + type: "NO_ENTITY" + } +} + +export type CommandErrorType = + | CommandErrorType.PROHIBITED + | CommandErrorType.SYNTAX + | CommandErrorType.NO_CONN + | CommandErrorType.SIZE + | CommandErrorType.LARGE + +export namespace CommandErrorType { + export type Tag = "PROHIBITED" | "SYNTAX" | "NO_CONN" | "SIZE" | "LARGE" + + interface Interface { + type: Tag + } + + export interface PROHIBITED extends Interface { + type: "PROHIBITED" + } + + export interface SYNTAX extends Interface { + type: "SYNTAX" + } + + export interface NO_CONN extends Interface { + type: "NO_CONN" + } + + export interface SIZE extends Interface { + type: "SIZE" + } + + export interface LARGE extends Interface { + type: "LARGE" + } +} + +export interface ComposedMessage { + fileSource?: CryptoFile + quotedItemId?: number // int64 + msgContent: MsgContent + mentions: {[key: string]: number} // string : int64 +} + +export enum ConnStatus { + New = "new", + Prepared = "prepared", + Joined = "joined", + Requested = "requested", + Accepted = "accepted", + Snd_ready = "snd-ready", + Ready = "ready", + Deleted = "deleted", +} + +export enum ConnType { + Contact = "contact", + Member = "member", + User_contact = "user_contact", +} + +export interface Connection { + connId: number // int64 + agentConnId: string + connChatVersion: number // int + peerChatVRange: VersionRange + connLevel: number // int + viaContact?: number // int64 + viaUserContactLink?: number // int64 + viaGroupLink: boolean + groupLinkId?: string + xContactId?: string + customUserProfileId?: number // int64 + connType: ConnType + connStatus: ConnStatus + contactConnInitiated: boolean + localAlias: string + entityId?: number // int64 + connectionCode?: SecurityCode + pqSupport: boolean + pqEncryption: boolean + pqSndEnabled?: boolean + pqRcvEnabled?: boolean + authErrCounter: number // int + quotaErrCounter: number // int + createdAt: string // ISO-8601 timestamp +} + +export type ConnectionEntity = + | ConnectionEntity.RcvDirectMsgConnection + | ConnectionEntity.RcvGroupMsgConnection + | ConnectionEntity.UserContactConnection + +export namespace ConnectionEntity { + export type Tag = "rcvDirectMsgConnection" | "rcvGroupMsgConnection" | "userContactConnection" + + interface Interface { + type: Tag + } + + export interface RcvDirectMsgConnection extends Interface { + type: "rcvDirectMsgConnection" + entityConnection: Connection + contact?: Contact + } + + export interface RcvGroupMsgConnection extends Interface { + type: "rcvGroupMsgConnection" + entityConnection: Connection + groupInfo: GroupInfo + groupMember: GroupMember + } + + export interface UserContactConnection extends Interface { + type: "userContactConnection" + entityConnection: Connection + userContact: UserContact + } +} + +export type ConnectionErrorType = + | ConnectionErrorType.NOT_FOUND + | ConnectionErrorType.DUPLICATE + | ConnectionErrorType.SIMPLEX + | ConnectionErrorType.NOT_ACCEPTED + | ConnectionErrorType.NOT_AVAILABLE + +export namespace ConnectionErrorType { + export type Tag = "NOT_FOUND" | "DUPLICATE" | "SIMPLEX" | "NOT_ACCEPTED" | "NOT_AVAILABLE" + + interface Interface { + type: Tag + } + + export interface NOT_FOUND extends Interface { + type: "NOT_FOUND" + } + + export interface DUPLICATE extends Interface { + type: "DUPLICATE" + } + + export interface SIMPLEX extends Interface { + type: "SIMPLEX" + } + + export interface NOT_ACCEPTED extends Interface { + type: "NOT_ACCEPTED" + } + + export interface NOT_AVAILABLE extends Interface { + type: "NOT_AVAILABLE" + } +} + +export enum ConnectionMode { + INV = "inv", + CON = "con", +} + +export type ConnectionPlan = + | ConnectionPlan.InvitationLink + | ConnectionPlan.ContactAddress + | ConnectionPlan.GroupLink + | ConnectionPlan.Error + +export namespace ConnectionPlan { + export type Tag = "invitationLink" | "contactAddress" | "groupLink" | "error" + + interface Interface { + type: Tag + } + + export interface InvitationLink extends Interface { + type: "invitationLink" + invitationLinkPlan: InvitationLinkPlan + } + + export interface ContactAddress extends Interface { + type: "contactAddress" + contactAddressPlan: ContactAddressPlan + } + + export interface GroupLink extends Interface { + type: "groupLink" + groupLinkPlan: GroupLinkPlan + } + + export interface Error extends Interface { + type: "error" + chatError: ChatError + } +} + +export interface Contact { + contactId: number // int64 + localDisplayName: string + profile: LocalProfile + activeConn?: Connection + contactUsed: boolean + contactStatus: ContactStatus + chatSettings: ChatSettings + userPreferences: Preferences + mergedPreferences: ContactUserPreferences + createdAt: string // ISO-8601 timestamp + updatedAt: string // ISO-8601 timestamp + chatTs?: string // ISO-8601 timestamp + preparedContact?: PreparedContact + contactRequestId?: number // int64 + contactGroupMemberId?: number // int64 + contactGrpInvSent: boolean + groupDirectInv?: GroupDirectInvitation + chatTags: number[] // int64 + chatItemTTL?: number // int64 + uiThemes?: UIThemeEntityOverrides + chatDeleted: boolean + customData?: object +} + +export type ContactAddressPlan = + | ContactAddressPlan.Ok + | ContactAddressPlan.OwnLink + | ContactAddressPlan.ConnectingConfirmReconnect + | ContactAddressPlan.ConnectingProhibit + | ContactAddressPlan.Known + | ContactAddressPlan.ContactViaAddress + +export namespace ContactAddressPlan { + export type Tag = + | "ok" + | "ownLink" + | "connectingConfirmReconnect" + | "connectingProhibit" + | "known" + | "contactViaAddress" + + interface Interface { + type: Tag + } + + export interface Ok extends Interface { + type: "ok" + contactSLinkData_?: ContactShortLinkData + } + + export interface OwnLink extends Interface { + type: "ownLink" + } + + export interface ConnectingConfirmReconnect extends Interface { + type: "connectingConfirmReconnect" + } + + export interface ConnectingProhibit extends Interface { + type: "connectingProhibit" + contact: Contact + } + + export interface Known extends Interface { + type: "known" + contact: Contact + } + + export interface ContactViaAddress extends Interface { + type: "contactViaAddress" + contact: Contact + } +} + +export interface ContactShortLinkData { + profile: Profile + message?: MsgContent + business: boolean +} + +export enum ContactStatus { + Active = "active", + Deleted = "deleted", + DeletedByUser = "deletedByUser", +} + +export type ContactUserPref = ContactUserPref.Contact | ContactUserPref.User + +export namespace ContactUserPref { + export type Tag = "contact" | "user" + + interface Interface { + type: Tag + } + + export interface Contact extends Interface { + type: "contact" + preference: SimplePreference + } + + export interface User extends Interface { + type: "user" + preference: SimplePreference + } +} + +export interface ContactUserPreference { + enabled: PrefEnabled + userPreference: ContactUserPref + contactPreference: SimplePreference +} + +export interface ContactUserPreferences { + timedMessages: ContactUserPreference + fullDelete: ContactUserPreference + reactions: ContactUserPreference + voice: ContactUserPreference + files: ContactUserPreference + calls: ContactUserPreference + sessions: ContactUserPreference + commands?: ChatBotCommand[] +} + +export interface CreatedConnLink { + connFullLink: string + connShortLink?: string +} + +export namespace CreatedConnLink { + export function cmdString(self: CreatedConnLink): string { + return self.connFullLink + (self.connShortLink ? ' ' + self.connShortLink : '') + } +} + +export interface CryptoFile { + filePath: string + cryptoArgs?: CryptoFileArgs +} + +export interface CryptoFileArgs { + fileKey: string + fileNonce: string +} + +export interface E2EInfo { + pqEnabled?: boolean +} + +export type ErrorType = + | ErrorType.BLOCK + | ErrorType.SESSION + | ErrorType.CMD + | ErrorType.PROXY + | ErrorType.AUTH + | ErrorType.BLOCKED + | ErrorType.SERVICE + | ErrorType.CRYPTO + | ErrorType.QUOTA + | ErrorType.STORE + | ErrorType.NO_MSG + | ErrorType.LARGE_MSG + | ErrorType.EXPIRED + | ErrorType.INTERNAL + | ErrorType.DUPLICATE_ + +export namespace ErrorType { + export type Tag = + | "BLOCK" + | "SESSION" + | "CMD" + | "PROXY" + | "AUTH" + | "BLOCKED" + | "SERVICE" + | "CRYPTO" + | "QUOTA" + | "STORE" + | "NO_MSG" + | "LARGE_MSG" + | "EXPIRED" + | "INTERNAL" + | "DUPLICATE_" + + interface Interface { + type: Tag + } + + export interface BLOCK extends Interface { + type: "BLOCK" + } + + export interface SESSION extends Interface { + type: "SESSION" + } + + export interface CMD extends Interface { + type: "CMD" + cmdErr: CommandError + } + + export interface PROXY extends Interface { + type: "PROXY" + proxyErr: ProxyError + } + + export interface AUTH extends Interface { + type: "AUTH" + } + + export interface BLOCKED extends Interface { + type: "BLOCKED" + blockInfo: BlockingInfo + } + + export interface SERVICE extends Interface { + type: "SERVICE" + } + + export interface CRYPTO extends Interface { + type: "CRYPTO" + } + + export interface QUOTA extends Interface { + type: "QUOTA" + } + + export interface STORE extends Interface { + type: "STORE" + storeErr: string + } + + export interface NO_MSG extends Interface { + type: "NO_MSG" + } + + export interface LARGE_MSG extends Interface { + type: "LARGE_MSG" + } + + export interface EXPIRED extends Interface { + type: "EXPIRED" + } + + export interface INTERNAL extends Interface { + type: "INTERNAL" + } + + export interface DUPLICATE_ extends Interface { + type: "DUPLICATE_" + } +} + +export enum FeatureAllowed { + Always = "always", + Yes = "yes", + No = "no", +} + +export interface FileDescr { + fileDescrText: string + fileDescrPartNo: number // int + fileDescrComplete: boolean +} + +export type FileError = + | FileError.Auth + | FileError.Blocked + | FileError.NoFile + | FileError.Relay + | FileError.Other + +export namespace FileError { + export type Tag = "auth" | "blocked" | "noFile" | "relay" | "other" + + interface Interface { + type: Tag + } + + export interface Auth extends Interface { + type: "auth" + } + + export interface Blocked extends Interface { + type: "blocked" + server: string + blockInfo: BlockingInfo + } + + export interface NoFile extends Interface { + type: "noFile" + } + + export interface Relay extends Interface { + type: "relay" + srvError: SrvError + } + + export interface Other extends Interface { + type: "other" + fileError: string + } +} + +export type FileErrorType = + | FileErrorType.NOT_APPROVED + | FileErrorType.SIZE + | FileErrorType.REDIRECT + | FileErrorType.FILE_IO + | FileErrorType.NO_FILE + +export namespace FileErrorType { + export type Tag = "NOT_APPROVED" | "SIZE" | "REDIRECT" | "FILE_IO" | "NO_FILE" + + interface Interface { + type: Tag + } + + export interface NOT_APPROVED extends Interface { + type: "NOT_APPROVED" + } + + export interface SIZE extends Interface { + type: "SIZE" + } + + export interface REDIRECT extends Interface { + type: "REDIRECT" + redirectError: string + } + + export interface FILE_IO extends Interface { + type: "FILE_IO" + fileIOError: string + } + + export interface NO_FILE extends Interface { + type: "NO_FILE" + } +} + +export interface FileInvitation { + fileName: string + fileSize: number // int64 + fileDigest?: string + fileConnReq?: string + fileInline?: InlineFileMode + fileDescr?: FileDescr +} + +export enum FileProtocol { + SMP = "smp", + XFTP = "xftp", + LOCAL = "local", +} + +export enum FileStatus { + New = "new", + Accepted = "accepted", + Connected = "connected", + Complete = "complete", + Cancelled = "cancelled", +} + +export interface FileTransferMeta { + fileId: number // int64 + xftpSndFile?: XFTPSndFile + xftpRedirectFor?: number // int64 + fileName: string + filePath: string + fileSize: number // int64 + fileInline?: InlineFileMode + chunkSize: number // int64 + cancelled: boolean +} + +export type Format = + | Format.Bold + | Format.Italic + | Format.StrikeThrough + | Format.Snippet + | Format.Secret + | Format.Colored + | Format.Uri + | Format.HyperLink + | Format.SimplexLink + | Format.Command + | Format.Mention + | Format.Email + | Format.Phone + +export namespace Format { + export type Tag = + | "bold" + | "italic" + | "strikeThrough" + | "snippet" + | "secret" + | "colored" + | "uri" + | "hyperLink" + | "simplexLink" + | "command" + | "mention" + | "email" + | "phone" + + interface Interface { + type: Tag + } + + export interface Bold extends Interface { + type: "bold" + } + + export interface Italic extends Interface { + type: "italic" + } + + export interface StrikeThrough extends Interface { + type: "strikeThrough" + } + + export interface Snippet extends Interface { + type: "snippet" + } + + export interface Secret extends Interface { + type: "secret" + } + + export interface Colored extends Interface { + type: "colored" + color: Color + } + + export interface Uri extends Interface { + type: "uri" + } + + export interface HyperLink extends Interface { + type: "hyperLink" + showText?: string + linkUri: string + } + + export interface SimplexLink extends Interface { + type: "simplexLink" + showText?: string + linkType: SimplexLinkType + simplexUri: string + smpHosts: string[] // non-empty + } + + export interface Command extends Interface { + type: "command" + commandStr: string + } + + export interface Mention extends Interface { + type: "mention" + memberName: string + } + + export interface Email extends Interface { + type: "email" + } + + export interface Phone extends Interface { + type: "phone" + } +} + +export interface FormattedText { + format?: Format + text: string +} + +export interface FullGroupPreferences { + timedMessages: TimedMessagesGroupPreference + directMessages: RoleGroupPreference + fullDelete: GroupPreference + reactions: GroupPreference + voice: RoleGroupPreference + files: RoleGroupPreference + simplexLinks: RoleGroupPreference + reports: GroupPreference + history: GroupPreference + sessions: RoleGroupPreference + commands: ChatBotCommand[] +} + +export interface FullPreferences { + timedMessages: TimedMessagesPreference + fullDelete: SimplePreference + reactions: SimplePreference + voice: SimplePreference + files: SimplePreference + calls: SimplePreference + sessions: SimplePreference + commands: ChatBotCommand[] +} + +export interface Group { + groupInfo: GroupInfo + members: GroupMember[] +} + +export type GroupChatScope = GroupChatScope.MemberSupport + +export namespace GroupChatScope { + export type Tag = "memberSupport" + + interface Interface { + type: Tag + } + + export interface MemberSupport extends Interface { + type: "memberSupport" + groupMemberId_?: number // int64 + } + + export function cmdString(self: GroupChatScope): string { + return '(_support' + (self.groupMemberId_ ? ':' + self.groupMemberId_ : '') + ')' + } +} + +export type GroupChatScopeInfo = GroupChatScopeInfo.MemberSupport + +export namespace GroupChatScopeInfo { + export type Tag = "memberSupport" + + interface Interface { + type: Tag + } + + export interface MemberSupport extends Interface { + type: "memberSupport" + groupMember_?: GroupMember + } +} + +export interface GroupDirectInvitation { + groupDirectInvLink: string + fromGroupId_?: number // int64 + fromGroupMemberId_?: number // int64 + fromGroupMemberConnId_?: number // int64 + groupDirectInvStartedConnection: boolean +} + +export enum GroupFeature { + TimedMessages = "timedMessages", + DirectMessages = "directMessages", + FullDelete = "fullDelete", + Reactions = "reactions", + Voice = "voice", + Files = "files", + SimplexLinks = "simplexLinks", + Reports = "reports", + History = "history", + Sessions = "sessions", +} + +export enum GroupFeatureEnabled { + On = "on", + Off = "off", +} + +export interface GroupInfo { + groupId: number // int64 + useRelays: boolean + localDisplayName: string + groupProfile: GroupProfile + localAlias: string + businessChat?: BusinessChatInfo + fullGroupPreferences: FullGroupPreferences + membership: GroupMember + chatSettings: ChatSettings + createdAt: string // ISO-8601 timestamp + updatedAt: string // ISO-8601 timestamp + chatTs?: string // ISO-8601 timestamp + userMemberProfileSentAt?: string // ISO-8601 timestamp + preparedGroup?: PreparedGroup + chatTags: number[] // int64 + chatItemTTL?: number // int64 + uiThemes?: UIThemeEntityOverrides + customData?: object + groupSummary: GroupSummary + membersRequireAttention: number // int + viaGroupLinkUri?: string +} + +export interface GroupLink { + userContactLinkId: number // int64 + connLinkContact: CreatedConnLink + shortLinkDataSet: boolean + shortLinkLargeDataSet: boolean + groupLinkId: string + acceptMemberRole: GroupMemberRole +} + +export type GroupLinkPlan = + | GroupLinkPlan.Ok + | GroupLinkPlan.OwnLink + | GroupLinkPlan.ConnectingConfirmReconnect + | GroupLinkPlan.ConnectingProhibit + | GroupLinkPlan.Known + +export namespace GroupLinkPlan { + export type Tag = "ok" | "ownLink" | "connectingConfirmReconnect" | "connectingProhibit" | "known" + + interface Interface { + type: Tag + } + + export interface Ok extends Interface { + type: "ok" + groupSLinkData_?: GroupShortLinkData + } + + export interface OwnLink extends Interface { + type: "ownLink" + groupInfo: GroupInfo + } + + export interface ConnectingConfirmReconnect extends Interface { + type: "connectingConfirmReconnect" + } + + export interface ConnectingProhibit extends Interface { + type: "connectingProhibit" + groupInfo_?: GroupInfo + } + + export interface Known extends Interface { + type: "known" + groupInfo: GroupInfo + } +} + +export interface GroupMember { + groupMemberId: number // int64 + groupId: number // int64 + memberId: string + memberRole: GroupMemberRole + memberCategory: GroupMemberCategory + memberStatus: GroupMemberStatus + memberSettings: GroupMemberSettings + blockedByAdmin: boolean + invitedBy: InvitedBy + invitedByGroupMemberId?: number // int64 + localDisplayName: string + memberProfile: LocalProfile + memberContactId?: number // int64 + memberContactProfileId: number // int64 + activeConn?: Connection + memberChatVRange: VersionRange + createdAt: string // ISO-8601 timestamp + updatedAt: string // ISO-8601 timestamp + supportChat?: GroupSupportChat +} + +export interface GroupMemberAdmission { + review?: MemberCriteria +} + +export enum GroupMemberCategory { + User = "user", + Invitee = "invitee", + Host = "host", + Pre = "pre", + Post = "post", +} + +export interface GroupMemberRef { + groupMemberId: number // int64 + profile: Profile +} + +export enum GroupMemberRole { + Observer = "observer", + Author = "author", + Member = "member", + Moderator = "moderator", + Admin = "admin", + Owner = "owner", +} + +export interface GroupMemberSettings { + showMessages: boolean +} + +export enum GroupMemberStatus { + Rejected = "rejected", + Removed = "removed", + Left = "left", + Deleted = "deleted", + Unknown = "unknown", + Invited = "invited", + Pending_approval = "pending_approval", + Pending_review = "pending_review", + Introduced = "introduced", + Intro_inv = "intro-inv", + Accepted = "accepted", + Announced = "announced", + Connected = "connected", + Complete = "complete", + Creator = "creator", +} + +export interface GroupPreference { + enable: GroupFeatureEnabled +} + +export interface GroupPreferences { + timedMessages?: TimedMessagesGroupPreference + directMessages?: RoleGroupPreference + fullDelete?: GroupPreference + reactions?: GroupPreference + voice?: RoleGroupPreference + files?: RoleGroupPreference + simplexLinks?: RoleGroupPreference + reports?: GroupPreference + history?: GroupPreference + sessions?: RoleGroupPreference + commands?: ChatBotCommand[] +} + +export interface GroupProfile { + displayName: string + fullName: string + shortDescr?: string + description?: string + image?: string + groupPreferences?: GroupPreferences + memberAdmission?: GroupMemberAdmission +} + +export interface GroupShortLinkData { + groupProfile: GroupProfile +} + +export interface GroupSummary { + currentMembers: number // int64 +} + +export interface GroupSupportChat { + chatTs: string // ISO-8601 timestamp + unread: number // int64 + memberAttention: number // int64 + mentions: number // int64 + lastMsgFromMemberTs?: string // ISO-8601 timestamp +} + +export enum HandshakeError { + PARSE = "PARSE", + IDENTITY = "IDENTITY", + BAD_AUTH = "BAD_AUTH", + BAD_SERVICE = "BAD_SERVICE", +} + +export enum InlineFileMode { + Offer = "offer", + Sent = "sent", +} + +export type InvitationLinkPlan = + | InvitationLinkPlan.Ok + | InvitationLinkPlan.OwnLink + | InvitationLinkPlan.Connecting + | InvitationLinkPlan.Known + +export namespace InvitationLinkPlan { + export type Tag = "ok" | "ownLink" | "connecting" | "known" + + interface Interface { + type: Tag + } + + export interface Ok extends Interface { + type: "ok" + contactSLinkData_?: ContactShortLinkData + } + + export interface OwnLink extends Interface { + type: "ownLink" + } + + export interface Connecting extends Interface { + type: "connecting" + contact_?: Contact + } + + export interface Known extends Interface { + type: "known" + contact: Contact + } +} + +export type InvitedBy = InvitedBy.Contact | InvitedBy.User | InvitedBy.Unknown + +export namespace InvitedBy { + export type Tag = "contact" | "user" | "unknown" + + interface Interface { + type: Tag + } + + export interface Contact extends Interface { + type: "contact" + byContactId: number // int64 + } + + export interface User extends Interface { + type: "user" + } + + export interface Unknown extends Interface { + type: "unknown" + } +} + +export type LinkContent = LinkContent.Page | LinkContent.Image | LinkContent.Video | LinkContent.Unknown + +export namespace LinkContent { + export type Tag = "page" | "image" | "video" | "unknown" + + interface Interface { + type: Tag + } + + export interface Page extends Interface { + type: "page" + } + + export interface Image extends Interface { + type: "image" + } + + export interface Video extends Interface { + type: "video" + duration?: number // int + } + + export interface Unknown extends Interface { + type: "unknown" + tag: string + json: object + } +} + +export interface LinkPreview { + uri: string + title: string + description: string + image: string + content?: LinkContent +} + +export interface LocalProfile { + profileId: number // int64 + displayName: string + fullName: string + shortDescr?: string + image?: string + contactLink?: string + preferences?: Preferences + peerType?: ChatPeerType + localAlias: string +} + +export enum MemberCriteria { + All = "all", +} +// Connection link sent in a message - only short links are allowed. + +export type MsgChatLink = MsgChatLink.Contact | MsgChatLink.Invitation | MsgChatLink.Group + +export namespace MsgChatLink { + export type Tag = "contact" | "invitation" | "group" + + interface Interface { + type: Tag + } + + export interface Contact extends Interface { + type: "contact" + connLink: string + profile: Profile + business: boolean + } + + export interface Invitation extends Interface { + type: "invitation" + invLink: string + profile: Profile + } + + export interface Group extends Interface { + type: "group" + connLink: string + groupProfile: GroupProfile + } +} + +export type MsgContent = + | MsgContent.Text + | MsgContent.Link + | MsgContent.Image + | MsgContent.Video + | MsgContent.Voice + | MsgContent.File + | MsgContent.Report + | MsgContent.Chat + | MsgContent.Unknown + +export namespace MsgContent { + export type Tag = "text" | "link" | "image" | "video" | "voice" | "file" | "report" | "chat" | "unknown" + + interface Interface { + type: Tag + } + + export interface Text extends Interface { + type: "text" + text: string + } + + export interface Link extends Interface { + type: "link" + text: string + preview: LinkPreview + } + + export interface Image extends Interface { + type: "image" + text: string + image: string + } + + export interface Video extends Interface { + type: "video" + text: string + image: string + duration: number // int + } + + export interface Voice extends Interface { + type: "voice" + text: string + duration: number // int + } + + export interface File extends Interface { + type: "file" + text: string + } + + export interface Report extends Interface { + type: "report" + text: string + reason: ReportReason + } + + export interface Chat extends Interface { + type: "chat" + text: string + chatLink: MsgChatLink + } + + export interface Unknown extends Interface { + type: "unknown" + tag: string + text: string + json: object + } +} + +export enum MsgDecryptError { + RatchetHeader = "ratchetHeader", + TooManySkipped = "tooManySkipped", + RatchetEarlier = "ratchetEarlier", + Other = "other", + RatchetSync = "ratchetSync", +} + +export enum MsgDirection { + Rcv = "rcv", + Snd = "snd", +} + +export type MsgErrorType = + | MsgErrorType.MsgSkipped + | MsgErrorType.MsgBadId + | MsgErrorType.MsgBadHash + | MsgErrorType.MsgDuplicate + +export namespace MsgErrorType { + export type Tag = "msgSkipped" | "msgBadId" | "msgBadHash" | "msgDuplicate" + + interface Interface { + type: Tag + } + + export interface MsgSkipped extends Interface { + type: "msgSkipped" + fromMsgId: number // int64 + toMsgId: number // int64 + } + + export interface MsgBadId extends Interface { + type: "msgBadId" + msgId: number // int64 + } + + export interface MsgBadHash extends Interface { + type: "msgBadHash" + } + + export interface MsgDuplicate extends Interface { + type: "msgDuplicate" + } +} + +export enum MsgFilter { + None = "none", + All = "all", + Mentions = "mentions", +} + +export type MsgReaction = MsgReaction.Emoji | MsgReaction.Unknown + +export namespace MsgReaction { + export type Tag = "emoji" | "unknown" + + interface Interface { + type: Tag + } + + export interface Emoji extends Interface { + type: "emoji" + emoji: string + } + + export interface Unknown extends Interface { + type: "unknown" + tag: string + json: object + } +} + +export enum MsgReceiptStatus { + Ok = "ok", + BadMsgHash = "badMsgHash", +} + +export type NetworkError = + | NetworkError.ConnectError + | NetworkError.TLSError + | NetworkError.UnknownCAError + | NetworkError.FailedError + | NetworkError.TimeoutError + | NetworkError.SubscribeError + +export namespace NetworkError { + export type Tag = + | "connectError" + | "tLSError" + | "unknownCAError" + | "failedError" + | "timeoutError" + | "subscribeError" + + interface Interface { + type: Tag + } + + export interface ConnectError extends Interface { + type: "connectError" + connectError: string + } + + export interface TLSError extends Interface { + type: "tLSError" + tlsError: string + } + + export interface UnknownCAError extends Interface { + type: "unknownCAError" + } + + export interface FailedError extends Interface { + type: "failedError" + } + + export interface TimeoutError extends Interface { + type: "timeoutError" + } + + export interface SubscribeError extends Interface { + type: "subscribeError" + subscribeError: string + } +} + +export interface NewUser { + profile?: Profile + pastTimestamp: boolean +} + +export interface NoteFolder { + noteFolderId: number // int64 + userId: number // int64 + createdAt: string // ISO-8601 timestamp + updatedAt: string // ISO-8601 timestamp + chatTs: string // ISO-8601 timestamp + favorite: boolean + unread: boolean +} + +export interface PendingContactConnection { + pccConnId: number // int64 + pccAgentConnId: string + pccConnStatus: ConnStatus + viaContactUri: boolean + viaUserContactLink?: number // int64 + groupLinkId?: string + customUserProfileId?: number // int64 + connLinkInv?: CreatedConnLink + localAlias: string + createdAt: string // ISO-8601 timestamp + updatedAt: string // ISO-8601 timestamp +} + +export interface PrefEnabled { + forUser: boolean + forContact: boolean +} + +export interface Preferences { + timedMessages?: TimedMessagesPreference + fullDelete?: SimplePreference + reactions?: SimplePreference + voice?: SimplePreference + files?: SimplePreference + calls?: SimplePreference + sessions?: SimplePreference + commands?: ChatBotCommand[] +} + +export interface PreparedContact { + connLinkToConnect: CreatedConnLink + uiConnLinkType: ConnectionMode + welcomeSharedMsgId?: string + requestSharedMsgId?: string +} + +export interface PreparedGroup { + connLinkToConnect: CreatedConnLink + connLinkPreparedConnection: boolean + connLinkStartedConnection: boolean + welcomeSharedMsgId?: string + requestSharedMsgId?: string +} + +export interface Profile { + displayName: string + fullName: string + shortDescr?: string + image?: string + contactLink?: string + preferences?: Preferences + peerType?: ChatPeerType +} + +export type ProxyClientError = + | ProxyClientError.ProtocolError + | ProxyClientError.UnexpectedResponse + | ProxyClientError.ResponseError + +export namespace ProxyClientError { + export type Tag = "protocolError" | "unexpectedResponse" | "responseError" + + interface Interface { + type: Tag + } + + export interface ProtocolError extends Interface { + type: "protocolError" + protocolErr: ErrorType + } + + export interface UnexpectedResponse extends Interface { + type: "unexpectedResponse" + responseStr: string + } + + export interface ResponseError extends Interface { + type: "responseError" + responseErr: ErrorType + } +} + +export type ProxyError = ProxyError.PROTOCOL | ProxyError.BROKER | ProxyError.BASIC_AUTH | ProxyError.NO_SESSION + +export namespace ProxyError { + export type Tag = "PROTOCOL" | "BROKER" | "BASIC_AUTH" | "NO_SESSION" + + interface Interface { + type: Tag + } + + export interface PROTOCOL extends Interface { + type: "PROTOCOL" + protocolErr: ErrorType + } + + export interface BROKER extends Interface { + type: "BROKER" + brokerErr: BrokerErrorType + } + + export interface BASIC_AUTH extends Interface { + type: "BASIC_AUTH" + } + + export interface NO_SESSION extends Interface { + type: "NO_SESSION" + } +} + +export type RCErrorType = + | RCErrorType.Internal + | RCErrorType.Identity + | RCErrorType.NoLocalAddress + | RCErrorType.NewController + | RCErrorType.NotDiscovered + | RCErrorType.TLSStartFailed + | RCErrorType.Exception + | RCErrorType.CtrlAuth + | RCErrorType.CtrlNotFound + | RCErrorType.CtrlError + | RCErrorType.Invitation + | RCErrorType.Version + | RCErrorType.Encrypt + | RCErrorType.Decrypt + | RCErrorType.BlockSize + | RCErrorType.Syntax + +export namespace RCErrorType { + export type Tag = + | "internal" + | "identity" + | "noLocalAddress" + | "newController" + | "notDiscovered" + | "tLSStartFailed" + | "exception" + | "ctrlAuth" + | "ctrlNotFound" + | "ctrlError" + | "invitation" + | "version" + | "encrypt" + | "decrypt" + | "blockSize" + | "syntax" + + interface Interface { + type: Tag + } + + export interface Internal extends Interface { + type: "internal" + internalErr: string + } + + export interface Identity extends Interface { + type: "identity" + } + + export interface NoLocalAddress extends Interface { + type: "noLocalAddress" + } + + export interface NewController extends Interface { + type: "newController" + } + + export interface NotDiscovered extends Interface { + type: "notDiscovered" + } + + export interface TLSStartFailed extends Interface { + type: "tLSStartFailed" + } + + export interface Exception extends Interface { + type: "exception" + exception: string + } + + export interface CtrlAuth extends Interface { + type: "ctrlAuth" + } + + export interface CtrlNotFound extends Interface { + type: "ctrlNotFound" + } + + export interface CtrlError extends Interface { + type: "ctrlError" + ctrlErr: string + } + + export interface Invitation extends Interface { + type: "invitation" + } + + export interface Version extends Interface { + type: "version" + } + + export interface Encrypt extends Interface { + type: "encrypt" + } + + export interface Decrypt extends Interface { + type: "decrypt" + } + + export interface BlockSize extends Interface { + type: "blockSize" + } + + export interface Syntax extends Interface { + type: "syntax" + syntaxErr: string + } +} + +export enum RatchetSyncState { + Ok = "ok", + Allowed = "allowed", + Required = "required", + Started = "started", + Agreed = "agreed", +} + +export type RcvConnEvent = + | RcvConnEvent.SwitchQueue + | RcvConnEvent.RatchetSync + | RcvConnEvent.VerificationCodeReset + | RcvConnEvent.PqEnabled + +export namespace RcvConnEvent { + export type Tag = "switchQueue" | "ratchetSync" | "verificationCodeReset" | "pqEnabled" + + interface Interface { + type: Tag + } + + export interface SwitchQueue extends Interface { + type: "switchQueue" + phase: SwitchPhase + } + + export interface RatchetSync extends Interface { + type: "ratchetSync" + syncStatus: RatchetSyncState + } + + export interface VerificationCodeReset extends Interface { + type: "verificationCodeReset" + } + + export interface PqEnabled extends Interface { + type: "pqEnabled" + enabled: boolean + } +} + +export type RcvDirectEvent = + | RcvDirectEvent.ContactDeleted + | RcvDirectEvent.ProfileUpdated + | RcvDirectEvent.GroupInvLinkReceived + +export namespace RcvDirectEvent { + export type Tag = "contactDeleted" | "profileUpdated" | "groupInvLinkReceived" + + interface Interface { + type: Tag + } + + export interface ContactDeleted extends Interface { + type: "contactDeleted" + } + + export interface ProfileUpdated extends Interface { + type: "profileUpdated" + fromProfile: Profile + toProfile: Profile + } + + export interface GroupInvLinkReceived extends Interface { + type: "groupInvLinkReceived" + groupProfile: GroupProfile + } +} + +export interface RcvFileDescr { + fileDescrId: number // int64 + fileDescrText: string + fileDescrPartNo: number // int + fileDescrComplete: boolean +} + +export type RcvFileStatus = + | RcvFileStatus.New + | RcvFileStatus.Accepted + | RcvFileStatus.Connected + | RcvFileStatus.Complete + | RcvFileStatus.Cancelled + +export namespace RcvFileStatus { + export type Tag = "new" | "accepted" | "connected" | "complete" | "cancelled" + + interface Interface { + type: Tag + } + + export interface New extends Interface { + type: "new" + } + + export interface Accepted extends Interface { + type: "accepted" + filePath: string + } + + export interface Connected extends Interface { + type: "connected" + filePath: string + } + + export interface Complete extends Interface { + type: "complete" + filePath: string + } + + export interface Cancelled extends Interface { + type: "cancelled" + filePath_?: string + } +} + +export interface RcvFileTransfer { + fileId: number // int64 + xftpRcvFile?: XFTPRcvFile + fileInvitation: FileInvitation + fileStatus: RcvFileStatus + rcvFileInline?: InlineFileMode + senderDisplayName: string + chunkSize: number // int64 + cancelled: boolean + grpMemberId?: number // int64 + cryptoArgs?: CryptoFileArgs +} + +export type RcvGroupEvent = + | RcvGroupEvent.MemberAdded + | RcvGroupEvent.MemberConnected + | RcvGroupEvent.MemberAccepted + | RcvGroupEvent.UserAccepted + | RcvGroupEvent.MemberLeft + | RcvGroupEvent.MemberRole + | RcvGroupEvent.MemberBlocked + | RcvGroupEvent.UserRole + | RcvGroupEvent.MemberDeleted + | RcvGroupEvent.UserDeleted + | RcvGroupEvent.GroupDeleted + | RcvGroupEvent.GroupUpdated + | RcvGroupEvent.InvitedViaGroupLink + | RcvGroupEvent.MemberCreatedContact + | RcvGroupEvent.MemberProfileUpdated + | RcvGroupEvent.NewMemberPendingReview + +export namespace RcvGroupEvent { + export type Tag = + | "memberAdded" + | "memberConnected" + | "memberAccepted" + | "userAccepted" + | "memberLeft" + | "memberRole" + | "memberBlocked" + | "userRole" + | "memberDeleted" + | "userDeleted" + | "groupDeleted" + | "groupUpdated" + | "invitedViaGroupLink" + | "memberCreatedContact" + | "memberProfileUpdated" + | "newMemberPendingReview" + + interface Interface { + type: Tag + } + + export interface MemberAdded extends Interface { + type: "memberAdded" + groupMemberId: number // int64 + profile: Profile + } + + export interface MemberConnected extends Interface { + type: "memberConnected" + } + + export interface MemberAccepted extends Interface { + type: "memberAccepted" + groupMemberId: number // int64 + profile: Profile + } + + export interface UserAccepted extends Interface { + type: "userAccepted" + } + + export interface MemberLeft extends Interface { + type: "memberLeft" + } + + export interface MemberRole extends Interface { + type: "memberRole" + groupMemberId: number // int64 + profile: Profile + role: GroupMemberRole + } + + export interface MemberBlocked extends Interface { + type: "memberBlocked" + groupMemberId: number // int64 + profile: Profile + blocked: boolean + } + + export interface UserRole extends Interface { + type: "userRole" + role: GroupMemberRole + } + + export interface MemberDeleted extends Interface { + type: "memberDeleted" + groupMemberId: number // int64 + profile: Profile + } + + export interface UserDeleted extends Interface { + type: "userDeleted" + } + + export interface GroupDeleted extends Interface { + type: "groupDeleted" + } + + export interface GroupUpdated extends Interface { + type: "groupUpdated" + groupProfile: GroupProfile + } + + export interface InvitedViaGroupLink extends Interface { + type: "invitedViaGroupLink" + } + + export interface MemberCreatedContact extends Interface { + type: "memberCreatedContact" + } + + export interface MemberProfileUpdated extends Interface { + type: "memberProfileUpdated" + fromProfile: Profile + toProfile: Profile + } + + export interface NewMemberPendingReview extends Interface { + type: "newMemberPendingReview" + } +} + +export enum ReportReason { + Spam = "spam", + Content = "content", + Community = "community", + Profile = "profile", + Other = "other", +} + +export interface RoleGroupPreference { + enable: GroupFeatureEnabled + role?: GroupMemberRole +} + +export type SMPAgentError = + | SMPAgentError.A_MESSAGE + | SMPAgentError.A_PROHIBITED + | SMPAgentError.A_VERSION + | SMPAgentError.A_LINK + | SMPAgentError.A_CRYPTO + | SMPAgentError.A_DUPLICATE + | SMPAgentError.A_QUEUE + +export namespace SMPAgentError { + export type Tag = + | "A_MESSAGE" + | "A_PROHIBITED" + | "A_VERSION" + | "A_LINK" + | "A_CRYPTO" + | "A_DUPLICATE" + | "A_QUEUE" + + interface Interface { + type: Tag + } + + export interface A_MESSAGE extends Interface { + type: "A_MESSAGE" + } + + export interface A_PROHIBITED extends Interface { + type: "A_PROHIBITED" + prohibitedErr: string + } + + export interface A_VERSION extends Interface { + type: "A_VERSION" + } + + export interface A_LINK extends Interface { + type: "A_LINK" + linkErr: string + } + + export interface A_CRYPTO extends Interface { + type: "A_CRYPTO" + cryptoErr: AgentCryptoError + } + + export interface A_DUPLICATE extends Interface { + type: "A_DUPLICATE" + } + + export interface A_QUEUE extends Interface { + type: "A_QUEUE" + queueErr: string + } +} + +export interface SecurityCode { + securityCode: string + verifiedAt: string // ISO-8601 timestamp +} + +export interface SimplePreference { + allow: FeatureAllowed +} + +export enum SimplexLinkType { + Contact = "contact", + Invitation = "invitation", + Group = "group", + Channel = "channel", + Relay = "relay", +} + +export enum SndCIStatusProgress { + Partial = "partial", + Complete = "complete", +} + +export type SndConnEvent = SndConnEvent.SwitchQueue | SndConnEvent.RatchetSync | SndConnEvent.PqEnabled + +export namespace SndConnEvent { + export type Tag = "switchQueue" | "ratchetSync" | "pqEnabled" + + interface Interface { + type: Tag + } + + export interface SwitchQueue extends Interface { + type: "switchQueue" + phase: SwitchPhase + member?: GroupMemberRef + } + + export interface RatchetSync extends Interface { + type: "ratchetSync" + syncStatus: RatchetSyncState + member?: GroupMemberRef + } + + export interface PqEnabled extends Interface { + type: "pqEnabled" + enabled: boolean + } +} + +export type SndError = + | SndError.Auth + | SndError.Quota + | SndError.Expired + | SndError.Relay + | SndError.Proxy + | SndError.ProxyRelay + | SndError.Other + +export namespace SndError { + export type Tag = "auth" | "quota" | "expired" | "relay" | "proxy" | "proxyRelay" | "other" + + interface Interface { + type: Tag + } + + export interface Auth extends Interface { + type: "auth" + } + + export interface Quota extends Interface { + type: "quota" + } + + export interface Expired extends Interface { + type: "expired" + } + + export interface Relay extends Interface { + type: "relay" + srvError: SrvError + } + + export interface Proxy extends Interface { + type: "proxy" + proxyServer: string + srvError: SrvError + } + + export interface ProxyRelay extends Interface { + type: "proxyRelay" + proxyServer: string + srvError: SrvError + } + + export interface Other extends Interface { + type: "other" + sndError: string + } +} + +export interface SndFileTransfer { + fileId: number // int64 + fileName: string + filePath: string + fileSize: number // int64 + chunkSize: number // int64 + recipientDisplayName: string + connId: number // int64 + agentConnId: string + groupMemberId?: number // int64 + fileStatus: FileStatus + fileDescrId?: number // int64 + fileInline?: InlineFileMode +} + +export type SndGroupEvent = + | SndGroupEvent.MemberRole + | SndGroupEvent.MemberBlocked + | SndGroupEvent.UserRole + | SndGroupEvent.MemberDeleted + | SndGroupEvent.UserLeft + | SndGroupEvent.GroupUpdated + | SndGroupEvent.MemberAccepted + | SndGroupEvent.UserPendingReview + +export namespace SndGroupEvent { + export type Tag = + | "memberRole" + | "memberBlocked" + | "userRole" + | "memberDeleted" + | "userLeft" + | "groupUpdated" + | "memberAccepted" + | "userPendingReview" + + interface Interface { + type: Tag + } + + export interface MemberRole extends Interface { + type: "memberRole" + groupMemberId: number // int64 + profile: Profile + role: GroupMemberRole + } + + export interface MemberBlocked extends Interface { + type: "memberBlocked" + groupMemberId: number // int64 + profile: Profile + blocked: boolean + } + + export interface UserRole extends Interface { + type: "userRole" + role: GroupMemberRole + } + + export interface MemberDeleted extends Interface { + type: "memberDeleted" + groupMemberId: number // int64 + profile: Profile + } + + export interface UserLeft extends Interface { + type: "userLeft" + } + + export interface GroupUpdated extends Interface { + type: "groupUpdated" + groupProfile: GroupProfile + } + + export interface MemberAccepted extends Interface { + type: "memberAccepted" + groupMemberId: number // int64 + profile: Profile + } + + export interface UserPendingReview extends Interface { + type: "userPendingReview" + } +} + +export type SrvError = SrvError.Host | SrvError.Version | SrvError.Other + +export namespace SrvError { + export type Tag = "host" | "version" | "other" + + interface Interface { + type: Tag + } + + export interface Host extends Interface { + type: "host" + } + + export interface Version extends Interface { + type: "version" + } + + export interface Other extends Interface { + type: "other" + srvError: string + } +} + +export type StoreError = + | StoreError.DuplicateName + | StoreError.UserNotFound + | StoreError.UserNotFoundByName + | StoreError.UserNotFoundByContactId + | StoreError.UserNotFoundByGroupId + | StoreError.UserNotFoundByFileId + | StoreError.UserNotFoundByContactRequestId + | StoreError.ContactNotFound + | StoreError.ContactNotFoundByName + | StoreError.ContactNotFoundByMemberId + | StoreError.ContactNotReady + | StoreError.DuplicateContactLink + | StoreError.UserContactLinkNotFound + | StoreError.ContactRequestNotFound + | StoreError.ContactRequestNotFoundByName + | StoreError.InvalidContactRequestEntity + | StoreError.InvalidBusinessChatContactRequest + | StoreError.GroupNotFound + | StoreError.GroupNotFoundByName + | StoreError.GroupMemberNameNotFound + | StoreError.GroupMemberNotFound + | StoreError.GroupHostMemberNotFound + | StoreError.GroupMemberNotFoundByMemberId + | StoreError.MemberContactGroupMemberNotFound + | StoreError.GroupWithoutUser + | StoreError.DuplicateGroupMember + | StoreError.GroupAlreadyJoined + | StoreError.GroupInvitationNotFound + | StoreError.NoteFolderAlreadyExists + | StoreError.NoteFolderNotFound + | StoreError.UserNoteFolderNotFound + | StoreError.SndFileNotFound + | StoreError.SndFileInvalid + | StoreError.RcvFileNotFound + | StoreError.RcvFileDescrNotFound + | StoreError.FileNotFound + | StoreError.RcvFileInvalid + | StoreError.RcvFileInvalidDescrPart + | StoreError.LocalFileNoTransfer + | StoreError.SharedMsgIdNotFoundByFileId + | StoreError.FileIdNotFoundBySharedMsgId + | StoreError.SndFileNotFoundXFTP + | StoreError.RcvFileNotFoundXFTP + | StoreError.ConnectionNotFound + | StoreError.ConnectionNotFoundById + | StoreError.ConnectionNotFoundByMemberId + | StoreError.PendingConnectionNotFound + | StoreError.IntroNotFound + | StoreError.UniqueID + | StoreError.LargeMsg + | StoreError.InternalError + | StoreError.DBException + | StoreError.DBBusyError + | StoreError.BadChatItem + | StoreError.ChatItemNotFound + | StoreError.ChatItemNotFoundByText + | StoreError.ChatItemSharedMsgIdNotFound + | StoreError.ChatItemNotFoundByFileId + | StoreError.ChatItemNotFoundByContactId + | StoreError.ChatItemNotFoundByGroupId + | StoreError.ProfileNotFound + | StoreError.DuplicateGroupLink + | StoreError.GroupLinkNotFound + | StoreError.HostMemberIdNotFound + | StoreError.ContactNotFoundByFileId + | StoreError.NoGroupSndStatus + | StoreError.DuplicateGroupMessage + | StoreError.RemoteHostNotFound + | StoreError.RemoteHostUnknown + | StoreError.RemoteHostDuplicateCA + | StoreError.RemoteCtrlNotFound + | StoreError.RemoteCtrlDuplicateCA + | StoreError.ProhibitedDeleteUser + | StoreError.OperatorNotFound + | StoreError.UsageConditionsNotFound + | StoreError.InvalidQuote + | StoreError.InvalidMention + | StoreError.InvalidDeliveryTask + | StoreError.DeliveryTaskNotFound + | StoreError.InvalidDeliveryJob + | StoreError.DeliveryJobNotFound + | StoreError.WorkItemError + +export namespace StoreError { + export type Tag = + | "duplicateName" + | "userNotFound" + | "userNotFoundByName" + | "userNotFoundByContactId" + | "userNotFoundByGroupId" + | "userNotFoundByFileId" + | "userNotFoundByContactRequestId" + | "contactNotFound" + | "contactNotFoundByName" + | "contactNotFoundByMemberId" + | "contactNotReady" + | "duplicateContactLink" + | "userContactLinkNotFound" + | "contactRequestNotFound" + | "contactRequestNotFoundByName" + | "invalidContactRequestEntity" + | "invalidBusinessChatContactRequest" + | "groupNotFound" + | "groupNotFoundByName" + | "groupMemberNameNotFound" + | "groupMemberNotFound" + | "groupHostMemberNotFound" + | "groupMemberNotFoundByMemberId" + | "memberContactGroupMemberNotFound" + | "groupWithoutUser" + | "duplicateGroupMember" + | "groupAlreadyJoined" + | "groupInvitationNotFound" + | "noteFolderAlreadyExists" + | "noteFolderNotFound" + | "userNoteFolderNotFound" + | "sndFileNotFound" + | "sndFileInvalid" + | "rcvFileNotFound" + | "rcvFileDescrNotFound" + | "fileNotFound" + | "rcvFileInvalid" + | "rcvFileInvalidDescrPart" + | "localFileNoTransfer" + | "sharedMsgIdNotFoundByFileId" + | "fileIdNotFoundBySharedMsgId" + | "sndFileNotFoundXFTP" + | "rcvFileNotFoundXFTP" + | "connectionNotFound" + | "connectionNotFoundById" + | "connectionNotFoundByMemberId" + | "pendingConnectionNotFound" + | "introNotFound" + | "uniqueID" + | "largeMsg" + | "internalError" + | "dBException" + | "dBBusyError" + | "badChatItem" + | "chatItemNotFound" + | "chatItemNotFoundByText" + | "chatItemSharedMsgIdNotFound" + | "chatItemNotFoundByFileId" + | "chatItemNotFoundByContactId" + | "chatItemNotFoundByGroupId" + | "profileNotFound" + | "duplicateGroupLink" + | "groupLinkNotFound" + | "hostMemberIdNotFound" + | "contactNotFoundByFileId" + | "noGroupSndStatus" + | "duplicateGroupMessage" + | "remoteHostNotFound" + | "remoteHostUnknown" + | "remoteHostDuplicateCA" + | "remoteCtrlNotFound" + | "remoteCtrlDuplicateCA" + | "prohibitedDeleteUser" + | "operatorNotFound" + | "usageConditionsNotFound" + | "invalidQuote" + | "invalidMention" + | "invalidDeliveryTask" + | "deliveryTaskNotFound" + | "invalidDeliveryJob" + | "deliveryJobNotFound" + | "workItemError" + + interface Interface { + type: Tag + } + + export interface DuplicateName extends Interface { + type: "duplicateName" + } + + export interface UserNotFound extends Interface { + type: "userNotFound" + userId: number // int64 + } + + export interface UserNotFoundByName extends Interface { + type: "userNotFoundByName" + contactName: string + } + + export interface UserNotFoundByContactId extends Interface { + type: "userNotFoundByContactId" + contactId: number // int64 + } + + export interface UserNotFoundByGroupId extends Interface { + type: "userNotFoundByGroupId" + groupId: number // int64 + } + + export interface UserNotFoundByFileId extends Interface { + type: "userNotFoundByFileId" + fileId: number // int64 + } + + export interface UserNotFoundByContactRequestId extends Interface { + type: "userNotFoundByContactRequestId" + contactRequestId: number // int64 + } + + export interface ContactNotFound extends Interface { + type: "contactNotFound" + contactId: number // int64 + } + + export interface ContactNotFoundByName extends Interface { + type: "contactNotFoundByName" + contactName: string + } + + export interface ContactNotFoundByMemberId extends Interface { + type: "contactNotFoundByMemberId" + groupMemberId: number // int64 + } + + export interface ContactNotReady extends Interface { + type: "contactNotReady" + contactName: string + } + + export interface DuplicateContactLink extends Interface { + type: "duplicateContactLink" + } + + export interface UserContactLinkNotFound extends Interface { + type: "userContactLinkNotFound" + } + + export interface ContactRequestNotFound extends Interface { + type: "contactRequestNotFound" + contactRequestId: number // int64 + } + + export interface ContactRequestNotFoundByName extends Interface { + type: "contactRequestNotFoundByName" + contactName: string + } + + export interface InvalidContactRequestEntity extends Interface { + type: "invalidContactRequestEntity" + contactRequestId: number // int64 + } + + export interface InvalidBusinessChatContactRequest extends Interface { + type: "invalidBusinessChatContactRequest" + } + + export interface GroupNotFound extends Interface { + type: "groupNotFound" + groupId: number // int64 + } + + export interface GroupNotFoundByName extends Interface { + type: "groupNotFoundByName" + groupName: string + } + + export interface GroupMemberNameNotFound extends Interface { + type: "groupMemberNameNotFound" + groupId: number // int64 + groupMemberName: string + } + + export interface GroupMemberNotFound extends Interface { + type: "groupMemberNotFound" + groupMemberId: number // int64 + } + + export interface GroupHostMemberNotFound extends Interface { + type: "groupHostMemberNotFound" + groupId: number // int64 + } + + export interface GroupMemberNotFoundByMemberId extends Interface { + type: "groupMemberNotFoundByMemberId" + memberId: string + } + + export interface MemberContactGroupMemberNotFound extends Interface { + type: "memberContactGroupMemberNotFound" + contactId: number // int64 + } + + export interface GroupWithoutUser extends Interface { + type: "groupWithoutUser" + } + + export interface DuplicateGroupMember extends Interface { + type: "duplicateGroupMember" + } + + export interface GroupAlreadyJoined extends Interface { + type: "groupAlreadyJoined" + } + + export interface GroupInvitationNotFound extends Interface { + type: "groupInvitationNotFound" + } + + export interface NoteFolderAlreadyExists extends Interface { + type: "noteFolderAlreadyExists" + noteFolderId: number // int64 + } + + export interface NoteFolderNotFound extends Interface { + type: "noteFolderNotFound" + noteFolderId: number // int64 + } + + export interface UserNoteFolderNotFound extends Interface { + type: "userNoteFolderNotFound" + } + + export interface SndFileNotFound extends Interface { + type: "sndFileNotFound" + fileId: number // int64 + } + + export interface SndFileInvalid extends Interface { + type: "sndFileInvalid" + fileId: number // int64 + } + + export interface RcvFileNotFound extends Interface { + type: "rcvFileNotFound" + fileId: number // int64 + } + + export interface RcvFileDescrNotFound extends Interface { + type: "rcvFileDescrNotFound" + fileId: number // int64 + } + + export interface FileNotFound extends Interface { + type: "fileNotFound" + fileId: number // int64 + } + + export interface RcvFileInvalid extends Interface { + type: "rcvFileInvalid" + fileId: number // int64 + } + + export interface RcvFileInvalidDescrPart extends Interface { + type: "rcvFileInvalidDescrPart" + } + + export interface LocalFileNoTransfer extends Interface { + type: "localFileNoTransfer" + fileId: number // int64 + } + + export interface SharedMsgIdNotFoundByFileId extends Interface { + type: "sharedMsgIdNotFoundByFileId" + fileId: number // int64 + } + + export interface FileIdNotFoundBySharedMsgId extends Interface { + type: "fileIdNotFoundBySharedMsgId" + sharedMsgId: string + } + + export interface SndFileNotFoundXFTP extends Interface { + type: "sndFileNotFoundXFTP" + agentSndFileId: string + } + + export interface RcvFileNotFoundXFTP extends Interface { + type: "rcvFileNotFoundXFTP" + agentRcvFileId: string + } + + export interface ConnectionNotFound extends Interface { + type: "connectionNotFound" + agentConnId: string + } + + export interface ConnectionNotFoundById extends Interface { + type: "connectionNotFoundById" + connId: number // int64 + } + + export interface ConnectionNotFoundByMemberId extends Interface { + type: "connectionNotFoundByMemberId" + groupMemberId: number // int64 + } + + export interface PendingConnectionNotFound extends Interface { + type: "pendingConnectionNotFound" + connId: number // int64 + } + + export interface IntroNotFound extends Interface { + type: "introNotFound" + } + + export interface UniqueID extends Interface { + type: "uniqueID" + } + + export interface LargeMsg extends Interface { + type: "largeMsg" + } + + export interface InternalError extends Interface { + type: "internalError" + message: string + } + + export interface DBException extends Interface { + type: "dBException" + message: string + } + + export interface DBBusyError extends Interface { + type: "dBBusyError" + message: string + } + + export interface BadChatItem extends Interface { + type: "badChatItem" + itemId: number // int64 + itemTs?: string // ISO-8601 timestamp + } + + export interface ChatItemNotFound extends Interface { + type: "chatItemNotFound" + itemId: number // int64 + } + + export interface ChatItemNotFoundByText extends Interface { + type: "chatItemNotFoundByText" + text: string + } + + export interface ChatItemSharedMsgIdNotFound extends Interface { + type: "chatItemSharedMsgIdNotFound" + sharedMsgId: string + } + + export interface ChatItemNotFoundByFileId extends Interface { + type: "chatItemNotFoundByFileId" + fileId: number // int64 + } + + export interface ChatItemNotFoundByContactId extends Interface { + type: "chatItemNotFoundByContactId" + contactId: number // int64 + } + + export interface ChatItemNotFoundByGroupId extends Interface { + type: "chatItemNotFoundByGroupId" + groupId: number // int64 + } + + export interface ProfileNotFound extends Interface { + type: "profileNotFound" + profileId: number // int64 + } + + export interface DuplicateGroupLink extends Interface { + type: "duplicateGroupLink" + groupInfo: GroupInfo + } + + export interface GroupLinkNotFound extends Interface { + type: "groupLinkNotFound" + groupInfo: GroupInfo + } + + export interface HostMemberIdNotFound extends Interface { + type: "hostMemberIdNotFound" + groupId: number // int64 + } + + export interface ContactNotFoundByFileId extends Interface { + type: "contactNotFoundByFileId" + fileId: number // int64 + } + + export interface NoGroupSndStatus extends Interface { + type: "noGroupSndStatus" + itemId: number // int64 + groupMemberId: number // int64 + } + + export interface DuplicateGroupMessage extends Interface { + type: "duplicateGroupMessage" + groupId: number // int64 + sharedMsgId: string + authorGroupMemberId?: number // int64 + forwardedByGroupMemberId?: number // int64 + } + + export interface RemoteHostNotFound extends Interface { + type: "remoteHostNotFound" + remoteHostId: number // int64 + } + + export interface RemoteHostUnknown extends Interface { + type: "remoteHostUnknown" + } + + export interface RemoteHostDuplicateCA extends Interface { + type: "remoteHostDuplicateCA" + } + + export interface RemoteCtrlNotFound extends Interface { + type: "remoteCtrlNotFound" + remoteCtrlId: number // int64 + } + + export interface RemoteCtrlDuplicateCA extends Interface { + type: "remoteCtrlDuplicateCA" + } + + export interface ProhibitedDeleteUser extends Interface { + type: "prohibitedDeleteUser" + userId: number // int64 + contactId: number // int64 + } + + export interface OperatorNotFound extends Interface { + type: "operatorNotFound" + serverOperatorId: number // int64 + } + + export interface UsageConditionsNotFound extends Interface { + type: "usageConditionsNotFound" + } + + export interface InvalidQuote extends Interface { + type: "invalidQuote" + } + + export interface InvalidMention extends Interface { + type: "invalidMention" + } + + export interface InvalidDeliveryTask extends Interface { + type: "invalidDeliveryTask" + taskId: number // int64 + } + + export interface DeliveryTaskNotFound extends Interface { + type: "deliveryTaskNotFound" + taskId: number // int64 + } + + export interface InvalidDeliveryJob extends Interface { + type: "invalidDeliveryJob" + jobId: number // int64 + } + + export interface DeliveryJobNotFound extends Interface { + type: "deliveryJobNotFound" + jobId: number // int64 + } + + export interface WorkItemError extends Interface { + type: "workItemError" + errContext: string + } +} + +export enum SwitchPhase { + Started = "started", + Confirmed = "confirmed", + Secured = "secured", + Completed = "completed", +} + +export interface TimedMessagesGroupPreference { + enable: GroupFeatureEnabled + ttl?: number // int +} + +export interface TimedMessagesPreference { + allow: FeatureAllowed + ttl?: number // int +} + +export type TransportError = + | TransportError.BadBlock + | TransportError.Version + | TransportError.LargeMsg + | TransportError.BadSession + | TransportError.NoServerAuth + | TransportError.Handshake + +export namespace TransportError { + export type Tag = "badBlock" | "version" | "largeMsg" | "badSession" | "noServerAuth" | "handshake" + + interface Interface { + type: Tag + } + + export interface BadBlock extends Interface { + type: "badBlock" + } + + export interface Version extends Interface { + type: "version" + } + + export interface LargeMsg extends Interface { + type: "largeMsg" + } + + export interface BadSession extends Interface { + type: "badSession" + } + + export interface NoServerAuth extends Interface { + type: "noServerAuth" + } + + export interface Handshake extends Interface { + type: "handshake" + handshakeErr: HandshakeError + } +} + +export enum UIColorMode { + Light = "light", + Dark = "dark", +} + +export interface UIColors { + accent?: string + accentVariant?: string + secondary?: string + secondaryVariant?: string + background?: string + menus?: string + title?: string + accentVariant2?: string + sentMessage?: string + sentReply?: string + receivedMessage?: string + receivedReply?: string +} + +export interface UIThemeEntityOverride { + mode: UIColorMode + wallpaper?: ChatWallpaper + colors: UIColors +} + +export interface UIThemeEntityOverrides { + light?: UIThemeEntityOverride + dark?: UIThemeEntityOverride +} + +export interface UpdatedMessage { + msgContent: MsgContent + mentions: {[key: string]: number} // string : int64 +} + +export interface User { + userId: number // int64 + agentUserId: number // int64 + userContactId: number // int64 + localDisplayName: string + profile: LocalProfile + fullPreferences: FullPreferences + activeUser: boolean + activeOrder: number // int64 + viewPwdHash?: UserPwdHash + showNtfs: boolean + sendRcptsContacts: boolean + sendRcptsSmallGroups: boolean + autoAcceptMemberContacts: boolean + userMemberProfileUpdatedAt?: string // ISO-8601 timestamp + uiThemes?: UIThemeEntityOverrides +} + +export interface UserContact { + userContactLinkId: number // int64 + connReqContact: string + groupId?: number // int64 +} + +export interface UserContactLink { + userContactLinkId: number // int64 + connLinkContact: CreatedConnLink + shortLinkDataSet: boolean + shortLinkLargeDataSet: boolean + addressSettings: AddressSettings +} + +export interface UserContactRequest { + contactRequestId: number // int64 + agentInvitationId: string + contactId_?: number // int64 + businessGroupId_?: number // int64 + userContactLinkId_?: number // int64 + cReqChatVRange: VersionRange + localDisplayName: string + profileId: number // int64 + profile: Profile + createdAt: string // ISO-8601 timestamp + updatedAt: string // ISO-8601 timestamp + xContactId?: string + pqSupport: boolean + welcomeSharedMsgId?: string + requestSharedMsgId?: string +} + +export interface UserInfo { + user: User + unreadCount: number // int +} + +export interface UserProfileUpdateSummary { + updateSuccesses: number // int + updateFailures: number // int + changedContacts: Contact[] +} + +export interface UserPwdHash { + hash: string + salt: string +} + +export interface VersionRange { + minVersion: number // int + maxVersion: number // int +} + +export type XFTPErrorType = + | XFTPErrorType.BLOCK + | XFTPErrorType.SESSION + | XFTPErrorType.HANDSHAKE + | XFTPErrorType.CMD + | XFTPErrorType.AUTH + | XFTPErrorType.BLOCKED + | XFTPErrorType.SIZE + | XFTPErrorType.QUOTA + | XFTPErrorType.DIGEST + | XFTPErrorType.CRYPTO + | XFTPErrorType.NO_FILE + | XFTPErrorType.HAS_FILE + | XFTPErrorType.FILE_IO + | XFTPErrorType.TIMEOUT + | XFTPErrorType.INTERNAL + | XFTPErrorType.DUPLICATE_ + +export namespace XFTPErrorType { + export type Tag = + | "BLOCK" + | "SESSION" + | "HANDSHAKE" + | "CMD" + | "AUTH" + | "BLOCKED" + | "SIZE" + | "QUOTA" + | "DIGEST" + | "CRYPTO" + | "NO_FILE" + | "HAS_FILE" + | "FILE_IO" + | "TIMEOUT" + | "INTERNAL" + | "DUPLICATE_" + + interface Interface { + type: Tag + } + + export interface BLOCK extends Interface { + type: "BLOCK" + } + + export interface SESSION extends Interface { + type: "SESSION" + } + + export interface HANDSHAKE extends Interface { + type: "HANDSHAKE" + } + + export interface CMD extends Interface { + type: "CMD" + cmdErr: CommandError + } + + export interface AUTH extends Interface { + type: "AUTH" + } + + export interface BLOCKED extends Interface { + type: "BLOCKED" + blockInfo: BlockingInfo + } + + export interface SIZE extends Interface { + type: "SIZE" + } + + export interface QUOTA extends Interface { + type: "QUOTA" + } + + export interface DIGEST extends Interface { + type: "DIGEST" + } + + export interface CRYPTO extends Interface { + type: "CRYPTO" + } + + export interface NO_FILE extends Interface { + type: "NO_FILE" + } + + export interface HAS_FILE extends Interface { + type: "HAS_FILE" + } + + export interface FILE_IO extends Interface { + type: "FILE_IO" + } + + export interface TIMEOUT extends Interface { + type: "TIMEOUT" + } + + export interface INTERNAL extends Interface { + type: "INTERNAL" + } + + export interface DUPLICATE_ extends Interface { + type: "DUPLICATE_" + } +} + +export interface XFTPRcvFile { + rcvFileDescription: RcvFileDescr + agentRcvFileId?: string + agentRcvFileDeleted: boolean + userApprovedRelays: boolean +} + +export interface XFTPSndFile { + agentSndFileId: string + privateSndFileDescr?: string + agentSndFileDeleted: boolean + cryptoArgs?: CryptoFileArgs +} diff --git a/packages/simplex-chat-client/types/typescript/tsconfig.json b/packages/simplex-chat-client/types/typescript/tsconfig.json new file mode 100644 index 0000000000..d39db52454 --- /dev/null +++ b/packages/simplex-chat-client/types/typescript/tsconfig.json @@ -0,0 +1,22 @@ +{ + "include": ["src"], + "compilerOptions": { + "declaration": true, + "forceConsistentCasingInFileNames": true, + "lib": ["ES2018", "DOM"], + "module": "CommonJS", + "moduleResolution": "Node", + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noEmitOnError": true, + "outDir": "dist", + "sourceMap": true, + "strict": true, + "strictNullChecks": true, + "target": "ES2018" + } + } diff --git a/packages/simplex-chat-client/typescript/README.md b/packages/simplex-chat-client/typescript/README.md index 4940eb0f7c..c1756dc82c 100644 --- a/packages/simplex-chat-client/typescript/README.md +++ b/packages/simplex-chat-client/typescript/README.md @@ -38,6 +38,8 @@ See the example of a simple chat bot in [squaring-bot.js](./examples/squaring-bo Please refer to the available client API in [client.ts](./src/client.ts). +This library uses [@simplex-chat/types](https://www.npmjs.com/package/@simplex-chat/types) package with auto-generated [bot API types](https://github.com/simplex-chat/simplex-chat/tree/stable/bots) + ## License [AGPL v3](./LICENSE) diff --git a/packages/simplex-chat-client/typescript/package.json b/packages/simplex-chat-client/typescript/package.json index bb2fdda702..b721dbcce2 100644 --- a/packages/simplex-chat-client/typescript/package.json +++ b/packages/simplex-chat-client/typescript/package.json @@ -1,12 +1,16 @@ { "name": "simplex-chat", - "version": "0.2.1", + "version": "0.3.0", "description": "SimpleX Chat client", "main": "dist/index.js", "types": "dist/index.d.ts", "files": [ "dist" ], + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, "scripts": { "test": "npm run prettier:check && npm run eslint && jest --coverage", "build": "npm run prettier:write && npm run eslint && tsc && ./copy && npm run bundle", @@ -17,13 +21,17 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/simplex-chat/simplex-chat.git" + "url": "git+https://github.com/simplex-chat/simplex-chat.git", + "directory": "/packages/simplex-chat-client/typescript" }, "keywords": [ + "simplex", "messenger", "chat", "privacy", - "security" + "security", + "bots", + "websockets" ], "author": "SimpleX Chat", "license": "AGPL-3.0", @@ -35,6 +43,7 @@ "isomorphic-ws": "^4.0.1" }, "devDependencies": { + "@simplex-chat/types": "^0.1.0", "@types/jest": "^27.5.1", "@types/node": "^18.11.18", "@typescript-eslint/eslint-plugin": "^5.23.0", diff --git a/packages/simplex-chat-client/typescript/src/client.ts b/packages/simplex-chat-client/typescript/src/client.ts index f908e6a7e3..12fa150fc4 100644 --- a/packages/simplex-chat-client/typescript/src/client.ts +++ b/packages/simplex-chat-client/typescript/src/client.ts @@ -1,9 +1,6 @@ import {ABQueue} from "./queue" -import {ChatTransport, ChatServer, ChatSrvRequest, ChatSrvResponse, ChatResponseError, localServer, noop} from "./transport" -import {ChatCommand, ChatType, Profile} from "./command" -import {ChatResponse, ChatInfo} from "./response" -import * as CC from "./command" -import * as CR from "./response" +import {ChatTransport, ChatServer, ChatSrvRequest, ChatSrvResponse, ChatResponseError, localServer, noop, ChatSrvEvent} from "./transport" +import {CC, ChatResponse, CR, ChatEvent, T} from "@simplex-chat/types" export interface ChatClientConfig { readonly qSize: number @@ -12,7 +9,7 @@ export interface ChatClientConfig { export interface Request { readonly resolve: (resp: ChatResponse) => void - readonly reject: (err?: ChatResponseError | CR.CRChatCmdError) => void + readonly reject: (err?: ChatResponseError | CR.ChatCmdError) => void } export class ChatCommandError extends Error { @@ -39,44 +36,42 @@ export class ChatClient { private constructor( readonly server: ChatServer | string, readonly config: ChatClientConfig, - readonly msgQ: ABQueue, + readonly msgQ: ABQueue, readonly client: Promise, private readonly transport: ChatTransport ) {} static async create(server: ChatServer | string = localServer, cfg: ChatClientConfig = ChatClient.defaultConfig): Promise { const transport = await ChatTransport.connect(server, cfg.tcpTimeout, cfg.qSize) - const msgQ = new ABQueue(cfg.qSize) + const msgQ = new ABQueue(cfg.qSize) const client = runClient().then(noop, noop) const c = new ChatClient(server, cfg, msgQ, client, transport) return c async function runClient(): Promise { for await (const t of transport) { - const apiResp = (t instanceof Promise ? await t : t) as ChatSrvResponse | ChatResponseError + const apiResp = (t instanceof Promise ? await t : t) as ChatSrvResponse | ChatSrvEvent | ChatResponseError if (apiResp instanceof ChatResponseError) { console.log("chat response error: ", apiResp) - } else { + } else if ("corrId" in apiResp) { const {corrId, resp} = apiResp - if (corrId) { - const req = c.sentCommands.get(corrId) - if (req) { - c.sentCommands.delete(corrId) - req.resolve(resp) - } else { - // TODO send error to errQ? - console.log("no command sent for chat response: ", apiResp) - } + const req = c.sentCommands.get(corrId) + if (req) { + c.sentCommands.delete(corrId) + req.resolve(resp) } else { - await msgQ.enqueue(resp) + // TODO send error to errQ? + console.log("no command sent for chat response: ", apiResp) } + } else { + await msgQ.enqueue(apiResp.resp) } } c._connected = false } } - sendChatCmdStr(cmd: string): Promise { + sendChatCmd(cmd: string): Promise { const corrId = `${++this.clientCorrId}` const t: ChatSrvRequest = {corrId, cmd} const p = new Promise((resolve, reject) => this.sentCommands.set(corrId, {resolve, reject})) @@ -84,17 +79,13 @@ export class ChatClient { return p } - sendChatCommand(command: ChatCommand): Promise { - return this.sendChatCmdStr(CC.cmdString(command)) - } - async disconnect(): Promise { await this.transport.close() await this.client } - async apiGetActiveUser(): Promise { - const r = await this.sendChatCommand({type: "showActiveUser"}) + async apiGetActiveUser(): Promise { + const r = await this.sendChatCmd(CC.ShowActiveUser.cmdString({})) switch (r.type) { case "activeUser": return r.user @@ -106,90 +97,66 @@ export class ChatClient { } } - async apiCreateActiveUser(profile?: Profile, sameServers = true, pastTimestamp = false): Promise { - const r = await this.sendChatCommand({type: "createActiveUser", profile, sameServers, pastTimestamp}) + async apiCreateActiveUser(profile?: T.Profile): Promise { + const r = await this.sendChatCmd(CC.CreateActiveUser.cmdString({newUser: {profile, pastTimestamp: false}})) if (r.type === "activeUser") return r.user throw new ChatCommandError("unexpected response", r) } - async apiStartChat(): Promise { - const r = await this.sendChatCommand({type: "startChat"}) - if (r.type !== "chatStarted" && r.type !== "chatRunning") { - throw new ChatCommandError("error starting chat", r) - } - } - - async apiStopChat(): Promise { - const r = await this.sendChatCommand({type: "apiStopChat"}) - if (r.type !== "chatStopped") { - throw new ChatCommandError("error stopping chat", r) - } - } - - apiSetIncognito(incognito: boolean): Promise { - return this.okChatCommand({type: "setIncognito", incognito}) - } - - async enableAddressAutoAccept(acceptIncognito = false, autoReply?: CC.MsgContent): Promise { - const r = await this.sendChatCommand({type: "addressAutoAccept", autoAccept: {acceptIncognito, autoReply}}) + async enableAddressAutoAccept(userId: number, autoReply?: T.MsgContent, businessAddress = false): Promise { + const r = await this.sendChatCmd( + CC.APISetAddressSettings.cmdString({userId, settings: {businessAddress, autoAccept: {acceptIncognito: false}, autoReply}}) + ) if (r.type !== "userContactLinkUpdated") { throw new ChatCommandError("error changing user contact address mode", r) } } - async disableAddressAutoAccept(): Promise { - const r = await this.sendChatCommand({type: "addressAutoAccept"}) + async disableAddressAutoAccept(userId: number, businessAddress = false): Promise { + const r = await this.sendChatCmd(CC.APISetAddressSettings.cmdString({userId, settings: {businessAddress}})) if (r.type !== "userContactLinkUpdated") { throw new ChatCommandError("error changing user contact address mode", r) } } - async apiGetChats(userId: number): Promise { - const r = await this.sendChatCommand({type: "apiGetChats", userId}) - if (r.type === "apiChats") return r.chats - throw new ChatCommandError("error loading chats", r) - } - - async apiGetChat( - chatType: ChatType, - chatId: number, - pagination: CC.ChatPagination = {count: 100}, - search: string | undefined = undefined - ): Promise { - const r = await this.sendChatCommand({type: "apiGetChat", chatType, chatId, pagination, search}) - if (r.type === "apiChat") return r.chat - throw new ChatCommandError("error loading chat", r) - } - - async apiSendMessages(chatType: ChatType, chatId: number, messages: CC.ComposedMessage[]): Promise { - const r = await this.sendChatCommand({type: "apiSendMessage", chatType, chatId, messages}) + async apiSendMessages(chatType: T.ChatType, chatId: number, messages: T.ComposedMessage[]): Promise { + const r = await this.sendChatCmd( + CC.APISendMessages.cmdString({sendRef: {chatType, chatId}, composedMessages: messages, liveMessage: false}) + ) if (r.type === "newChatItems") return r.chatItems throw new ChatCommandError("unexpected response", r) } - async apiSendTextMessage(chatType: ChatType, chatId: number, text: string): Promise { - return this.apiSendMessages(chatType, chatId, [{msgContent: {type: "text", text}}]) + async apiSendTextMessage(chatType: T.ChatType, chatId: number, text: string): Promise { + return this.apiSendMessages(chatType, chatId, [{msgContent: {type: "text", text}, mentions: {}}]) } - async apiUpdateChatItem(chatType: ChatType, chatId: number, chatItemId: CC.ChatItemId, msgContent: CC.MsgContent): Promise { - const r = await this.sendChatCommand({type: "apiUpdateChatItem", chatType, chatId, chatItemId, msgContent}) + async apiUpdateChatItem(chatType: T.ChatType, chatId: number, chatItemId: number, msgContent: T.MsgContent): Promise { + const r = await this.sendChatCmd( + CC.APIUpdateChatItem.cmdString({ + chatRef: {chatType, chatId}, + chatItemId, + liveMessage: false, + updatedMessage: {msgContent, mentions: {}}, + }) + ) if (r.type === "chatItemUpdated") return r.chatItem.chatItem throw new ChatCommandError("error updating chat item", r) } - async apiDeleteChatItem( - chatType: ChatType, + async apiDeleteChatItems( + chatType: T.ChatType, chatId: number, - chatItemId: number, - deleteMode: CC.DeleteMode - ): Promise { - const r = await this.sendChatCommand({type: "apiDeleteChatItem", chatType, chatId, chatItemId, deleteMode}) - if (r.type === "chatItemDeleted") return r.toChatItem?.chatItem + chatItemIds: number[], + deleteMode: T.CIDeleteMode + ): Promise { + const r = await this.sendChatCmd(CC.APIDeleteChatItem.cmdString({chatRef: {chatType, chatId}, chatItemIds, deleteMode})) + if (r.type === "chatItemsDeleted") return r.chatItemDeletions throw new ChatCommandError("error deleting chat item", r) } - async apiCreateLink(): Promise { - const r = await this.sendChatCommand({type: "addContact"}) + async apiCreateLink(userId: number): Promise { + const r = await this.sendChatCmd(CC.APIAddContact.cmdString({userId, incognito: false})) if (r.type === "invitation") { const link = r.connLinkInvitation return link.connShortLink || link.connFullLink @@ -197,8 +164,8 @@ export class ChatClient { throw new ChatCommandError("error creating link", r) } - async apiConnect(connReq: string): Promise { - const r = await this.sendChatCommand({type: "connect", connReq}) + async apiConnectActiveUser(connLink: string): Promise { + const r = await this.sendChatCmd(CC.Connect.cmdString({incognito: false, connLink_: connLink})) switch (r.type) { case "sentConfirmation": return ConnReqType.Invitation @@ -209,30 +176,21 @@ export class ChatClient { } } - async apiDeleteChat(chatType: ChatType, chatId: number): Promise { - const r = await this.sendChatCommand({type: "apiDeleteChat", chatType, chatId}) + async apiDeleteChat(chatType: T.ChatType, chatId: number, deleteMode: T.ChatDeleteMode = {type: "full", notify: true}): Promise { + const r = await this.sendChatCmd(CC.APIDeleteChat.cmdString({chatRef: {chatType, chatId}, chatDeleteMode: deleteMode})) switch (chatType) { - case ChatType.Direct: + case T.ChatType.Direct: if (r.type === "contactDeleted") return break - case ChatType.Group: + case T.ChatType.Group: if (r.type === "groupDeletedUser") return break - case ChatType.ContactRequest: - if (r.type === "contactConnectionDeleted") return - break } throw new ChatCommandError("error deleting chat", r) } - async apiClearChat(chatType: ChatType, chatId: number): Promise { - const r = await this.sendChatCommand({type: "apiClearChat", chatType, chatId}) - if (r.type === "chatCleared") return r.chatInfo - throw new ChatCommandError("error clearing chat", r) - } - - async apiUpdateProfile(userId: number, profile: CC.Profile): Promise { - const r = await this.sendChatCommand({type: "apiUpdateProfile", userId, profile}) + async apiUpdateProfile(userId: number, profile: T.Profile): Promise { + const r = await this.sendChatCmd(CC.APIUpdateProfile.cmdString({userId, profile})) switch (r.type) { case "userProfileNoChange": return undefined @@ -243,14 +201,8 @@ export class ChatClient { } } - async apiSetContactAlias(contactId: number, localAlias: string): Promise { - const r = await this.sendChatCommand({type: "apiSetContactAlias", contactId, localAlias}) - if (r.type === "contactAliasUpdated") return r.toContact - throw new ChatCommandError("error updating contact alias", r) - } - - async apiCreateUserAddress(): Promise { - const r = await this.sendChatCommand({type: "createMyAddress"}) + async apiCreateUserAddress(userId: number): Promise { + const r = await this.sendChatCmd(CC.APICreateMyAddress.cmdString({userId})) if (r.type === "userContactLinkCreated") { const link = r.connLinkContact return link.connShortLink || link.connFullLink @@ -258,14 +210,14 @@ export class ChatClient { throw new ChatCommandError("error creating user address", r) } - async apiDeleteUserAddress(): Promise { - const r = await this.sendChatCommand({type: "deleteMyAddress"}) + async apiDeleteUserAddress(userId: number): Promise { + const r = await this.sendChatCmd(CC.APIDeleteMyAddress.cmdString({userId})) if (r.type === "userContactLinkDeleted") return throw new ChatCommandError("error deleting user address", r) } - async apiGetUserAddress(): Promise { - const r = await this.sendChatCommand({type: "showMyAddress"}) + async apiGetUserAddress(userId: number): Promise { + const r = await this.sendChatCmd(CC.APIShowMyAddress.cmdString({userId})) switch (r.type) { case "userContactLink": { const link = r.contactLink.connLinkContact @@ -279,87 +231,66 @@ export class ChatClient { } } - async apiAcceptContactRequest(contactReqId: number): Promise { - const r = await this.sendChatCommand({type: "apiAcceptContact", contactReqId}) + async apiAcceptContactRequest(contactReqId: number): Promise { + const r = await this.sendChatCmd(CC.APIAcceptContact.cmdString({contactReqId})) if (r.type === "acceptingContactRequest") return r.contact throw new ChatCommandError("error accepting contact request", r) } async apiRejectContactRequest(contactReqId: number): Promise { - const r = await this.sendChatCommand({type: "apiRejectContact", contactReqId}) + const r = await this.sendChatCmd(CC.APIRejectContact.cmdString({contactReqId})) if (r.type === "contactRequestRejected") return throw new ChatCommandError("error rejecting contact request", r) } - apiChatRead(chatType: ChatType, chatId: number, itemRange?: CC.ItemRange): Promise { - return this.okChatCommand({type: "apiChatRead", chatType, chatId, itemRange}) - } - - async apiContactInfo(contactId: number): Promise<[CR.ConnectionStats?, Profile?]> { - const r = await this.sendChatCommand({type: "apiContactInfo", contactId}) - if (r.type === "contactInfo") return [r.connectionStats, r.customUserProfile] - throw new ChatCommandError("error getting contact info", r) - } - - async apiGroupMemberInfo(groupId: number, memberId: number): Promise { - const r = await this.sendChatCommand({type: "apiGroupMemberInfo", groupId, memberId}) - if (r.type === "groupMemberInfo") return r.connectionStats_ - throw new ChatCommandError("error getting group info", r) - } - - async apiReceiveFile(fileId: number): Promise { - const r = await this.sendChatCommand({type: "receiveFile", fileId}) + async apiReceiveFile(fileId: number): Promise { + const r = await this.sendChatCmd(CC.ReceiveFile.cmdString({fileId, userApprovedRelays: true})) if (r.type === "rcvFileAccepted") return r.chatItem throw new ChatCommandError("error receiving file", r) } - async apiNewGroup(groupProfile: CR.GroupProfile): Promise { - const r = await this.sendChatCommand({type: "newGroup", groupProfile}) + async apiNewGroup(userId: number, groupProfile: T.GroupProfile): Promise { + const r = await this.sendChatCmd(CC.APINewGroup.cmdString({userId, groupProfile, incognito: false})) if (r.type === "groupCreated") return r.groupInfo throw new ChatCommandError("error creating group", r) } - async apiAddMember(groupId: number, contactId: number, memberRole: CC.GroupMemberRole): Promise { - const r = await this.sendChatCommand({type: "apiAddMember", groupId, contactId, memberRole}) + async apiAddMember(groupId: number, contactId: number, memberRole: T.GroupMemberRole): Promise { + const r = await this.sendChatCmd(CC.APIAddMember.cmdString({groupId, contactId, memberRole})) if (r.type === "sentGroupInvitation") return r.member throw new ChatCommandError("error adding member", r) } - async apiJoinGroup(groupId: number): Promise { - const r = await this.sendChatCommand({type: "apiJoinGroup", groupId}) + async apiJoinGroup(groupId: number): Promise { + const r = await this.sendChatCmd(CC.APIJoinGroup.cmdString({groupId})) if (r.type === "userAcceptedGroupSent") return r.groupInfo throw new ChatCommandError("error joining group", r) } - async apiRemoveMember(groupId: number, memberId: number): Promise { - const r = await this.sendChatCommand({type: "apiRemoveMember", groupId, memberId}) - if (r.type === "userDeletedMember") return r.member + async apiRemoveMembers(groupId: number, memberIds: number[], withMessages = false): Promise { + const r = await this.sendChatCmd(CC.APIRemoveMembers.cmdString({groupId, groupMemberIds: memberIds, withMessages})) + if (r.type === "userDeletedMembers") return r.members throw new ChatCommandError("error removing member", r) } - async apiLeaveGroup(groupId: number): Promise { - const r = await this.sendChatCommand({type: "apiLeaveGroup", groupId}) + async apiLeaveGroup(groupId: number): Promise { + const r = await this.sendChatCmd(CC.APILeaveGroup.cmdString({groupId})) if (r.type === "leftMemberUser") return r.groupInfo throw new ChatCommandError("error leaving group", r) } - async apiListMembers(groupId: number): Promise { - const r = await this.sendChatCommand({type: "apiListMembers", groupId}) + async apiListMembers(groupId: number): Promise { + const r = await this.sendChatCmd(CC.APIListMembers.cmdString({groupId})) if (r.type === "groupMembers") return r.group.members throw new ChatCommandError("error getting group members", r) } - async apiUpdateGroup(groupId: number, groupProfile: CR.GroupProfile): Promise { - const r = await this.sendChatCommand({type: "apiUpdateGroupProfile", groupId, groupProfile}) + async apiUpdateGroup(groupId: number, groupProfile: T.GroupProfile): Promise { + const r = await this.sendChatCmd(CC.APIUpdateGroupProfile.cmdString({groupId, groupProfile})) if (r.type === "groupUpdated") return r.toGroup throw new ChatCommandError("error updating group", r) } - private async okChatCommand(command: ChatCommand): Promise { - const r = await this.sendChatCommand(command) - if (r.type !== "cmdOk") throw new ChatCommandError(`${command.type} command error`, r) - } - get connected(): boolean { return this._connected } diff --git a/packages/simplex-chat-client/typescript/src/command.ts b/packages/simplex-chat-client/typescript/src/command.ts deleted file mode 100644 index 135dc41ba7..0000000000 --- a/packages/simplex-chat-client/typescript/src/command.ts +++ /dev/null @@ -1,822 +0,0 @@ -export type ChatCommand = - | ShowActiveUser - | CreateActiveUser - | ListUsers - | APISetActiveUser - | APIHideUser - | APIUnhideUser - | APIMuteUser - | APIUnmuteUser - | APIDeleteUser - | StartChat - | APIStopChat - | SetTempFolder - | SetFilesFolder - | SetIncognito - | APIExportArchive - | APIImportArchive - | APIDeleteStorage - | APIGetChats - | APIGetChat - | APISendMessage - | APIUpdateChatItem - | APIDeleteChatItem - | APIDeleteMemberChatItem - | APIChatRead - | APIDeleteChat - | APIClearChat - | APIAcceptContact - | APIRejectContact - | APIUpdateProfile - | APISetContactAlias - | NewGroup - | APIAddMember - | APIJoinGroup - | APIRemoveMember - | APILeaveGroup - | APIListMembers - | APIUpdateGroupProfile - | APICreateGroupLink - | APIGroupLinkMemberRole - | APIDeleteGroupLink - | APIGetGroupLink - | APIGetUserProtoServers - | APISetUserProtoServers - | APIContactInfo - | APIGroupMemberInfo - | APIGetContactCode - | APIGetGroupMemberCode - | APIVerifyContact - | APIVerifyGroupMember - | AddContact - | Connect - | ConnectSimplex - | CreateMyAddress - | DeleteMyAddress - | ShowMyAddress - | SetProfileAddress - | AddressAutoAccept - | APICreateMyAddress - | APIDeleteMyAddress - | APIShowMyAddress - | APISetProfileAddress - | APIAddressAutoAccept - | ReceiveFile - | CancelFile - | FileStatus - -// not included commands (they are not needed for Websocket clients, and can still be sent as strings): -// APIActivateChat -// APISuspendChat -// ResubscribeAllConnections -// APIGetChatItems - not implemented -// APISendCallInvitation -// APIRejectCall -// APISendCallOffer -// APISendCallAnswer -// APISendCallExtraInfo -// APIEndCall -// APIGetCallInvitations -// APICallStatus -// APIGetNtfToken -// APIRegisterToken -// APIVerifyToken -// APIDeleteToken -// APIGetNtfMessage -// APIMemberRole -- not implemented -// ListContacts -// ListGroups -// APISetChatItemTTL -// APIGetChatItemTTL -// APISetNetworkConfig -// APIGetNetworkConfig -// APISetChatSettings -// ShowMessages -// LastMessages -// SendMessageBroadcast - -type ChatCommandTag = - | "showActiveUser" - | "createActiveUser" - | "listUsers" - | "apiSetActiveUser" - | "setActiveUser" - | "apiHideUser" - | "apiUnhideUser" - | "apiMuteUser" - | "apiUnmuteUser" - | "apiDeleteUser" - | "startChat" - | "apiStopChat" - | "setTempFolder" - | "setFilesFolder" - | "setIncognito" - | "apiExportArchive" - | "apiImportArchive" - | "apiDeleteStorage" - | "apiGetChats" - | "apiGetChat" - | "apiSendMessage" - | "apiUpdateChatItem" - | "apiDeleteChatItem" - | "apiDeleteMemberChatItem" - | "apiChatRead" - | "apiDeleteChat" - | "apiClearChat" - | "apiAcceptContact" - | "apiRejectContact" - | "apiUpdateProfile" - | "apiSetContactAlias" - | "newGroup" - | "apiAddMember" - | "apiJoinGroup" - | "apiRemoveMember" - | "apiLeaveGroup" - | "apiListMembers" - | "apiUpdateGroupProfile" - | "apiCreateGroupLink" - | "apiGroupLinkMemberRole" - | "apiDeleteGroupLink" - | "apiGetGroupLink" - | "apiGetUserProtoServers" - | "apiSetUserProtoServers" - | "apiContactInfo" - | "apiGroupMemberInfo" - | "apiGetContactCode" - | "apiGetGroupMemberCode" - | "apiVerifyContact" - | "apiVerifyGroupMember" - | "addContact" - | "connect" - | "connectSimplex" - | "createMyAddress" - | "deleteMyAddress" - | "showMyAddress" - | "setProfileAddress" - | "addressAutoAccept" - | "apiCreateMyAddress" - | "apiDeleteMyAddress" - | "apiShowMyAddress" - | "apiSetProfileAddress" - | "apiAddressAutoAccept" - | "receiveFile" - | "cancelFile" - | "fileStatus" - -interface IChatCommand { - type: ChatCommandTag -} - -export interface ShowActiveUser extends IChatCommand { - type: "showActiveUser" -} - -export interface CreateActiveUser extends IChatCommand { - type: "createActiveUser" - profile?: Profile - sameServers: boolean - pastTimestamp: boolean -} - -export interface ListUsers extends IChatCommand { - type: "listUsers" -} - -export interface APISetActiveUser extends IChatCommand { - type: "apiSetActiveUser" - userId: number - viewPwd?: string -} - -export interface APIHideUser extends IChatCommand { - type: "apiHideUser" - userId: number - viewPwd: string -} - -export interface APIUnhideUser extends IChatCommand { - type: "apiUnhideUser" - userId: number - viewPwd: string -} - -export interface APIMuteUser extends IChatCommand { - type: "apiMuteUser" - userId: number -} - -export interface APIUnmuteUser extends IChatCommand { - type: "apiUnmuteUser" - userId: number -} - -export interface APIDeleteUser extends IChatCommand { - type: "apiDeleteUser" - userId: number - delSMPQueues: boolean - viewPwd?: string -} - -export interface StartChat extends IChatCommand { - type: "startChat" - subscribeConnections?: boolean - enableExpireChatItems?: boolean - startXFTPWorkers?: boolean -} - -export interface APIStopChat extends IChatCommand { - type: "apiStopChat" -} - -export interface SetTempFolder extends IChatCommand { - type: "setTempFolder" - tempFolder: string -} - -export interface SetFilesFolder extends IChatCommand { - type: "setFilesFolder" - filePath: string -} - -export interface SetIncognito extends IChatCommand { - type: "setIncognito" - incognito: boolean -} - -export interface APIExportArchive extends IChatCommand { - type: "apiExportArchive" - config: ArchiveConfig -} - -export interface APIImportArchive extends IChatCommand { - type: "apiImportArchive" - config: ArchiveConfig -} - -export interface APIDeleteStorage extends IChatCommand { - type: "apiDeleteStorage" -} - -export interface APIGetChats extends IChatCommand { - type: "apiGetChats" - userId: number - pendingConnections?: boolean -} - -export interface APIGetChat extends IChatCommand { - type: "apiGetChat" - chatType: ChatType - chatId: number - pagination: ChatPagination - search?: string -} - -export interface APISendMessage extends IChatCommand { - type: "apiSendMessage" - chatType: ChatType - chatId: number - messages: ComposedMessage[] -} - -export interface ComposedMessage { - filePath?: string - quotedItemId?: ChatItemId - msgContent: MsgContent -} - -export interface APIUpdateChatItem extends IChatCommand { - type: "apiUpdateChatItem" - chatType: ChatType - chatId: number - chatItemId: ChatItemId - msgContent: MsgContent -} - -export interface APIDeleteChatItem extends IChatCommand { - type: "apiDeleteChatItem" - chatType: ChatType - chatId: number - chatItemId: ChatItemId - deleteMode: DeleteMode -} - -export interface APIDeleteMemberChatItem extends IChatCommand { - type: "apiDeleteMemberChatItem" - groupId: number - groupMemberId: number - itemId: number -} - -export interface APIChatRead extends IChatCommand { - type: "apiChatRead" - chatType: ChatType - chatId: number - itemRange?: ItemRange -} - -export interface ItemRange { - fromItem: ChatItemId - toItem: ChatItemId -} - -export interface APIDeleteChat extends IChatCommand { - type: "apiDeleteChat" - chatType: ChatType - chatId: number -} - -export interface APIClearChat extends IChatCommand { - type: "apiClearChat" - chatType: ChatType - chatId: number -} - -export interface APIAcceptContact extends IChatCommand { - type: "apiAcceptContact" - contactReqId: number -} - -export interface APIRejectContact extends IChatCommand { - type: "apiRejectContact" - contactReqId: number -} - -export interface APIUpdateProfile extends IChatCommand { - type: "apiUpdateProfile" - userId: number - profile: Profile -} - -export interface APISetContactAlias extends IChatCommand { - type: "apiSetContactAlias" - contactId: number - localAlias: string -} - -export interface NewGroup extends IChatCommand { - type: "newGroup" - groupProfile: GroupProfile -} - -export interface APIAddMember extends IChatCommand { - type: "apiAddMember" - groupId: number - contactId: number - memberRole: GroupMemberRole -} - -export interface APIJoinGroup extends IChatCommand { - type: "apiJoinGroup" - groupId: number -} - -export interface APIRemoveMember extends IChatCommand { - type: "apiRemoveMember" - groupId: number - memberId: number -} - -export interface APILeaveGroup extends IChatCommand { - type: "apiLeaveGroup" - groupId: number -} - -export interface APIListMembers extends IChatCommand { - type: "apiListMembers" - groupId: number -} - -export interface APIUpdateGroupProfile extends IChatCommand { - type: "apiUpdateGroupProfile" - groupId: number - groupProfile: GroupProfile -} - -export interface APICreateGroupLink extends IChatCommand { - type: "apiCreateGroupLink" - groupId: number - memberRole: GroupMemberRole -} - -export interface APIGroupLinkMemberRole extends IChatCommand { - type: "apiGroupLinkMemberRole" - groupId: number - memberRole: GroupMemberRole -} - -export interface APIDeleteGroupLink extends IChatCommand { - type: "apiDeleteGroupLink" - groupId: number -} - -export interface APIGetGroupLink extends IChatCommand { - type: "apiGetGroupLink" - groupId: number -} - -export interface APIGetUserProtoServers extends IChatCommand { - type: "apiGetUserProtoServers" - userId: number - serverProtocol: ServerProtocol -} - -export interface APISetUserProtoServers extends IChatCommand { - type: "apiSetUserProtoServers" - userId: number - serverProtocol: ServerProtocol - servers: ServerCfg[] -} - -export interface ServerCfg { - server: string - preset: boolean - tested?: boolean - enabled: boolean -} - -export enum ServerProtocol { - SMP = "smp", - XFTP = "xftp", -} - -export interface APIContactInfo extends IChatCommand { - type: "apiContactInfo" - contactId: number -} - -export interface APIGroupMemberInfo extends IChatCommand { - type: "apiGroupMemberInfo" - groupId: number - memberId: number -} - -export interface APIGetContactCode extends IChatCommand { - type: "apiGetContactCode" - contactId: number -} - -export interface APIGetGroupMemberCode extends IChatCommand { - type: "apiGetGroupMemberCode" - groupId: number - groupMemberId: number -} - -export interface APIVerifyContact extends IChatCommand { - type: "apiVerifyContact" - contactId: number - connectionCode: string -} - -export interface APIVerifyGroupMember extends IChatCommand { - type: "apiVerifyGroupMember" - groupId: number - groupMemberId: number - connectionCode: string -} - -export interface AddContact extends IChatCommand { - type: "addContact" -} - -export interface Connect extends IChatCommand { - type: "connect" - connReq: string -} - -export interface ConnectSimplex extends IChatCommand { - type: "connectSimplex" -} - -export interface CreateMyAddress extends IChatCommand { - type: "createMyAddress" -} - -export interface DeleteMyAddress extends IChatCommand { - type: "deleteMyAddress" -} - -export interface ShowMyAddress extends IChatCommand { - type: "showMyAddress" -} - -export interface SetProfileAddress extends IChatCommand { - type: "setProfileAddress" - includeInProfile: boolean -} - -export interface AddressAutoAccept extends IChatCommand { - type: "addressAutoAccept" - autoAccept?: AutoAccept -} - -export interface APICreateMyAddress extends IChatCommand { - type: "apiCreateMyAddress" - userId: number -} - -export interface APIDeleteMyAddress extends IChatCommand { - type: "apiDeleteMyAddress" - userId: number -} - -export interface APIShowMyAddress extends IChatCommand { - type: "apiShowMyAddress" - userId: number -} - -export interface APISetProfileAddress extends IChatCommand { - type: "apiSetProfileAddress" - userId: number - includeInProfile: boolean -} - -export interface APIAddressAutoAccept extends IChatCommand { - type: "apiAddressAutoAccept" - userId: number - autoAccept?: AutoAccept -} - -export interface AutoAccept { - acceptIncognito: boolean - autoReply?: MsgContent -} - -export interface ReceiveFile extends IChatCommand { - type: "receiveFile" - fileId: number - filePath?: string -} - -export interface CancelFile extends IChatCommand { - type: "cancelFile" - fileId: number -} - -export interface FileStatus extends IChatCommand { - type: "fileStatus" - fileId: number -} - -interface NewUser { - profile?: Profile - sameServers: boolean - pastTimestamp: boolean -} - -export interface Profile { - displayName: string - fullName: string // can be empty string - image?: string - contactLink?: string - // preferences?: Preferences -} - -export interface LocalProfile { - profileId: number - displayName: string - fullName: string - image?: string - contactLink?: string - // preferences?: Preferences - localAlias: string -} - -export enum ChatType { - Direct = "@", - Group = "#", - ContactRequest = "<@", -} - -export type ChatPagination = - | {count: number} // count from the last item in case neither after nor before specified - | {count: number; after: ChatItemId} - | {count: number; before: ChatItemId} - -export type ChatItemId = number - -type MsgContentTag = "text" | "link" | "image" | "file" - -export type MsgContent = MCText | MCLink | MCImage | MCFile | MCUnknown - -interface MC { - type: MsgContentTag - text: string -} - -interface MCText extends MC { - type: "text" - text: string -} - -interface MCLink extends MC { - type: "link" - text: string - preview: LinkPreview -} - -interface MCImage extends MC { - type: "image" - image: string // image preview as base64 encoded data string -} - -interface MCFile extends MC { - type: "file" - text: string -} - -interface MCUnknown { - type: string - text: string -} - -interface LinkPreview { - uri: string - title: string - description: string - image: string -} - -export enum DeleteMode { - Broadcast = "broadcast", - Internal = "internal", -} - -interface ArchiveConfig { - archivePath: string - disableCompression?: boolean - parentTempDirectory?: string -} - -export enum GroupMemberRole { - GRMember = "member", - GRAdmin = "admin", - GROwner = "owner", -} - -interface GroupProfile { - displayName: string - fullName: string // can be empty string - image?: string -} - -export function cmdString(cmd: ChatCommand): string { - switch (cmd.type) { - case "showActiveUser": - return "/u" - case "createActiveUser": { - const user: NewUser = {profile: cmd.profile, sameServers: cmd.sameServers, pastTimestamp: cmd.pastTimestamp} - return `/_create user ${JSON.stringify(user)}` - } - case "listUsers": - return `/users` - case "apiSetActiveUser": - return `/_user ${cmd.userId}${maybeJSON(cmd.viewPwd)}` - case "apiHideUser": - return `/_hide user ${cmd.userId} ${JSON.stringify(cmd.viewPwd)}` - case "apiUnhideUser": - return `/_unhide user ${cmd.userId} ${JSON.stringify(cmd.viewPwd)}` - case "apiMuteUser": - return `/_mute user ${cmd.userId}` - case "apiUnmuteUser": - return `/_unmute user ${cmd.userId}` - case "apiDeleteUser": - return `/_delete user ${cmd.userId} del_smp=${onOff(cmd.delSMPQueues)}${maybeJSON(cmd.viewPwd)}` - case "startChat": - return `/_start subscribe=${cmd.subscribeConnections ? "on" : "off"} expire=${cmd.enableExpireChatItems ? "on" : "off"}` - case "apiStopChat": - return "/_stop" - case "setTempFolder": - return `/_temp_folder ${cmd.tempFolder}` - case "setFilesFolder": - return `/_files_folder ${cmd.filePath}` - case "setIncognito": - return `/incognito ${onOff(cmd.incognito)}` - case "apiExportArchive": - return `/_db export ${JSON.stringify(cmd.config)}` - case "apiImportArchive": - return `/_db import ${JSON.stringify(cmd.config)}` - case "apiDeleteStorage": - return "/_db delete" - case "apiGetChats": - return `/_get chats pcc=${onOff(cmd.pendingConnections)}` - case "apiGetChat": - return `/_get chat ${cmd.chatType}${cmd.chatId}${paginationStr(cmd.pagination)}` - case "apiSendMessage": - return `/_send ${cmd.chatType}${cmd.chatId} json ${JSON.stringify(cmd.messages)}` - case "apiUpdateChatItem": - return `/_update item ${cmd.chatType}${cmd.chatId} ${cmd.chatItemId} json ${JSON.stringify(cmd.msgContent)}` - case "apiDeleteChatItem": - return `/_delete item ${cmd.chatType}${cmd.chatId} ${cmd.chatItemId} ${cmd.deleteMode}` - case "apiDeleteMemberChatItem": - return `/_delete member item #${cmd.groupId} ${cmd.groupMemberId} ${cmd.itemId}` - case "apiChatRead": { - const itemRange = cmd.itemRange ? ` from=${cmd.itemRange.fromItem} to=${cmd.itemRange.toItem}` : "" - return `/_read chat ${cmd.chatType}${cmd.chatId}${itemRange}` - } - case "apiDeleteChat": - return `/_delete ${cmd.chatType}${cmd.chatId}` - case "apiClearChat": - return `/_clear chat ${cmd.chatType}${cmd.chatId}` - case "apiAcceptContact": - return `/_accept ${cmd.contactReqId}` - case "apiRejectContact": - return `/_reject ${cmd.contactReqId}` - case "apiUpdateProfile": - return `/_profile ${cmd.userId} ${JSON.stringify(cmd.profile)}` - case "apiSetContactAlias": - return `/_set alias @${cmd.contactId} ${cmd.localAlias.trim()}` - case "newGroup": - return `/_group ${JSON.stringify(cmd.groupProfile)}` - case "apiAddMember": - return `/_add #${cmd.groupId} ${cmd.contactId} ${cmd.memberRole}` - case "apiJoinGroup": - return `/_join #${cmd.groupId}` - case "apiRemoveMember": - return `/_remove #${cmd.groupId} ${cmd.memberId}` - case "apiLeaveGroup": - return `/_leave #${cmd.groupId}` - case "apiListMembers": - return `/_members #${cmd.groupId}` - case "apiUpdateGroupProfile": - return `/_group_profile #${cmd.groupId} ${JSON.stringify(cmd.groupProfile)}` - case "apiCreateGroupLink": - return `/_create link #${cmd.groupId} ${cmd.memberRole}` - case "apiGroupLinkMemberRole": - return `/_set link role #${cmd.groupId} ${cmd.memberRole}` - case "apiDeleteGroupLink": - return `/_delete link #${cmd.groupId}` - case "apiGetGroupLink": - return `/_get link #${cmd.groupId}` - case "apiGetUserProtoServers": - return `/_servers ${cmd.userId} ${cmd.serverProtocol}` - case "apiSetUserProtoServers": - return `/_servers ${cmd.userId} ${cmd.serverProtocol} ${JSON.stringify({servers: cmd.servers})}` - case "apiContactInfo": - return `/_info @${cmd.contactId}` - case "apiGroupMemberInfo": - return `/_info #${cmd.groupId} ${cmd.memberId}` - case "apiGetContactCode": - return `/_get code @${cmd.contactId}` - case "apiGetGroupMemberCode": - return `/_get code #${cmd.groupId} ${cmd.groupMemberId}` - case "apiVerifyContact": - return `/_verify code @${cmd.contactId}${maybe(cmd.connectionCode)}` - case "apiVerifyGroupMember": - return `/_verify code #${cmd.groupId} ${cmd.groupMemberId}${maybe(cmd.connectionCode)}` - case "addContact": - return "/connect" - case "connect": - return `/connect ${cmd.connReq}` - case "connectSimplex": - return "/simplex" - case "createMyAddress": - return "/address" - case "deleteMyAddress": - return "/delete_address" - case "showMyAddress": - return "/show_address" - case "setProfileAddress": - return `/profile_address ${onOff(cmd.includeInProfile)}` - case "addressAutoAccept": - return `/auto_accept ${autoAcceptStr(cmd.autoAccept)}` - case "apiCreateMyAddress": - return `/_address ${cmd.userId}` - case "apiDeleteMyAddress": - return `/_delete_address ${cmd.userId}` - case "apiShowMyAddress": - return `/_show_address ${cmd.userId}` - case "apiSetProfileAddress": - return `/_profile_address ${cmd.userId} ${onOff(cmd.includeInProfile)}` - case "apiAddressAutoAccept": - return `/_auto_accept ${cmd.userId} ${autoAcceptStr(cmd.autoAccept)}` - case "receiveFile": - return `/freceive ${cmd.fileId}${cmd.filePath ? " " + cmd.filePath : ""}` - case "cancelFile": - return `/fcancel ${cmd.fileId}` - case "fileStatus": - return `/fstatus ${cmd.fileId}` - } -} - -function paginationStr(cp: ChatPagination): string { - const base = "after" in cp ? ` after=${cp.after}` : "before" in cp ? ` before=${cp.before}` : "" - return base + ` count=${cp.count}` -} - -function maybe(value: T | undefined): string { - return value ? ` ${value}` : "" -} - -function maybeJSON(value: T | undefined): string { - return value ? ` json ${JSON.stringify(value)}` : "" -} - -function onOff(value: T | undefined): string { - return value ? "on" : "off" -} - -function autoAcceptStr(autoAccept: AutoAccept | undefined): string { - if (!autoAccept) return "off" - const msg = autoAccept.autoReply - return "on" + (autoAccept.acceptIncognito ? " incognito=on" : "") + (msg ? " json " + JSON.stringify(msg) : "") -} diff --git a/packages/simplex-chat-client/typescript/src/response.ts b/packages/simplex-chat-client/typescript/src/response.ts deleted file mode 100644 index 95eac89249..0000000000 --- a/packages/simplex-chat-client/typescript/src/response.ts +++ /dev/null @@ -1,1109 +0,0 @@ -import {ChatItemId, MsgContent, DeleteMode, Profile, GroupMemberRole, LocalProfile, ServerProtocol, ServerCfg} from "./command" - -export type ChatResponse = - | CRActiveUser - | CRUsersList - | CRChatStarted - | CRChatRunning - | CRChatStopped - | CRApiChats - | CRApiChat - | CRApiParsedMarkdown - | CRUserProtoServers - | CRContactInfo - | CRGroupMemberInfo - | CRNewChatItems - | CRChatItemStatusUpdated - | CRChatItemUpdated - | CRChatItemDeleted - | CRMsgIntegrityError - | CRCmdOk - | CRUserContactLink - | CRUserContactLinkUpdated - | CRContactRequestRejected - | CRUserProfile - | CRUserProfileNoChange - | CRUserProfileUpdated - | CRContactAliasUpdated - | CRInvitation - | CRSentConfirmation - | CRSentInvitation - | CRContactUpdated - | CRContactsMerged - | CRContactDeleted - | CRChatCleared - | CRUserContactLinkCreated - | CRUserContactLinkDeleted - | CRReceivedContactRequest - | CRAcceptingContactRequest - | CRContactAlreadyExists - | CRContactRequestAlreadyAccepted - | CRContactConnecting - | CRContactConnected - | CRContactAnotherClient - | CRContactSubError - | CRContactSubSummary - | CRContactsDisconnected - | CRContactsSubscribed - | CRHostConnected - | CRHostDisconnected - | CRGroupEmpty - | CRMemberSubError - | CRMemberSubSummary - | CRGroupSubscribed - | CRRcvFileAccepted - | CRRcvFileAcceptedSndCancelled - | CRRcvFileStart - | CRRcvFileComplete - | CRRcvFileCancelled - | CRRcvFileSndCancelled - | CRSndFileStart - | CRSndFileComplete - | CRSndFileCancelled - | CRSndFileRcvCancelled - | CRSndGroupFileCancelled - | CRSndFileSubError - | CRRcvFileSubError - | CRPendingSubSummary - | CRGroupCreated - | CRGroupMembers - | CRUserAcceptedGroupSent - | CRUserDeletedMember - | CRSentGroupInvitation - | CRLeftMemberUser - | CRGroupDeletedUser - | CRGroupInvitation - | CRReceivedGroupInvitation - | CRUserJoinedGroup - | CRJoinedGroupMember - | CRJoinedGroupMemberConnecting - | CRConnectedToGroupMember - | CRDeletedMember - | CRDeletedMemberUser - | CRLeftMember - | CRGroupRemoved - | CRGroupDeleted - | CRGroupUpdated - | CRUserContactLinkSubError - | CRContactConnectionDeleted - | CRMessageError - | CRChatCmdError - | CRChatError - -// not included -// CRChatItemDeletedNotFound -// CRBroadcastSent -// CRGroupsList -// CRFileTransferStatus - -type ChatResponseTag = - | "activeUser" - | "usersList" - | "chatStarted" - | "chatRunning" - | "chatStopped" - | "apiChats" - | "apiChat" - | "apiParsedMarkdown" - | "userProtoServers" - | "contactInfo" - | "groupMemberInfo" - | "newChatItems" - | "chatItemStatusUpdated" - | "chatItemUpdated" - | "chatItemDeleted" - | "msgIntegrityError" - | "cmdOk" - | "userContactLink" - | "userContactLinkUpdated" - | "userContactLinkCreated" - | "userContactLinkDeleted" - | "contactRequestRejected" - | "userProfile" - | "userProfileNoChange" - | "userProfileUpdated" - | "contactAliasUpdated" - | "invitation" - | "sentConfirmation" - | "sentInvitation" - | "contactUpdated" - | "contactsMerged" - | "contactDeleted" - | "chatCleared" - | "receivedContactRequest" - | "acceptingContactRequest" - | "contactAlreadyExists" - | "contactRequestAlreadyAccepted" - | "contactConnecting" - | "contactConnected" - | "contactAnotherClient" - | "contactSubError" - | "contactSubSummary" - | "contactsDisconnected" - | "contactsSubscribed" - | "hostConnected" - | "hostDisconnected" - | "groupEmpty" - | "memberSubError" - | "memberSubSummary" - | "groupSubscribed" - | "rcvFileAccepted" - | "rcvFileAcceptedSndCancelled" - | "rcvFileStart" - | "rcvFileComplete" - | "rcvFileCancelled" - | "rcvFileSndCancelled" - | "sndFileStart" - | "sndFileComplete" - | "sndFileCancelled" - | "sndFileRcvCancelled" - | "sndGroupFileCancelled" - | "fileTransferStatus" - | "sndFileSubError" - | "rcvFileSubError" - | "pendingSubSummary" - | "groupCreated" - | "groupMembers" - | "userAcceptedGroupSent" - | "userDeletedMember" - | "sentGroupInvitation" - | "leftMemberUser" - | "groupDeletedUser" - | "groupInvitation" - | "receivedGroupInvitation" - | "userJoinedGroup" - | "joinedGroupMember" - | "joinedGroupMemberConnecting" - | "connectedToGroupMember" - | "deletedMember" - | "deletedMemberUser" - | "leftMember" - | "groupRemoved" - | "groupDeleted" - | "groupUpdated" - | "userContactLinkSubError" - | "newContactConnection" - | "contactConnectionDeleted" - | "messageError" - | "chatCmdError" - | "chatError" - -interface CR { - type: ChatResponseTag -} - -export interface CRActiveUser extends CR { - type: "activeUser" - user: User -} - -export interface CRUsersList extends CR { - type: "usersList" - users: UserInfo[] -} - -export interface CRChatStarted extends CR { - type: "chatStarted" -} - -export interface CRChatRunning extends CR { - type: "chatRunning" -} - -export interface CRChatStopped extends CR { - type: "chatStopped" -} - -export interface CRApiChats extends CR { - type: "apiChats" - user: User - chats: Chat[] -} - -export interface CRApiChat extends CR { - type: "apiChat" - user: User - chat: Chat -} - -export interface CRApiParsedMarkdown extends CR { - type: "apiParsedMarkdown" - formattedText?: FormattedText[] -} - -export interface CRUserProtoServers extends CR { - type: "userProtoServers" - user: User - servers: UserProtoServers -} - -export interface CRContactInfo extends CR { - type: "contactInfo" - user: User - contact: Contact - connectionStats: ConnectionStats - customUserProfile?: Profile -} - -export interface CRGroupMemberInfo extends CR { - type: "groupMemberInfo" - user: User - groupInfo: GroupInfo - member: GroupMember - connectionStats_?: ConnectionStats -} - -export interface CRNewChatItems extends CR { - type: "newChatItems" - user: User - chatItems: AChatItem[] -} - -export interface CRChatItemStatusUpdated extends CR { - type: "chatItemStatusUpdated" - user: User - chatItem: AChatItem -} - -export interface CRChatItemUpdated extends CR { - type: "chatItemUpdated" - user: User - chatItem: AChatItem -} - -export interface CRChatItemDeleted extends CR { - type: "chatItemDeleted" - user: User - deletedChatItem: AChatItem - toChatItem?: AChatItem - byUser: boolean -} - -export interface CRMsgIntegrityError extends CR { - type: "msgIntegrityError" - user: User - msgError: MsgErrorType -} - -export interface CRCmdOk extends CR { - type: "cmdOk" - user_?: User -} - -export interface CRUserContactLink extends CR { - type: "userContactLink" - user: User - contactLink: UserContactLink -} - -export interface CRUserContactLinkUpdated extends CR { - type: "userContactLinkUpdated" - user: User - contactLink: UserContactLink -} - -export interface CRContactRequestRejected extends CR { - type: "contactRequestRejected" - user: User - contactRequest: UserContactRequest -} - -export interface CRUserProfile extends CR { - type: "userProfile" - user: User - profile: Profile -} - -export interface CRUserProfileNoChange extends CR { - type: "userProfileNoChange" - user: User -} - -export interface CRUserProfileUpdated extends CR { - type: "userProfileUpdated" - user: User - fromProfile: Profile - toProfile: Profile -} - -export interface CRContactAliasUpdated extends CR { - type: "contactAliasUpdated" - user: User - toContact: Contact -} - -export interface CRInvitation extends CR { - type: "invitation" - user: User - connLinkInvitation: CreatedConnLink -} - -export interface CRSentConfirmation extends CR { - type: "sentConfirmation" - user: User -} - -export interface CRSentInvitation extends CR { - type: "sentInvitation" - user: User -} - -export interface CRContactUpdated extends CR { - type: "contactUpdated" - user: User - fromContact: Contact - toContact: Contact -} - -export interface CRContactsMerged extends CR { - type: "contactsMerged" - user: User - intoContact: Contact - mergedContact: Contact -} - -export interface CRContactDeleted extends CR { - type: "contactDeleted" - user: User - contact: Contact -} - -export interface CRChatCleared extends CR { - type: "chatCleared" - user: User - chatInfo: ChatInfo -} - -export interface CRUserContactLinkCreated extends CR { - type: "userContactLinkCreated" - user: User - connLinkContact: CreatedConnLink -} - -export interface CRUserContactLinkDeleted extends CR { - type: "userContactLinkDeleted" - user: User -} - -export interface CRReceivedContactRequest extends CR { - type: "receivedContactRequest" - user: User - contactRequest: UserContactRequest -} - -export interface CRAcceptingContactRequest extends CR { - type: "acceptingContactRequest" - user: User - contact: Contact -} - -export interface CRContactAlreadyExists extends CR { - type: "contactAlreadyExists" - user: User - contact: Contact -} - -export interface CRContactRequestAlreadyAccepted extends CR { - type: "contactRequestAlreadyAccepted" - user: User - contact: Contact -} - -export interface CRContactConnecting extends CR { - type: "contactConnecting" - user: User - contact: Contact -} - -export interface CRContactConnected extends CR { - type: "contactConnected" - contact: Contact - user: User - userCustomProfile?: Profile -} - -export interface CRContactAnotherClient extends CR { - type: "contactAnotherClient" - user: User - contact: Contact -} - -export interface CRContactSubError extends CR { - type: "contactSubError" - user: User - contact: Contact - chatError: ChatError -} - -export interface CRContactSubSummary extends CR { - type: "contactSubSummary" - user: User - contactSubscriptions: ContactSubStatus[] -} - -export interface CRContactsDisconnected extends CR { - type: "contactsDisconnected" - user: User - server: string - contactRefs: ContactRef[] -} - -export interface CRContactsSubscribed extends CR { - type: "contactsSubscribed" - user: User - server: string - contactRefs: ContactRef[] -} - -export interface CRHostConnected extends CR { - type: "hostConnected" - protocol: string - transportHost: string -} - -export interface CRHostDisconnected extends CR { - type: "hostDisconnected" - protocol: string - transportHost: string -} - -export interface CRGroupEmpty extends CR { - type: "groupEmpty" - user: User - groupInfo: GroupInfo -} - -export interface CRMemberSubError extends CR { - type: "memberSubError" - user: User - groupInfo: GroupInfo - member: GroupMember - chatError: ChatError -} - -export interface CRMemberSubSummary extends CR { - type: "memberSubSummary" - user: User - memberSubscriptions: MemberSubStatus[] -} - -export interface CRGroupSubscribed extends CR { - type: "groupSubscribed" - user: User - groupInfo: GroupInfo -} - -export interface CRRcvFileAccepted extends CR { - type: "rcvFileAccepted" - user: User - chatItem: AChatItem -} - -export interface CRRcvFileAcceptedSndCancelled extends CR { - type: "rcvFileAcceptedSndCancelled" - user: User - rcvFileTransfer: RcvFileTransfer -} - -export interface CRRcvFileStart extends CR { - type: "rcvFileStart" - user: User - chatItem: AChatItem -} - -export interface CRRcvFileComplete extends CR { - type: "rcvFileComplete" - user: User - chatItem: AChatItem -} - -export interface CRRcvFileCancelled extends CR { - type: "rcvFileCancelled" - user: User - rcvFileTransfer: RcvFileTransfer -} - -export interface CRRcvFileSndCancelled extends CR { - type: "rcvFileSndCancelled" - user: User - rcvFileTransfer: RcvFileTransfer -} - -export interface CRSndFileStart extends CR { - type: "sndFileStart" - user: User - chatItem: AChatItem - sndFileTransfer: SndFileTransfer -} - -export interface CRSndFileComplete extends CR { - type: "sndFileComplete" - user: User - chatItem: AChatItem - sndFileTransfer: SndFileTransfer -} - -export interface CRSndFileCancelled extends CR { - type: "sndFileCancelled" - user: User - chatItem: AChatItem - sndFileTransfer: SndFileTransfer -} - -export interface CRSndFileRcvCancelled extends CR { - type: "sndFileRcvCancelled" - user: User - chatItem: AChatItem - sndFileTransfer: SndFileTransfer -} - -export interface CRSndGroupFileCancelled extends CR { - type: "sndGroupFileCancelled" - user: User - chatItem: AChatItem - fileTransferMeta: FileTransferMeta - sndFileTransfers: SndFileTransfer[] -} - -export interface CRSndFileSubError extends CR { - type: "sndFileSubError" - user: User - sndFileTransfer: SndFileTransfer - chatError: ChatError -} - -export interface CRRcvFileSubError extends CR { - type: "rcvFileSubError" - user: User - rcvFileTransfer: RcvFileTransfer - chatError: ChatError -} - -export interface CRPendingSubSummary extends CR { - type: "pendingSubSummary" - user: User - pendingSubStatus: PendingSubStatus[] -} - -export interface CRGroupCreated extends CR { - type: "groupCreated" - user: User - groupInfo: GroupInfo -} - -export interface CRGroupMembers extends CR { - type: "groupMembers" - user: User - group: Group -} - -export interface CRUserAcceptedGroupSent extends CR { - type: "userAcceptedGroupSent" - user: User - groupInfo: GroupInfo - hostContact?: Contact // included when joining group via group link -} - -export interface CRUserDeletedMember extends CR { - type: "userDeletedMember" - user: User - groupInfo: GroupInfo - member: GroupMember -} - -export interface CRSentGroupInvitation extends CR { - type: "sentGroupInvitation" - user: User - groupInfo: GroupInfo - contact: Contact - member: GroupMember -} - -export interface CRLeftMemberUser extends CR { - type: "leftMemberUser" - user: User - groupInfo: GroupInfo -} - -export interface CRGroupDeletedUser extends CR { - type: "groupDeletedUser" - user: User - groupInfo: GroupInfo -} - -export interface CRGroupInvitation extends CR { - type: "groupInvitation" - user: User - groupInfo: GroupInfo -} - -export interface CRReceivedGroupInvitation extends CR { - type: "receivedGroupInvitation" - user: User - groupInfo: GroupInfo - contact: Contact - memberRole: GroupMemberRole -} - -export interface CRUserJoinedGroup extends CR { - type: "userJoinedGroup" - user: User - groupInfo: GroupInfo - hostMember: GroupMember -} - -export interface CRJoinedGroupMember extends CR { - type: "joinedGroupMember" - user: User - groupInfo: GroupInfo - member: GroupMember -} - -export interface CRJoinedGroupMemberConnecting extends CR { - type: "joinedGroupMemberConnecting" - user: User - groupInfo: GroupInfo - hostMember: GroupMember - member: GroupMember -} - -export interface CRConnectedToGroupMember extends CR { - type: "connectedToGroupMember" - user: User - groupInfo: GroupInfo - member: GroupMember -} - -export interface CRDeletedMember extends CR { - type: "deletedMember" - user: User - groupInfo: GroupInfo - byMember: GroupMember - deletedMember: GroupMember -} - -export interface CRDeletedMemberUser extends CR { - type: "deletedMemberUser" - user: User - groupInfo: GroupInfo - member: GroupMember -} - -export interface CRLeftMember extends CR { - type: "leftMember" - user: User - groupInfo: GroupInfo - member: GroupMember -} - -export interface CRGroupRemoved extends CR { - type: "groupRemoved" - user: User - groupInfo: GroupInfo -} - -export interface CRGroupDeleted extends CR { - type: "groupDeleted" - user: User - groupInfo: GroupInfo - member: GroupMember -} - -export interface CRGroupUpdated extends CR { - type: "groupUpdated" - user: User - fromGroup: GroupInfo - toGroup: GroupInfo - member_?: GroupMember -} - -export interface CRUserContactLinkSubError extends CR { - type: "userContactLinkSubError" - chatError: ChatError -} - -export interface CRContactConnectionDeleted extends CR { - type: "contactConnectionDeleted" - user: User - connection: PendingContactConnection -} - -export interface CRMessageError extends CR { - type: "messageError" - user: User - severity: string - errorMessage: string -} - -export interface CRChatCmdError extends CR { - type: "chatCmdError" - user_?: User - chatError: ChatError -} - -export interface CRChatError extends CR { - type: "chatError" - user_?: User - chatError: ChatError -} - -export interface User { - userId: number - agentUserId: string - userContactId: number - localDisplayName: string - profile: LocalProfile - // fullPreferences :: FullPreferences - activeUser: boolean - viewPwdHash: string - showNtfs: boolean -} - -export interface UserProtoServers { - serverProtocol: ServerProtocol - protoServers: ServerCfg[] - presetServers: string -} - -export interface Chat { - chatInfo: ChatInfo - chatItems: [ChatItem] - chatStats: ChatStats -} - -export type ChatInfo = CInfoDirect | CInfoGroup | CInfoContactRequest - -export enum ChatInfoType { - Direct = "direct", - Group = "group", - ContactRequest = "contactRequest", -} - -interface IChatInfo { - type: ChatInfoType -} - -interface CInfoDirect extends IChatInfo { - type: ChatInfoType.Direct - contact: Contact -} - -interface CInfoGroup extends IChatInfo { - type: ChatInfoType.Group - groupInfo: GroupInfo -} - -interface CInfoContactRequest extends IChatInfo { - type: ChatInfoType.ContactRequest - contactRequest: UserContactRequest -} - -export interface UserPwdHash { - hash: string - salt: string -} - -interface UserInfo { - user: User - unreadCount: number -} - -export interface Contact { - contactId: number - localDisplayName: string - profile: Profile - activeConn: Connection - viaGroup?: number - createdAt: Date -} - -export interface ContactRef { - contactId: number - localDisplayName: string -} - -export interface Group { - groupInfo: GroupInfo - members: GroupMember[] -} - -export interface GroupInfo { - groupId: number - localDisplayName: string - groupProfile: GroupProfile - membership: GroupMember - createdAt: Date -} - -export interface GroupProfile { - displayName: string - fullName: string - image?: string // web-compatible data/base64 string for the image -} - -export interface GroupMember { - groupMemberId: number - memberId: string - memberRole: GroupMemberRole - // memberCategory: GroupMemberCategory - // memberStatus: GroupMemberStatus - // invitedBy: InvitedBy - localDisplayName: string - memberProfile: Profile - memberContactId?: number - activeConn?: Connection -} - -export interface UserContactRequest { - contactRequestId: number - localDisplayName: string - profile: Profile - createdAt: Date -} - -interface Connection { - connId: number -} - -export interface AChatItem { - chatInfo: ChatInfo - chatItem: ChatItem -} - -export interface ChatItem { - chatDir: CIDirection - meta: CIMeta - content: CIContent - formattedText?: FormattedText[] - quotedItem?: CIQuote -} - -export type CIDirection = CIDirectSnd | CIDirectRcv | CIGroupSnd | CIGroupRcv - -interface ICIDirection { - type: "directSnd" | "directRcv" | "groupSnd" | "groupRcv" -} - -interface CIDirectSnd extends ICIDirection { - type: "directSnd" -} - -interface CIDirectRcv extends ICIDirection { - type: "directRcv" -} - -interface CIGroupSnd extends ICIDirection { - type: "groupSnd" -} - -interface CIGroupRcv extends ICIDirection { - type: "groupRcv" - groupMember: GroupMember -} - -export interface CIMeta { - itemId: ChatItemId - itemTs: Date - itemText: string - itemStatus: CIStatus - createdAt: Date - itemDeleted: boolean - itemEdited: boolean - editable: boolean -} - -export type CIContent = CISndMsgContent | CIRcvMsgContent | CISndDeleted | CIRcvDeleted | CISndFileInvitation | CIRcvFileInvitation - -interface ICIContent { - type: "sndMsgContent" | "rcvMsgContent" | "sndDeleted" | "rcvDeleted" | "sndFileInvitation" | "rcvFileInvitation" -} - -interface CISndMsgContent extends ICIContent { - type: "sndMsgContent" - msgContent: MsgContent -} - -interface CIRcvMsgContent extends ICIContent { - type: "rcvMsgContent" - msgContent: MsgContent -} - -interface CISndDeleted extends ICIContent { - type: "sndDeleted" - deleteMode: DeleteMode -} - -interface CIRcvDeleted extends ICIContent { - type: "rcvDeleted" - deleteMode: DeleteMode -} - -interface CISndFileInvitation extends ICIContent { - type: "sndFileInvitation" - fileId: number - filePath: string -} - -interface CIRcvFileInvitation extends ICIContent { - type: "rcvFileInvitation" - rcvFileTransfer: RcvFileTransfer -} - -export function ciContentText(content: CIContent): string | undefined { - switch (content.type) { - case "sndMsgContent": - return content.msgContent.text - case "rcvMsgContent": - return content.msgContent.text - default: - return undefined - } -} - -interface RcvFileTransfer { - fileId: number - // fileInvitation: FileInvitation - // fileStatus: RcvFileStatus - senderDisplayName: string - chunkSize: number - cancelled: boolean - grpMemberId?: number -} - -interface SndFileTransfer { - fileId: number - fileName: string - filePath: string - fileSize: number - chunkSize: number - recipientDisplayName: string - connId: number - // agentConnId: string - // fileStatus: FileStatus -} - -interface FileTransferMeta { - fileId: number - fileName: string - filePath: string - fileSize: number - chunkSize: number - cancelled: boolean -} - -interface UserContactLink { - connLinkContact: CreatedConnLink - autoAccept?: AutoAccept -} - -interface CreatedConnLink { - connFullLink: string - connShortLink?: string -} - -interface AutoAccept { - acceptIncognito: boolean - autoReply?: MsgContent -} - -export interface ChatStats { - unreadCount: number - minUnreadItemId: number -} - -export interface CIQuote { - chatDir?: CIDirection - itemId?: number - sharedMsgId?: string - sentAt: Date - content: MsgContent - formattedText?: FormattedText[] -} - -export type CIStatus = CISndNew | CISndSent | CISndErrorAuth | CISndError | CIRcvNew | CIRcvRead - -interface ICIStatus { - type: "sndNew" | "sndSent" | "sndErrorAuth" | "sndError" | "rcvNew" | "rcvRead" -} - -interface CISndNew extends ICIStatus { - type: "sndNew" -} - -interface CISndSent extends ICIStatus { - type: "sndSent" -} - -interface CISndErrorAuth extends ICIStatus { - type: "sndErrorAuth" -} - -interface CISndError extends ICIStatus { - type: "sndError" - agentError: AgentErrorType -} - -interface CIRcvNew extends ICIStatus { - type: "rcvNew" -} - -interface CIRcvRead extends ICIStatus { - type: "rcvRead" -} - -interface FormattedText {} - -interface MsgErrorType {} - -export type ChatError = ChatErrorChat | ChatErrorAgent | ChatErrorStore - -interface ChatErrorChat { - type: "error" - errorType: ChatErrorType -} - -interface ChatErrorAgent { - type: "errorAgent" - agentError: AgentErrorType -} - -interface ChatErrorStore { - type: "errorStore" - storeError: StoreErrorType -} - -type ChatErrorType = CENoActiveUser | CEActiveUserExists - -interface CENoActiveUser { - type: "noActiveUser" -} - -interface CEActiveUserExists { - type: "activeUserExists" -} - -interface ContactSubStatus {} - -interface PendingSubStatus {} - -export interface ConnectionStats { - rcvServers?: string[] - sndServers?: string[] -} - -interface PendingContactConnection {} - -interface MemberSubStatus { - member: GroupMember - memberError?: ChatError -} - -interface AgentErrorType { - type: string - [x: string]: any -} - -interface StoreErrorType { - type: string - [x: string]: any -} diff --git a/packages/simplex-chat-client/typescript/src/transport.ts b/packages/simplex-chat-client/typescript/src/transport.ts index 7ac4673ca1..aad3f39579 100644 --- a/packages/simplex-chat-client/typescript/src/transport.ts +++ b/packages/simplex-chat-client/typescript/src/transport.ts @@ -1,5 +1,5 @@ import {ABQueue, NextIter} from "./queue" -import {ChatResponse} from "./response" +import {ChatResponse, ChatEvent} from "@simplex-chat/types" export class TransportError extends Error {} @@ -90,13 +90,17 @@ export interface ChatSrvRequest { } export interface ChatSrvResponse { - corrId?: string + corrId: string resp: ChatResponse } +export interface ChatSrvEvent { + resp: ChatEvent +} + interface ParsedChatSrvResponse { corrId?: string - resp?: ChatResponse + resp?: ChatResponse | ChatEvent } export class ChatResponseError extends Error { @@ -105,7 +109,7 @@ export class ChatResponseError extends Error { } } -export class ChatTransport extends Transport { +export class ChatTransport extends Transport { private constructor(private readonly ws: WSTransport, readonly timeout: number, qSize: number) { super(qSize) } diff --git a/packages/simplex-chat-client/typescript/tests/client.test.ts b/packages/simplex-chat-client/typescript/tests/client.test.ts index eef57558f2..9cd0a365f1 100644 --- a/packages/simplex-chat-client/typescript/tests/client.test.ts +++ b/packages/simplex-chat-client/typescript/tests/client.test.ts @@ -1,42 +1,42 @@ import * as assert from "assert" import {ChatClient} from "../src/index" import {ConnReqType} from "../src/client" -import * as CC from "../src/command" -import * as CR from "../src/response" +import {ChatEvent, CEvt, T} from "@simplex-chat/types" +// This test is currently failing - it gets as far as starting connection. +// It has to be written differently, with event loop to only process "interesting" events, as some events may arrive in different order. describe.skip("ChatClient (expects SimpleX Chat server with a user, without contacts, on localhost:5225)", () => { test("connect, send message to themselves, delete contact", async () => { const c = await ChatClient.create("ws://localhost:5225") - assert.strictEqual((await c.msgQ.dequeue()).type, "contactSubSummary") - assert.strictEqual((await c.msgQ.dequeue()).type, "memberSubErrors") - assert.strictEqual((await c.msgQ.dequeue()).type, "pendingSubSummary") + assert.strictEqual((await c.msgQ.dequeue()).type, "connSubSummary") + assert.strictEqual((await c.msgQ.dequeue()).type, "terminalEvent") + assert.strictEqual((await c.msgQ.dequeue()).type, "terminalEvent") const user = await c.apiGetActiveUser() assert.strictEqual(typeof user?.localDisplayName, "string") - const connReq = await c.apiCreateLink() + const connReq = await c.apiCreateLink(user!.userId) + console.log("created link") assert.strictEqual(typeof connReq, "string") - assert.strictEqual((await c.msgQ.dequeue()).type, "newContactConnection") // TODO add to response types - const connReqType = await c.apiConnect(connReq) - assert.strictEqual((await c.msgQ.dequeue()).type, "newContactConnection") // TODO add to response types - assert((await c.msgQ.dequeue()).type === "contactConnecting") - assert((await c.msgQ.dequeue()).type === "contactConnecting") + const connReqType = await c.apiConnectActiveUser(connReq) + assert.strictEqual((await c.msgQ.dequeue()).type, "contactConnecting") + assert.strictEqual((await c.msgQ.dequeue()).type, "contactConnected") assert(connReqType === ConnReqType.Invitation || connReqType === ConnReqType.Contact) const r1 = await c.msgQ.dequeue() const r2 = await c.msgQ.dequeue() - assert(r1.type === "contactConnected") - assert(r2.type === "contactConnected") - const contact1 = (r1 as CR.CRContactConnected).contact + assert.strictEqual(r1.type, "contactConnecting") + assert.strictEqual(r2.type, "contactConnected") + const contact1 = (r1 as CEvt.ContactConnected).contact // const contact2 = (r2 as C.CRContactConnected).contact - const r3 = await c.apiSendTextMessage(CC.ChatType.Direct, contact1.contactId, "hello") + const r3 = await c.apiSendTextMessage(T.ChatType.Direct, contact1.contactId, "hello") assert(r3[0].chatItem.content.type === "sndMsgContent" && r3[0].chatItem.content.msgContent.text === "hello") const r4 = await c.msgQ.dequeue() assert(isItemSent(r4) || isNewRcvItem(r4)) await c.disconnect() - function isItemSent(r: CR.ChatResponse): boolean { - return r.type === "chatItemStatusUpdated" && r.chatItem.chatItem.meta.itemStatus.type === "sndSent" + function isItemSent(r: ChatEvent): boolean { + return r.type === "chatItemsStatusesUpdated" && r.chatItems[0].chatItem.meta.itemStatus.type === "sndSent" } - function isNewRcvItem(r: CR.ChatResponse): boolean { + function isNewRcvItem(r: ChatEvent): boolean { return ( r.type === "newChatItems" && r.chatItems[0].chatItem.content.type === "rcvMsgContent" && diff --git a/scripts/db/README.md b/scripts/db/README.md index 68d4f74ac7..c204e036c4 100644 --- a/scripts/db/README.md +++ b/scripts/db/README.md @@ -1,96 +1,238 @@ -# Transfer data from SQLite to Postgres database +## Transfer data from SQLite to Postgres database -1. \* Decrypt SQLite database if it is encrypted. +1. Decrypt SQLite database if it is encrypted. - ```sh - sqlcipher encrypted_simplex_v1_agent.db - ``` + 1. Agent: - ```sql - PRAGMA key = 'password'; - ATTACH DATABASE 'simplex_v1_agent.db' AS plaintext KEY ''; - SELECT sqlcipher_export('plaintext'); - DETACH DATABASE plaintext; - ``` + 1. Open sqlite db: - Repeat for `simplex_v1_chat.db`. + ```sh + sqlcipher simplex_v1_agent.db + ``` + + 2. Run in sqlcipher: + + ```sql + PRAGMA key = ''; -- Set your db password + SELECT count(*) FROM sqlite_master; -- Check if db was successfully decrypted + ATTACH DATABASE 'simplex_v1_agent_plaintext.db' AS plaintext KEY ''; -- Attach new empty db + SELECT sqlcipher_export('plaintext'); -- Export opened db to attached db as plaintext + DETACH DATABASE plaintext; + ``` + + 2. Chat: + + 1. Open sqlite db: + + ```sh + sqlcipher simplex_v1_chat.db + ``` + + 2. Run in sqlcipher: + + ```sql + PRAGMA key = ''; + SELECT count(*) FROM sqlite_master; + ATTACH DATABASE 'simplex_v1_chat_plaintext.db' AS plaintext KEY ''; + SELECT sqlcipher_export('plaintext'); + DETACH DATABASE plaintext; + ``` 2. Prepare Postgres database. - - Create Postgres database. In shell: + 1. Connect to PostgreSQL databse: ```sh - createdb -O simplex simplex_v1 + psql -U postgres -h localhost ``` - Or via query. + 2. Run in psql: - - Build `simplex-chat` executable with `client_postgres` flag and run it to initialize new chat database. + ```sql + CREATE USER simplex WITH ENCRYPTED PASSWORD '123123'; -- Create user with password + -- or + -- CREATE USER simplex; + CREATE DATABASE simplex_v1; -- Create database + GRANT ALL PRIVILEGES ON DATABASE simplex_v1 TO simplex; -- Assign permissions + ``` - This should create `simplex_v1_agent_schema` and `simplex_v1_chat_schema` schemas in `simplex_v1` database, with `migrations` tables populated. Some tables would have initialization data - it will be truncated via pgloader command in next step. +3. Prepare database: + + You should build the CLI binary from the same `TAG` as the desktop. + + 1. Build CLI with PostgreSQL support: + + ```sh + cabal build -fclient_postgres exe:simplex-chat + ``` + + And rename it to: + + ```sh + mv simplex-chat simplex-chat-pg + ``` + + 2. Execute CLI: + + ```sh + ./simplex-chat-pg -d "postgresql://simplex:123123@localhost:5432/simplex_v1" --create-schema + ``` + + Press `Ctrl+C` when CLI ask for a display name. + + This should create `simplex_v1_agent_schema` and `simplex_v1_chat_schema` schemas in `simplex_v1` database, with `migrations` tables populated. Some tables would have initialization data - it will be truncated via pgloader command in next step. 3. Load data from decrypted SQLite databases to Postgres database via pgloader. Install pgloader and add it to PATH. Run in shell (substitute paths): ```sh - SQLITE_DBPATH='simplex_v1_agent.db' POSTGRES_CONN='postgres://simplex@/simplex_v1' POSTGRES_SCHEMA='simplex_v1_agent_schema' pgloader --on-error-stop sqlite.load + export POSTGRES_CONN='postgresql://simplex:123123@localhost:5432/simplex_v1' + ``` - SQLITE_DBPATH='simplex_v1_chat.db' POSTGRES_CONN='postgres://simplex@/simplex_v1' POSTGRES_SCHEMA='simplex_v1_chat_schema' pgloader --on-error-stop sqlite.load + And then: + + ```sh + SQLITE_DBPATH='simplex_v1_agent_plaintext.db' \ + POSTGRES_SCHEMA='simplex_v1_agent_schema' \ + CPU_CORES=$(nproc) WORKERS=$((CPU_CORES - 1)) pgloader --dynamic-space-size 262144 --on-error-stop sqlite.load + + SQLITE_DBPATH='simplex_v1_chat_plaintext.db' \ + POSTGRES_SCHEMA='simplex_v1_chat_schema' \ + CPU_CORES=$(nproc) WORKERS=$((CPU_CORES - 1)) pgloader --dynamic-space-size 262144 --on-error-stop sqlite.load ``` 4. Update sequences for Postgres tables. - ```sql - DO $$ - DECLARE - rec RECORD; - BEGIN - EXECUTE 'SET SEARCH_PATH TO simplex_v1_agent_schema'; + Connect to db: - FOR rec IN - SELECT - table_name, - column_name, - pg_get_serial_sequence(table_name, column_name) AS seq_name - FROM - information_schema.columns - WHERE - table_schema = 'simplex_v1_agent_schema' - AND identity_generation = 'ALWAYS' - LOOP - EXECUTE format( - 'SELECT setval(%L, (SELECT MAX(%I) FROM %I))', - rec.seq_name, rec.column_name, rec.table_name - ); - END LOOP; - END $$; + ```sh + PGPASSWORD=123123 psql -h localhost -U simplex -d simplex_v1 ``` - Repeat for `simplex_v1_chat_schema`. + Execute the following: -5. \* Compare number of rows between Postgres and SQLite tables. + 1. For `agent`: - To check number of rows for all tables in Postgres database schema run: + ```sql + DO $$ + DECLARE + rec RECORD; + BEGIN + EXECUTE 'SET SEARCH_PATH TO simplex_v1_agent_schema'; - ```sql - WITH tbl AS ( - SELECT table_schema, table_name - FROM information_schema.Tables - WHERE table_name NOT LIKE 'pg_%' - AND table_schema IN ('simplex_v1_agent_schema') - ) - SELECT - table_schema AS schema_name, - table_name, - (xpath('/row/c/text()', query_to_xml( - format('SELECT count(*) AS c FROM %I.%I', table_schema, table_name), false, true, '' - )))[1]::text::int AS records_count - FROM tbl - ORDER BY records_count DESC; - ``` + FOR rec IN + SELECT + table_name, + column_name, + pg_get_serial_sequence(table_name, column_name) AS seq_name + FROM + information_schema.columns + WHERE + table_schema = 'simplex_v1_agent_schema' + AND identity_generation = 'ALWAYS' + LOOP + EXECUTE format( + 'SELECT setval(%L, (SELECT MAX(%I) FROM %I))', + rec.seq_name, rec.column_name, rec.table_name + ); + END LOOP; + END $$; + ``` - Repeat for `simplex_v1_chat_schema`. + 2. For `chat`: + + ```sql + DO $$ + DECLARE + rec RECORD; + BEGIN + EXECUTE 'SET SEARCH_PATH TO simplex_v1_chat_schema'; + + FOR rec IN + SELECT + table_name, + column_name, + pg_get_serial_sequence(table_name, column_name) AS seq_name + FROM + information_schema.columns + WHERE + table_schema = 'simplex_v1_chat_schema' + AND identity_generation = 'ALWAYS' + LOOP + EXECUTE format( + 'SELECT setval(%L, (SELECT MAX(%I) FROM %I))', + rec.seq_name, rec.column_name, rec.table_name + ); + END LOOP; + END $$; + ``` + +5. Compare number of rows between Postgres and SQLite tables. + + **PostgreSQL**: + + 1. For `agent`: + + ```sql + WITH tbl AS ( + SELECT table_schema, table_name + FROM information_schema.Tables + WHERE table_name NOT LIKE 'pg_%' + AND table_schema IN ('simplex_v1_agent_schema') + ) + SELECT + table_schema AS schema_name, + table_name, + (xpath('/row/c/text()', query_to_xml( + format('SELECT count(*) AS c FROM %I.%I', table_schema, table_name), false, true, '' + )))[1]::text::int AS records_count + FROM tbl + ORDER BY records_count DESC; + ``` + + 2. For `chat`: + + ```sql + WITH tbl AS ( + SELECT table_schema, table_name + FROM information_schema.Tables + WHERE table_name NOT LIKE 'pg_%' + AND table_schema IN ('simplex_v1_chat_schema') + ) + SELECT + table_schema AS schema_name, + table_name, + (xpath('/row/c/text()', query_to_xml( + format('SELECT count(*) AS c FROM %I.%I', table_schema, table_name), false, true, '' + )))[1]::text::int AS records_count + FROM tbl + ORDER BY records_count DESC; + ``` + + **SQLite**: + + 1. For `agent`: + + ```sh + db="simplex_v1_agent_plaintext.db" + sqlite3 "$db" "SELECT name FROM sqlite_master WHERE type='table';" | + while read table; do + count=$(sqlite3 "$db" "SELECT COUNT(*) FROM \"$table\";") + echo "$table: $count" + done | sort -k2 -nr | less + ``` + + 2. For `chat`: + + ```sh + db="simplex_v1_chat_plaintext.db" + sqlite3 "$db" "SELECT name FROM sqlite_master WHERE type='table';" | + while read table; do + count=$(sqlite3 "$db" "SELECT COUNT(*) FROM \"$table\";") + echo "$table: $count" + done | sort -k2 -nr | less + ``` 6. Build and run desktop app with Postgres backend. @@ -103,3 +245,47 @@ # or ./gradlew packageDmg -Pdatabase.backend=postgres ``` + +## Transfer data from Postgres to SQLite database + +1. Prepare sqlite db: + + 1. Download simplex-chat CLI: + + You should download the CLI binary from the same `TAG` as the desktop. + + ```sh + export TAG='v6.4.3.1' + curl -L "https://github.com/simplex-chat/simplex-chat/releases/download/${TAG}/simplex-chat-ubuntu-22_04-x86_64" -o 'simplex-chat' + ``` + + 2. Run the CLI: + + ```sh + ./simplex-chat + ``` + + Press `Ctrl+C` when CLI ask for a display name. + + 3. Move database: + + ```sh + mv ~/.simplex/simplex_v1_* ~/.local/share/simplex/ + ``` + +2. Transfer data: + + ```sh + ./pg2sqlite.py --verbose 'postgresql://simplex:123123@localhost:5432/simplex_v1' ~/.local/share/simplex/ + ``` + +4. Update BLOBs: + + ```sh + sqlite3 simplex_v1_chat.db + ``` + + ```sh + UPDATE group_members SET member_role = CAST(member_role as BLOB); + UPDATE user_contact_links SET group_link_member_role = CAST(group_link_member_role AS BLOB) WHERE group_link_member_role is not null; + ``` diff --git a/scripts/db/pg2sqlite.py b/scripts/db/pg2sqlite.py new file mode 100755 index 0000000000..533b15d143 --- /dev/null +++ b/scripts/db/pg2sqlite.py @@ -0,0 +1,899 @@ +#!/usr/bin/env python3 +""" +PostgreSQL -> per-schema SQLite migration with colored, clean logging. + +Usage example: + python db_migrate.py 'postgresql://user:pass@host:5432/db' /path/to/sqlite/dir --dry-run + +Note: color output will be disabled automatically if stdout is not a TTY or if --no-color is passed. +""" + +from __future__ import annotations +import argparse +import logging +import os +import sqlite3 +import sys +import re +import datetime +from pathlib import Path +from typing import Set, List, Tuple, Dict, Optional, NamedTuple + +import psycopg +from psycopg import sql + +# ---------------------- +# Small utilities +# ---------------------- + +try: + sqlite3.register_adapter( + datetime.datetime, + lambda v: v.isoformat(sep=" ", timespec="microseconds"), + ) + sqlite3.register_adapter(datetime.date, lambda v: v.isoformat()) + sqlite3.register_adapter( + datetime.time, lambda v: v.isoformat(timespec="microseconds") + ) +except ValueError: + pass # already registered + +ANSI = { + "reset": "\x1b[0m", + "bold": "\x1b[1m", + "dim": "\x1b[2m", + "red": "\x1b[31m", + "green": "\x1b[32m", + "yellow": "\x1b[33m", + "blue": "\x1b[34m", + "magenta": "\x1b[35m", + "cyan": "\x1b[36m", + "gray": "\x1b[90m", +} + +DEFAULT_BATCH_SIZE = 10000 + +TYPE_COMPATIBILITY = { + "bytea": ["BLOB", "CHAR", "CLOB", "TEXT", "JSON"], + "int": ["INT", "NUMERIC"], + "serial": ["INT", "NUMERIC"], + "numeric": ["NUMERIC", "DECIMAL", "REAL", "FLOAT", "DOUBLE"], + "decimal": ["NUMERIC", "DECIMAL", "REAL", "FLOAT", "DOUBLE"], + "real": ["NUMERIC", "DECIMAL", "REAL", "FLOAT", "DOUBLE"], + "double": ["NUMERIC", "DECIMAL", "REAL", "FLOAT", "DOUBLE"], + "float": ["NUMERIC", "DECIMAL", "REAL", "FLOAT", "DOUBLE"], + "money": ["NUMERIC", "DECIMAL", "REAL", "FLOAT", "DOUBLE"], + "bool": ["BOOL", "INT", "NUMERIC"], + "varchar": ["CHAR", "CLOB", "TEXT"], + "char": ["CHAR", "CLOB", "TEXT"], + "text": ["CHAR", "CLOB", "TEXT"], + "citext": ["CHAR", "CLOB", "TEXT"], + "timestamp": ["DATE", "TIME", "CHAR", "TEXT", "DATETIME"], + "time": ["DATE", "TIME", "CHAR", "TEXT", "DATETIME"], + "date": ["DATE", "TIME", "CHAR", "TEXT", "DATETIME"], + "uuid": ["CHAR", "TEXT", "UUID", "CLOB"], + "json": ["JSON", "TEXT", "CHAR", "CLOB"], + "jsonb": ["JSON", "TEXT", "CHAR", "CLOB"], +} + + +def _sanitize_cursor_name(s: str) -> str: + return re.sub(r"[^A-Za-z0-9_]+", "_", s) + + +def supports_color(force_no: bool) -> bool: + """Return True when we should emit ANSI colors.""" + if force_no: + return False + if os.getenv("NO_COLOR"): + return False + term = os.getenv("TERM", "") + if term == "" or term.lower() == "dumb": + return False + try: + isatty = sys.stdout.isatty() + except Exception: + isatty = False + return isatty + + +class ColoredFormatter(logging.Formatter): + LEVEL_COLORS = { + logging.DEBUG: ANSI["gray"], + logging.INFO: ANSI["green"], + logging.WARNING: ANSI["yellow"], + logging.ERROR: ANSI["red"], + logging.CRITICAL: ANSI["red"] + ANSI["bold"], + } + + TAG_COLORS = { + "SKIP": ANSI["yellow"], + "SCHEMA": ANSI["blue"], + "OK": ANSI["magenta"], + } + + def __init__(self, use_color: bool = True): + super().__init__(fmt="%(message)s") + self.use_color = use_color + + def format(self, record: logging.LogRecord) -> str: + msg = super().format(record) + parts = msg.split(" ", 1) + tag = parts[0] + rest = parts[1] if len(parts) > 1 else "" + plain_label = f"[{tag}]" + + if not self.use_color: + return f"{plain_label}{(' ' + rest) if rest else ''}" + + color = self.TAG_COLORS.get( + tag.upper(), self.LEVEL_COLORS.get(record.levelno, "") + ) + reset = ANSI["reset"] + return f"{color}{plain_label}{reset}{(' ' + rest) if rest else ''}" + + +def setup_logger(verbose: bool, no_color: bool) -> logging.Logger: + use_color = supports_color(no_color) + logger = logging.getLogger("db_migrate") + logger.setLevel(logging.DEBUG if verbose else logging.INFO) + handler = logging.StreamHandler() + handler.setLevel(logging.DEBUG if verbose else logging.INFO) + handler.setFormatter(ColoredFormatter(use_color=use_color)) + logger.handlers.clear() + logger.addHandler(handler) + logging.getLogger("psycopg").setLevel(logging.WARNING) + + if verbose: + logger.debug(f"color_support: {use_color}") + try: + isatty = sys.stdout.isatty() + except Exception: + isatty = False + logger.debug( + f"TERM={os.getenv('TERM', '')!r} " + f"NO_COLOR={os.getenv('NO_COLOR')!r} " + f"isatty={isatty}" + ) + return logger + + +def quote_sqlite_identifier(name: str) -> str: + return '"' + name.replace('"', '""') + '"' + + +def quote_pg_identifier(name: str) -> str: + """Simple PG identifier quoting for building safe SQL strings.""" + return '"' + name.replace('"', '""') + '"' + + +def sqlite_decl_satisfies(pg_type: str, sqlite_decl: str) -> bool: + # Treat empty/blank SQLite declarations more permissively based on PG type. + decl_raw = sqlite_decl or "" + if not decl_raw.strip(): + pg = (pg_type or "").lower() + # arrays -> textual affinity + if pg.endswith("[]"): + return True + # integer-like + if re.search(r"\b(?:int|serial|bigint)\b", pg): + return True + # numeric/float + if re.search(r"\b(?:numeric|decimal|real|double|float|money)\b", pg): + return True + # boolean + if re.search(r"\b(?:bool|boolean)\b", pg): + return True + # binary + if re.search(r"\b(?:bytea)\b", pg): + return True + # textual/json/uuid/timestamps/dates/times + if re.search( + r"\b(?:varchar|char|text|citext|jsonb|json|uuid|timestamp|time|date)\b", pg + ): + return True + # conservative fallback: accept empty decl as permissive + return True + + decl = decl_raw.upper() + pg = (pg_type or "").lower() + + # array type + if pg.endswith("[]"): + return any(tok in decl for tok in ("TEXT", "CHAR", "CLOB", "JSON")) + + for key, allowed_types in TYPE_COMPATIBILITY.items(): + if re.search(r"\b" + re.escape(key) + r"\b", pg): + return any(tok in decl for tok in allowed_types) + + return any( + tok in decl for tok in ("TEXT", "CHAR", "CLOB", "NUMERIC", "BLOB", "INT") + ) + + +# ---------------------- +# Postgres helpers +# ---------------------- + + +def list_user_schemas(pg_cursor) -> List[str]: + pg_cursor.execute( + """ + SELECT nspname + FROM pg_namespace + WHERE nspname NOT LIKE 'pg_%' + AND nspname != 'information_schema' + AND nspname != 'public' + ORDER BY nspname; + """ + ) + return [r[0] for r in pg_cursor.fetchall()] + + +def list_tables_in_schema(pg_cursor, schema: str) -> List[str]: + pg_cursor.execute( + sql.SQL( + """ + SELECT table_name + FROM information_schema.tables + WHERE table_schema = %s + ORDER BY table_name; + """ + ), + (schema,), + ) + return [r[0] for r in pg_cursor.fetchall()] + + +def get_pg_columns(pg_cursor, schema: str, table: str) -> List[Tuple[str, str]]: + pg_cursor.execute( + sql.SQL( + """ + SELECT column_name, data_type + FROM information_schema.columns + WHERE table_schema = %s AND table_name = %s + ORDER BY ordinal_position; + """ + ), + (schema, table), + ) + return [(r[0], r[1]) for r in pg_cursor.fetchall()] + + +# ---------------------- +# SQLite helpers +# ---------------------- + + +class SQLiteCol(NamedTuple): + cid: int + name: str + type: str + notnull: int + dflt_value: str + pk: int + + +def get_sqlite_table_info(sqlite_cursor, table_name: str) -> list[SQLiteCol]: + qname = quote_sqlite_identifier(table_name) + sqlite_cursor.execute(f"PRAGMA table_info({qname});") + return [SQLiteCol(*row) for row in sqlite_cursor.fetchall()] + + +def sqlite_table_has_autoincrement(sqlite_cursor, table_name: str) -> bool: + sqlite_cursor.execute( + "SELECT sql FROM sqlite_master WHERE type='table' AND name = ?;", (table_name,) + ) + row = sqlite_cursor.fetchone() + if not row or not row[0]: + return False + return "AUTOINCREMENT" in row[0].upper() + + +def sqlite_sequence_table_exists(sqlite_cursor) -> bool: + sqlite_cursor.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='sqlite_sequence';" + ) + exists = sqlite_cursor.fetchone() is not None + if not exists: + logging.getLogger("db_migrate").debug("sqlite_sequence table not present") + return exists + + +# ---------------------- +# Core migration logic +# ---------------------- + + +def build_select_for_sqlite_columns( + schema: str, + table: str, + sqlite_cols: list[SQLiteCol], + pg_columns_map: Dict[str, Tuple[str, str]], +) -> str: + """Return a plain SQL string (no trailing semicolon) selecting PG columns in the order of sqlite_cols. + Uses simple identifier quoting to avoid psycopg.sql objects which may vary by driver version. + """ + select_parts = [] + for col in sqlite_cols: + lc = col.name.lower() + if lc in pg_columns_map: + pg_name, pg_type = pg_columns_map[lc] + pg_type_l = (pg_type or "").lower() + if any(tok in pg_type_l for tok in ("timestamp", "time")): + expr = f"{quote_pg_identifier(pg_name)}::text" + else: + expr = f"{quote_pg_identifier(pg_name)}" + select_parts.append(expr) + else: + select_parts.append("NULL") + select_list = ", ".join(select_parts) + # Quote schema and table names simply (this is not comprehensive for every corner case but avoids psycopg.sql use) + return f"SELECT {select_list} FROM {quote_pg_identifier(schema)}.{quote_pg_identifier(table)}" + + +def validate_column_compatibility( + sqlite_cols: list[SQLiteCol], + pg_columns_map: Dict[str, Tuple[str, str]], + schema: str, + table: str, +) -> None: + for col in sqlite_cols: + lc = col.name.lower() + if lc not in pg_columns_map: + continue + pg_type = pg_columns_map[lc][1] + if not sqlite_decl_satisfies(pg_type, col.type): + raise ValueError( + f"Type mismatch for {schema}.{table}.{col.name}: " + f"PG='{pg_type}' vs SQLite='{col.type}'" + ) + + +def process_row_for_sqlite(row: Tuple, sqlite_cols: list[SQLiteCol]) -> Tuple: + out = [] + for i, val in enumerate(row): + col = sqlite_cols[i] + decl_raw = col.type or "" + decl = decl_raw.upper() + decl_is_empty = decl_raw.strip() == "" + + if isinstance(val, memoryview): + b = val.tobytes() + if "BLOB" in decl or decl_is_empty: + out.append(sqlite3.Binary(b)) + else: + try: + out.append(b.decode("utf-8")) + except UnicodeDecodeError as e: + raise ValueError( + f"UTF-8 decode failed for column '{col.name}': {e}" + ) + elif isinstance(val, (bytes, bytearray)): + b = bytes(val) + if "BLOB" in decl or decl_is_empty: + out.append(sqlite3.Binary(b)) + else: + try: + out.append(b.decode("utf-8")) + except UnicodeDecodeError as e: + raise ValueError( + f"UTF-8 decode failed for column '{col.name}': {e}" + ) + else: + out.append(val) + return tuple(out) + + +def fetch_and_validate_row_batches( + pg_cursor, + select_sql: str, + sqlite_cols: list[SQLiteCol], + batch_size: int = DEFAULT_BATCH_SIZE, +): + pg_cursor.execute(select_sql) + total_rows_seen = 0 + while True: + rows = pg_cursor.fetchmany(batch_size) + if not rows: + break + validated_batch = [] + for row in rows: + total_rows_seen += 1 + try: + validated_batch.append(process_row_for_sqlite(row, sqlite_cols)) + except ValueError as e: + raise ValueError(f"Row {total_rows_seen} validation error: {e}") + yield validated_batch + + +def _get_postgres_sequence_info( + pg_cursor, schema: str, table: str, pk_col: str +) -> Optional[tuple[str, int]]: + """Get PostgreSQL sequence name and last_value for a table primary key, if any. + + Returns (sequence_name_text, last_value) or None. + """ + pg_cursor.execute( + """ + SELECT data_type + FROM information_schema.columns + WHERE table_schema = %s AND table_name = %s AND column_name = %s; + """, + (schema, table, pk_col), + ) + r = pg_cursor.fetchone() + if not r: + return None + + pg_type = (r[0] or "").lower() + # Match whole words to avoid matching 'interval' etc. + if not re.search(r"\b(?:int|serial|bigint)\b", pg_type): + return None + + # Get sequence name text (NULL if none) + pg_cursor.execute( + "SELECT pg_get_serial_sequence(%s, %s);", + (f"{schema}.{table}", pk_col), + ) + row = pg_cursor.fetchone() + seq_name = row[0] if row else None + if not seq_name: + return None + + try: + # Read last_value by casting the pg_get_serial_sequence result to regclass. + pg_cursor.execute( + "SELECT last_value FROM pg_get_serial_sequence(%s, %s)::regclass;", + (f"{schema}.{table}", pk_col), + ) + rr = pg_cursor.fetchone() + if rr and rr[0] is not None: + return (seq_name, int(rr[0])) + except Exception: + # Swallow errors (best-effort); callers treat missing info as absent + pass + + return None + + +def _get_sqlite_max_pk_value(sqlite_cursor, table: str, pk_col: str) -> Optional[int]: + """Get the maximum primary key value from SQLite table.""" + logger = logging.getLogger("db_migrate") + try: + sqlite_cursor.execute( + f"SELECT MAX({quote_sqlite_identifier(pk_col)}) " + f"FROM {quote_sqlite_identifier(table)};" + ) + r3 = sqlite_cursor.fetchone() + if r3 and r3[0] is not None: + return int(r3[0]) + except Exception: + logger.debug("Failed to read sqlite max pk", exc_info=True) + return None + + +def _update_sqlite_sequence( + sqlite_conn: sqlite3.Connection, table: str, sequence_value: int +) -> bool: + """Update SQLite sqlite_sequence with new value. + + IMPORTANT: This function does not commit; caller must commit/rollback. + """ + s_cur = sqlite_conn.cursor() + s_cur.execute( + "UPDATE sqlite_sequence SET seq = ? WHERE name = ?;", + (sequence_value, table), + ) + if s_cur.rowcount == 0: + s_cur.execute( + "INSERT INTO sqlite_sequence(name, seq) VALUES (?, ?);", + (table, sequence_value), + ) + return True + + +def try_update_sqlite_sequence( + pg_cursor, + sqlite_conn: sqlite3.Connection, + schema: str, + pg_table: str, # PostgreSQL table name + sqlite_table: str, # SQLite table name + sqlite_cols: list[SQLiteCol], +) -> bool: + """Update SQLite sequence table based on PostgreSQL sequence values.""" + logger = logging.getLogger("db_migrate") + # Check if table has a single primary key + pks = [c for c in sqlite_cols if c.pk] + if len(pks) != 1: + return False + pk_col = pks[0].name + + # Check if SQLite has AUTOINCREMENT (use sqlite_table) + s_cur = sqlite_conn.cursor() + if not sqlite_table_has_autoincrement(s_cur, sqlite_table): + return False + if not sqlite_sequence_table_exists(s_cur): + return False + + # Get PostgreSQL sequence info (use pg_table) + seq_info = _get_postgres_sequence_info(pg_cursor, schema, pg_table, pk_col) + pg_last_value = seq_info[1] if seq_info else None + + # Get SQLite max PK value (use sqlite_table) + sqlite_max = _get_sqlite_max_pk_value(s_cur, sqlite_table, pk_col) + + # Determine the value to use + if pg_last_value is not None and sqlite_max is not None: + candidate = max(pg_last_value, sqlite_max) + elif pg_last_value is not None: + candidate = pg_last_value + elif sqlite_max is not None: + candidate = sqlite_max + else: + return False + + updated = _update_sqlite_sequence(sqlite_conn, sqlite_table, candidate) + if updated: + try: + sqlite_conn.commit() + except Exception: + logger.debug("Failed to commit sqlite_sequence update", exc_info=True) + # Let caller proceed; treat as best-effort + return updated + + +# ---------------------- +# Flow: per-schema migration +# ---------------------- + + +def migrate_schema( + pg_conn, + sqlite_dir: str, + schema: str, + skipped_tables: Set[str], + logger: logging.Logger, + dry_run: bool = False, + batch_size: int = DEFAULT_BATCH_SIZE, +) -> Tuple[int, int]: + """Migrate a single schema. Returns (tables_migrated, rows_inserted_total).""" + processed_schema = schema[:-7] if schema.endswith("_schema") else schema + sqlite_db_path = Path(sqlite_dir) / f"{processed_schema}.db" + if not sqlite_db_path.is_file(): + logger.error(f"Missing SQLite DB: {sqlite_db_path}") + return 0, 0 + + tables_migrated = 0 + rows_inserted = 0 + + sqlite_path = sqlite_db_path.resolve() + if dry_run: + uri = sqlite_path.as_uri() + "?mode=ro" + conn_args = {"database": uri, "uri": True} + else: + conn_args = {"database": str(sqlite_path)} + + with sqlite3.connect(**conn_args) as sqlite_conn: + sqlite_cur = sqlite_conn.cursor() + sqlite_cur.execute("SELECT name FROM sqlite_master WHERE type='table';") + sqlite_tables = [r[0] for r in sqlite_cur.fetchall()] + # O(1) lookup map for matching by lowercase name + sqlite_table_map = {t.lower(): t for t in sqlite_tables} + + with pg_conn.cursor() as pg_cur: + # Ensure we start in a clean state for this connection + try: + pg_conn.rollback() + except Exception: + # Ignore rollback failure; connection is newly opened most likely + logger.debug( + "pg_conn.rollback() at schema start ignored", exc_info=True + ) + + # Get table list for this schema, but catch/rollback on failure + try: + tables = list_tables_in_schema(pg_cur, schema) + except Exception as e: + logger.error("ERROR %s (list tables): %s", schema, e) + logger.debug("Traceback (list tables):", exc_info=True) + try: + pg_conn.rollback() + except Exception: + logger.debug( + "pg_conn.rollback() failed after list tables error", + exc_info=True, + ) + return 0, 0 + + for table in tables: + # Defensive: ensure connection is in a clean state before any new PG work. + # A prior error can leave the connection in an aborted transaction; calling + # rollback() clears that and allows subsequent SELECTs to run. + try: + pg_conn.rollback() + except Exception: + # ignore: best-effort cleanup + logger.debug( + "pg_conn.rollback() ignored at start of table loop", + exc_info=True, + ) + + if table.lower() in skipped_tables: + logger.info("SKIP %s.%s (explicit)", schema, table) + continue + if table.lower() not in sqlite_table_map: + logger.info("SKIP %s.%s (no target table)", schema, table) + continue + + matched_table = sqlite_table_map[table.lower()] + logger.info("OK %s.%s -> %s", schema, table, matched_table) + + # Fetch PG columns, but defend against aborted transaction here + try: + pg_cols = get_pg_columns(pg_cur, schema, table) + except Exception as e: + logger.error("ERROR %s.%s (get columns): %s", schema, table, e) + logger.debug("Traceback (get columns):", exc_info=True) + try: + pg_conn.rollback() + except Exception: + logger.debug( + "pg_conn.rollback() failed after get columns error", + exc_info=True, + ) + continue + + pg_map: Dict[str, Tuple[str, str]] = { + name.lower(): (name, dtype) for name, dtype in pg_cols + } + + sqlite_info = get_sqlite_table_info(sqlite_cur, matched_table) + if not sqlite_info: + logger.warning(f"SKIP {schema}.{table} (no sqlite info)") + continue + + # Warn once about schema drift (extra columns) + pg_col_names = {name.lower() for name, _ in pg_cols} + extra_in_sqlite = { + c.name for c in sqlite_info if c.name.lower() not in pg_col_names + } + if extra_in_sqlite: + logger.warning( + f"Schema drift: {schema}.{table} has extra SQLite columns {extra_in_sqlite}" + ) + + try: + validate_column_compatibility(sqlite_info, pg_map, schema, table) + except ValueError as e: + logger.error(f"ERROR {schema}.{table} (type mismatch): {e}") + continue + + select_sql = build_select_for_sqlite_columns( + schema, table, sqlite_info, pg_map + ) + + csr_name = _sanitize_cursor_name(f"csr_{schema}_{table}") + try: + with pg_conn.cursor(name=csr_name) as data_cur: + batch_gen = fetch_and_validate_row_batches( + data_cur, select_sql, sqlite_info, batch_size=batch_size + ) + first_batch = next(batch_gen, None) + + if not first_batch: + logger.info(f"SKIP {schema}.{table} (no rows)") + # data_cur will be closed automatically on leaving the 'with' + continue + + if dry_run: + count = len(first_batch) + for batch in batch_gen: + count += len(batch) + inserted = 0 + logger.info( + f"DRY {schema}.{table} rows_validated={count}" + ) + else: + cur = sqlite_conn.cursor() + quoted_table = quote_sqlite_identifier(matched_table) + col_names = [c.name for c in sqlite_info] + quoted_cols = ", ".join( + quote_sqlite_identifier(c) for c in col_names + ) + placeholders = ", ".join(["?"] * len(col_names)) + + cur.execute("BEGIN;") + try: + cur.execute(f"DELETE FROM {quoted_table};") + inserted = 0 + if first_batch: + cur.executemany( + f"INSERT INTO {quoted_table} ({quoted_cols}) VALUES ({placeholders});", + first_batch, + ) + inserted += len(first_batch) + for batch in batch_gen: + if not batch: + continue + cur.executemany( + f"INSERT INTO {quoted_table} ({quoted_cols}) VALUES ({placeholders});", + batch, + ) + inserted += len(batch) + except Exception: + cur.execute("ROLLBACK;") + raise + else: + cur.execute("COMMIT;") + except ValueError as e: + logger.error("ERROR %s.%s (row validation): %s", schema, table, e) + try: + pg_conn.rollback() + except Exception: + logger.debug( + "pg_conn.rollback() failed after row validation error", + exc_info=True, + ) + continue + except Exception as e: + logger.error("ERROR %s.%s (select failed): %s", schema, table, e) + logger.debug("Traceback (select failed):", exc_info=True) + try: + pg_conn.rollback() + except Exception: + logger.debug( + "pg_conn.rollback() failed after select failed", + exc_info=True, + ) + continue + + rows_inserted += inserted + tables_migrated += 1 + logger.info(f"DONE {schema}.{table} rows={inserted}") + + if not dry_run: + try: + if try_update_sqlite_sequence( + pg_cur, + sqlite_conn, + schema, + table, + matched_table, + sqlite_info, + ): + logger.info( + "SEQ %s.%s sqlite_sequence updated", schema, table + ) + except Exception as e: + logger.warning( + "SEQ %s.%s update failed (ignored): %s", schema, table, e + ) + + return tables_migrated, rows_inserted + + +def migrate_data( + pg_conn_str: str, + sqlite_dir: str, + schema_filter: Optional[str], + dry_run: bool, + skip_tables: str, + logger: logging.Logger, + batch_size: int, +) -> None: + skipped_tables = {t.strip().lower() for t in skip_tables.split(",") if t.strip()} + total_tables = 0 + total_rows = 0 + total_errors = 0 + + # List schemas once with a short-lived connection + with psycopg.connect(pg_conn_str) as tmp_conn: + with tmp_conn.cursor() as cur: + schemas = list_user_schemas(cur) + + if schema_filter: + schemas = [s for s in schemas if s == schema_filter] + if not schemas: + logger.error("No schemas to process") + return + + for schema in schemas: + logger.info("SCHEMA %s", schema) + if dry_run: + logger.info("(dry-run) validating only — no writes will be performed") + + # Use a fresh connection per-schema to isolate failures/aborted transactions + try: + with psycopg.connect(pg_conn_str) as pg_conn: + migrated_tables, inserted_rows = migrate_schema( + pg_conn, + sqlite_dir, + schema, + skipped_tables, + logger, + dry_run=dry_run, + batch_size=batch_size, + ) + except Exception as e: + logger.error("Schema %s failed: %s", schema, e) + logger.debug("Traceback (schema failure):", exc_info=True) + total_errors += 1 + continue + + total_tables += migrated_tables + total_rows += inserted_rows + + logger.info("---") + logger.info( + f"SUMMARY: schemas={len(schemas)} " + f"tables_migrated={total_tables} " + f"rows_inserted={total_rows} " + f"errors={total_errors}" + ) + + +# ---------------------- +# CLI +# ---------------------- + + +def parse_args(argv): + p = argparse.ArgumentParser( + description="Migrate Postgres data into per-schema SQLite DBs (non-invasive)." + ) + p.add_argument("pg_conn", help="Postgres connection string") + p.add_argument( + "sqlite_dir", help="Directory containing per-schema sqlite .db files" + ) + p.add_argument( + "--schema", help="Only migrate this schema (exact match)", default=None + ) + p.add_argument( + "--dry-run", + help="Validate and report only; do not write to sqlite", + action="store_true", + ) + p.add_argument("--verbose", help="Verbose logging (debug)", action="store_true") + p.add_argument("--no-color", help="Disable ANSI color output", action="store_true") + p.add_argument( + "--batch-size", + type=int, + default=DEFAULT_BATCH_SIZE, + help="Batch size for data fetching", + ) + p.add_argument( + "--skip-tables", + help="Comma-separated list of tables to skip", + default="migrations,servers_stats", + ) + + return p.parse_args(argv[1:]) + + +def main(argv): + args = parse_args(argv) + logger = setup_logger(args.verbose, args.no_color) + + sqlite_dir_path = Path(args.sqlite_dir) + if not sqlite_dir_path.is_dir(): + logger.error("SQLite directory does not exist or is not a directory.") + raise SystemExit(1) + + try: + migrate_data( + args.pg_conn, + str(sqlite_dir_path), + args.schema, + args.dry_run, + args.skip_tables, + logger, + args.batch_size, + ) + except Exception as e: + logger.error(f"Fatal error: {e}") + raise SystemExit(2) + + +if __name__ == "__main__": + main(sys.argv) diff --git a/scripts/db/sqlite.load b/scripts/db/sqlite.load index 105e8c39d2..7f60e72c2f 100644 --- a/scripts/db/sqlite.load +++ b/scripts/db/sqlite.load @@ -10,7 +10,8 @@ LOAD DATABASE -- pgloader implementation doesn't find "GENERATED ALWAYS AS IDENTITY" sequences, -- instead we reset sequences manually via custom query after load reset no sequences, - data only + data only, + workers = {{WORKERS}} EXCLUDING TABLE NAMES LIKE 'migrations', 'sqlite_sequence' diff --git a/scripts/desktop/build-lib-mac.sh b/scripts/desktop/build-lib-mac.sh index 934d9b8d7d..6e3415bd80 100755 --- a/scripts/desktop/build-lib-mac.sh +++ b/scripts/desktop/build-lib-mac.sh @@ -28,7 +28,7 @@ rm -rf $BUILD_DIR if [[ "$DATABASE_BACKEND" == "postgres" ]]; then echo "Building with postgres backend..." - cabal build -f client_postgres lib:simplex-chat lib:simplex-chat --ghc-options="-optl-Wl,-rpath,@loader_path -optl-Wl,-L$GHC_LIBS_DIR/$ARCH-osx-ghc-$GHC_VERSION -optl-lHSrts_thr-ghc$GHC_VERSION -optl-lffi" --constraint 'simplexmq +client_library' --constraint 'simplex-chat +client_library' + cabal build lib:simplex-chat lib:simplex-chat --ghc-options="-optl-Wl,-rpath,@loader_path -optl-Wl,-L$GHC_LIBS_DIR/$ARCH-osx-ghc-$GHC_VERSION -optl-lHSrts_thr-ghc$GHC_VERSION -optl-lffi" --constraint 'simplexmq +client_library +client_postgres' --constraint 'simplex-chat +client_library +client_postgres' else echo "Building with sqlite backend..." cabal build lib:simplex-chat lib:simplex-chat --ghc-options="-optl-Wl,-rpath,@loader_path -optl-Wl,-L$GHC_LIBS_DIR/$ARCH-osx-ghc-$GHC_VERSION -optl-lHSrts_thr-ghc$GHC_VERSION -optl-lffi" --constraint 'simplexmq +client_library' --constraint 'simplex-chat +client_library' @@ -41,12 +41,12 @@ mkdir deps 2> /dev/null || true #cp $GHC_LIBS_DIR/libffi.dylib ./deps ( BUILD=$PWD - cp /tmp/libffi-3.4.4/*-apple-darwin*/.libs/libffi.dylib $BUILD/deps || \ + cp /tmp/libffi-3.5.2/*-apple-darwin*/.libs/libffi.dylib $BUILD/deps || \ ( \ cd /tmp && \ - curl --tlsv1.2 "https://gitlab.haskell.org/ghc/libffi-tarballs/-/raw/libffi-3.4.4/libffi-3.4.4.tar.gz?inline=false" -o libffi.tar.gz && \ + curl --tlsv1.2 "https://gitlab.haskell.org/ghc/libffi-tarballs/-/raw/3914c27381526ce586ea0ac0359a332fd82987af/libffi-3.5.2.tar.gz?inline=false" -o libffi.tar.gz && \ tar -xzvf libffi.tar.gz && \ - cd "libffi-3.4.4" && \ + cd "libffi-3.5.2" && \ ./configure && \ make && \ cp *-apple-darwin*/.libs/libffi.dylib $BUILD/deps \ diff --git a/scripts/desktop/make-deb-linux.sh b/scripts/desktop/make-deb-linux.sh index c9c4d5a81c..3226c22709 100755 --- a/scripts/desktop/make-deb-linux.sh +++ b/scripts/desktop/make-deb-linux.sh @@ -37,8 +37,12 @@ export SOURCE_DATE_EPOCH=1704067200 dpkg-deb -R ./release/main/deb/simplex*.deb ./extracted +# Source the distribution variables (VERSION_CODENAME) +. /etc/os-release + rm -f ./extracted/opt/*imple*/lib/app/*skiko-awt-runtime-linux* sed -i -e '/skiko-awt-runtime-linux/d' ./extracted/opt/*imple*/lib/app/simplex.cfg +sed -i "/Version/ s/\$/~$VERSION_CODENAME/" ./extracted/DEBIAN/control find ./extracted/ -exec touch -d "@$SOURCE_DATE_EPOCH" {} + dpkg-deb --build --root-owner-group --uniform-compression ./extracted ./release/main/deb/simplex_${ARCH}.deb diff --git a/scripts/flatpak/chat.simplex.simplex.metainfo.xml b/scripts/flatpak/chat.simplex.simplex.metainfo.xml index 6b6af1032b..950cb42ea2 100644 --- a/scripts/flatpak/chat.simplex.simplex.metainfo.xml +++ b/scripts/flatpak/chat.simplex.simplex.metainfo.xml @@ -38,6 +38,90 @@ + + https://simplex.chat/blog/20250729-simplex-chat-v6-4-1-welcome-contacts-protect-groups-app-security.html + +

New in v6.4.7:

+
    +
  • fix exporting database larger than 4gb.
  • +
+

New in v6.4-6.4.6:

+
    +
  • new UX to connect.
  • +
  • review new group members.
  • +
  • chat with group admins.
  • +
  • new UI languages: Catalan, Indonesian, Romanian and Vietnamese.
  • +
  • Linux app builds for aarch64 CPUs
  • +
  • UI support for bot commands.
  • +
  • support markdown hyperlinks, such as [click here](https://example.com).
  • +
  • option to remove tracking parameters from the links.
  • +
  • better information about network errors.
  • +
+
+
+ + https://simplex.chat/blog/20250729-simplex-chat-v6-4-1-welcome-contacts-protect-groups-app-security.html + +

New in v6.4.6:

+
    +
  • fixed opening SimpleX links from outside of the app.
  • +
+

New in v6.4-6.4.5:

+
    +
  • new UX to connect.
  • +
  • review new group members.
  • +
  • chat with group admins.
  • +
  • new UI languages: Catalan, Indonesian, Romanian and Vietnamese.
  • +
  • Linux app builds for aarch64 CPUs
  • +
  • UI support for bot commands.
  • +
  • support markdown hyperlinks, such as [click here](https://example.com).
  • +
  • option to remove tracking parameters from the links.
  • +
  • better information about network errors.
  • +
+
+
+ + https://simplex.chat/blog/20250729-simplex-chat-v6-4-1-welcome-contacts-protect-groups-app-security.html + +

New in v6.4.5:

+
    +
  • fixes for chats with admins.
  • +
  • better information about network errors.
  • +
+

New in v6.4-6.4.4:

+
    +
  • new UX to connect.
  • +
  • review new group members.
  • +
  • chat with group admins.
  • +
  • new UI languages: Catalan, Indonesian, Romanian and Vietnamese.
  • +
  • Linux app builds for aarch64 CPUs
  • +
  • UI support for bot commands.
  • +
  • support markdown hyperlinks, such as [click here](https://example.com).
  • +
  • option to remove tracking parameters from the links.
  • +
+
+
+ + https://simplex.chat/blog/20250729-simplex-chat-v6-4-1-welcome-contacts-protect-groups-app-security.html + +

New in v6.4.4:

+
    +
  • reduced battery usage.
  • +
  • fixes.
  • +
+

New in v6.4-6.4.3.1:

+
    +
  • new UX to connect.
  • +
  • review new group members.
  • +
  • chat with group admins.
  • +
  • new UI languages: Catalan, Indonesian, Romanian and Vietnamese.
  • +
  • Linux app builds for aarch64 CPUs
  • +
  • UI support for bot commands.
  • +
  • support markdown hyperlinks, such as [click here](https://example.com).
  • +
  • option to remove tracking parameters from the links.
  • +
+
+
https://simplex.chat/blog/20250729-simplex-chat-v6-4-1-welcome-contacts-protect-groups-app-security.html diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 591067ddd5..fded002c61 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,12 +1,12 @@ { - "https://github.com/simplex-chat/simplexmq.git"."2488d7de7c554e6e1655c903540ec7ab9fc7f28c" = "07l58ndw54dbzb6nndb2jldzbjdvlyracc277m0m0qgzgsshqgpr"; + "https://github.com/simplex-chat/simplexmq.git"."e764c3cea98417ebea59c55bd591c5d75d61d90b" = "1gjr8vysnpadvfvrg5p3kwbcazk2sqiiwy5gzrpbwgq4fnndj7zr"; "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"; "https://github.com/simplex-chat/aeson.git"."aab7b5a14d6c5ea64c64dcaee418de1bb00dcc2b" = "0jz7kda8gai893vyvj96fy962ncv8dcsx71fbddyy8zrvc88jfrr"; "https://github.com/simplex-chat/haskell-terminal.git"."f708b00009b54890172068f168bf98508ffcd495" = "0zmq7lmfsk8m340g47g5963yba7i88n4afa6z93sg9px5jv1mijj"; "https://github.com/simplex-chat/android-support.git"."9aa09f148089d6752ce563b14c2df1895718d806" = "0pbf2pf13v2kjzi397nr13f1h3jv0imvsq8rpiyy2qyx5vd50pqn"; - "https://github.com/simplex-chat/zip.git"."bd421c6b19cc4c465cd7af1f6f26169fb8ee1ebc" = "1csqfjhvc8wb5h4kxxndmb6iw7b4ib9ff2n81hrizsmnf45a6gg0"; + "https://github.com/simplex-chat/zip.git"."2eff156c3aac389e35d38bf10a52733d7061640a" = "052vahd5d4lxnazjrb6l60i261aycn2js7jhzafyb72n15ns4r6p"; "https://github.com/yesodweb/wai.git"."ec5e017d896a78e787a5acea62b37a4e677dec2e" = "1ckcpmpjfy9jiqrb52q20lj7ln4hmq9v2jk6kpkf3m68c1m9c2bx"; "https://github.com/simplex-chat/wai.git"."2f6e5aa5f05ba9140ac99e195ee647b4f7d926b0" = "199g4rjdf1zp1fcw8nqdsyr1h36hmg424qqx03071jk7j00z7ay4"; } diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 9b9c5a9581..a731f14eb9 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 6.4.4.1 +version: 6.5.0.4 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat @@ -40,6 +40,7 @@ library Simplex.Chat.AppSettings Simplex.Chat.Call Simplex.Chat.Controller + Simplex.Chat.Delivery Simplex.Chat.Files Simplex.Chat.Library.Commands Simplex.Chat.Library.Internal @@ -72,6 +73,7 @@ library Simplex.Chat.Store.AppSettings Simplex.Chat.Store.Connections Simplex.Chat.Store.ContactRequest + Simplex.Chat.Store.Delivery Simplex.Chat.Store.Direct Simplex.Chat.Store.Files Simplex.Chat.Store.Groups @@ -85,7 +87,6 @@ library Simplex.Chat.Types.Preferences Simplex.Chat.Types.Shared Simplex.Chat.Types.UITheme - Simplex.Chat.Types.Util Simplex.Chat.Util if !flag(client_library) exposed-modules: @@ -115,6 +116,11 @@ library Simplex.Chat.Store.Postgres.Migrations.M20250729_member_contact_requests Simplex.Chat.Store.Postgres.Migrations.M20250801_via_group_link_uri Simplex.Chat.Store.Postgres.Migrations.M20250802_chat_peer_type + Simplex.Chat.Store.Postgres.Migrations.M20250813_delivery_tasks + Simplex.Chat.Store.Postgres.Migrations.M20250919_group_summary + 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 else exposed-modules: Simplex.Chat.Archive @@ -255,6 +261,11 @@ library Simplex.Chat.Store.SQLite.Migrations.M20250729_member_contact_requests Simplex.Chat.Store.SQLite.Migrations.M20250801_via_group_link_uri Simplex.Chat.Store.SQLite.Migrations.M20250802_chat_peer_type + Simplex.Chat.Store.SQLite.Migrations.M20250813_delivery_tasks + Simplex.Chat.Store.SQLite.Migrations.M20250919_group_summary + 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 other-modules: Paths_simplex_chat hs-source-dirs: @@ -300,7 +311,7 @@ library , unliftio-core ==0.2.* , uri-bytestring >=0.3.3.1 && <0.4 , uuid ==1.3.* - , zip ==2.0.* + , zip ==2.2.* , zstd ==0.1.3.* default-language: Haskell2010 if flag(swift) @@ -453,10 +464,13 @@ executable simplex-directory-service Directory.BlockedWords Directory.Captcha Directory.Events + Directory.Listing Directory.Options Directory.Search Directory.Service Directory.Store + Directory.Store.Migrate + Directory.Util Paths_simplex_chat ghc-options: -O2 -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=missing-methods -Werror=incomplete-uni-patterns -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -threaded build-depends: @@ -464,9 +478,13 @@ executable simplex-directory-service , async ==2.2.* , attoparsec ==0.14.* , base >=4.7 && <5 + , base64-bytestring >=1.0 && <1.3 , composition ==1.0.* , containers ==0.6.* + , crypton ==0.34.* , directory ==1.3.* + , filepath ==1.4.* + , memory ==0.18.* , mtl >=2.3.1 && <3.0 , optparse-applicative >=0.15 && <0.17 , process >=1.6 && <1.6.18 @@ -479,7 +497,17 @@ executable simplex-directory-service , unicode-transforms ==0.4.* default-language: Haskell2010 if flag(client_postgres) + other-modules: + Directory.Store.Postgres.Migrations + build-depends: + postgresql-simple ==0.7.* + , raw-strings-qq ==1.1.* cpp-options: -DdbPostgres + else + other-modules: + Directory.Store.SQLite.Migrations + build-depends: + sqlcipher-simple ==0.4.* if impl(ghc >= 9.6.2) build-depends: bytestring ==0.11.* @@ -524,6 +552,7 @@ test-suite simplex-chat-test API.Docs.Commands API.Docs.Events API.Docs.Generate + API.Docs.Generate.TypeScript API.Docs.Responses API.Docs.Syntax API.Docs.Syntax.Types @@ -534,10 +563,13 @@ test-suite simplex-chat-test Directory.BlockedWords Directory.Captcha Directory.Events + Directory.Listing Directory.Options Directory.Search Directory.Service Directory.Store + Directory.Store.Migrate + Directory.Util Paths_simplex_chat if flag(client_postgres) other-modules: @@ -590,10 +622,15 @@ test-suite simplex-chat-test , uri-bytestring >=0.3.3.1 && <0.4 default-language: Haskell2010 if flag(client_postgres) + other-modules: + Directory.Store.Postgres.Migrations build-depends: postgresql-simple ==0.7.* + , raw-strings-qq ==1.1.* cpp-options: -DdbPostgres else + other-modules: + Directory.Store.SQLite.Migrations build-depends: sqlcipher-simple ==0.4.* if impl(ghc >= 9.6.2) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index e14275b75c..9b711c2b50 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -39,13 +39,13 @@ import Simplex.Chat.Store.Profiles import Simplex.Chat.Types import Simplex.Chat.Util (shuffle) import Simplex.FileTransfer.Client.Presets (defaultXFTPServers) -import Simplex.Messaging.Agent as Agent +import Simplex.Messaging.Agent import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), InitialAgentServers (..), ServerCfg (..), allRoles, createAgentStore, defaultAgentConfig, presetServerCfg) import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.Store.Common (DBStore (dbNew)) import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Agent.Store.Entity -import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation (..), MigrationError) +import Simplex.Messaging.Agent.Store.Shared (MigrationConfig (..), MigrationConfirmation (..), MigrationError) import Simplex.Messaging.Client (defaultNetworkConfig) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Protocol (ProtoServerWithAuth (..), ProtocolType (..), SProtocolType (..), SubscriptionMode (..), UserProtocol) @@ -106,8 +106,9 @@ defaultChatConfig = cleanupManagerInterval = 30 * 60, -- 30 minutes cleanupManagerStepDelay = 3 * 1000000, -- 3 seconds ciExpirationInterval = 30 * 60 * 1000000, -- 30 minutes - coreApi = False, highlyAvailable = False, + deliveryWorkerDelay = 0, + deliveryBucketSize = 10000, deviceNameForRemote = "", chatHooks = defaultChatHooks } @@ -115,10 +116,10 @@ defaultChatConfig = logCfg :: LogConfig logCfg = LogConfig {lc_file = Nothing, lc_stderr = True} -createChatDatabase :: ChatDbOpts -> MigrationConfirmation -> IO (Either MigrationError ChatDatabase) -createChatDatabase chatDbOpts confirmMigrations = runExceptT $ do - chatStore <- ExceptT $ createChatStore (toDBOpts chatDbOpts chatSuffix False) confirmMigrations - agentStore <- ExceptT $ createAgentStore (toDBOpts chatDbOpts agentSuffix False) confirmMigrations +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 pure ChatDatabase {chatStore, agentStore} newChatController :: ChatDatabase -> Maybe User -> ChatConfig -> ChatOpts -> Bool -> IO ChatController @@ -148,7 +149,6 @@ newChatController eventSeq <- newTVarIO 0 inputQ <- newTBQueueIO tbqSize outputQ <- newTBQueueIO tbqSize - connNetworkStatuses <- TM.emptyIO subscriptionMode <- newTVarIO SMSubscribe chatLock <- newEmptyTMVarIO entityLocks <- TM.emptyIO @@ -163,6 +163,8 @@ newChatController remoteCtrlSession <- newTVarIO Nothing filesFolder <- newTVarIO optFilesFolder chatStoreChanged <- newTVarIO False + deliveryTaskWorkers <- TM.emptyIO + deliveryJobWorkers <- TM.emptyIO expireCIThreads <- TM.emptyIO expireCIFlags <- TM.emptyIO cleanupManagerAsync <- newTVarIO Nothing @@ -188,7 +190,6 @@ newChatController eventSeq, inputQ, outputQ, - connNetworkStatuses, subscriptionMode, chatLock, entityLocks, @@ -203,6 +204,8 @@ newChatController remoteCtrlSession, config, filesFolder, + deliveryTaskWorkers, + deliveryJobWorkers, expireCIThreads, expireCIFlags, cleanupManagerAsync, @@ -247,7 +250,7 @@ newChatController ops <- getUpdateServerOperators db presetOps (null users) let opDomains = operatorDomains $ mapMaybe snd ops (smp', xftp') <- unzip <$> mapM (getServers ops opDomains) users - pure InitialAgentServers {smp = M.fromList (optServers smp' smpServers), xftp = M.fromList (optServers xftp' xftpServers), ntf, netCfg, presetDomains} + pure InitialAgentServers {smp = M.fromList (optServers smp' smpServers), xftp = M.fromList (optServers xftp' xftpServers), ntf, netCfg, presetDomains, presetServers = L.toList allPresetServers} where optServers :: [(UserId, NonEmpty (ServerCfg p))] -> [ProtoServerWithAuth p] -> [(UserId, NonEmpty (ServerCfg p))] optServers srvs overrides_ = case L.nonEmpty overrides_ of diff --git a/src/Simplex/Chat/Archive.hs b/src/Simplex/Chat/Archive.hs index 2cbc941b44..b54c23959b 100644 --- a/src/Simplex/Chat/Archive.hs +++ b/src/Simplex/Chat/Archive.hs @@ -158,7 +158,7 @@ sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = D -- closing after encryption prevents closing in case wrong encryption key was passed liftIO $ closeDBStore `withStores` fs (moveExported `withStores` fs) - `catchChatError` \e -> (restore `withDBs` fs) >> throwError e + `catchAllErrors` \e -> (restore `withDBs` fs) >> throwError e where backup f = copyFile f (f <> ".bak") restore f = copyFile (f <> ".bak") f diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index eb7ed53283..8003f66324 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -19,7 +19,7 @@ module Simplex.Chat.Controller where import Control.Concurrent (ThreadId) import Control.Concurrent.Async (Async) -import Control.Exception (Exception, SomeException) +import Control.Exception (Exception) import qualified Control.Exception as E import Control.Monad.Except import Control.Monad.IO.Unlift @@ -54,6 +54,7 @@ import Numeric.Natural import qualified Paths_simplex_chat as SC import Simplex.Chat.AppSettings import Simplex.Chat.Call +import Simplex.Chat.Delivery import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent import Simplex.Chat.Operators @@ -61,16 +62,16 @@ import Simplex.Chat.Protocol import Simplex.Chat.Remote.AppVersion import Simplex.Chat.Remote.Types import Simplex.Chat.Stats (PresentedServersSummary) -import Simplex.Chat.Store (AddressSettings, ChatLockEntity, GroupLink, GroupLinkInfo, StoreError (..), UserContactLink, UserMsgReceiptSettings) +import Simplex.Chat.Store (AddressSettings, ChatLockEntity, GroupLinkInfo, StoreError (..), UserContactLink, UserMsgReceiptSettings) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme import Simplex.Chat.Util (liftIOEither) import Simplex.FileTransfer.Description (FileDescriptionURI) -import Simplex.Messaging.Agent (AgentClient, SubscriptionsInfo) +import Simplex.Messaging.Agent (AgentClient, DatabaseDiff, SubscriptionsInfo) import Simplex.Messaging.Agent.Client (AgentLocks, AgentQueuesInfo (..), AgentWorkersDetails (..), AgentWorkersSummary (..), ProtocolTestFailure, SMPServerSubs, ServerQueueInfo, UserNetworkInfo) -import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, NetworkConfig, ServerCfg) +import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, NetworkConfig, ServerCfg, Worker) import Simplex.Messaging.Agent.Lock import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.Store.Common (DBStore, withTransaction, withTransactionPriority) @@ -88,7 +89,7 @@ import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType (..), Msg import Simplex.Messaging.TMap (TMap) import Simplex.Messaging.Transport (TLS, TransportPeer (..), simplexMQVersion) import Simplex.Messaging.Transport.Client (SocksProxyWithAuth, TransportHost) -import Simplex.Messaging.Util (allFinally, catchAllErrors, catchAllErrors', tryAllErrors, tryAllErrors', (<$$>)) +import Simplex.Messaging.Util (AnyError (..), catchAllErrors, (<$$>)) import Simplex.RemoteControl.Client import Simplex.RemoteControl.Invitation (RCSignedInvitation, RCVerifiedInvitation) import Simplex.RemoteControl.Types @@ -154,7 +155,8 @@ data ChatConfig = ChatConfig cleanupManagerInterval :: NominalDiffTime, cleanupManagerStepDelay :: Int64, ciExpirationInterval :: Int64, -- microseconds - coreApi :: Bool, + deliveryWorkerDelay :: Int64, -- microseconds + deliveryBucketSize :: Int, highlyAvailable :: Bool, deviceNameForRemote :: Text, chatHooks :: ChatHooks @@ -168,7 +170,13 @@ data RandomAgentServers = RandomAgentServers -- The hooks can be used to extend or customize chat core in mobile or CLI clients. data ChatHooks = ChatHooks - { -- preCmdHook can be used to process or modify the commands before they are processed. + { -- preStartHook can be used to verify some data, + -- It is called before chat controller is started, unless the core is started in maintenance mode. + preStartHook :: Maybe (ChatController -> IO ()), + -- postStartHook can be used to update some data after start (e.g. commands in bot or group profiles), + -- It is called after chat controller is started. + postStartHook :: Maybe (ChatController -> IO ()), + -- preCmdHook can be used to process or modify the commands before they are processed. -- This hook should be used to process CustomChatCommand. -- if this hook returns ChatResponse, the command processing will be skipped. preCmdHook :: Maybe (ChatController -> ChatCommand -> IO (Either (Either ChatError ChatResponse) ChatCommand)), @@ -180,7 +188,7 @@ data ChatHooks = ChatHooks } defaultChatHooks :: ChatHooks -defaultChatHooks = ChatHooks Nothing Nothing Nothing +defaultChatHooks = ChatHooks Nothing Nothing Nothing Nothing Nothing data PresetServers = PresetServers { operators :: NonEmpty PresetOperator, @@ -223,7 +231,6 @@ data ChatController = ChatController eventSeq :: TVar Int, inputQ :: TBQueue String, outputQ :: TBQueue (Maybe RemoteHostId, Either ChatError ChatEvent), - connNetworkStatuses :: TMap AgentConnId NetworkStatus, subscriptionMode :: TVar SubscriptionMode, chatLock :: Lock, entityLocks :: TMap ChatLockEntity Lock, @@ -238,6 +245,8 @@ data ChatController = ChatController remoteCtrlSession :: TVar (Maybe (SessionSeq, RemoteCtrlSession)), -- Supervisor process for hosted controllers config :: ChatConfig, filesFolder :: TVar (Maybe FilePath), -- path to files folder for mobile apps, + deliveryTaskWorkers :: TMap DeliveryWorkerKey Worker, + deliveryJobWorkers :: TMap DeliveryWorkerKey Worker, expireCIThreads :: TMap UserId (Maybe (Async ())), expireCIFlags :: TMap UserId Bool, cleanupManagerAsync :: TVar (Maybe (Async ())), @@ -282,6 +291,7 @@ data ChatCommand | APIStopChat | APIActivateChat {restoreChat :: Bool} | APISuspendChat {suspendTimeout :: Int} + | ShowConnectionsDiff Bool | ResubscribeAllConnections | SetTempFolder FilePath | SetFilesFolder FilePath @@ -304,8 +314,8 @@ data ChatCommand | APIGetAppSettings (Maybe AppSettings) | APIGetChatTags UserId | APIGetChats {userId :: UserId, pendingConnections :: Bool, pagination :: PaginationByTime, query :: ChatListQuery} - | APIGetChat {chatRef :: ChatRef, contentTag :: Maybe MsgContentTag, chatPagination :: ChatPagination, search :: Maybe String} - | APIGetChatItems {chatPagination :: ChatPagination, search :: Maybe String} + | APIGetChat {chatRef :: ChatRef, contentTag :: Maybe MsgContentTag, chatPagination :: ChatPagination, search :: Maybe Text} + | APIGetChatItems {chatPagination :: ChatPagination, search :: Maybe Text} | APIGetChatItemInfo {chatRef :: ChatRef, chatItemId :: ChatItemId} | APISendMessages {sendRef :: SendRef, liveMessage :: Bool, ttl :: Maybe Int, composedMessages :: NonEmpty ComposedMessage} | APICreateChatTag ChatTagData @@ -343,7 +353,6 @@ data ChatCommand | APIEndCall ContactId | APIGetCallInvitations | APICallStatus ContactId WebRTCCallStatus - | APIGetNetworkStatuses | APIUpdateProfile {userId :: UserId, profile :: Profile} | APISetContactPrefs {contactId :: ContactId, preferences :: Preferences} | APISetContactAlias {contactId :: ContactId, localAlias :: LocalAlias} @@ -454,8 +463,8 @@ data ChatCommand | APIChangePreparedGroupUser GroupId UserId | APIConnectPreparedContact {contactId :: ContactId, incognito :: IncognitoEnabled, msgContent_ :: Maybe MsgContent} | APIConnectPreparedGroup GroupId IncognitoEnabled (Maybe MsgContent) - | APIConnect {userId :: UserId, incognito :: IncognitoEnabled, connLink_ :: Maybe ACreatedConnLink} -- Maybe is used to report link parsing failure as special error - | Connect IncognitoEnabled (Maybe AConnectionLink) + | APIConnect {userId :: UserId, incognito :: IncognitoEnabled, preparedLink_ :: Maybe ACreatedConnLink} -- Maybe is used to report link parsing failure as special error + | Connect {incognito :: IncognitoEnabled, connLink_ :: Maybe AConnectionLink} | APIConnectContactViaAddress UserId IncognitoEnabled ContactId | ConnectSimplex IncognitoEnabled -- UserId (not used in UI) | DeleteContact ContactName ChatDeleteMode @@ -502,8 +511,8 @@ data ChatCommand | ClearGroup GroupName | ListMembers GroupName | ListMemberSupportChats GroupName - | APIListGroups {userId :: UserId, contactId_ :: Maybe ContactId, search :: Maybe String} - | ListGroups (Maybe ContactName) (Maybe String) + | APIListGroups {userId :: UserId, contactId_ :: Maybe ContactId, search :: Maybe Text} + | ListGroups (Maybe ContactName) (Maybe Text) | UpdateGroupNames GroupName GroupProfile | ShowGroupProfile GroupName | UpdateGroupDescription GroupName (Maybe Text) @@ -515,7 +524,7 @@ data ChatCommand | SendGroupMessageQuote {groupName :: GroupName, contactName_ :: Maybe ContactName, quotedMsg :: Text, message :: Text} | ClearNoteFolder | LastChats (Maybe Int) -- UserId (not used in UI) - | LastMessages (Maybe ChatName) Int (Maybe String) -- UserId (not used in UI) + | LastMessages (Maybe ChatName) Int (Maybe Text) -- UserId (not used in UI) | LastChatItemId (Maybe ChatName) Int -- UserId (not used in UI) | ShowChatItem (Maybe ChatItemId) -- UserId (not used in UI) | ShowChatItemInfo ChatName Text @@ -623,6 +632,7 @@ data ChatResponse | CRChatStarted | CRChatRunning | CRChatStopped + | CRConnectionsDiff {showIds :: Bool, userIds :: DatabaseDiff AgentUserId, connIds :: DatabaseDiff AgentConnId} | CRApiChats {user :: User, chats :: [AChat]} | CRChats {chats :: [AChat]} | CRApiChat {user :: User, chat :: AChat, navInfo :: Maybe NavigationInfo} @@ -638,7 +648,7 @@ data ChatResponse | CRChatItemTTL {user :: User, chatItemTTL :: Maybe Int64} | CRNetworkConfig {networkConfig :: NetworkConfig} | CRContactInfo {user :: User, contact :: Contact, connectionStats_ :: Maybe ConnectionStats, customUserProfile :: Maybe Profile} - | CRGroupInfo {user :: User, groupInfo :: GroupInfo, groupSummary :: GroupSummary} + | CRGroupInfo {user :: User, groupInfo :: GroupInfo} | CRGroupMemberInfo {user :: User, groupInfo :: GroupInfo, member :: GroupMember, connectionStats_ :: Maybe ConnectionStats} | CRQueueInfo {user :: User, rcvMsgInfo :: Maybe RcvMsgInfo, queueInfo :: ServerQueueInfo} | CRContactSwitchStarted {user :: User, contact :: Contact, connectionStats :: ConnectionStats} @@ -673,7 +683,7 @@ data ChatResponse | CRContactRequestRejected {user :: User, contactRequest :: UserContactRequest, contact_ :: Maybe Contact} | CRUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact} | CRUserDeletedMembers {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], withMessages :: Bool} - | CRGroupsList {user :: User, groups :: [GroupInfoSummary]} + | CRGroupsList {user :: User, groups :: [GroupInfo]} | CRSentGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, member :: GroupMember} | CRFileTransferStatus User (FileTransfer, [Integer]) -- TODO refactor this type to FileTransferStatus | CRFileTransferStatusXFTP User AChatItem @@ -717,9 +727,9 @@ data ChatResponse | CRGroupAliasUpdated {user :: User, toGroup :: GroupInfo} | CRConnectionAliasUpdated {user :: User, toConnection :: PendingContactConnection} | CRContactPrefsUpdated {user :: User, fromContact :: Contact, toContact :: Contact} - | CRNetworkStatuses {user_ :: Maybe User, networkStatuses :: [ConnNetworkStatus]} | CRJoinedGroupMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRMemberAccepted {user :: User, groupInfo :: GroupInfo, member :: GroupMember} + | CRMemberSupportChatRead {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRMemberSupportChatDeleted {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRMembersRoleUser {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], toRole :: GroupMemberRole} | CRMembersBlockedForAllUser {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], blocked :: Bool} @@ -784,7 +794,6 @@ data ChatEvent | CEvtSentGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, member :: GroupMember} -- there is the same command response | CEvtContactUpdated {user :: User, fromContact :: Contact, toContact :: Contact} | CEvtGroupMemberUpdated {user :: User, groupInfo :: GroupInfo, fromMember :: GroupMember, toMember :: GroupMember} - | CEvtContactsMerged {user :: User, intoContact :: Contact, mergedContact :: Contact, updatedContact :: Contact} | CEvtContactDeletedByContact {user :: User, contact :: Contact} | CEvtReceivedContactRequest {user :: User, contactRequest :: UserContactRequest, chat_ :: Maybe AChat} | CEvtAcceptingContactRequest {user :: User, contact :: Contact} -- there is the same command response @@ -815,14 +824,9 @@ data ChatEvent | CEvtContactConnected {user :: User, contact :: Contact, userCustomProfile :: Maybe Profile} | CEvtContactSndReady {user :: User, contact :: Contact} | CEvtContactAnotherClient {user :: User, contact :: Contact} + | CEvtConnectionsDiff {userIds :: DatabaseDiff AgentUserId, connIds :: DatabaseDiff AgentConnId} | CEvtSubscriptionEnd {user :: User, connectionEntity :: ConnectionEntity} - | CEvtContactsDisconnected {server :: SMPServer, contactRefs :: [ContactRef]} - | CEvtContactsSubscribed {server :: SMPServer, contactRefs :: [ContactRef]} - | CEvtContactSubError {user :: User, contact :: Contact, chatError :: ChatError} - | CEvtContactSubSummary {user :: User, contactSubscriptions :: [ContactSubStatus]} - | CEvtUserContactSubSummary {user :: User, userContactSubscriptions :: [UserContactSubStatus]} - | CEvtNetworkStatus {networkStatus :: NetworkStatus, connections :: [AgentConnId]} - | CEvtNetworkStatuses {user_ :: Maybe User, networkStatuses :: [ConnNetworkStatus]} -- there is the same command response + | CEvtSubscriptionStatus {server :: SMPServer, subscriptionStatus :: SubscriptionStatus, connections :: [AgentConnId]} | CEvtHostConnected {protocol :: AProtocolType, transportHost :: TransportHost} | CEvtHostDisconnected {protocol :: AProtocolType, transportHost :: TransportHost} | CEvtReceivedGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, fromMemberRole :: GroupMemberRole, memberRole :: GroupMemberRole} @@ -878,14 +882,6 @@ data TerminalEvent | TENewMemberContact {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} | TEContactVerificationReset {user :: User, contact :: Contact} | TEGroupMemberVerificationReset {user :: User, groupInfo :: GroupInfo, member :: GroupMember} - | TEGroupEmpty {user :: User, shortGroupInfo :: ShortGroupInfo} - | TEGroupSubscribed {user :: User, shortGroupInfo :: ShortGroupInfo} - | TEGroupInvitation {user :: User, shortGroupInfo :: ShortGroupInfo} - | TEMemberSubError {user :: User, shortGroupInfo :: ShortGroupInfo, memberToSubscribe :: ShortGroupMember, chatError :: ChatError} - | TEMemberSubSummary {user :: User, memberSubscriptions :: [MemberSubStatus]} - | TEPendingSubSummary {user :: User, pendingSubscriptions :: [PendingSubStatus]} - | TESndFileSubError {user :: User, sndFileTransfer :: SndFileTransfer, chatError :: ChatError} - | TERcvFileSubError {user :: User, rcvFileTransfer :: RcvFileTransfer, chatError :: ChatError} deriving (Show) data DeletedRcvQueue = DeletedRcvQueue @@ -910,9 +906,7 @@ allowRemoteEvent = \case logEventToFile :: ChatEvent -> Bool logEventToFile = \case - CEvtContactsDisconnected {} -> True - CEvtContactsSubscribed {} -> True - CEvtContactSubError {} -> True + CEvtSubscriptionStatus {} -> True CEvtHostConnected {} -> True CEvtHostDisconnected {} -> True CEvtConnectionDisabled {} -> True @@ -921,11 +915,6 @@ logEventToFile = \case CEvtAgentUserDeleted {} -> True -- CRChatCmdError {} -> True -- TODO this should be separately logged to file as command error CEvtMessageError {} -> True - CEvtTerminalEvent te -> case te of - TEMemberSubError {} -> True - TESndFileSubError {} -> True - TERcvFileSubError {} -> True - _ -> False _ -> False data SendRef @@ -1101,27 +1090,9 @@ defaultSimpleNetCfg = logTLSErrors = False } -data ContactSubStatus = ContactSubStatus - { contact :: Contact, - contactError :: Maybe ChatError - } - deriving (Show) - -data MemberSubStatus = MemberSubStatus - { member :: ShortGroupMember, - memberError :: Maybe ChatError - } - deriving (Show) - -data UserContactSubStatus = UserContactSubStatus - { userContact :: UserContact, - userContactError :: Maybe ChatError - } - deriving (Show) - -data PendingSubStatus = PendingSubStatus - { connection :: PendingContactConnection, - connError :: Maybe ChatError +data ConnSubResult = ConnSubResult + { agentConnId :: AgentConnId, + connSubError :: Maybe ChatError } deriving (Show) @@ -1264,7 +1235,7 @@ data SlowSQLQuery = SlowSQLQuery data ChatError = ChatError {errorType :: ChatErrorType} - | ChatErrorAgent {agentError :: AgentErrorType, connectionEntity_ :: Maybe ConnectionEntity} + | ChatErrorAgent {agentError :: AgentErrorType, agentConnId :: AgentConnId, connectionEntity_ :: Maybe ConnectionEntity} | ChatErrorStore {storeError :: StoreError} | ChatErrorDatabase {databaseError :: DatabaseError} | ChatErrorRemoteCtrl {remoteCtrlError :: RemoteCtrlError} @@ -1320,7 +1291,6 @@ data ChatErrorType | CEFileCancelled {message :: String} | CEFileCancel {fileId :: FileTransferId, message :: String} | CEFileAlreadyExists {filePath :: FilePath} - | CEFileRead {filePath :: FilePath, message :: String} | CEFileWrite {filePath :: FilePath, message :: String} | CEFileSend {fileId :: FileTransferId, agentError :: AgentErrorType} | CEFileRcvChunk {message :: String} @@ -1413,6 +1383,10 @@ data ArchiveError | AEFileError {file :: String, fileError :: String} deriving (Show, Exception) +instance AnyError ChatError where + fromSomeException = ChatError . CEException . show + {-# INLINE fromSomeException #-} + -- | Host (mobile) side of transport to process remote commands and forward notifications data RemoteCtrlSession = RCSessionStarting @@ -1495,50 +1469,10 @@ chatModifyVar' :: (ChatController -> TVar a) -> (a -> a) -> CM' () chatModifyVar' f newValue = asks f >>= atomically . (`modifyTVar'` newValue) {-# INLINE chatModifyVar' #-} -setContactNetworkStatus :: Contact -> NetworkStatus -> CM' () -setContactNetworkStatus Contact {activeConn = Nothing} _ = pure () -setContactNetworkStatus Contact {activeConn = Just Connection {agentConnId}} status = chatModifyVar' connNetworkStatuses $ M.insert agentConnId status - -tryChatError :: CM a -> CM (Either ChatError a) -tryChatError = tryAllErrors mkChatError -{-# INLINE tryChatError #-} - -tryChatError' :: CM a -> CM' (Either ChatError a) -tryChatError' = tryAllErrors' mkChatError -{-# INLINE tryChatError' #-} - -catchChatError :: CM a -> (ChatError -> CM a) -> CM a -catchChatError = catchAllErrors mkChatError -{-# INLINE catchChatError #-} - -catchChatError' :: CM a -> (ChatError -> CM' a) -> CM' a -catchChatError' = catchAllErrors' mkChatError -{-# INLINE catchChatError' #-} - -chatFinally :: CM a -> CM b -> CM a -chatFinally = allFinally mkChatError -{-# INLINE chatFinally #-} - onChatError :: CM a -> CM b -> CM a -a `onChatError` onErr = a `catchChatError` \e -> onErr >> throwError e +a `onChatError` onErr = a `catchAllErrors` \e -> onErr >> throwError e {-# INLINE onChatError #-} -mkChatError :: SomeException -> ChatError -mkChatError = ChatError . CEException . show -{-# INLINE mkChatError #-} - -catchStoreError :: ExceptT StoreError IO a -> (StoreError -> ExceptT StoreError IO a) -> ExceptT StoreError IO a -catchStoreError = catchAllErrors mkStoreError -{-# INLINE catchStoreError #-} - -tryStoreError' :: ExceptT StoreError IO a -> IO (Either StoreError a) -tryStoreError' = tryAllErrors' mkStoreError -{-# INLINE tryStoreError' #-} - -mkStoreError :: SomeException -> StoreError -mkStoreError = SEInternalError . show -{-# INLINE mkStoreError #-} - throwCmdError :: String -> CM a throwCmdError = throwError . ChatError . CECommandError {-# INLINE throwCmdError #-} @@ -1631,7 +1565,7 @@ withAgent :: (AgentClient -> ExceptT AgentErrorType IO a) -> CM a withAgent action = asks smpAgent >>= liftIO . runExceptT . action - >>= liftEither . first (`ChatErrorAgent` Nothing) + >>= liftEither . first (\e -> ChatErrorAgent e (AgentConnId "") Nothing) withAgent' :: (AgentClient -> IO a) -> CM' a withAgent' action = asks smpAgent >>= liftIO . action @@ -1664,13 +1598,7 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CP") ''ConnectionPlan) $(JQ.deriveJSON defaultJSON ''AppFilePathsConfig) -$(JQ.deriveJSON defaultJSON ''ContactSubStatus) - -$(JQ.deriveJSON defaultJSON ''MemberSubStatus) - -$(JQ.deriveJSON defaultJSON ''UserContactSubStatus) - -$(JQ.deriveJSON defaultJSON ''PendingSubStatus) +$(JQ.deriveJSON defaultJSON ''ConnSubResult) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "AE") ''ArchiveError) diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs index 7ad822da91..131b420bd9 100644 --- a/src/Simplex/Chat/Core.hs +++ b/src/Simplex/Chat/Core.hs @@ -30,7 +30,7 @@ import Simplex.Chat.Store.Profiles import Simplex.Chat.Types import Simplex.Chat.Types.Preferences (FeatureAllowed (..), FilesPreference (..), Preferences (..), emptyChatPrefs) import Simplex.Chat.View (ChatResponseEvent, serializeChatError, serializeChatResponse) -import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation (..)) +import Simplex.Messaging.Agent.Store.Shared (MigrationConfig (..), MigrationConfirmation (..)) import Simplex.Messaging.Agent.Store.Common (DBStore, withTransaction) import System.Exit (exitFailure) import System.IO (hFlush, stdout) @@ -38,15 +38,15 @@ import Text.Read (readMaybe) import UnliftIO.Async simplexChatCore :: ChatConfig -> ChatOpts -> (User -> ChatController -> IO ()) -> IO () -simplexChatCore cfg@ChatConfig {confirmMigrations, testView} opts@ChatOpts {coreOptions = CoreChatOpts {dbOptions, logAgent, yesToUpMigrations}, createBot, maintenance} chat = +simplexChatCore cfg@ChatConfig {confirmMigrations, testView, chatHooks} opts@ChatOpts {coreOptions = CoreChatOpts {dbOptions, logAgent, yesToUpMigrations, migrationBackupPath}, createBot, maintenance} chat = case logAgent of Just level -> do setLogLevel level withGlobalLogging logCfg initRun _ -> initRun where - initRun = createChatDatabase dbOptions confirm' >>= either exit run - confirm' = if confirmMigrations == MCConsole && yesToUpMigrations then MCYesUp else confirmMigrations + initRun = createChatDatabase dbOptions migrationConfig >>= either exit run + migrationConfig = MigrationConfig (if confirmMigrations == MCConsole && yesToUpMigrations then MCYesUp else confirmMigrations) migrationBackupPath exit e = do putStrLn $ "Error opening database: " <> show e exitFailure @@ -56,13 +56,15 @@ simplexChatCore cfg@ChatConfig {confirmMigrations, testView} opts@ChatOpts {core cc <- newChatController db u_ cfg opts backgroundMode u <- maybe (createActiveUser cc createBot) pure u_ unless testView $ putStrLn $ "Current user: " <> userStr u + unless maintenance $ forM_ (preStartHook chatHooks) ($ cc) runSimplexChat opts u cc chat runSimplexChat :: ChatOpts -> User -> ChatController -> (User -> ChatController -> IO ()) -> IO () -runSimplexChat ChatOpts {maintenance} u cc chat +runSimplexChat ChatOpts {maintenance} u cc@ChatController {config = ChatConfig {chatHooks}} chat | maintenance = wait =<< async (chat u cc) | otherwise = do a1 <- runReaderT (startChatController True True) cc + forM_ (postStartHook chatHooks) ($ cc) a2 <- async $ chat u cc waitEither_ a1 a2 diff --git a/src/Simplex/Chat/Delivery.hs b/src/Simplex/Chat/Delivery.hs new file mode 100644 index 0000000000..d0a77514eb --- /dev/null +++ b/src/Simplex/Chat/Delivery.hs @@ -0,0 +1,188 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module Simplex.Chat.Delivery where + +import Data.ByteString.Char8 (ByteString) +import Data.Int (Int64) +import Data.Maybe (fromMaybe) +import Data.Time.Clock (UTCTime) +import Simplex.Chat.Messages (GroupChatScopeInfo (..), MessageId) +import Simplex.Chat.Options.DB (FromField (..), ToField (..)) +import Simplex.Chat.Protocol +import Simplex.Chat.Types +import Simplex.Chat.Types.Shared +import Simplex.Messaging.Agent.Store.DB (fromTextField_) +import Simplex.Messaging.Encoding.String + +type DeliveryWorkerKey = (GroupId, DeliveryWorkerScope) + +data DeliveryWorkerScope + = DWSGroup + | DWSMemberSupport + -- | DWSMemberProfileUpdate + deriving (Eq, Ord, Show) + +instance FromField DeliveryWorkerScope where fromField = fromTextField_ textDecode + +instance ToField DeliveryWorkerScope where toField = toField . textEncode + +instance TextEncoding DeliveryWorkerScope where + textDecode = \case + "group" -> Just DWSGroup + "member_support" -> Just DWSMemberSupport + -- "member_profile_update" -> Just DWSMemberProfileUpdate + _ -> Nothing + textEncode = \case + DWSGroup -> "group" + DWSMemberSupport -> "member_support" + -- DWSMemberProfileUpdate -> "member_profile_update" + +data DeliveryJobScope + = DJSGroup {jobSpec :: DeliveryJobSpec} + | DJSMemberSupport {supportGMId :: GroupMemberId} + -- | DJSMemberProfileUpdate + deriving (Show) + +data DeliveryJobSpec + = DJDeliveryJob {includePending :: Bool} + | DJRelayRemoved + deriving (Show) + +data DeliveryJobSpecTag + = DJSTDeliveryJob + | DJSTRelayRemoved + deriving (Show) + +instance FromField DeliveryJobSpecTag where fromField = fromTextField_ textDecode + +instance ToField DeliveryJobSpecTag where toField = toField . textEncode + +instance TextEncoding DeliveryJobSpecTag where + textDecode = \case + "delivery_job" -> Just DJSTDeliveryJob + "relay_removed" -> Just DJSTRelayRemoved + _ -> Nothing + textEncode = \case + DJSTDeliveryJob -> "delivery_job" + DJSTRelayRemoved -> "relay_removed" + +toWorkerScope :: DeliveryJobScope -> DeliveryWorkerScope +toWorkerScope = \case + DJSGroup _ -> DWSGroup + DJSMemberSupport _ -> DWSMemberSupport + -- DJSMemberProfileUpdate -> DWSMemberProfileUpdate + +isRelayRemoved :: DeliveryJobScope -> Bool +isRelayRemoved = \case + DJSGroup {jobSpec} -> case jobSpec of + DJRelayRemoved -> True + _ -> False + _ -> False + +jobScopeImpliedSpec :: DeliveryJobScope -> DeliveryJobSpec +jobScopeImpliedSpec = \case + DJSGroup {jobSpec} -> jobSpec + DJSMemberSupport {} -> DJDeliveryJob {includePending = False} + +jobSpecImpliedPending :: DeliveryJobSpec -> Bool +jobSpecImpliedPending = \case + DJDeliveryJob {includePending} -> includePending + DJRelayRemoved -> True + +infoToDeliveryScope :: GroupInfo -> Maybe GroupChatScopeInfo -> DeliveryJobScope +infoToDeliveryScope GroupInfo {membership} = \case + Nothing -> DJSGroup {jobSpec = DJDeliveryJob {includePending = False}} + Just GCSIMemberSupport {groupMember_} -> + let supportGMId = groupMemberId' $ fromMaybe membership groupMember_ + in DJSMemberSupport {supportGMId} + +memberEventDeliveryScope :: GroupMember -> Maybe DeliveryJobScope +memberEventDeliveryScope m@GroupMember {memberRole, memberStatus} + | memberStatus == GSMemPendingApproval = Nothing + | memberStatus == GSMemPendingReview = Just $ DJSMemberSupport {supportGMId = groupMemberId' m} + | memberRole >= GRModerator = Just DJSGroup {jobSpec = DJDeliveryJob {includePending = True}} + | otherwise = Just DJSGroup {jobSpec = DJDeliveryJob {includePending = False}} + +data NewMessageDeliveryTask = NewMessageDeliveryTask + { messageId :: MessageId, + jobScope :: DeliveryJobScope, + messageFromChannel :: MessageFromChannel + } + deriving (Show) + +data MessageDeliveryTask = MessageDeliveryTask + { taskId :: Int64, + jobScope :: DeliveryJobScope, + senderGMId :: GroupMemberId, + senderMemberId :: MemberId, + senderMemberName :: ContactName, + brokerTs :: UTCTime, + chatMessage :: ChatMessage 'Json, + messageFromChannel :: MessageFromChannel + } + deriving (Show) + +deliveryTaskId :: MessageDeliveryTask -> Int64 +deliveryTaskId = taskId + +data DeliveryTaskStatus + = DTSNew -- created for delivery task worker to pick up and convert into a delivery job + | DTSProcessed -- processed by delivery task worker, delivery job created, task can be deleted + | DTSError -- permanent error + deriving (Show) + +instance FromField DeliveryTaskStatus where fromField = fromTextField_ textDecode + +instance ToField DeliveryTaskStatus where toField = toField . textEncode + +instance TextEncoding DeliveryTaskStatus where + textDecode = \case + "new" -> Just DTSNew + "processed" -> Just DTSProcessed + "error" -> Just DTSError + _ -> Nothing + textEncode = \case + DTSNew -> "new" + DTSProcessed -> "processed" + DTSError -> "error" + +data MessageDeliveryJob = MessageDeliveryJob + { jobId :: Int64, + jobScope :: DeliveryJobScope, + singleSenderGMId_ :: Maybe GroupMemberId, -- Just for single-sender deliveries, Nothing for multi-sender deliveries + body :: ByteString, + cursorGMId_ :: Maybe GroupMemberId + } + deriving (Show) + +deliveryJobId :: MessageDeliveryJob -> Int64 +deliveryJobId = jobId + +data DeliveryJobStatus + = DJSPending -- created for delivery job worker to pick up + | DJSComplete -- complete by delivery job worker, job can be deleted + | DJSError -- permanent error + deriving (Show) + +instance FromField DeliveryJobStatus where fromField = fromTextField_ textDecode + +instance ToField DeliveryJobStatus where toField = toField . textEncode + +instance TextEncoding DeliveryJobStatus where + textDecode = \case + "pending" -> Just DJSPending + "complete" -> Just DJSComplete + "error" -> Just DJSError + _ -> Nothing + textEncode = \case + DJSPending -> "pending" + DJSComplete -> "complete" + DJSError -> "error" + +-- data MemberProfileUpdateTask = undefined + +-- data MemberProfileUpdateJob = undefined diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 4ae58d8a29..630cad4e70 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -17,7 +17,6 @@ module Simplex.Chat.Library.Commands where -import qualified Codec.Compression.Zstd as Z1 import Control.Applicative (optional, (<|>)) import Control.Concurrent.STM (retry) import Control.Logger.Simple @@ -74,6 +73,7 @@ import Simplex.Chat.Store import Simplex.Chat.Store.AppSettings import Simplex.Chat.Store.ContactRequest import Simplex.Chat.Store.Connections +import Simplex.Chat.Store.Delivery import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Files import Simplex.Chat.Store.Groups @@ -87,7 +87,7 @@ import Simplex.Chat.Types.Shared import Simplex.Chat.Util (liftIOEither, zipWith3') import qualified Simplex.Chat.Util as U import Simplex.FileTransfer.Description (FileDescriptionURI (..), maxFileSize, maxFileSizeHard) -import Simplex.Messaging.Agent as Agent +import Simplex.Messaging.Agent import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles) import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.Store.Entity @@ -96,7 +96,6 @@ import Simplex.Messaging.Agent.Store.Shared (upMigration) import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Agent.Store.Interface (getCurrentMigrations) import Simplex.Messaging.Client (NetworkConfig (..), NetworkRequestMode (..), NetworkTimeout (..), SMPWebPortServers (..), SocksMode (SMAlways), textToHostMode) -import Simplex.Messaging.Compression (compressionLevel) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF @@ -104,7 +103,6 @@ import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport (..), patt import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (base64P) import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), MsgFlags (..), NtfServer, ProtoServerWithAuth (..), ProtocolServer, ProtocolType (..), ProtocolTypeI (..), SProtocolType (..), SubscriptionMode (..), UserProtocol, userProtocol) -import qualified Simplex.Messaging.Protocol as SMP import Simplex.Messaging.ServiceScheme (ServiceScheme (..)) import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport.Client (defaultSocksProxyWithAuth) @@ -166,10 +164,20 @@ startChatController mainApp enableSndFiles = do asks smpAgent >>= liftIO . resumeAgentClient unless mainApp $ chatWriteVar' subscriptionMode SMOnlyCreate users <- fromRight [] <$> runExceptT (withFastStore' getUsers) + runExceptT (syncConnections' users) >>= \case + Left e -> liftIO $ putStrLn $ "Error synchronizing connections: " <> show e + Right _ -> pure () restoreCalls s <- asks agentAsync readTVarIO s >>= maybe (start s users) (pure . fst) where + syncConnections' users = + whenM (withFastStore' shouldSyncConnections) $ do + let aUserIds = map aUserId users + connIds <- concat <$> forM users getConnsToSub + (userDiff, connDiff) <- withAgent (\a -> syncConnections a aUserIds connIds) + withFastStore' setConnectionsSyncTs + toView $ CEvtConnectionsDiff (AgentUserId <$> userDiff) (AgentConnId <$> connDiff) start s users = do a1 <- async agentSubscriber a2 <- @@ -181,6 +189,7 @@ startChatController mainApp enableSndFiles = do then do startXFTP xftpStartWorkers void $ forkIO $ startFilesToReceive users + startDeliveryWorkers startCleanupManager void $ forkIO $ mapM_ startExpireCIs users else when enableSndFiles $ startXFTP xftpStartSndWorkers @@ -190,6 +199,10 @@ startChatController mainApp enableSndFiles = do runExceptT (withAgent $ \a -> startWorkers a tmp) >>= \case Left e -> liftIO $ putStrLn $ "Error starting XFTP workers: " <> show e Right _ -> pure () + startDeliveryWorkers = + runExceptT (startDeliveryTaskWorkers >> startDeliveryJobWorkers) >>= \case + Left e -> liftIO $ putStrLn $ "Error starting delivery workers: " <> show e + Right _ -> pure () startCleanupManager = do cleanupAsync <- asks cleanupManagerAsync readTVarIO cleanupAsync >>= \case @@ -207,15 +220,19 @@ startChatController mainApp enableSndFiles = do ttlCount <- getChatTTLCount db user pure $ ttl > 0 || ttlCount > 0 +getConnsToSub :: User -> CM [ConnId] +getConnsToSub user = + withFastStore' $ \db -> do + ctConnIds <- getContactConnsToSub db user False + uclConnIds <- getUCLConnsToSub db user False + memberConnIds <- getMemberConnsToSub db user False + pendingConnIds <- getPendingConnsToSub db user False + pure $ ctConnIds <> uclConnIds <> memberConnIds <> pendingConnIds + subscribeUsers :: Bool -> [User] -> CM' () subscribeUsers onlyNeeded users = do - let (us, us') = partition activeUser users - vr <- chatVersionRange' - subscribe vr us - subscribe vr us' - where - subscribe :: VersionRangeChat -> [User] -> CM' () - subscribe vr = mapM_ $ runExceptT . subscribeUserConnections vr onlyNeeded Agent.subscribeConnections + let activeUserId_ = (\User {agentUserId = AgentUserId uId} -> uId) <$> find activeUser users + withAgent (\a -> subscribeAllConnections a onlyNeeded activeUserId_) `catchAllErrors'` eToView' startFilesToReceive :: [User] -> CM' () startFilesToReceive users = do @@ -230,7 +247,7 @@ startReceiveUserFiles :: User -> CM () startReceiveUserFiles user = do filesToReceive <- withStore' (`getRcvFilesToReceive` user) forM_ filesToReceive $ \ft -> - flip catchChatError eToView $ + flip catchAllErrors eToView $ toView =<< receiveFileEvt' user ft False Nothing Nothing restoreCalls :: CM' () @@ -300,7 +317,7 @@ handleCommandError a = runExceptT a `E.catches` ioErrors where ioErrors = [ E.Handler $ \(e :: ExitCode) -> E.throwIO e, - E.Handler $ pure . Left . mkChatError + E.Handler $ pure . Left . fromSomeException ] parseChatCommand :: ByteString -> Either String ChatCommand @@ -324,7 +341,7 @@ processChatCommand vr nm = \case user <- withFastStore $ \db -> do user <- createUserRecordAt db (AgentUserId auId) p True ts mapM_ (setUserServers db user ts) uss - createPresetContactCards db user `catchStoreError` \_ -> pure () + createPresetContactCards db user `catchAllErrors` \_ -> pure () createNoteFolder db user pure user atomically . writeTVar u $ Just user @@ -363,7 +380,7 @@ processChatCommand vr nm = \case chatWriteVar currentUser $ Just user'' pure $ CRActiveUser user'' SetActiveUser uName viewPwd_ -> do - tryChatError (withFastStore (`getUserIdByName` uName)) >>= \case + tryAllErrors (withFastStore (`getUserIdByName` uName)) >>= \case Left _ -> throwChatError CEUserUnknown Right userId -> processChatCommand vr nm $ APISetActiveUser userId viewPwd_ SetAllContactReceipts onOff -> withUser $ \_ -> withFastStore' (`updateAllContactReceipts` onOff) >> ok_ @@ -445,6 +462,12 @@ processChatCommand vr nm = \case stopRemoteCtrl lift $ withAgent' (`suspendAgent` t) ok_ + ShowConnectionsDiff showIds -> do + users <- withFastStore' getUsers + let aUserIds = map aUserId users + connIds <- concat <$> forM users getConnsToSub + (userDiff, connDiff) <- withAgent (\a -> compareConnections a aUserIds connIds) + pure $ CRConnectionsDiff showIds (AgentUserId <$> userDiff) (AgentConnId <$> connDiff) ResubscribeAllConnections -> withStore' getUsers >>= lift . subscribeUsers False >> ok_ -- has to be called before StartChat SetTempFolder tf -> do @@ -517,13 +540,36 @@ processChatCommand vr nm = \case pure $ CRApiChat user (AChat SCTDirect directChat) navInfo CTGroup -> do (groupChat, navInfo) <- withFastStore (\db -> getGroupChat db vr user cId scope_ contentFilter pagination search) - pure $ CRApiChat user (AChat SCTGroup groupChat) navInfo + groupChat' <- checkSupportChatAttention user groupChat + pure $ CRApiChat user (AChat SCTGroup groupChat') navInfo CTLocal -> do when (isJust contentFilter) $ throwCmdError "content filter not supported" (localChat, navInfo) <- withFastStore (\db -> getLocalChat db user cId pagination search) pure $ CRApiChat user (AChat SCTLocal localChat) navInfo CTContactRequest -> throwCmdError "not implemented" CTContactConnection -> throwCmdError "not supported" + where + checkSupportChatAttention :: User -> Chat 'CTGroup -> CM (Chat 'CTGroup) + checkSupportChatAttention user groupChat@Chat {chatInfo, chatItems} = + case chatInfo of + GroupChat gInfo (Just GCSIMemberSupport {groupMember_ = Just scopeMem@GroupMember {supportChat = Just suppChat}}) -> do + case correctedMemAttention (groupMemberId' scopeMem) suppChat chatItems of + Just newMemAttention -> do + (gInfo', scopeMem') <- + withFastStore' $ \db -> setSupportChatMemberAttention db vr user gInfo scopeMem newMemAttention + pure (groupChat {chatInfo = GroupChat gInfo' (Just $ GCSIMemberSupport (Just scopeMem'))} :: Chat 'CTGroup) + Nothing -> pure groupChat + _ -> pure groupChat + where + correctedMemAttention :: GroupMemberId -> GroupSupportChat -> [CChatItem 'CTGroup] -> Maybe Int64 + correctedMemAttention scopeGMId GroupSupportChat {memberAttention} items = + let numNewFromMember = fromIntegral . length . takeWhile newFromMember $ reverse items + in if numNewFromMember == memberAttention then Nothing else Just numNewFromMember + where + newFromMember :: CChatItem 'CTGroup -> Bool + newFromMember (CChatItem _ ChatItem {chatDir = CIGroupRcv m, meta = CIMeta {itemStatus = CISRcvNew}}) = + groupMemberId' m == scopeGMId + newFromMember _ = False APIGetChatItems pagination search -> withUser $ \user -> do chatItems <- withFastStore $ \db -> getAllChatItems db vr user pagination search pure $ CRChatItems user Nothing chatItems @@ -584,7 +630,6 @@ processChatCommand vr nm = \case gInfo <- withFastStore $ \db -> getGroupInfo db vr user gId let mc = MCReport reportText reportReason cm = ComposedMessage {fileSource = Nothing, quotedItemId = Just reportedItemId, msgContent = mc, mentions = M.empty} - -- TODO [knocking] reports sent to support scope may be wrong sendGroupContentMessages user gInfo (Just $ GCSMemberSupport Nothing) False Nothing [composedMessageReq cm] ReportMessage {groupName, contactName_, reportReason, reportedMessage} -> withUser $ \user -> do gId <- withFastStore $ \db -> getGroupIdByName db user groupName @@ -629,7 +674,8 @@ processChatCommand vr nm = \case CChatItem SMDSnd ci@ChatItem {meta = CIMeta {itemSharedMsgId, itemTimed, itemLive, editable}, content = ciContent} -> do case (ciContent, itemSharedMsgId, editable) of (CISndMsgContent oldMC, Just itemSharedMId, True) -> do - (chatScopeInfo, recipients) <- getGroupRecipients vr user gInfo scope groupKnockingVersion + chatScopeInfo <- mapM (getChatScopeInfo vr user) scope + recipients <- getGroupRecipients vr user gInfo chatScopeInfo groupKnockingVersion let changed = mc /= oldMC if changed || fromMaybe False itemLive then do @@ -684,15 +730,14 @@ processChatCommand vr nm = \case CTGroup -> withGroupLock "deleteChatItem" chatId $ do (gInfo, items) <- getCommandGroupChatItems user chatId itemIds -- TODO [knocking] check scope for all items? + chatScopeInfo <- mapM (getChatScopeInfo vr user) scope deletions <- case mode of CIDMInternal -> do - chatScopeInfo <- mapM (getChatScopeInfo vr user) scope deleteGroupCIs user gInfo chatScopeInfo items Nothing =<< liftIO getCurrentTime CIDMInternalMark -> do - chatScopeInfo <- mapM (getChatScopeInfo vr user) scope markGroupCIsDeleted user gInfo chatScopeInfo items Nothing =<< liftIO getCurrentTime CIDMBroadcast -> do - (chatScopeInfo, recipients) <- getGroupRecipients vr user gInfo scope groupKnockingVersion + recipients <- getGroupRecipients vr user gInfo chatScopeInfo groupKnockingVersion assertDeletable items assertUserGroupRole gInfo GRObserver -- can still delete messages sent earlier let msgIds = itemsMsgIds items @@ -775,7 +820,8 @@ processChatCommand vr nm = \case (g@GroupInfo {membership}, CChatItem md ci) <- withFastStore $ \db -> do g <- getGroupInfo db vr user chatId (g,) <$> getGroupCIWithReactions db user g itemId - (chatScopeInfo, recipients) <- getGroupRecipients vr user g scope groupKnockingVersion + chatScopeInfo <- mapM (getChatScopeInfo vr user) scope + recipients <- getGroupRecipients vr user g chatScopeInfo groupKnockingVersion case ci of ChatItem {meta = CIMeta {itemSharedMsgId = Just itemSharedMId}} -> do unless (groupFeatureAllowed SGFReactions g) $ @@ -998,7 +1044,7 @@ processChatCommand vr nm = \case pure $ prefix <> formattedDate <> ext APIUserRead userId -> withUserId userId $ \user -> withFastStore' (`setUserChatsRead` user) >> ok user UserRead -> withUser $ \User {userId} -> processChatCommand vr nm $ APIUserRead userId - APIChatRead chatRef@(ChatRef cType chatId scope) -> withUser $ \_ -> case cType of + APIChatRead chatRef@(ChatRef cType chatId scope_) -> withUser $ \_ -> case cType of CTDirect -> do user <- withFastStore $ \db -> getUserByContactId db chatId ts <- liftIO getCurrentTime @@ -1014,12 +1060,23 @@ processChatCommand vr nm = \case gInfo <- getGroupInfo db vr user chatId pure (user, gInfo) ts <- liftIO getCurrentTime - timedItems <- withFastStore' $ \db -> do - timedItems <- getGroupUnreadTimedItems db user chatId - updateGroupChatItemsRead db user gInfo scope - setGroupChatItemsDeleteAt db user chatId timedItems ts - forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt - ok user + case scope_ of + Nothing -> do + timedItems <- withFastStore' $ \db -> do + timedItems <- getGroupUnreadTimedItems db user chatId Nothing + updateGroupChatItemsRead db user gInfo + setGroupChatItemsDeleteAt db user chatId timedItems ts + forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt + ok user + Just scope -> do + scopeInfo <- getChatScopeInfo vr user scope + (gInfo', m', timedItems) <- withFastStore' $ \db -> do + timedItems <- getGroupUnreadTimedItems db user chatId (Just scope) + (gInfo', m') <- updateSupportChatItemsRead db vr user gInfo scopeInfo + timedItems' <- setGroupChatItemsDeleteAt db user chatId timedItems ts + pure (gInfo', m', timedItems') + forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt + pure $ CRMemberSupportChatRead user gInfo' m' CTLocal -> do user <- withFastStore $ \db -> getUserByNoteFolderId db chatId withFastStore' $ \db -> updateLocalChatItemsRead db user chatId @@ -1061,8 +1118,8 @@ processChatCommand vr nm = \case -- TODO [knocking] set support chat as unread? CTGroup | isNothing scope -> do withFastStore $ \db -> do - Group {groupInfo} <- getGroup db vr user chatId - liftIO $ updateGroupUnreadChat db user groupInfo unreadChat + gInfo <- getGroupInfo db vr user chatId + liftIO $ updateGroupUnreadChat db user gInfo unreadChat ok user CTLocal -> do withFastStore $ \db -> do @@ -1101,7 +1158,7 @@ processChatCommand vr nm = \case where sendDelDeleteConns ct notify = do let doSendDel = contactReady ct && contactActive ct && notify - when doSendDel $ void (sendDirectContactMessage user ct XDirectDel) `catchChatError` const (pure ()) + when doSendDel $ void (sendDirectContactMessage user ct XDirectDel) `catchAllErrors` const (pure ()) contactConnIds <- map aConnId <$> withFastStore' (\db -> getContactConnections db vr userId ct) deleteAgentConnectionsAsync' contactConnIds doSendDel CTContactConnection -> withConnectionLock "deleteChat contactConnection" chatId $ do @@ -1110,7 +1167,7 @@ processChatCommand vr nm = \case withFastStore' $ \db -> deletePendingContactConnection db userId chatId pure $ CRContactConnectionDeleted user conn CTGroup | isNothing scope -> do - Group gInfo@GroupInfo {membership} members <- withFastStore $ \db -> getGroup db vr user chatId + gInfo@GroupInfo {membership} <- withFastStore $ \db -> getGroupInfo db vr user chatId let GroupMember {memberRole = membershipMemRole} = membership let isOwner = membershipMemRole == GROwner canDelete = isOwner || not (memberCurrent membership) @@ -1118,17 +1175,25 @@ processChatCommand vr nm = \case filesInfo <- withFastStore' $ \db -> getGroupFileInfo db user gInfo withGroupLock "deleteChat group" chatId $ do deleteCIFiles user filesInfo + (members, recipients) <- getRecipients gInfo let doSendDel = memberActive membership && isOwner - recipients = filter memberCurrentOrPending members when doSendDel . void $ sendGroupMessage' user gInfo recipients XGrpDel deleteGroupLinkIfExists user gInfo deleteMembersConnections' user members doSendDel - updateCIGroupInvitationStatus user gInfo CIGISRejected `catchChatError` \_ -> pure () + updateCIGroupInvitationStatus user gInfo CIGISRejected `catchAllErrors` \_ -> pure () withFastStore' $ \db -> deleteGroupChatItems db user gInfo withFastStore' $ \db -> cleanupHostGroupLinkConn db user gInfo withFastStore' $ \db -> deleteGroupMembers db user gInfo withFastStore' $ \db -> deleteGroup db user gInfo pure $ CRGroupDeletedUser user gInfo + where + getRecipients gInfo@GroupInfo {useRelays} + | isTrue useRelays = do + relays <- withFastStore' $ \db -> getGroupRelays db vr user gInfo + pure (relays, relays) + | otherwise = do + ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo + pure (ms, filter memberCurrentOrPending ms) _ -> throwCmdError "not supported" APIClearChat (ChatRef cType chatId scope) -> withUser $ \user@User {userId} -> case cType of CTDirect -> do @@ -1303,8 +1368,6 @@ processChatCommand vr nm = \case user <- getUserByContactId db contactId contact <- getContact db vr user contactId pure RcvCallInvitation {user, contact, callType = peerCallType, sharedKey, callUUID, callTs} - APIGetNetworkStatuses -> withUser $ \_ -> - CRNetworkStatuses Nothing . map (uncurry ConnNetworkStatus) . M.toList <$> chatReadVar connNetworkStatuses APICallStatus contactId receivedStatus -> withCurrentCall contactId $ \user ct call -> updateCallItemStatus user ct call receivedStatus Nothing $> Just call @@ -1467,7 +1530,7 @@ processChatCommand vr nm = \case oldTTL = fromMaybe globalTTL oldTTL_ when (newTTL > 0 && (newTTL < oldTTL || oldTTL == 0)) $ do lift $ setExpireCIFlag user False - expireChat user globalTTL `catchChatError` eToView + expireChat user globalTTL `catchAllErrors` eToView lift $ setChatItemsExpiration user globalTTL ttlCount ok user where @@ -1534,12 +1597,17 @@ processChatCommand vr nm = \case ok user CTGroup | isNothing scope -> do ms <- withFastStore $ \db -> do - Group _ ms <- getGroup db vr user chatId + gInfo <- getGroupInfo db vr user chatId + ms <- liftIO $ getMembers db gInfo liftIO $ updateGroupSettings db user chatId chatSettings pure ms forM_ (filter memberActive ms) $ \m -> forM_ (memberConnId m) $ \connId -> - withAgent (\a -> toggleConnectionNtfs a connId $ chatHasNtfs chatSettings) `catchChatError` eToView + withAgent (\a -> toggleConnectionNtfs a connId $ chatHasNtfs chatSettings) `catchAllErrors` eToView ok user + where + getMembers db gInfo@GroupInfo {useRelays} + | isTrue useRelays = getGroupRelays db vr user gInfo + | otherwise = getGroupMembers db vr user gInfo _ -> throwCmdError "not supported" APISetMemberSettings gId gMemberId settings -> withUser $ \user -> do m <- withFastStore $ \db -> do @@ -1562,9 +1630,8 @@ processChatCommand vr nm = \case case activeConn of Just conn -> getConnQueueInfo user conn Nothing -> throwChatError $ CEContactNotActive ct - APIGroupInfo gId -> withUser $ \user -> do - (g, s) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> liftIO (getGroupSummary db user gId) - pure $ CRGroupInfo user g s + APIGroupInfo gId -> withUser $ \user -> + CRGroupInfo user <$> withFastStore (\db -> getGroupInfo db vr user gId) APIGroupMemberInfo gId gMemberId -> withUser $ \user -> do (g, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db vr user gId gMemberId connectionStats <- mapM (withAgent . flip getConnectionServers) (memberConnId m) @@ -1707,8 +1774,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 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 @@ -1746,11 +1814,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 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 @@ -1829,7 +1897,7 @@ processChatCommand vr nm = \case case preparedContact of Nothing -> throwCmdError "contact doesn't have link to connect" Just PreparedContact {connLinkToConnect = ACCL SCMInvitation ccLink} -> do - (_, customUserProfile) <- connectViaInvitation user incognito ccLink (Just contactId) `catchChatError` \e -> do + (_, customUserProfile) <- connectViaInvitation user incognito ccLink (Just contactId) `catchAllErrors` \e -> do -- get updated contact, in case connection was started - in UI it would lock ability to change -- user or incognito profile for contact, in case server received request while client got network error ct' <- withFastStore $ \db -> getContact db vr user contactId @@ -1852,7 +1920,7 @@ processChatCommand vr nm = \case smId <- getSharedMsgId withFastStore' $ \db -> setRequestSharedMsgIdForContact db contactId smId pure (smId, mc) - r <- connectViaContact user (Just $ PCEContact ct) incognito ccLink welcomeSharedMsgId msg_ `catchChatError` \e -> do + r <- connectViaContact user (Just $ PCEContact ct) incognito ccLink welcomeSharedMsgId msg_ `catchAllErrors` \e -> do -- get updated contact, in case connection was started - in UI it would lock ability to change -- user or incognito profile for contact, in case server received request while client got network error ct' <- withFastStore $ \db -> getContact db vr user contactId @@ -1880,7 +1948,7 @@ processChatCommand vr nm = \case smId <- getSharedMsgId withFastStore' $ \db -> setRequestSharedMsgIdForGroup db groupId smId pure (smId, mc) - r <- connectViaContact user (Just $ PCEGroup gInfo hostMember) incognito connLinkToConnect welcomeSharedMsgId msg_ `catchChatError` \e -> do + r <- connectViaContact user (Just $ PCEGroup gInfo hostMember) incognito connLinkToConnect welcomeSharedMsgId msg_ `catchAllErrors` \e -> do -- get updated group info, in case connection was started (connLinkPreparedConnection) - in UI it would lock ability to change -- user or incognito profile for group or business chat, in case server received request while client got network error gInfo' <- withFastStore $ \db -> getGroupInfo db vr user groupId @@ -1908,7 +1976,7 @@ processChatCommand vr nm = \case CVRSentInvitation conn incognitoProfile -> pure $ CRSentInvitation user (mkPendingContactConnection conn Nothing) incognitoProfile APIConnect _ _ Nothing -> throwChatError CEInvalidConnReq Connect incognito (Just cLink@(ACL m cLink')) -> withUser $ \user -> do - (ccLink, plan) <- connectPlan user cLink `catchChatError` \e -> case cLink' of CLFull cReq -> pure (ACCL m (CCLink cReq Nothing), CPInvitationLink (ILPOk Nothing)); _ -> throwError e + (ccLink, plan) <- connectPlan user cLink `catchAllErrors` \e -> case cLink' of CLFull cReq -> pure (ACCL m (CCLink cReq Nothing), CPInvitationLink (ILPOk Nothing)); _ -> throwError e connectWithPlan user incognito ccLink plan Connect _ Nothing -> throwChatError CEInvalidConnReq APIConnectContactViaAddress userId incognito contactId -> withUserId userId $ \user -> do @@ -1919,14 +1987,14 @@ processChatCommand vr nm = \case (cReq, _cData) <- getShortLinkConnReq user sLnk pure $ CCLink cReq $ Just sLnk Nothing -> throwCmdError "no address in contact profile" - connectContactViaAddress user incognito ct ccLink `catchChatError` \e -> do + connectContactViaAddress user incognito ct ccLink `catchAllErrors` \e -> do -- get updated contact, in case connection was started - in UI it would lock ability to change incognito choice -- on next connection attempt, in case server received request while client got network error ct' <- withFastStore $ \db -> getContact db vr user contactId toView $ CEvtChatInfoUpdated user (AChatInfo SCTDirect $ DirectChat ct') throwError e ConnectSimplex incognito -> withUser $ \user -> do - plan <- contactRequestPlan user adminContactReq Nothing `catchChatError` const (pure $ CPContactAddress (CAPOk Nothing)) + plan <- contactRequestPlan user adminContactReq Nothing `catchAllErrors` const (pure $ CPContactAddress (CAPOk Nothing)) connectWithPlan user incognito (ACCL SCMContact (CCLink adminContactReq Nothing)) plan DeleteContact cName cdm -> withContactName cName $ \ctId -> APIDeleteChat (ChatRef CTDirect ctId Nothing) cdm ClearContact cName -> withContactName cName $ \chatId -> APIClearChat $ ChatRef CTDirect chatId Nothing @@ -1941,8 +2009,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 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' @@ -2049,7 +2118,7 @@ processChatCommand vr nm = \case case memberContactId m of Nothing -> do g <- withFastStore $ \db -> getGroupInfo db vr user gId - unless (groupFeatureMemberAllowed SGFDirectMessages (membership g) g) $ throwCmdError "direct messages not allowed" + unless (groupFeatureUserAllowed SGFDirectMessages g) $ throwCmdError "direct messages not allowed" toView $ CEvtNoMemberContactCreating user g m processChatCommand vr nm (APICreateMemberContact gId mId) >>= \case CRNewMemberContact _ ct@Contact {contactId} _ _ -> do @@ -2162,7 +2231,7 @@ processChatCommand vr nm = \case gVar <- asks random subMode <- chatReadVar subscriptionMode -- TODO [certs rcv] - (agentConnId, (CCLink cReq _, _serviceId)) <- withAgent $ \a -> createConnection a nm (aUserId user) True SCMInvitation Nothing Nothing IKPQOff subMode + (agentConnId, (CCLink cReq _, _serviceId)) <- withAgent $ \a -> createConnection a nm (aUserId user) True False SCMInvitation Nothing Nothing IKPQOff subMode member <- withFastStore $ \db -> createNewContactMember db gVar user gInfo contact memRole agentConnId cReq subMode sendInvitation member cReq pure $ CRSentGroupInvitation user gInfo contact member @@ -2200,12 +2269,12 @@ processChatCommand vr nm = \case -- MFAll is default for new groups unless (enableNtfs == MFAll) $ updateGroupSettings db user groupId chatSettings {enableNtfs} void (withAgent $ \a -> joinConnection a nm (aUserId user) agentConnId (enableNtfs /= MFNone) connRequest dm PQSupportOff subMode) - `catchChatError` \e -> do + `catchAllErrors` \e -> do withFastStore' $ \db -> do updateGroupMemberStatus db userId fromMember GSMemInvited updateGroupMemberStatus db userId membership GSMemInvited throwError e - updateCIGroupInvitationStatus user g CIGISAccepted `catchChatError` eToView + updateCIGroupInvitationStatus user g CIGISAccepted `catchAllErrors` eToView pure $ CRUserAcceptedGroupSent user g {membership = membership {memberStatus = GSMemAccepted}} Nothing Nothing -> throwChatError $ CEContactNotActive ct APIAcceptMember groupId gmId role -> withUser $ \user@User {userId} -> do @@ -2275,6 +2344,7 @@ processChatCommand vr nm = \case pure $ CRMemberSupportChatDeleted user gInfo' m' APIMembersRole groupId memberIds newRole -> withUser $ \user -> withGroupLock "memberRole" groupId $ do + -- TODO [channels fwd] possible optimization is to read only required members + relays g@(Group gInfo members) <- withFastStore $ \db -> getGroup db vr user groupId when (selfSelected gInfo) $ throwCmdError "can't change role for self" let (invitedMems, currentMems, unchangedMems, maxRole, anyAdmin, anyPending) = selectMembers members @@ -2308,7 +2378,7 @@ processChatCommand vr nm = \case changeRoleInvitedMems :: User -> GroupInfo -> [GroupMember] -> CM ([ChatError], [GroupMember]) changeRoleInvitedMems user gInfo memsToChange = do -- not batched, as we need to send different invitations to different connections anyway - mems_ <- forM memsToChange $ \m -> (Right <$> changeRole m) `catchChatError` (pure . Left) + mems_ <- forM memsToChange $ \m -> (Right <$> changeRole m) `catchAllErrors` (pure . Left) pure $ partitionEithers mems_ where changeRole :: GroupMember -> CM GroupMember @@ -2343,8 +2413,10 @@ processChatCommand vr nm = \case pure (m :: GroupMember) {memberRole = newRole} APIBlockMembersForAll groupId memberIds blockFlag -> withUser $ \user -> withGroupLock "blockForAll" groupId $ do + -- TODO [channels fwd] possible optimization is to read only required members + relays Group gInfo members <- withFastStore $ \db -> getGroup db vr user groupId when (selfSelected gInfo) $ throwCmdError "can't block/unblock self" + -- TODO [channels fwd] consider sending restriction to all members (remove filtering), as we do in delivery jobs let (blockMems, remainingMems, maxRole, anyAdmin, anyPending) = selectMembers members when (length blockMems /= length memberIds) $ throwChatError CEGroupMemberNotFound when (length memberIds > 1 && anyAdmin) $ throwCmdError "can't block/unblock multiple members when admins selected" @@ -2389,6 +2461,7 @@ processChatCommand vr nm = \case in NewSndChatItemData msg content ts M.empty Nothing Nothing Nothing APIRemoveMembers {groupId, groupMemberIds, withMessages} -> withUser $ \user -> withGroupLock "removeMembers" groupId $ do + -- TODO [channels fwd] possible optimization is to read only required members + relays Group gInfo members <- withFastStore $ \db -> getGroup db vr user groupId let (count, invitedMems, pendingApprvMems, pendingRvwMems, currentMems, maxRole, anyAdmin) = selectMembers gmIds members gmIds = S.fromList $ L.toList groupMemberIds @@ -2449,19 +2522,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. @@ -2470,14 +2549,14 @@ processChatCommand vr nm = \case void $ deleteOrUpdateMemberRecordIO db user gInfo m pure m {memberStatus = GSMemRemoved} deleteMessages user gInfo@GroupInfo {membership} ms - | groupFeatureMemberAllowed SGFFullDelete membership gInfo = deleteGroupMembersCIs user gInfo ms membership + | groupFeatureUserAllowed SGFFullDelete gInfo = deleteGroupMembersCIs user gInfo ms membership | otherwise = markGroupMembersCIsDeleted user gInfo ms membership APILeaveGroup groupId -> withUser $ \user@User {userId} -> do - Group gInfo@GroupInfo {membership} members <- withFastStore $ \db -> getGroup db vr user groupId + gInfo@GroupInfo {membership} <- withFastStore $ \db -> getGroupInfo db vr user groupId filesInfo <- withFastStore' $ \db -> getGroupFileInfo db user gInfo withGroupLock "leaveGroup" groupId $ do cancelFilesInProgress user filesInfo - let recipients = filter memberCurrentOrPending members + (members, recipients) <- getRecipients user gInfo msg <- sendGroupMessage' user gInfo recipients XGrpLeave (gInfo', scopeInfo) <- mkLocalGroupChatScope gInfo ci <- saveSndChatItem user (CDGroupSnd gInfo' scopeInfo) msg (CISndGroupEvent SGEUserLeft) @@ -2488,6 +2567,14 @@ processChatCommand vr nm = \case deleteMembersConnections' user members True withFastStore' $ \db -> updateGroupMemberStatus db userId membership GSMemLeft pure $ CRLeftMemberUser user gInfo' {membership = membership {memberStatus = GSMemLeft}} + where + getRecipients user gInfo@GroupInfo {useRelays} + | isTrue useRelays = do + relays <- withFastStore' $ \db -> getGroupRelays db vr user gInfo + pure (relays, relays) + | otherwise = do + ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo + pure (ms, filter memberCurrentOrPending ms) APIListMembers groupId -> withUser $ \user -> CRGroupMembers user <$> withFastStore (\db -> getGroup db vr user groupId) -- -- validate: prohibit to delete/archive if member is pending (has to communicate approval or rejection) @@ -2530,13 +2617,13 @@ processChatCommand vr nm = \case let memberSupportChats = filter (isJust . supportChat) members pure $ CRMemberSupportChats user gInfo memberSupportChats APIListGroups userId contactId_ search_ -> withUserId userId $ \user -> - CRGroupsList user <$> withFastStore' (\db -> getUserGroupsWithSummary db vr user contactId_ search_) + CRGroupsList user <$> withFastStore' (\db -> getBaseGroupDetails db vr user contactId_ search_) ListGroups cName_ search_ -> withUser $ \user@User {userId} -> do ct_ <- forM cName_ $ \cName -> withFastStore $ \db -> getContactByName db vr user cName processChatCommand vr nm $ APIListGroups userId (contactId' <$> ct_) search_ APIUpdateGroupProfile groupId p' -> withUser $ \user -> do - g <- withFastStore $ \db -> getGroup db vr user groupId - runUpdateGroupProfile user g p' + gInfo <- withFastStore $ \db -> getGroupInfo db vr user groupId + runUpdateGroupProfile user gInfo p' UpdateGroupNames gName GroupProfile {displayName, fullName, shortDescr} -> updateGroupProfileByName gName $ \p -> p {displayName, fullName, shortDescr} ShowGroupProfile gName -> withUser $ \user -> @@ -2552,9 +2639,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 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 @@ -2582,11 +2670,12 @@ processChatCommand vr nm = \case gInfo <- getGroupInfo db vr user groupId gLink <- getGroupLink db user gInfo pure (gInfo, gLink) - setGroupLinkData user gInfo gLink + gLink' <- setGroupLinkData nm user gInfo gLink + pure $ CRGroupLink user gInfo gLink' APICreateMemberContact gId gMemberId -> withUser $ \user -> do (g, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db vr user gId gMemberId assertUserGroupRole g GRAuthor - unless (groupFeatureMemberAllowed SGFDirectMessages (membership g) g) $ throwCmdError "direct messages not allowed" + unless (groupFeatureUserAllowed SGFDirectMessages g) $ throwCmdError "direct messages not allowed" case memberConn m of Just mConn@Connection {peerChatVRange} -> do unless (maxVersion peerChatVRange >= groupDirectInvVersion) $ throwChatError CEPeerChatVRangeIncompatible @@ -2594,12 +2683,11 @@ processChatCommand vr nm = \case subMode <- chatReadVar subscriptionMode -- TODO PQ should negotitate contact connection with PQSupportOn? -- TODO [certs rcv] - (connId, (CCLink cReq _, _serviceId)) <- withAgent $ \a -> createConnection a nm (aUserId user) True SCMInvitation Nothing Nothing IKPQOff subMode + (connId, (CCLink cReq _, _serviceId)) <- withAgent $ \a -> createConnection a nm (aUserId user) True False SCMInvitation Nothing Nothing IKPQOff subMode -- [incognito] reuse membership incognito profile ct <- withFastStore' $ \db -> createMemberContact db user connId cReq g m mConn subMode void $ createChatItem user (CDDirectSnd ct) False CIChatBanner Nothing (Just epochStart) -- TODO not sure it is correct to set connections status here? - lift $ setContactNetworkStatus ct NSConnected pure $ CRNewMemberContact user ct g m _ -> throwChatError CEGroupMemberNotActive APISendMemberContactInvitation contactId msgContent_ -> withUser $ \user -> do @@ -2620,7 +2708,7 @@ processChatCommand vr nm = \case APIAcceptMemberContact contactId -> withUser $ \user -> do (g, mConn, ct, groupDirectInv) <- withFastStore $ \db -> getMemberContactInvited db vr user contactId when (groupDirectInvStartedConnection groupDirectInv) $ throwCmdError "connection already started" - connectMemberContact user g mConn ct groupDirectInv `catchChatError` \e -> do + connectMemberContact user g mConn ct groupDirectInv `catchAllErrors` \e -> do -- get updated contact, in case connection was started ct' <- withFastStore $ \db -> getContact db vr user contactId toView $ CEvtChatInfoUpdated user (AChatInfo SCTDirect $ DirectChat ct') @@ -2753,8 +2841,7 @@ processChatCommand vr nm = \case | not (null fts) && all fileCancelledOrCompleteSMP fts -> throwChatError $ CEFileCancel fileId "file transfer is complete" | otherwise -> do - fileAgentConnIds <- cancelSndFile user ftm fts True - deleteAgentConnectionsAsync fileAgentConnIds + cancelSndFile user ftm fts True cref_ <- withFastStore' $ \db -> lookupChatRefByFileId db user fileId aci_ <- withFastStore $ \db -> lookupChatItemByFileId db vr user fileId case (cref_, aci_) of @@ -2766,7 +2853,8 @@ processChatCommand vr nm = \case pure $ CRSndFileCancelled user (Just aci) ftm fts (Just (ChatRef CTGroup groupId scope), Just aci) -> do (gInfo, sharedMsgId) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user groupId <*> getSharedMsgIdByFileId db userId fileId - (_chatScopeInfo, recipients) <- getGroupRecipients vr user gInfo scope groupKnockingVersion + chatScopeInfo <- mapM (getChatScopeInfo vr user) scope + recipients <- getGroupRecipients vr user gInfo chatScopeInfo groupKnockingVersion void . sendGroupMessage user gInfo scope recipients $ XFileCancel sharedMsgId pure $ CRSndFileCancelled user (Just aci) ftm fts (Just _, _) -> throwChatError $ CEFileInternal "invalid chat ref for file transfer" @@ -2778,7 +2866,7 @@ processChatCommand vr nm = \case | rcvFileComplete fileStatus -> throwChatError $ CEFileCancel fileId "file transfer is complete" | otherwise -> case xftpRcvFile of Nothing -> do - cancelRcvFileTransfer user ftr >>= mapM_ deleteAgentConnectionAsync + cancelRcvFileTransfer user ftr ci <- withFastStore $ \db -> lookupChatItemByFileId db vr user fileId pure $ CRRcvFileCancelled user ci ftr Just XFTPRcvFile {agentRcvFileId} -> do @@ -2883,7 +2971,7 @@ processChatCommand vr nm = \case ShowVersion -> do -- simplexmqCommitQ makes iOS builds crash m( let versionInfo = coreVersionInfo "" - chatMigrations <- map upMigration <$> withFastStore' getCurrentMigrations + chatMigrations <- map upMigration <$> withFastStore' (getCurrentMigrations Nothing) agentMigrations <- withAgent getAgentMigrations pure $ CRVersionInfo {versionInfo, chatMigrations, agentMigrations} DebugLocks -> lift $ do @@ -3046,25 +3134,25 @@ processChatCommand vr nm = \case Just (PCEContact ct@Contact {activeConn}) -> case activeConn of Nothing -> connect' Nothing Nothing Just conn@Connection {connStatus, xContactId} -> case connStatus of - ConnPrepared -> joinPreparedConn' xContactId conn False + ConnPrepared -> joinPreparedConn' xContactId conn Nothing _ -> pure $ CVRConnectedContact ct - Just (PCEGroup _gInfo GroupMember {activeConn}) -> case activeConn of + Just (PCEGroup gInfo GroupMember {activeConn}) -> case activeConn of Nothing -> connect' groupLinkId Nothing Just conn@Connection {connStatus, xContactId} -> case connStatus of - ConnPrepared -> joinPreparedConn' xContactId conn $ isJust groupLinkId + ConnPrepared -> joinPreparedConn' xContactId conn $ Just (Just gInfo) _ -> connect' groupLinkId xContactId -- why not "already connected" for host member? Nothing -> withFastStore' (\db -> getConnReqContactXContactId db vr user cReqHash1 cReqHash2) >>= \case Right ct@Contact {activeConn} -> case groupLinkId of Nothing -> case activeConn of - Just conn@Connection {connStatus = ConnPrepared, xContactId} -> joinPreparedConn' xContactId conn False + Just conn@Connection {connStatus = ConnPrepared, xContactId} -> joinPreparedConn' xContactId conn Nothing _ -> pure $ CVRConnectedContact ct Just gLinkId -> -- allow repeat contact request -- TODO [short links] is this branch needed? it probably remained from the time we created host contact connect' (Just gLinkId) Nothing Left conn_ -> case conn_ of - Just conn@Connection {connStatus = ConnPrepared, xContactId} -> joinPreparedConn' xContactId conn $ isJust groupLinkId + Just conn@Connection {connStatus = ConnPrepared, xContactId} -> joinPreparedConn' xContactId conn $ groupLinkId $> Nothing -- TODO [short links] this is executed on repeat request after success -- it probably should send the second message without creating the second connection? Just Connection {xContactId} -> connect' groupLinkId xContactId @@ -3073,12 +3161,12 @@ processChatCommand vr nm = \case cReqHash = ConnReqUriHash . C.sha256Hash . strEncode cReqHash1 = cReqHash $ CRContactUri crData {crScheme = SSSimplex} cReqHash2 = cReqHash $ CRContactUri crData {crScheme = simplexChat} - joinPreparedConn' xContactId_ conn@Connection {customUserProfileId} inGroup = do + joinPreparedConn' xContactId_ conn@Connection {customUserProfileId} gInfo_ = do when (incognito /= isJust customUserProfileId) $ throwCmdError "incognito mode is different from prepared connection" xContactId <- mkXContactId xContactId_ localIncognitoProfile <- forM customUserProfileId $ \pId -> withFastStore $ \db -> getProfileById db userId pId let incognitoProfile = fromLocalProfile <$> localIncognitoProfile - conn' <- joinContact user conn cReq incognitoProfile xContactId welcomeSharedMsgId msg_ inGroup PQSupportOn + conn' <- joinContact user conn cReq incognitoProfile xContactId welcomeSharedMsgId msg_ gInfo_ PQSupportOn pure $ CVRSentInvitation conn' incognitoProfile connect' groupLinkId xContactId_ = do let inGroup = isJust groupLinkId @@ -3090,7 +3178,7 @@ processChatCommand vr nm = \case subMode <- chatReadVar subscriptionMode let sLnk' = serverShortLink <$> sLnk conn <- withFastStore' $ \db -> createConnReqConnection db userId connId preparedEntity_ cReq cReqHash1 sLnk' xContactId incognitoProfile groupLinkId subMode chatV pqSup - conn' <- joinContact user conn cReq incognitoProfile xContactId welcomeSharedMsgId msg_ inGroup pqSup + conn' <- joinContact user conn cReq incognitoProfile xContactId welcomeSharedMsgId msg_ (groupLinkId $> Nothing) pqSup pure $ CVRSentInvitation conn' incognitoProfile connectContactViaAddress :: User -> IncognitoEnabled -> Contact -> CreatedLinkContact -> CM ChatResponse connectContactViaAddress user@User {userId} incognito ct@Contact {contactId, activeConn} (CCLink cReq shortLink) = @@ -3105,7 +3193,7 @@ processChatCommand vr nm = \case subMode <- chatReadVar subscriptionMode let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq conn <- withFastStore' $ \db -> createConnReqConnection db userId connId (Just $ PCEContact ct) cReq cReqHash shortLink newXContactId incognitoProfile Nothing subMode chatV pqSup - void $ joinContact user conn cReq incognitoProfile newXContactId Nothing Nothing False pqSup + void $ joinContact user conn cReq incognitoProfile newXContactId Nothing Nothing Nothing pqSup ct' <- withStore $ \db -> getContact db vr user contactId pure $ CRSentInvitationToContact user ct' incognitoProfile Just conn@Connection {connStatus, xContactId = xContactId_, customUserProfileId} -> case connStatus of @@ -3114,7 +3202,7 @@ processChatCommand vr nm = \case xContactId <- mkXContactId xContactId_ localIncognitoProfile <- forM customUserProfileId $ \pId -> withFastStore $ \db -> getProfileById db userId pId let incognitoProfile = fromLocalProfile <$> localIncognitoProfile - void $ joinContact user conn cReq incognitoProfile xContactId Nothing Nothing False PQSupportOn + void $ joinContact user conn cReq incognitoProfile xContactId Nothing Nothing Nothing PQSupportOn ct' <- withStore $ \db -> getContact db vr user contactId pure $ CRSentInvitationToContact user ct' incognitoProfile _ -> throwCmdError "contact already has connection" @@ -3131,12 +3219,14 @@ processChatCommand vr nm = \case pure (connId, chatV) mkXContactId :: Maybe XContactId -> CM XContactId mkXContactId = maybe (XContactId <$> drgRandomBytes 16) pure - joinContact :: User -> Connection -> ConnReqContact -> Maybe Profile -> XContactId -> Maybe SharedMsgId -> Maybe (SharedMsgId, MsgContent) -> Bool -> PQSupport -> CM Connection - joinContact user conn@Connection {connChatVersion = chatV} cReq incognitoProfile xContactId welcomeSharedMsgId msg_ inGroup pqSup = do - let profileToSend = - if inGroup - then userProfileInGroup user incognitoProfile - else userProfileDirect user incognitoProfile Nothing True + joinContact :: User -> Connection -> ConnReqContact -> Maybe Profile -> XContactId -> Maybe SharedMsgId -> Maybe (SharedMsgId, MsgContent) -> Maybe (Maybe GroupInfo) -> PQSupport -> CM Connection + joinContact user conn@Connection {connChatVersion = chatV} cReq incognitoProfile xContactId welcomeSharedMsgId msg_ gInfo_ pqSup = do + -- gInfo_ is Maybe (Maybe GroupInfo), where Just Nothing means "some unknown group", e.g. when joining via link without profile + let profileToSend = case gInfo_ of + Just gInfo_' -> + let allowSimplexLinks = maybe True (groupFeatureUserAllowed SGFSimplexLinks) gInfo_' + in userProfileInGroup' user allowSimplexLinks incognitoProfile + Nothing -> userProfileDirect user incognitoProfile Nothing True dm <- encodeConnInfoPQ pqSup chatV (XContact profileToSend (Just xContactId) welcomeSharedMsgId msg_) subMode <- chatReadVar subscriptionMode void $ withAgent $ \a -> joinConnection a nm (aUserId user) (aConnId conn) True cReq dm pqSup subMode @@ -3216,7 +3306,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'}} @@ -3233,52 +3324,44 @@ processChatCommand vr nm = \case mergedProfile' = userProfileDirect user (fromLocalProfile <$> incognitoProfile) (Just ct') False when (mergedProfile' /= mergedProfile) $ withContactLock "updateContactPrefs" (contactId' ct) $ do - void (sendDirectContactMessage user ct' $ XInfo mergedProfile') `catchChatError` eToView + void (sendDirectContactMessage user ct' $ XInfo mergedProfile') `catchAllErrors` eToView lift . when (directOrUsed ct') $ createSndFeatureItems user ct ct' pure $ CRContactPrefsUpdated user ct ct' - runUpdateGroupProfile :: User -> Group -> GroupProfile -> CM ChatResponse - runUpdateGroupProfile user (Group g@GroupInfo {businessChat, groupProfile = p@GroupProfile {displayName = n}} ms) p'@GroupProfile {displayName = n'} = do - assertUserGroupRole g GROwner + runUpdateGroupProfile :: User -> GroupInfo -> GroupProfile -> CM ChatResponse + runUpdateGroupProfile user gInfo@GroupInfo {businessChat, groupProfile = p@GroupProfile {displayName = n}} p'@GroupProfile {displayName = n'} = do + assertUserGroupRole gInfo GROwner when (n /= n') $ checkValidName n' - g' <- withStore $ \db -> updateGroupProfile db user g p' + gInfo' <- withStore $ \db -> updateGroupProfile db user gInfo p' msg <- case businessChat of Just BusinessChatInfo {businessId} -> do + ms <- withStore' $ \db -> getGroupMembers db vr user gInfo' let (newMs, oldMs) = partition (\m -> maxVersion (memberChatVRange m) >= businessChatPrefsVersion) ms -- this is a fallback to send the members with the old version correct profile of the business when preferences change unless (null oldMs) $ do GroupMember {memberProfile = LocalProfile {displayName, fullName, shortDescr, image}} <- - withStore $ \db -> getGroupMemberByMemberId db vr user g businessId + withStore $ \db -> getGroupMemberByMemberId db vr user gInfo' businessId let p'' = p' {displayName, fullName, shortDescr, image} :: GroupProfile recipients = filter memberCurrentOrPending oldMs - void $ sendGroupMessage user g' Nothing recipients (XGrpInfo p'') + void $ sendGroupMessage user gInfo' Nothing recipients (XGrpInfo p'') let ps' = fromMaybe defaultBusinessGroupPrefs $ groupPreferences p' recipients = filter memberCurrentOrPending newMs - sendGroupMessage user g' Nothing recipients $ XGrpPrefs ps' + sendGroupMessage user gInfo' Nothing recipients $ XGrpPrefs ps' Nothing -> do - setGroupLinkData' - let recipients = filter memberCurrentOrPending ms - sendGroupMessage user g' Nothing recipients (XGrpInfo p') + setGroupLinkData' nm user gInfo' + recipients <- getRecipients + sendGroupMessage user gInfo' Nothing recipients (XGrpInfo p') where - setGroupLinkData' :: CM () - setGroupLinkData' = - withFastStore' (\db -> runExceptT $ getGroupLink db user g') >>= \case - Right gLink@GroupLink {shortLinkDataSet} - | shortLinkDataSet -> void $ setGroupLinkData user g' gLink - _ -> pure () - let cd = CDGroupSnd g' Nothing + getRecipients + | isTrue (useRelays gInfo') = withFastStore' $ \db -> getGroupRelays db vr user gInfo' + | otherwise = do + ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo' + pure $ filter memberCurrentOrPending ms + let cd = CDGroupSnd gInfo' Nothing unless (sameGroupProfileInfo p p') $ do ci <- saveSndChatItem user cd msg (CISndGroupEvent $ SGEGroupUpdated p') - toView $ CEvtNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat g' Nothing) ci] - createGroupFeatureChangedItems user cd CISndGroupFeature g g' - pure $ CRGroupUpdated user g g' Nothing - setGroupLinkData :: User -> GroupInfo -> GroupLink -> CM ChatResponse - setGroupLinkData user gInfo@GroupInfo {groupProfile} gLink@GroupLink {groupLinkId} = do - conn <- withFastStore $ \db -> getGroupLinkConnection db vr user gInfo - let userData = encodeShortLinkData $ GroupShortLinkData groupProfile - crClientData = encodeJSON $ CRDataGroup groupLinkId - sLnk <- shortenShortLink' . toShortGroupLink =<< withAgent (\a -> setConnShortLink a nm (aConnId conn) SCMContact userData (Just crClientData)) - gLink' <- withFastStore' $ \db -> setGroupLinkShortLink db gLink sLnk - pure $ CRGroupLink user gInfo gLink' + toView $ CEvtNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat gInfo' Nothing) ci] + createGroupFeatureChangedItems user cd CISndGroupFeature gInfo gInfo' + pure $ CRGroupUpdated user gInfo gInfo' Nothing checkValidName :: GroupName -> CM () checkValidName displayName = do when (T.null displayName) $ throwChatError CEInvalidDisplayName {displayName, validName = ""} @@ -3325,14 +3408,14 @@ processChatCommand vr nm = \case ciIds <- concat <$> withStore' (\db -> forM items $ \(CChatItem _ ci) -> markMessageReportsDeleted db user gInfo ci membership deletedTs) unless (null ciIds) $ toView $ CEvtGroupChatItemsDeleted user gInfo ciIds True (Just membership) let m = if moderation then Just membership else Nothing - if groupFeatureMemberAllowed SGFFullDelete membership gInfo + if groupFeatureUserAllowed SGFFullDelete gInfo then deleteGroupCIs user gInfo chatScopeInfo items m deletedTs else markGroupCIsDeleted user gInfo chatScopeInfo items m deletedTs updateGroupProfileByName :: GroupName -> (GroupProfile -> GroupProfile) -> CM ChatResponse updateGroupProfileByName gName update = withUser $ \user -> do - g@(Group GroupInfo {groupProfile = p} _) <- withStore $ \db -> - getGroupIdByName db user gName >>= getGroup db vr user - runUpdateGroupProfile user g $ update p + gInfo@GroupInfo {groupProfile = p} <- withStore $ \db -> + getGroupIdByName db user gName >>= getGroupInfo db vr user + runUpdateGroupProfile user gInfo $ update p withCurrentCall :: ContactId -> (User -> Contact -> Call -> CM (Maybe Call)) -> CM ChatResponse withCurrentCall ctId action = do (user, ct) <- withStore $ \db -> do @@ -3375,7 +3458,7 @@ processChatCommand vr nm = \case forwardFile :: ChatName -> FileTransferId -> (ChatName -> CryptoFile -> ChatCommand) -> CM ChatResponse forwardFile chatName fileId sendCommand = withUser $ \user -> do withStore (\db -> getFileTransfer db user fileId) >>= \case - FTRcv RcvFileTransfer {fileStatus = RFSComplete RcvFileInfo {filePath}, cryptoArgs} -> forward filePath cryptoArgs + FTRcv RcvFileTransfer {fileStatus = RFSComplete filePath, cryptoArgs} -> forward filePath cryptoArgs FTSnd {fileTransferMeta = FileTransferMeta {filePath, xftpSndFile}} -> forward filePath $ xftpSndFile >>= \XFTPSndFile {cryptoArgs} -> cryptoArgs _ -> throwChatError CEFileNotReceived {fileId} where @@ -3411,7 +3494,7 @@ processChatCommand vr nm = \case drgRandomBytes n = asks random >>= atomically . C.randomBytes n privateGetUser :: UserId -> CM User privateGetUser userId = - tryChatError (withStore (`getUser` userId)) >>= \case + tryAllErrors (withStore (`getUser` userId)) >>= \case Left _ -> throwChatError CEUserUnknown Right user -> pure user validateUserPassword :: User -> User -> Maybe UserPwd -> CM () @@ -3452,8 +3535,8 @@ processChatCommand vr nm = \case filesInfo <- withFastStore' (`getUserFileInfo` user) deleteCIFiles user filesInfo withAgent (\a -> deleteUser a (aUserId user) delSMPQueues) - `catchChatError` \case - e@(ChatErrorAgent NO_USER _) -> eToView e + `catchAllErrors` \case + e@(ChatErrorAgent NO_USER _ _) -> eToView e e -> throwError e withFastStore' (`deleteUserRecord` user) when (activeUser user) $ chatWriteVar currentUser Nothing @@ -3491,11 +3574,11 @@ processChatCommand vr nm = \case -- deleted contact is returned as known, as invitation link cannot be re-used too connect anyway Nothing -> bimap inv (CPInvitationLink . ILPKnown) <$$> getContactViaShortLinkToConnect db vr user l' invitationReqAndPlan cReq sLnk_ contactSLinkData_ = do - plan <- invitationRequestPlan user cReq contactSLinkData_ `catchChatError` (pure . CPError) + plan <- invitationRequestPlan user cReq contactSLinkData_ `catchAllErrors` (pure . CPError) pure (ACCL SCMInvitation (CCLink cReq sLnk_), plan) connectPlan user (ACL SCMContact cLink) = case cLink of CLFull cReq -> do - plan <- contactOrGroupRequestPlan user cReq `catchChatError` (pure . CPError) + plan <- contactOrGroupRequestPlan user cReq `catchAllErrors` (pure . CPError) pure (ACCL SCMContact $ CCLink cReq Nothing, plan) CLShort l@(CSLContact _ ct _ _) -> do let l' = serverShortLink l @@ -3632,7 +3715,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, @@ -3648,41 +3731,12 @@ processChatCommand vr nm = \case business = maybe False businessAddress settings contactData = ContactShortLinkData p msg business in encodeShortLinkData contactData - encodeShortLinkData :: J.ToJSON a => a -> UserLinkData - encodeShortLinkData d = - let s = LB.toStrict $ J.encode d - -- 10kb size limit for compression to be used is based on 13784 limit for link data - -- and the space reserved for the other fields in ConnLinkData encoding (most of these fields are currently unused). - s' - | B.length s > 10240 = B.cons 'X' $ Z1.compress compressionLevel s - | otherwise = s - in UserLinkData s' - decodeShortLinkData :: J.FromJSON a => ConnLinkData c -> IO (Maybe a) - decodeShortLinkData cData - | B.null s = pure Nothing - | B.head s == 'X' = case Z1.decompress $ B.drop 1 s of - Z1.Error e -> Nothing <$ logError ("Error decompressing link data: " <> tshow e) - Z1.Skip -> pure Nothing - Z1.Decompress s' -> decode s' - | otherwise = decode s - where - decode s' = case J.eitherDecodeStrict s' of - Right d -> pure $ Just d - Left e -> Nothing <$ logError ("Error decoding link data: " <> tshow e) - s = linkUserData' cData updatePCCShortLinkData :: PendingContactConnection -> Profile -> CM (Maybe ShortLinkInvitation) 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) - shortenShortLink' :: ConnShortLink m -> CM (ConnShortLink m) - shortenShortLink' l = (`shortenShortLink` l) <$> asks (shortLinkPresetServers . config) - shortenCreatedLink :: CreatedConnLink m -> CM (CreatedConnLink m) - shortenCreatedLink (CCLink cReq sLnk) = CCLink cReq <$> mapM shortenShortLink' sLnk - createdGroupLink :: CreatedLinkContact -> CreatedLinkContact - createdGroupLink (CCLink cReq shortLink) = CCLink cReq (toShortGroupLink <$> shortLink) - toShortGroupLink :: ShortLinkContact -> ShortLinkContact - toShortGroupLink (CSLContact sch _ srv k) = CSLContact sch CCTGroup srv k + 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 @@ -3764,7 +3818,8 @@ processChatCommand vr nm = \case sendGroupContentMessages :: User -> GroupInfo -> Maybe GroupChatScope -> Bool -> Maybe Int -> NonEmpty ComposedMessageReq -> CM ChatResponse sendGroupContentMessages user gInfo scope live itemTTL cmrs = do assertMultiSendable live cmrs - (chatScopeInfo, recipients) <- getGroupRecipients vr user gInfo scope modsCompatVersion + chatScopeInfo <- mapM (getChatScopeInfo vr user) scope + recipients <- getGroupRecipients vr user gInfo chatScopeInfo modsCompatVersion sendGroupContentMessages_ user gInfo scope chatScopeInfo recipients live itemTTL cmrs where hasReport = any (\(ComposedMessage {msgContent}, _, _, _) -> isReport msgContent) cmrs @@ -3795,6 +3850,7 @@ processChatCommand vr nm = \case Nothing processComposedMessages :: CM ChatResponse processComposedMessages = do + -- TODO [channels fwd] single description for all recipients (fInvs_, ciFiles_) <- L.unzip <$> setupSndFileTransfers (length recipients) timed_ <- sndGroupCITimed live gInfo itemTTL (chatMsgEvents, quotedItems_) <- L.unzip <$> prepareMsgs (L.zip cmrs fInvs_) timed_ @@ -3875,7 +3931,7 @@ processChatCommand vr nm = \case case contactOrGroup of CGContact Contact {activeConn} -> forM_ activeConn $ \conn -> withFastStore' $ \db -> createSndFTDescrXFTP db user Nothing conn ft dummyFileDescr - CGGroup _ ms -> forM_ ms $ \m -> saveMemberFD m `catchChatError` eToView + CGGroup _ ms -> forM_ ms $ \m -> saveMemberFD m `catchAllErrors` eToView where -- we are not sending files to pending members, same as with inline files saveMemberFD m@GroupMember {activeConn = Just conn@Connection {connStatus}} = @@ -4061,7 +4117,7 @@ startExpireCIThread user@User {userId} = do liftIO $ threadDelay' delay interval <- asks $ ciExpirationInterval . config forever $ do - flip catchChatError' (eToView') $ do + flip catchAllErrors' (eToView') $ do expireFlags <- asks expireCIFlags atomically $ TM.lookup userId expireFlags >>= \b -> unless (b == Just True) retry lift waitChatStartedAndActivated @@ -4093,7 +4149,7 @@ agentSubscriber = do q <- asks $ subQ . smpAgent forever (atomically (readTBQueue q) >>= process) `E.catchAny` \e -> do - eToView' $ ChatErrorAgent (CRITICAL True $ "Message reception stopped: " <> show e) Nothing + eToView' $ ChatErrorAgent (CRITICAL True $ "Message reception stopped: " <> show e) (AgentConnId "") Nothing E.throwIO e where process :: (ACorrId, AEntityId, AEvt) -> CM' () @@ -4103,167 +4159,9 @@ agentSubscriber = do SAERcvFile -> processAgentMsgRcvFile corrId entId msg SAESndFile -> processAgentMsgSndFile corrId entId msg where - run action = action `catchChatError'` (eToView') + run action = action `catchAllErrors'` (eToView') -type AgentBatchSubscribe = AgentClient -> [ConnId] -> ExceptT AgentErrorType IO (Map ConnId (Either AgentErrorType (Maybe ClientServiceId))) - --- TODO [certs rcv] -subscribeUserConnections :: VersionRangeChat -> Bool -> AgentBatchSubscribe -> User -> CM () -subscribeUserConnections vr onlyNeeded agentBatchSubscribe user = do - -- get user connections - ce <- asks $ subscriptionEvents . config - (conns, cts, ucs, gs, ms, sfts, rfts, pcs) <- - if onlyNeeded - then do - (conns, entities) <- withStore' (`getConnectionsToSubscribe` vr) - let (cts, ucs, ms, sfts, rfts, pcs) = foldl' addEntity (M.empty, M.empty, M.empty, M.empty, M.empty, M.empty) entities - pure (conns, cts, ucs, [], ms, sfts, rfts, pcs) - else do - withStore' unsetConnectionToSubscribe - (ctConns, cts) <- getContactConns - (ucConns, ucs) <- getUserContactLinkConns - (gs, mConns, ms) <- getGroupMemberConns - (sftConns, sfts) <- getSndFileTransferConns - (rftConns, rfts) <- getRcvFileTransferConns - (pcConns, pcs) <- getPendingContactConns - let conns = concat ([ctConns, ucConns, mConns, sftConns, rftConns, pcConns] :: [[ConnId]]) - pure (conns, cts, ucs, gs, ms, sfts, rfts, pcs) - -- subscribe using batched commands - rs <- withAgent $ \a -> agentBatchSubscribe a conns - -- send connection events to view - contactSubsToView rs cts ce - unlessM (asks $ coreApi . config) $ do - contactLinkSubsToView rs ucs - groupSubsToView rs gs ms ce - sndFileSubsToView rs sfts - rcvFileSubsToView rs rfts - pendingConnSubsToView rs pcs - where - addEntity (cts, ucs, ms, sfts, rfts, pcs) = \case - RcvDirectMsgConnection c (Just ct) -> let cts' = addConn c ct cts in (cts', ucs, ms, sfts, rfts, pcs) - RcvDirectMsgConnection c Nothing -> let pcs' = addConn c (mkPendingContactConnection c Nothing) pcs in (cts, ucs, ms, sfts, rfts, pcs') - RcvGroupMsgConnection c _g m -> let ms' = addConn c (toShortMember m c) ms in (cts, ucs, ms', sfts, rfts, pcs) - SndFileConnection c sft -> let sfts' = addConn c sft sfts in (cts, ucs, ms, sfts', rfts, pcs) - RcvFileConnection c rft -> let rfts' = addConn c rft rfts in (cts, ucs, ms, sfts, rfts', pcs) - UserContactConnection c uc -> let ucs' = addConn c uc ucs in (cts, ucs', ms, sfts, rfts, pcs) - addConn :: Connection -> a -> Map ConnId a -> Map ConnId a - addConn = M.insert . aConnId - toShortMember GroupMember {groupMemberId, groupId, localDisplayName} Connection {agentConnId} = - ShortGroupMember - { groupMemberId, - groupId, - memberName = localDisplayName, - connId = agentConnId - } - getContactConns :: CM ([ConnId], Map ConnId Contact) - getContactConns = do - cts <- withStore_ (`getUserContacts` vr) - let cts' = mapMaybe (\ct -> (,ct) <$> contactConnId ct) $ filter contactActive cts - pure (map fst cts', M.fromList cts') - getUserContactLinkConns :: CM ([ConnId], Map ConnId UserContact) - getUserContactLinkConns = do - (cs, ucs) <- unzip <$> withStore_ (`getUserContactLinks` vr) - let connIds = map aConnId cs - pure (connIds, M.fromList $ zip connIds ucs) - getGroupMemberConns :: CM ([ShortGroup], [ConnId], Map ConnId ShortGroupMember) - getGroupMemberConns = do - gs <- withStore_ getUserGroupsToSubscribe - let mPairs = concatMap (\(ShortGroup _ ms) -> map (\m -> (shortMemConnId m, m)) ms) gs - pure (gs, map fst mPairs, M.fromList mPairs) - where - shortMemConnId ShortGroupMember{connId = AgentConnId acId} = acId - getSndFileTransferConns :: CM ([ConnId], Map ConnId SndFileTransfer) - getSndFileTransferConns = do - sfts <- withStore_ getLiveSndFileTransfers - let connIds = map sndFileTransferConnId sfts - pure (connIds, M.fromList $ zip connIds sfts) - getRcvFileTransferConns :: CM ([ConnId], Map ConnId RcvFileTransfer) - getRcvFileTransferConns = do - rfts <- withStore_ getLiveRcvFileTransfers - let rftPairs = mapMaybe (\ft -> (,ft) <$> liveRcvFileTransferConnId ft) rfts - pure (map fst rftPairs, M.fromList rftPairs) - getPendingContactConns :: CM ([ConnId], Map ConnId PendingContactConnection) - getPendingContactConns = do - pcs <- withStore_ getPendingContactConnections - let connIds = map aConnId' pcs - pure (connIds, M.fromList $ zip connIds pcs) - contactSubsToView :: Map ConnId (Either AgentErrorType (Maybe ClientServiceId)) -> Map ConnId Contact -> Bool -> CM () - contactSubsToView rs cts ce = do - chatModifyVar connNetworkStatuses $ M.union (M.fromList statuses) - ifM (asks $ coreApi . config) (notifyAPI statuses) notifyCLI - where - notifyCLI = do - let cRs = resultsFor rs cts - cErrors = sortOn (\(Contact {localDisplayName = n}, _) -> n) $ filterErrors cRs - toView . CEvtContactSubSummary user $ map (uncurry ContactSubStatus) cRs - when ce $ mapM_ (toView . uncurry (CEvtContactSubError user)) cErrors - notifyAPI = toView . CEvtNetworkStatuses (Just user) . map (uncurry ConnNetworkStatus) - statuses = M.foldrWithKey' addStatus [] cts - where - addStatus :: ConnId -> Contact -> [(AgentConnId, NetworkStatus)] -> [(AgentConnId, NetworkStatus)] - addStatus _ Contact {activeConn = Nothing} nss = nss - addStatus connId Contact {activeConn = Just Connection {agentConnId}} nss = - let ns = (agentConnId, netStatus $ resultErr connId rs) - in ns : nss - netStatus :: Maybe ChatError -> NetworkStatus - netStatus = maybe NSConnected $ NSError . errorNetworkStatus - errorNetworkStatus :: ChatError -> String - errorNetworkStatus = \case - ChatErrorAgent (BROKER _ NETWORK) _ -> "network" - ChatErrorAgent (SMP _ SMP.AUTH) _ -> "contact deleted" - e -> show e - -- TODO possibly below could be replaced with less noisy events for API - contactLinkSubsToView :: Map ConnId (Either AgentErrorType (Maybe ClientServiceId)) -> Map ConnId UserContact -> CM () - contactLinkSubsToView rs = toView . CEvtUserContactSubSummary user . map (uncurry UserContactSubStatus) . resultsFor rs - groupSubsToView :: Map ConnId (Either AgentErrorType (Maybe ClientServiceId)) -> [ShortGroup] -> Map ConnId ShortGroupMember -> Bool -> CM () - groupSubsToView rs gs ms ce = do - mapM_ groupSub $ - sortOn (\(ShortGroup ShortGroupInfo {groupName = g} _) -> g) gs - toViewTE . TEMemberSubSummary user $ map (uncurry MemberSubStatus) mRs - where - mRs = resultsFor rs ms - groupSub :: ShortGroup -> CM () - groupSub (ShortGroup g@ShortGroupInfo {groupId = gId, membershipStatus} members) = do - when ce $ mapM_ (toViewTE . uncurry (TEMemberSubError user g)) mErrors - toViewTE groupEvent - where - mErrors :: [(ShortGroupMember, ChatError)] - mErrors = - sortOn (\(ShortGroupMember {memberName = n}, _) -> n) - . filterErrors - $ filter (\(ShortGroupMember {groupId}, _) -> groupId == gId) mRs - groupEvent :: TerminalEvent - groupEvent - | membershipStatus == GSMemInvited = TEGroupInvitation user g - | null members = TEGroupEmpty user g - | otherwise = TEGroupSubscribed user g - sndFileSubsToView :: Map ConnId (Either AgentErrorType (Maybe ClientServiceId)) -> Map ConnId SndFileTransfer -> CM () - sndFileSubsToView rs sfts = do - let sftRs = resultsFor rs sfts - forM_ sftRs $ \(ft@SndFileTransfer {fileId, fileStatus}, err_) -> do - forM_ err_ $ toViewTE . TESndFileSubError user ft - void . forkIO $ do - threadDelay 1000000 - when (fileStatus == FSConnected) . unlessM (isFileActive fileId sndFiles) . withChatLock "subscribe sendFileChunk" $ - sendFileChunk user ft - rcvFileSubsToView :: Map ConnId (Either AgentErrorType (Maybe ClientServiceId)) -> Map ConnId RcvFileTransfer -> CM () - rcvFileSubsToView rs = mapM_ (toViewTE . uncurry (TERcvFileSubError user)) . filterErrors . resultsFor rs - pendingConnSubsToView :: Map ConnId (Either AgentErrorType (Maybe ClientServiceId)) -> Map ConnId PendingContactConnection -> CM () - pendingConnSubsToView rs = toViewTE . TEPendingSubSummary user . map (uncurry PendingSubStatus) . resultsFor rs - withStore_ :: (DB.Connection -> User -> IO [a]) -> CM [a] - withStore_ a = withStore' (`a` user) `catchChatError` \e -> eToView e $> [] - filterErrors :: [(a, Maybe ChatError)] -> [(a, ChatError)] - filterErrors = mapMaybe (\(a, e_) -> (a,) <$> e_) - resultsFor :: Map ConnId (Either AgentErrorType (Maybe ClientServiceId)) -> Map ConnId a -> [(a, Maybe ChatError)] - resultsFor rs = M.foldrWithKey' addResult [] - where - addResult :: ConnId -> a -> [(a, Maybe ChatError)] -> [(a, Maybe ChatError)] - addResult connId = (:) . (,resultErr connId rs) - resultErr :: ConnId -> Map ConnId (Either AgentErrorType (Maybe ClientServiceId)) -> Maybe ChatError - resultErr connId rs = case M.lookup connId rs of - Just (Left e) -> Just $ ChatErrorAgent e Nothing - Just _ -> Nothing - _ -> Just . ChatError . CEAgentNoSubResult $ AgentConnId connId +type AgentSubResult = Map ConnId (Either AgentErrorType (Maybe ClientServiceId)) cleanupManager :: CM () cleanupManager = do @@ -4273,44 +4171,54 @@ cleanupManager = do liftIO $ threadDelay' initialDelay stepDelay <- asks (cleanupManagerStepDelay . config) forever $ do - flip catchChatError eToView $ do + flip catchAllErrors eToView $ do lift waitChatStartedAndActivated users <- withStore' getUsers let (us, us') = partition activeUser users forM_ us $ cleanupUser interval stepDelay forM_ us' $ cleanupUser interval stepDelay - cleanupMessages `catchChatError` eToView + cleanupMessages `catchAllErrors` eToView + cleanupDeliveryTasks `catchAllErrors` eToView + cleanupDeliveryJobs `catchAllErrors` eToView -- TODO possibly, also cleanup async commands - cleanupProbes `catchChatError` eToView + cleanupProbes `catchAllErrors` eToView liftIO $ threadDelay' $ diffToMicroseconds interval where - runWithoutInitialDelay cleanupInterval = flip catchChatError eToView $ do + runWithoutInitialDelay cleanupInterval = flip catchAllErrors eToView $ do lift waitChatStartedAndActivated users <- withStore' getUsers let (us, us') = partition activeUser users - forM_ us $ \u -> cleanupTimedItems cleanupInterval u `catchChatError` eToView - forM_ us' $ \u -> cleanupTimedItems cleanupInterval u `catchChatError` eToView + forM_ us $ \u -> cleanupTimedItems cleanupInterval u `catchAllErrors` eToView + forM_ us' $ \u -> cleanupTimedItems cleanupInterval u `catchAllErrors` eToView cleanupUser cleanupInterval stepDelay user = do - cleanupTimedItems cleanupInterval user `catchChatError` eToView + cleanupTimedItems cleanupInterval user `catchAllErrors` eToView liftIO $ threadDelay' stepDelay -- TODO remove in future versions: legacy step - contacts are no longer marked as deleted - cleanupDeletedContacts user `catchChatError` eToView + cleanupDeletedContacts user `catchAllErrors` eToView liftIO $ threadDelay' stepDelay cleanupTimedItems cleanupInterval user = do ts <- liftIO getCurrentTime let startTimedThreadCutoff = addUTCTime cleanupInterval ts timedItems <- withStore' $ \db -> getTimedItems db user startTimedThreadCutoff - forM_ timedItems $ \(itemRef, deleteAt) -> startTimedItemThread user itemRef deleteAt `catchChatError` const (pure ()) + forM_ timedItems $ \(itemRef, deleteAt) -> startTimedItemThread user itemRef deleteAt `catchAllErrors` const (pure ()) cleanupDeletedContacts user = do vr <- chatVersionRange contacts <- withStore' $ \db -> getDeletedContacts db vr user forM_ contacts $ \ct -> withStore (\db -> deleteContactWithoutGroups db user ct) - `catchChatError` eToView + `catchAllErrors` eToView cleanupMessages = do ts <- liftIO getCurrentTime let cutoffTs = addUTCTime (-(30 * nominalDay)) ts withStore' (`deleteOldMessages` cutoffTs) + cleanupDeliveryTasks = do + ts <- liftIO getCurrentTime + let cutoffTs = addUTCTime (-(7 * nominalDay)) ts + withStore' (`deleteDoneDeliveryTasks` cutoffTs) + cleanupDeliveryJobs = do + ts <- liftIO getCurrentTime + let cutoffTs = addUTCTime (-(7 * nominalDay)) ts + withStore' (`deleteDoneDeliveryJobs` cutoffTs) cleanupProbes = do ts <- liftIO getCurrentTime let cutoffTs = addUTCTime (-(14 * nominalDay)) ts @@ -4332,7 +4240,7 @@ expireChatItems user@User {userId} globalTTL sync = do loop :: [Int64] -> (Int64 -> CM ()) -> CM () loop [] _ = pure () loop (a : as) process = continue $ do - process a `catchChatError` eToView + process a `catchAllErrors` eToView loop as process continue :: CM () -> CM () continue a = @@ -4347,7 +4255,7 @@ expireContactChatItems :: User -> VersionRangeChat -> Int64 -> ContactId -> CM ( expireContactChatItems user vr globalTTL ctId = -- reading contacts and groups inside the loop, -- to allow ttl changing while processing and to reduce memory usage - tryChatError (withStore $ \db -> getContact db vr user ctId) >>= mapM_ process + tryAllErrors (withStore $ \db -> getContact db vr user ctId) >>= mapM_ process where process ct@Contact {chatItemTTL} = withExpirationDate globalTTL chatItemTTL $ \expirationDate -> do @@ -4358,7 +4266,7 @@ expireContactChatItems user vr globalTTL ctId = expireGroupChatItems :: User -> VersionRangeChat -> Int64 -> UTCTime -> GroupId -> CM () expireGroupChatItems user vr globalTTL createdAtCutoff groupId = - tryChatError (withStore $ \db -> getGroupInfo db vr user groupId) >>= mapM_ process + tryAllErrors (withStore $ \db -> getGroupInfo db vr user groupId) >>= mapM_ process where process gInfo@GroupInfo {chatItemTTL} = withExpirationDate globalTTL chatItemTTL $ \expirationDate -> do @@ -4418,6 +4326,7 @@ chatCommandP = "/_app activate restore=" *> (APIActivateChat <$> onOffP), "/_app activate" $> APIActivateChat True, "/_app suspend " *> (APISuspendChat <$> A.decimal), + "/_connections diff" *> (ShowConnectionsDiff <$> (" show_ids=" *> onOffP <|> pure False)), "/_resubscribe all" $> ResubscribeAllConnections, -- deprecated, use /set file paths "/_temp_folder " *> (SetTempFolder <$> filePath), @@ -4452,8 +4361,8 @@ chatCommandP = <*> (A.space *> paginationByTimeP <|> pure (PTLast 5000)) <*> (A.space *> jsonP <|> pure clqNoFilters) ), - "/_get chat " *> (APIGetChat <$> chatRefP <*> optional (" content=" *> strP) <* A.space <*> chatPaginationP <*> optional (" search=" *> stringP)), - "/_get items " *> (APIGetChatItems <$> chatPaginationP <*> optional (" search=" *> stringP)), + "/_get chat " *> (APIGetChat <$> chatRefP <*> optional (" content=" *> strP) <* A.space <*> chatPaginationP <*> optional (" search=" *> textP)), + "/_get items " *> (APIGetChatItems <$> chatPaginationP <*> optional (" search=" *> textP)), "/_get item info " *> (APIGetChatItemInfo <$> chatRefP <* A.space <*> A.decimal), "/_send " *> (APISendMessages <$> sendRefP <*> liveMessageP <*> sendMessageTTLP <*> (" json " *> jsonP <|> " text " *> composedMessagesTextP)), "/_create tag " *> (APICreateChatTag <$> jsonP), @@ -4491,7 +4400,6 @@ chatCommandP = "/_call end @" *> (APIEndCall <$> A.decimal), "/_call status @" *> (APICallStatus <$> A.decimal <* A.space <*> strP), "/_call get" $> APIGetCallInvitations, - "/_network_statuses" $> APIGetNetworkStatuses, "/_profile " *> (APIUpdateProfile <$> A.decimal <* A.space <*> jsonP), "/_set alias @" *> (APISetContactAlias <$> A.decimal <*> (A.space *> textP <|> pure "")), "/_set alias #" *> (APISetGroupAlias <$> A.decimal <*> (A.space *> textP <|> pure "")), @@ -4610,8 +4518,8 @@ chatCommandP = "/clear " *> char_ '@' *> (ClearContact <$> displayNameP), ("/members " <|> "/ms ") *> char_ '#' *> (ListMembers <$> displayNameP), "/member support chats #" *> (ListMemberSupportChats <$> displayNameP), - "/_groups" *> (APIListGroups <$> A.decimal <*> optional (" @" *> A.decimal) <*> optional (A.space *> stringP)), - ("/groups" <|> "/gs") *> (ListGroups <$> optional (" @" *> displayNameP) <*> optional (A.space *> stringP)), + "/_groups" *> (APIListGroups <$> A.decimal <*> optional (" @" *> A.decimal) <*> optional (A.space *> textP)), + ("/groups" <|> "/gs") *> (ListGroups <$> optional (" @" *> displayNameP) <*> optional (A.space *> textP)), "/_group_profile #" *> (APIUpdateGroupProfile <$> A.decimal <* A.space <*> jsonP), ("/group_profile " <|> "/gp ") *> char_ '#' *> (UpdateGroupNames <$> displayNameP <* A.space <*> groupProfile), ("/group_profile " <|> "/gp ") *> char_ '#' *> (ShowGroupProfile <$> displayNameP), @@ -4665,7 +4573,7 @@ chatCommandP = "/feed " *> (SendMessageBroadcast . MCText <$> msgTextP), ("/chats" <|> "/cs") *> (LastChats <$> (" all" $> Nothing <|> Just <$> (A.space *> A.decimal <|> pure 20))), ("/tail" <|> "/t") *> (LastMessages <$> optional (A.space *> chatNameP) <*> msgCountP <*> pure Nothing), - ("/search" <|> "/?") *> (LastMessages <$> optional (A.space *> chatNameP) <*> msgCountP <*> (Just <$> (A.space *> stringP))), + ("/search" <|> "/?") *> (LastMessages <$> optional (A.space *> chatNameP) <*> msgCountP <*> (Just <$> (A.space *> textP))), "/last_item_id" *> (LastChatItemId <$> optional (A.space *> chatNameP) <*> (A.space *> A.decimal <|> pure 0)), "/show" *> (ShowLiveItems <$> (A.space *> onOffP <|> pure True)), "/show " *> (ShowChatItem . Just <$> A.decimal), diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 413a36f567..163a37cb3f 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -18,6 +18,7 @@ module Simplex.Chat.Library.Internal where +import qualified Codec.Compression.Zstd as Z1 import Control.Applicative ((<|>)) import Control.Concurrent.STM (retry) import Control.Logger.Simple @@ -26,9 +27,11 @@ import Control.Monad.Except import Control.Monad.IO.Unlift import Control.Monad.Reader import Crypto.Random (ChaChaDRG) +import qualified Data.Aeson as J import Data.Bifunctor (first) import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B +import qualified Data.ByteString.Lazy.Char8 as LB import Data.Char (isDigit) import Data.Containers.ListUtils (nubOrd) import Data.Either (partitionEithers, rights) @@ -77,19 +80,19 @@ import Simplex.FileTransfer.Description (FileDescriptionURI (..), ValidFileDescr import qualified Simplex.FileTransfer.Description as FD import Simplex.FileTransfer.Protocol (FileParty (..), FilePartyI) import Simplex.FileTransfer.Types (RcvFileId, SndFileId) -import Simplex.Messaging.Agent as Agent +import Simplex.Messaging.Agent import Simplex.Messaging.Agent.Client (getFastNetworkConfig, ipAddressProtected, withLockMap) import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), ServerCfg (..)) import Simplex.Messaging.Agent.Lock (withLock) import Simplex.Messaging.Agent.Protocol import qualified Simplex.Messaging.Agent.Protocol as AP (AgentErrorType (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB -import Simplex.Messaging.Client (NetworkConfig (..), NetworkRequestMode) +import Simplex.Messaging.Client (NetworkConfig (..), NetworkRequestMode (..)) +import Simplex.Messaging.Compression (compressionLevel) import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport (..), pattern IKPQOff, pattern PQEncOff, pattern PQEncOn, pattern PQSupportOff, pattern PQSupportOn) import qualified Simplex.Messaging.Crypto.Ratchet as CR -import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Protocol (MsgBody, MsgFlags (..), ProtoServerWithAuth (..), ProtocolServer, ProtocolTypeI (..), SProtocolType (..), SubscriptionMode (..), UserProtocol, XFTPServer) import qualified Simplex.Messaging.Protocol as SMP @@ -97,10 +100,10 @@ import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Util import Simplex.Messaging.Version import System.FilePath (takeFileName, ()) -import System.IO (Handle, IOMode (..), SeekMode (..), hFlush) +import System.IO (Handle, IOMode (..), hFlush) import UnliftIO.Concurrent (forkFinally, mkWeakThreadId) import UnliftIO.Directory -import UnliftIO.IO (hClose, hSeek, hTell, openFile) +import UnliftIO.IO (hClose, openFile) import UnliftIO.STM maxMsgReactions :: Int @@ -193,7 +196,7 @@ toggleNtf :: GroupMember -> Bool -> CM () toggleNtf m ntfOn = when (memberActive m) $ forM_ (memberConnId m) $ \connId -> - withAgent (\a -> toggleConnectionNtfs a connId ntfOn) `catchChatError` eToView + withAgent (\a -> toggleConnectionNtfs a connId ntfOn) `catchAllErrors` eToView prepareGroupMsg :: DB.Connection -> User -> GroupInfo -> Maybe MsgScope -> MsgContent -> Map MemberName MsgMention -> Maybe ChatItemId -> Maybe CIForwardedFrom -> Maybe FileInvitation -> Maybe CITimed -> Bool -> ExceptT StoreError IO (ChatMsgEvent 'Json, Maybe (CIQuote 'CTGroup)) prepareGroupMsg db user g@GroupInfo {membership} msgScope mc mentions quotedItemId_ itemForwarded fInv_ timed_ live = case (quotedItemId_, itemForwarded) of @@ -346,9 +349,9 @@ prohibitedSimplexLinks :: GroupInfo -> GroupMember -> Maybe MarkdownList -> Bool prohibitedSimplexLinks gInfo m ft = not (groupFeatureMemberAllowed SGFSimplexLinks m gInfo) && maybe False (any ftIsSimplexLink) ft - where - ftIsSimplexLink :: FormattedText -> Bool - ftIsSimplexLink FormattedText {format} = maybe False isSimplexLink format + +ftIsSimplexLink :: FormattedText -> Bool +ftIsSimplexLink FormattedText {format} = maybe False isSimplexLink format roundedFDCount :: Int -> Int roundedFDCount n @@ -385,17 +388,13 @@ cancelFilesInProgress :: User -> [CIFileInfo] -> CM () cancelFilesInProgress user filesInfo = do let filesInfo' = filter (not . fileEnded) filesInfo (sfs, rfs) <- lift $ splitFTTypes <$> withStoreBatch (\db -> map (getFT db) filesInfo') - forM_ rfs $ \RcvFileTransfer {fileId} -> lift (closeFileHandle fileId rcvFiles) `catchChatError` \_ -> pure () + forM_ rfs $ \RcvFileTransfer {fileId} -> lift (closeFileHandle fileId rcvFiles) `catchAllErrors` \_ -> pure () lift . void . withStoreBatch' $ \db -> map (updateSndFileCancelled db) sfs lift . void . withStoreBatch' $ \db -> map (updateRcvFileCancelled db) rfs let xsfIds = mapMaybe (\(FileTransferMeta {fileId, xftpSndFile}, _) -> (,fileId) <$> xftpSndFile) sfs xrfIds = mapMaybe (\RcvFileTransfer {fileId, xftpRcvFile} -> (,fileId) <$> xftpRcvFile) rfs lift $ agentXFTPDeleteSndFilesRemote user xsfIds lift $ agentXFTPDeleteRcvFiles xrfIds - let smpSFConnIds = concatMap (\(ft, sfts) -> mapMaybe (smpSndFileConnId ft) sfts) sfs - smpRFConnIds = mapMaybe smpRcvFileConnId rfs - deleteAgentConnectionsAsync smpSFConnIds - deleteAgentConnectionsAsync smpRFConnIds where fileEnded CIFileInfo {fileStatus} = case fileStatus of Just (AFS _ status) -> ciFileEnded status @@ -405,12 +404,7 @@ cancelFilesInProgress user filesInfo = do updateSndFileCancelled :: DB.Connection -> (FileTransferMeta, [SndFileTransfer]) -> IO () updateSndFileCancelled db (FileTransferMeta {fileId}, sfts) = do updateFileCancelled db user fileId CIFSSndCancelled - forM_ sfts updateSndFTCancelled - where - updateSndFTCancelled :: SndFileTransfer -> IO () - updateSndFTCancelled ft = unless (sndFTEnded ft) $ do - updateSndFileStatus db ft FSCancelled - deleteSndFileChunks db ft + forM_ sfts $ \sft -> unless (sndFTEnded sft) $ updateSndFileStatus db sft FSCancelled updateRcvFileCancelled :: DB.Connection -> RcvFileTransfer -> IO () updateRcvFileCancelled db ft@RcvFileTransfer {fileId} = do updateFileCancelled db user fileId CIFSRcvCancelled @@ -423,14 +417,6 @@ cancelFilesInProgress user filesInfo = do FTSnd ft@FileTransferMeta {cancelled} sfts | not cancelled -> ((ft, sfts) : sfs, rfs) FTRcv ft@RcvFileTransfer {cancelled} | not cancelled -> (sfs, ft : rfs) _ -> (sfs, rfs) - smpSndFileConnId :: FileTransferMeta -> SndFileTransfer -> Maybe ConnId - smpSndFileConnId FileTransferMeta {xftpSndFile} sft@SndFileTransfer {agentConnId = AgentConnId acId, fileInline} - | isNothing xftpSndFile && isNothing fileInline && not (sndFTEnded sft) = Just acId - | otherwise = Nothing - smpRcvFileConnId :: RcvFileTransfer -> Maybe ConnId - smpRcvFileConnId ft@RcvFileTransfer {xftpRcvFile, rcvFileInline} - | isNothing xftpRcvFile && isNothing rcvFileInline = liveRcvFileTransferConnId ft - | otherwise = Nothing sndFTEnded SndFileTransfer {fileStatus} = fileStatus == FSCancelled || fileStatus == FSComplete deleteFilesLocally :: [CIFileInfo] -> CM () @@ -655,7 +641,7 @@ setFileToEncrypt ft@RcvFileTransfer {fileId} = do receiveFile' :: User -> RcvFileTransfer -> Bool -> Maybe Bool -> Maybe FilePath -> CM ChatResponse receiveFile' user ft userApprovedRelays rcvInline_ filePath_ = do - (CRRcvFileAccepted user <$> acceptFileReceive user ft userApprovedRelays rcvInline_ filePath_) `catchChatError` processError + (CRRcvFileAccepted user <$> acceptFileReceive user ft userApprovedRelays rcvInline_ filePath_) `catchAllErrors` processError where -- TODO AChatItem in Cancelled events processError e @@ -664,7 +650,7 @@ receiveFile' user ft userApprovedRelays rcvInline_ filePath_ = do receiveFileEvt' :: User -> RcvFileTransfer -> Bool -> Maybe Bool -> Maybe FilePath -> CM ChatEvent receiveFileEvt' user ft userApprovedRelays rcvInline_ filePath_ = do - (CEvtRcvFileAccepted user <$> acceptFileReceive user ft userApprovedRelays rcvInline_ filePath_) `catchChatError` processError + (CEvtRcvFileAccepted user <$> acceptFileReceive user ft userApprovedRelays rcvInline_ filePath_) `catchAllErrors` processError where -- TODO AChatItem in Cancelled events processError e @@ -673,8 +659,8 @@ receiveFileEvt' user ft userApprovedRelays rcvInline_ filePath_ = do rctFileCancelled :: ChatError -> Bool rctFileCancelled = \case - ChatErrorAgent (SMP _ SMP.AUTH) _ -> True - ChatErrorAgent (CONN DUPLICATE _) _ -> True + ChatErrorAgent (SMP _ SMP.AUTH) _ _ -> True + ChatErrorAgent (CONN DUPLICATE _) _ _ -> True _ -> False acceptFileReceive :: User -> RcvFileTransfer -> Bool -> Maybe Bool -> Maybe FilePath -> CM AChatItem @@ -684,13 +670,6 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI _ -> throwChatError $ CEFileAlreadyReceiving fName vr <- chatVersionRange case (xftpRcvFile, fileConnReq) of - -- direct file protocol - (Nothing, Just connReq) -> do - subMode <- chatReadVar subscriptionMode - dm <- encodeConnInfo $ XFileAcpt fName - connIds <- joinAgentConnectionAsync user True connReq dm subMode - filePath <- getRcvFilePath fileId filePath_ fName True - withStore $ \db -> acceptRcvFileTransfer db vr user fileId connIds ConnJoined filePath subMode -- XFTP (Just XFTPRcvFile {userApprovedRelays = approvedBeforeReady}, _) -> do let userApproved = approvedBeforeReady || userApprovedRelays @@ -703,23 +682,24 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI pure (ci, rfd) receiveViaCompleteFD user fileId rfd userApproved cryptoArgs pure ci + (Nothing, Just _fileConnReq) -> throwChatError $ CEException "accepting file via a separate connection is deprecated" -- group & direct file protocol _ -> do chatRef <- withStore $ \db -> getChatRefByFileId db user fileId case (chatRef, grpMemberId) of (ChatRef CTDirect contactId _, Nothing) -> do ct <- withStore $ \db -> getContact db vr user contactId - acceptFile CFCreateConnFileInvDirect $ \msg -> void $ sendDirectContactMessage user ct msg + acceptFile $ \msg -> void $ sendDirectContactMessage user ct msg (ChatRef CTGroup groupId _, Just memId) -> do GroupMember {activeConn} <- withStore $ \db -> getGroupMember db vr user groupId memId case activeConn of Just conn -> do - acceptFile CFCreateConnFileInvGroup $ \msg -> void $ sendDirectMemberMessage conn msg groupId + acceptFile $ \msg -> void $ sendDirectMemberMessage conn msg groupId _ -> throwChatError $ CEFileInternal "member connection not active" _ -> throwChatError $ CEFileInternal "invalid chat ref for file transfer" where - acceptFile :: CommandFunction -> (ChatMsgEvent 'Json -> CM ()) -> CM AChatItem - acceptFile cmdFunction send = do + acceptFile :: (ChatMsgEvent 'Json -> CM ()) -> CM AChatItem + acceptFile send = do filePath <- getRcvFilePath fileId filePath_ fName True inline <- receiveInline vr <- chatVersionRange @@ -731,11 +711,7 @@ acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileI send $ XFileAcptInv sharedMsgId Nothing fName pure ci | fileInline == Just IFMSent -> throwChatError $ CEFileAlreadyReceiving fName - | otherwise -> do - -- accepting via a new connection - subMode <- chatReadVar subscriptionMode - connIds <- createAgentConnectionAsync user cmdFunction True SCMInvitation subMode - withStore $ \db -> acceptRcvFileTransfer db vr user fileId connIds ConnNew filePath subMode + | otherwise -> throwChatError $ CEException "accepting file via a separate connection is deprecated" receiveInline :: CM Bool receiveInline = do ChatConfig {fileChunkSize, inlineFiles = InlineFilesConfig {receiveChunks, offerChunks}} <- asks config @@ -788,7 +764,7 @@ receiveViaCompleteFD user fileId RcvFileDescr {fileDescrText, fileDescrComplete} cleanupACIFile :: AChatItem -> CM () cleanupACIFile (AChatItem _ _ _ ChatItem {file = Just CIFile {fileSource = Just CryptoFile {filePath}}}) = do fsFilePath <- lift $ toFSFilePath filePath - removeFile fsFilePath `catchChatError` \_ -> pure () + removeFile fsFilePath `catchAllErrors` \_ -> pure () cleanupACIFile _ = pure () getKnownAgentServers :: (ProtocolTypeI p, UserProtocol p) => SProtocolType p -> User -> CM (NonEmpty (ServerCfg p)) @@ -951,7 +927,7 @@ acceptGroupJoinRequestAsync (groupMemberId, memberId) <- withStore $ \db -> createJoiningMember db gVar user gInfo cReqChatVRange cReqProfile cReqXContactId_ welcomeMsgId_ gLinkMemRole initialStatus currentMemCount <- withStore' $ \db -> getGroupCurrentMembersCount db user gInfo - let Profile {displayName} = userProfileInGroup user (fromIncognitoProfile <$> incognitoProfile) + let Profile {displayName} = userProfileInGroup user gInfo (fromIncognitoProfile <$> incognitoProfile) GroupMember {memberRole = userRole, memberId = userMemberId} = membership msg = XGrpLinkInv $ @@ -1077,7 +1053,7 @@ introduceToRemaining vr user gInfo m = do 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 m) msgScope + void . sendGroupMessage' user gInfo introduceToMembers $ XGrpMemNew (memberInfo gInfo m) msgScope sendIntroductions introduceToMembers where sendIntroductions members = do @@ -1089,10 +1065,10 @@ introduceMember vr user gInfo@GroupInfo {groupId} m@GroupMember {activeConn = Ju forM_ (L.nonEmpty events) $ \events' -> sendGroupMemberMessages user conn events' groupId else forM_ shuffledIntros $ \intro -> - processIntro intro `catchChatError` eToView + processIntro intro `catchAllErrors` eToView memberIntro :: GroupMember -> ChatMsgEvent 'Json memberIntro reMember = - let mInfo = memberInfo reMember + let mInfo = memberInfo gInfo reMember mRestrictions = memberRestrictions reMember in XGrpMemIntro mInfo mRestrictions shuffleIntros :: [GroupMemberIntro] -> IO [GroupMemberIntro] @@ -1108,12 +1084,40 @@ introduceMember vr user gInfo@GroupInfo {groupId} m@GroupMember {activeConn = Ju void $ sendDirectMemberMessage conn (memberIntro $ reMember intro) groupId withStore' $ \db -> updateIntroStatus db introId GMIntroSent +userProfileInGroup :: User -> GroupInfo -> Maybe Profile -> Profile +userProfileInGroup user = userProfileInGroup' user . groupFeatureUserAllowed SGFSimplexLinks +{-# INLINE userProfileInGroup #-} + +userProfileInGroup' :: User -> Bool -> Maybe Profile -> Profile +userProfileInGroup' User {profile = p} allowSimplexLinks incognitoProfile = + let p' = fromMaybe (fromLocalProfile p) incognitoProfile + in redactedMemberProfile allowSimplexLinks p' + +memberInfo :: GroupInfo -> GroupMember -> MemberInfo +memberInfo g m@GroupMember {memberId, memberRole, memberProfile, activeConn} = + MemberInfo + { memberId, + memberRole, + v = ChatVersionRange . peerChatVRange <$> activeConn, + profile = redactedMemberProfile allowSimplexLinks $ fromLocalProfile memberProfile + } + where + allowSimplexLinks = groupFeatureMemberAllowed SGFSimplexLinks m g + +redactedMemberProfile :: Bool -> Profile -> Profile +redactedMemberProfile allowSimplexLinks Profile {displayName, fullName, shortDescr, image, peerType} = + Profile {displayName, fullName, shortDescr = removeSimplexLink =<< shortDescr, image, contactLink = Nothing, preferences = Nothing, peerType} + where + removeSimplexLink s + | allowSimplexLinks = Just s + | otherwise = maybe (Just s) (\fts -> if any ftIsSimplexLink fts then Nothing else Just s) $ parseMaybeMarkdownList s + sendHistory :: User -> GroupInfo -> GroupMember -> CM () sendHistory _ _ GroupMember {activeConn = Nothing} = throwChatError $ CEInternalError "member connection not active" sendHistory user gInfo@GroupInfo {groupId, membership} m@GroupMember {activeConn = Just conn} = when (m `supportsVersion` batchSendVersion) $ do (errs, items) <- partitionEithers <$> withStore' (\db -> getGroupHistoryItems db user gInfo m 100) - (errs', events) <- partitionEithers <$> mapM (tryChatError . itemForwardEvents) items + (errs', events) <- partitionEithers <$> mapM (tryAllErrors . itemForwardEvents) items let errors = map ChatErrorStore errs <> errs' unless (null errors) $ toView $ CEvtChatErrors errors let events' = concat events @@ -1216,6 +1220,59 @@ splitFileDescr partSize rfdText = splitParts 1 rfdText then fileDescr :| [] else fileDescr <| splitParts (partNo + 1) rest +setGroupLinkData' :: NetworkRequestMode -> User -> GroupInfo -> CM () +setGroupLinkData' nm user gInfo = + withFastStore' (\db -> runExceptT $ getGroupLink db user gInfo) >>= \case + Right gLink@GroupLink {shortLinkDataSet} + | shortLinkDataSet -> void $ setGroupLinkData nm user gInfo gLink + _ -> pure () + +setGroupLinkData :: NetworkRequestMode -> User -> GroupInfo -> GroupLink -> CM GroupLink +setGroupLinkData nm user gInfo@GroupInfo {groupProfile} gLink@GroupLink {groupLinkId} = do + 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 userLinkData (Just crClientData)) + withFastStore' $ \db -> setGroupLinkShortLink db gLink sLnk + +encodeShortLinkData :: J.ToJSON a => a -> UserLinkData +encodeShortLinkData d = + let s = LB.toStrict $ J.encode d + -- 10kb size limit for compression to be used is based on 13784 limit for link data + -- and the space reserved for the other fields in ConnLinkData encoding (most of these fields are currently unused). + s' + | B.length s > 10240 = B.cons 'X' $ Z1.compress compressionLevel s + | otherwise = s + in UserLinkData s' + +decodeShortLinkData :: J.FromJSON a => ConnLinkData c -> IO (Maybe a) +decodeShortLinkData cData + | B.null s = pure Nothing + | B.head s == 'X' = case Z1.decompress $ B.drop 1 s of + Z1.Error e -> Nothing <$ logError ("Error decompressing link data: " <> tshow e) + Z1.Skip -> pure Nothing + Z1.Decompress s' -> decode s' + | otherwise = decode s + where + decode s' = case J.eitherDecodeStrict s' of + Right d -> pure $ Just d + Left e -> Nothing <$ logError ("Error decoding link data: " <> tshow e) + s = linkUserData' cData + +shortenShortLink' :: ConnShortLink m -> CM (ConnShortLink m) +shortenShortLink' l = (`shortenShortLink` l) <$> asks (shortLinkPresetServers . config) + +shortenCreatedLink :: CreatedConnLink m -> CM (CreatedConnLink m) +shortenCreatedLink (CCLink cReq sLnk) = CCLink cReq <$> mapM shortenShortLink' sLnk + +createdGroupLink :: CreatedLinkContact -> CreatedLinkContact +createdGroupLink (CCLink cReq shortLink) = CCLink cReq (toShortGroupLink <$> shortLink) + +toShortGroupLink :: ShortLinkContact -> ShortLinkContact +toShortGroupLink (CSLContact sch _ srv k) = CSLContact sch CCTGroup srv k + deleteGroupLink' :: User -> GroupInfo -> CM () deleteGroupLink' user gInfo = do vr <- chatVersionRange @@ -1286,7 +1343,7 @@ metaBrokerTs MsgMeta {broker = (_, brokerTs)} = brokerTs createContactPQSndItem :: User -> Contact -> Connection -> PQEncryption -> CM (Contact, Connection) createContactPQSndItem user ct conn@Connection {pqSndEnabled} pqSndEnabled' = - flip catchChatError (const $ pure (ct, conn)) $ case (pqSndEnabled, pqSndEnabled') of + flip catchAllErrors (const $ pure (ct, conn)) $ case (pqSndEnabled, pqSndEnabled') of (Just b, b') | b' /= b -> createPQItem $ CISndConnEvent (SCEPqEnabled pqSndEnabled') (Nothing, PQEncOn) -> createPQItem $ CISndDirectE2EEInfo (E2EInfo $ Just pqSndEnabled') _ -> pure (ct, conn) @@ -1301,7 +1358,7 @@ createContactPQSndItem user ct conn@Connection {pqSndEnabled} pqSndEnabled' = updateContactPQRcv :: User -> Contact -> Connection -> PQEncryption -> CM (Contact, Connection) updateContactPQRcv user ct conn@Connection {connId, pqRcvEnabled} pqRcvEnabled' = - flip catchChatError (const $ pure (ct, conn)) $ case (pqRcvEnabled, pqRcvEnabled') of + flip catchAllErrors (const $ pure (ct, conn)) $ case (pqRcvEnabled, pqRcvEnabled') of (Just b, b') | b' /= b -> updatePQ $ CIRcvConnEvent (RCEPqEnabled pqRcvEnabled') (Nothing, PQEncOn) -> updatePQ $ CIRcvDirectE2EEInfo (E2EInfo $ Just pqRcvEnabled') _ -> pure (ct, conn) @@ -1393,31 +1450,30 @@ getChatScopeInfo vr user = \case supportMem <- withFastStore $ \db -> getGroupMemberById db vr user gmId pure $ GCSIMemberSupport (Just supportMem) --- TODO [knocking] refactor to GroupChatScope -> "a" function, "a" is some new type? Or possibly split to get scope/get recipients steps -getGroupRecipients :: VersionRangeChat -> User -> GroupInfo -> Maybe GroupChatScope -> VersionChat -> CM (Maybe GroupChatScopeInfo, [GroupMember]) -getGroupRecipients vr user gInfo@GroupInfo {membership} scope modsCompatVersion = case scope of - Nothing -> do - unless (memberCurrent membership && memberActive membership) $ throwChatError $ CECommandError "not current member" - ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo - let recipients = filter memberCurrent ms - pure (Nothing, recipients) - Just (GCSMemberSupport Nothing) -> do - modMs <- withFastStore' $ \db -> getGroupModerators db vr user gInfo - let rcpModMs' = filter (\m -> compatible m && memberCurrent m) modMs - when (null rcpModMs') $ throwChatError $ CECommandError "no admins support this message" - let scopeInfo = GCSIMemberSupport Nothing - pure (Just scopeInfo, rcpModMs') - Just (GCSMemberSupport (Just gmId)) -> do - unless (memberCurrent membership && memberActive membership) $ throwChatError $ CECommandError "not current member" - supportMem <- withFastStore $ \db -> getGroupMemberById db vr user gmId - unless (memberCurrentOrPending supportMem) $ throwChatError $ CECommandError "support member not current or pending" - let scopeInfo = GCSIMemberSupport (Just supportMem) - if memberStatus supportMem == GSMemPendingApproval - then pure (Just scopeInfo, [supportMem]) - else do +getGroupRecipients :: VersionRangeChat -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> VersionChat -> CM [GroupMember] +getGroupRecipients vr user gInfo@GroupInfo {useRelays, membership} scopeInfo modsCompatVersion + | isTrue useRelays && not (isMemberRelay membership) = do + unless (memberCurrent membership && memberActive membership) $ throwChatError $ CECommandError "not current member" + withFastStore' $ \db -> getGroupRelays db vr user gInfo + | otherwise = case scopeInfo of + Nothing -> do + unless (memberCurrent membership && memberActive membership) $ throwChatError $ CECommandError "not current member" + ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo + pure $ filter memberCurrent ms + Just (GCSIMemberSupport Nothing) -> do modMs <- withFastStore' $ \db -> getGroupModerators db vr user gInfo let rcpModMs' = filter (\m -> compatible m && memberCurrent m) modMs - pure (Just scopeInfo, [supportMem] <> rcpModMs') + when (null rcpModMs') $ throwChatError $ CECommandError "no admins support this message" + pure rcpModMs' + Just (GCSIMemberSupport (Just supportMem)) -> do + unless (memberCurrent membership && memberActive membership) $ throwChatError $ CECommandError "not current member" + unless (memberCurrentOrPending supportMem) $ throwChatError $ CECommandError "support member not current or pending" + if memberStatus supportMem == GSMemPendingApproval + then pure [supportMem] + else do + modMs <- withFastStore' $ \db -> getGroupModerators db vr user gInfo + let rcpModMs' = filter (\m -> compatible m && memberCurrent m) modMs + pure $ [supportMem] <> rcpModMs' where compatible GroupMember {activeConn, memberChatVRange} = maxVersion (maybe memberChatVRange peerChatVRange activeConn) >= modsCompatVersion @@ -1441,21 +1497,26 @@ mkGroupChatScope gInfo@GroupInfo {membership} m | otherwise = pure (gInfo, m, Nothing) -mkGetMessageChatScope :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> Maybe MsgScope -> CM (GroupInfo, GroupMember, Maybe GroupChatScopeInfo) -mkGetMessageChatScope vr user gInfo@GroupInfo {membership} m msgScope_ = +mkGetMessageChatScope :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> MsgContent -> Maybe MsgScope -> CM (GroupInfo, GroupMember, Maybe GroupChatScopeInfo) +mkGetMessageChatScope vr user gInfo@GroupInfo {membership} m mc msgScope_ = mkGroupChatScope gInfo m >>= \case groupScope@(_gInfo', _m', Just _scopeInfo) -> pure groupScope - (_, _, Nothing) -> case msgScope_ of - Nothing -> pure (gInfo, m, Nothing) - Just (MSMember mId) - | sameMemberId mId membership -> do - (gInfo', scopeInfo) <- mkGroupSupportChatInfo gInfo - pure (gInfo', m, Just scopeInfo) - | otherwise -> do - referredMember <- withStore $ \db -> getGroupMemberByMemberId db vr user gInfo mId - -- TODO [knocking] return patched _referredMember' too? - (_referredMember', scopeInfo) <- mkMemberSupportChatInfo referredMember - pure (gInfo, m, Just scopeInfo) + (_, _, Nothing) + | isReport mc -> do + -- TODO [knocking] return patched _m'? + (_m', scopeInfo) <- mkMemberSupportChatInfo m -- only support scope member can send a report (m is sender) + pure (gInfo, m, Just scopeInfo) + | otherwise -> case msgScope_ of + Nothing -> pure (gInfo, m, Nothing) + Just (MSMember mId) + | sameMemberId mId membership -> do + (gInfo', scopeInfo) <- mkGroupSupportChatInfo gInfo + pure (gInfo', m, Just scopeInfo) + | otherwise -> do + referredMember <- withStore $ \db -> getGroupMemberByMemberId db vr user gInfo mId + -- TODO [knocking] return patched _referredMember'? + (_referredMember', scopeInfo) <- mkMemberSupportChatInfo referredMember + pure (gInfo, m, Just scopeInfo) mkGroupSupportChatInfo :: GroupInfo -> CM (GroupInfo, GroupChatScopeInfo) mkGroupSupportChatInfo gInfo@GroupInfo {membership} = @@ -1483,49 +1544,13 @@ mkMemberSupportChatInfo m@GroupMember {groupMemberId, supportChat} = let scopeInfo = GCSIMemberSupport {groupMember_ = Just m} in pure (m, scopeInfo) -sendFileChunk :: User -> SndFileTransfer -> CM () -sendFileChunk user ft@SndFileTransfer {fileId, fileStatus, agentConnId = AgentConnId acId} = - unless (fileStatus == FSComplete || fileStatus == FSCancelled) $ do - vr <- chatVersionRange - withStore' (`createSndFileChunk` ft) >>= \case - Just chunkNo -> sendFileChunkNo ft chunkNo - Nothing -> do - ci <- withStore $ \db -> do - liftIO $ updateSndFileStatus db ft FSComplete - liftIO $ deleteSndFileChunks db ft - updateDirectCIFileStatus db vr user fileId CIFSSndComplete - toView $ CEvtSndFileComplete user ci ft - lift $ closeFileHandle fileId sndFiles - deleteAgentConnectionAsync acId - -sendFileChunkNo :: SndFileTransfer -> Integer -> CM () -sendFileChunkNo ft@SndFileTransfer {agentConnId = AgentConnId acId} chunkNo = do - chunkBytes <- readFileChunk ft chunkNo - (msgId, _) <- withAgent $ \a -> sendMessage a acId PQEncOff SMP.noMsgFlags $ smpEncode FileChunk {chunkNo, chunkBytes} - withStore' $ \db -> updateSndFileChunkMsg db ft chunkNo msgId - -readFileChunk :: SndFileTransfer -> Integer -> CM ByteString -readFileChunk SndFileTransfer {fileId, filePath, chunkSize} chunkNo = do - fsFilePath <- lift $ toFSFilePath filePath - read_ fsFilePath `catchThrow` (ChatError . CEFileRead filePath . show) - where - read_ fsFilePath = do - h <- getFileHandle fileId fsFilePath sndFiles ReadMode - pos <- hTell h - let pos' = (chunkNo - 1) * chunkSize - when (pos /= pos') $ hSeek h AbsoluteSeek pos' - liftIO . B.hGet h $ fromInteger chunkSize - -parseFileChunk :: ByteString -> CM FileChunk -parseFileChunk = liftEither . first (ChatError . CEFileRcvChunk) . smpDecode - appendFileChunk :: RcvFileTransfer -> Integer -> ByteString -> Bool -> CM () appendFileChunk ft@RcvFileTransfer {fileId, fileStatus, cryptoArgs, fileInvitation = FileInvitation {fileName}} chunkNo chunk final = case fileStatus of - RFSConnected RcvFileInfo {filePath} -> append_ filePath + RFSConnected filePath -> append_ filePath -- sometimes update of file transfer status to FSConnected -- doesn't complete in time before MSG with first file chunk - RFSAccepted RcvFileInfo {filePath} -> append_ filePath + RFSAccepted filePath -> append_ filePath RFSCancelled _ -> pure () _ -> throwChatError $ CEFileInternal "receiving file transfer not in progress" where @@ -1539,13 +1564,13 @@ appendFileChunk ft@RcvFileTransfer {fileId, fileStatus, cryptoArgs, fileInvitati lift $ closeFileHandle fileId rcvFiles forM_ cryptoArgs $ \cfArgs -> do tmpFile <- lift getChatTempDirectory >>= liftIO . (`uniqueCombine` fileName) - tryChatError (liftError encryptErr $ encryptFile fsFilePath tmpFile cfArgs) >>= \case + tryAllErrors (liftError encryptErr $ encryptFile fsFilePath tmpFile cfArgs) >>= \case Right () -> do - removeFile fsFilePath `catchChatError` \_ -> pure () + removeFile fsFilePath `catchAllErrors` \_ -> pure () renameFile tmpFile fsFilePath Left e -> do eToView e - removeFile tmpFile `catchChatError` \_ -> pure () + removeFile tmpFile `catchAllErrors` \_ -> pure () withStore' (`removeFileCryptoArgs` fileId) where encryptErr e = fileErr $ e <> ", received file not encrypted" @@ -1567,9 +1592,9 @@ isFileActive fileId files = do fs <- asks files isJust . M.lookup fileId <$> readTVarIO fs -cancelRcvFileTransfer :: User -> RcvFileTransfer -> CM (Maybe ConnId) -cancelRcvFileTransfer user ft@RcvFileTransfer {fileId, xftpRcvFile, rcvFileInline} = - cancel' `catchChatError` (\e -> eToView e $> fileConnId) +cancelRcvFileTransfer :: User -> RcvFileTransfer -> CM () +cancelRcvFileTransfer user ft@RcvFileTransfer {fileId, xftpRcvFile} = + cancel' `catchAllErrors` \e -> eToView e where cancel' = do lift $ closeFileHandle fileId rcvFiles @@ -1581,40 +1606,31 @@ cancelRcvFileTransfer user ft@RcvFileTransfer {fileId, xftpRcvFile, rcvFileInlin Just XFTPRcvFile {agentRcvFileId = Just (AgentRcvFileId aFileId), agentRcvFileDeleted} -> unless agentRcvFileDeleted $ agentXFTPDeleteRcvFile aFileId fileId _ -> pure () - pure fileConnId - fileConnId = if isNothing xftpRcvFile && isNothing rcvFileInline then liveRcvFileTransferConnId ft else Nothing -cancelSndFile :: User -> FileTransferMeta -> [SndFileTransfer] -> Bool -> CM [ConnId] +cancelSndFile :: User -> FileTransferMeta -> [SndFileTransfer] -> Bool -> CM () cancelSndFile user FileTransferMeta {fileId, xftpSndFile} fts sendCancel = do withStore' (\db -> updateFileCancelled db user fileId CIFSSndCancelled) - `catchChatError` eToView + `catchAllErrors` eToView case xftpSndFile of Nothing -> - catMaybes <$> forM fts (\ft -> cancelSndFileTransfer user ft sendCancel) + forM_ fts (\ft -> cancelSndFileTransfer user ft sendCancel) Just xsf -> do forM_ fts (\ft -> cancelSndFileTransfer user ft False) - lift (agentXFTPDeleteSndFileRemote user xsf fileId) `catchChatError` eToView - pure [] + lift (agentXFTPDeleteSndFileRemote user xsf fileId) `catchAllErrors` eToView --- TODO v6.0 remove -cancelSndFileTransfer :: User -> SndFileTransfer -> Bool -> CM (Maybe ConnId) -cancelSndFileTransfer user@User {userId} ft@SndFileTransfer {fileId, connId, agentConnId = AgentConnId acId, fileStatus, fileInline} sendCancel = - if fileStatus == FSCancelled || fileStatus == FSComplete - then pure Nothing - else cancel' `catchChatError` (\e -> eToView e $> fileConnId) +cancelSndFileTransfer :: User -> SndFileTransfer -> Bool -> CM () +cancelSndFileTransfer user@User {userId} ft@SndFileTransfer {fileId, connId, fileStatus, fileInline} sendCancel = + unless (fileStatus == FSCancelled || fileStatus == FSComplete) $ + cancel' `catchAllErrors` \e -> eToView e where cancel' = do - withStore' $ \db -> do - updateSndFileStatus db ft FSCancelled - deleteSndFileChunks db ft + withStore' $ \db -> updateSndFileStatus db ft FSCancelled when sendCancel $ case fileInline of Just _ -> do vr <- chatVersionRange (sharedMsgId, conn) <- withStore $ \db -> (,) <$> getSharedMsgIdByFileId db userId fileId <*> getConnectionById db vr user connId void $ sendDirectMessage_ conn (BFileChunk sharedMsgId FileChunkCancel) (ConnectionId connId) - _ -> withAgent $ \a -> void . sendMessage a acId PQEncOff SMP.noMsgFlags $ smpEncode FileChunkCancel - pure fileConnId - fileConnId = if isNothing fileInline then Just acId else Nothing + _ -> throwChatError $ CEException "cancelSndFileTransfer: cancelling file via a separate connection is deprecated" closeFileHandle :: Int64 -> (ChatController -> TVar (Map Int64 Handle)) -> CM' () closeFileHandle fileId files = do @@ -1661,7 +1677,7 @@ sendDirectContactMessages user ct events = do if v >= batchSend2Version then sendDirectContactMessages' user ct events else forM (L.toList events) $ \evt -> - (Right . fst <$> sendDirectContactMessage user ct evt) `catchChatError` \e -> pure (Left e) + (Right . fst <$> sendDirectContactMessage user ct evt) `catchAllErrors` \e -> pure (Left e) sendDirectContactMessages' :: MsgEncodingI e => User -> Contact -> NonEmpty (ChatMsgEvent e) -> CM [Either ChatError SndMessage] sendDirectContactMessages' user ct events = do @@ -1827,7 +1843,7 @@ deliverMessagesB msgReqs = do Left _ce -> (prev, Left (AP.INTERNAL "ChatError, skip")) -- as long as it is Left, the agent batchers should just step over it prepareBatch (Right req) (Right ar) = Right (req, ar) prepareBatch (Left ce) _ = Left ce -- restore original ChatError - prepareBatch _ (Left ae) = Left $ ChatErrorAgent ae Nothing + prepareBatch _ (Left ae) = Left $ ChatErrorAgent ae (AgentConnId "") Nothing createDelivery :: DB.Connection -> (ChatMsgReq, (AgentMsgId, PQEncryption)) -> IO (Either ChatError ([Int64], PQEncryption)) createDelivery db ((Connection {connId}, _, (_, msgIds)), (agentMsgId, pqEnc')) = do Right . (,pqEnc') <$> mapM (createSndMsgDelivery db (SndMsgDelivery {connId, agentMsgId})) msgIds @@ -1856,7 +1872,7 @@ sendGroupMessages :: MsgEncodingI e => User -> GroupInfo -> Maybe GroupChatScope sendGroupMessages user gInfo scope members events = do -- TODO [knocking] send current profile to pending member after approval? when shouldSendProfileUpdate $ - sendProfileUpdate `catchChatError` eToView + sendProfileUpdate `catchAllErrors` eToView sendGroupMessages_ user gInfo members events where User {profile = p, userMemberProfileUpdatedAt} = user @@ -1871,7 +1887,8 @@ sendGroupMessages user gInfo scope members events = do _ -> False sendProfileUpdate = do let members' = filter (`supportsVersion` memberProfileUpdateVersion) members - profileUpdateEvent = XInfo $ redactedMemberProfile $ fromLocalProfile p + allowSimplexLinks = groupFeatureUserAllowed SGFSimplexLinks gInfo + profileUpdateEvent = XInfo $ redactedMemberProfile allowSimplexLinks $ fromLocalProfile p void $ sendGroupMessage' user gInfo members' profileUpdateEvent currentTs <- liftIO getCurrentTime withStore' $ \db -> updateUserMemberProfileSentAt db user gInfo currentTs @@ -1972,13 +1989,22 @@ sendGroupMessages_ _user gInfo@GroupInfo {groupId} recipientMembers events = do data MemberSendAction = MSASend Connection | MSASendBatched Connection | MSAPending | MSAForwarded memberSendAction :: GroupInfo -> NonEmpty (ChatMsgEvent e) -> [GroupMember] -> GroupMember -> Maybe MemberSendAction -memberSendAction gInfo events members m@GroupMember {memberRole, memberStatus} = case memberConn m of - Nothing -> pendingOrForwarded - Just conn@Connection {connStatus} - | connDisabled conn || connStatus == ConnDeleted || memberStatus == GSMemRejected -> Nothing - | connInactive conn -> Just MSAPending - | connStatus == ConnSndReady || connStatus == ConnReady -> sendBatchedOrSeparate conn - | otherwise -> pendingOrForwarded +memberSendAction GroupInfo {useRelays, membership} events members m@GroupMember {memberRole, memberStatus} + -- groups with relays require newer version - we don't need to check member version for batching and forwarding support + | isTrue useRelays = + if + -- if user is chat relay, send to all non chat relay members + | isMemberRelay membership && not (isMemberRelay m) -> MSASendBatched . snd <$> readyMemberConn m + -- if user is not chat relay, send only to chat relays + | not (isMemberRelay membership) && isMemberRelay m -> MSASendBatched . snd <$> readyMemberConn m + | otherwise -> Nothing -- TODO [channels fwd] MSAForwarded to create GSSForwarded snd statuses? + | otherwise = case memberConn m of + Nothing -> pendingOrForwarded + Just conn@Connection {connStatus} + | connDisabled conn || connStatus == ConnDeleted || memberStatus == GSMemRejected -> Nothing + | connInactive conn -> Just MSAPending + | connStatus == ConnSndReady || connStatus == ConnReady -> sendBatchedOrSeparate conn + | otherwise -> pendingOrForwarded where sendBatchedOrSeparate conn -- admin doesn't support batch forwarding - send messages separately so that admin can forward one by one @@ -1989,7 +2015,7 @@ memberSendAction gInfo events members m@GroupMember {memberRole, memberStatus} = GCUserMember -> Nothing -- shouldn't happen GCInviteeMember -> Just MSAPending GCHostMember -> Just MSAPending - GCPreMember -> forwardSupportedOrPending (invitedByGroupMemberId $ membership gInfo) + GCPreMember -> forwardSupportedOrPending (invitedByGroupMemberId membership) GCPostMember -> forwardSupportedOrPending (invitedByGroupMemberId m) where forwardSupportedOrPending invitingMemberId_ @@ -2010,10 +2036,21 @@ memberSendAction gInfo events members m@GroupMember {memberRole, memberStatus} = XGrpMsgForward {} -> True _ -> False +-- Should match memberSendAction logic +readyMemberConn :: GroupMember -> Maybe (GroupMemberId, Connection) +readyMemberConn GroupMember {groupMemberId, activeConn = Just conn@Connection {connStatus}, memberStatus} + | (connStatus == ConnReady || connStatus == ConnSndReady) + && not (connDisabled conn) + && not (connInactive conn) + && memberStatus /= GSMemRejected = + Just (groupMemberId, conn) + | 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 msg <- createSndMessage chatMsgEvent (GroupId groupId) - messageMember msg `catchChatError` eToView + messageMember msg `catchAllErrors` eToView where messageMember :: SndMessage -> CM () messageMember SndMessage {msgId, msgBody} = forM_ (memberSendAction gInfo (chatMsgEvent :| []) [m] m) $ \case @@ -2041,7 +2078,8 @@ saveDirectRcvMSG :: MsgEncodingI e => Connection -> MsgMeta -> MsgBody -> ChatMe saveDirectRcvMSG conn@Connection {connId} agentMsgMeta msgBody ChatMessage {chatVRange, msgId = sharedMsgId_, chatMsgEvent} = do conn' <- updatePeerChatVRange conn chatVRange let agentMsgId = fst $ recipient agentMsgMeta - newMsg = NewRcvMessage {chatMsgEvent, msgBody} + brokerTs = metaBrokerTs agentMsgMeta + newMsg = NewRcvMessage {chatMsgEvent, msgBody, brokerTs} rcvMsgDelivery = RcvMsgDelivery {connId, agentMsgId, agentMsgMeta} msg <- withStore $ \db -> createNewMessageAndRcvMsgDelivery db (ConnectionId connId) newMsg sharedMsgId_ rcvMsgDelivery Nothing pure (conn', msg) @@ -2050,11 +2088,12 @@ saveGroupRcvMsg :: MsgEncodingI e => User -> GroupId -> GroupMember -> Connectio saveGroupRcvMsg user groupId authorMember conn@Connection {connId} agentMsgMeta msgBody ChatMessage {chatVRange, msgId = sharedMsgId_, chatMsgEvent} = do (am'@GroupMember {memberId = amMemId, groupMemberId = amGroupMemId}, conn') <- updateMemberChatVRange authorMember conn chatVRange let agentMsgId = fst $ recipient agentMsgMeta - newMsg = NewRcvMessage {chatMsgEvent, msgBody} + brokerTs = metaBrokerTs agentMsgMeta + newMsg = NewRcvMessage {chatMsgEvent, msgBody, brokerTs} rcvMsgDelivery = RcvMsgDelivery {connId, agentMsgId, agentMsgMeta} msg <- withStore (\db -> createNewMessageAndRcvMsgDelivery db (GroupId groupId) newMsg sharedMsgId_ rcvMsgDelivery $ Just amGroupMemId) - `catchChatError` \e -> case e of + `catchAllErrors` \e -> case e of ChatErrorStore (SEDuplicateGroupMessage _ _ _ (Just forwardedByGroupMemberId)) -> do vr <- chatVersionRange fm <- withStore $ \db -> getGroupMember db vr user groupId forwardedByGroupMemberId @@ -2064,22 +2103,27 @@ saveGroupRcvMsg user groupId authorMember conn@Connection {connId} agentMsgMeta _ -> throwError e pure (am', conn', msg) -saveGroupFwdRcvMsg :: MsgEncodingI e => User -> GroupId -> GroupMember -> GroupMember -> MsgBody -> ChatMessage e -> CM RcvMessage -saveGroupFwdRcvMsg user groupId forwardingMember refAuthorMember@GroupMember {memberId = refMemberId} msgBody ChatMessage {msgId = sharedMsgId_, chatMsgEvent} = do - let newMsg = NewRcvMessage {chatMsgEvent, msgBody} +saveGroupFwdRcvMsg :: MsgEncodingI e => User -> GroupInfo -> GroupMember -> GroupMember -> MsgBody -> ChatMessage e -> UTCTime -> CM (Maybe RcvMessage) +saveGroupFwdRcvMsg user GroupInfo {groupId, useRelays} forwardingMember refAuthorMember@GroupMember {memberId = refMemberId} msgBody ChatMessage {msgId = sharedMsgId_, chatMsgEvent} brokerTs = do + let newMsg = NewRcvMessage {chatMsgEvent, msgBody, brokerTs} fwdMemberId = Just $ groupMemberId' forwardingMember refAuthorId = Just $ groupMemberId' refAuthorMember - withStore (\db -> createNewRcvMessage db (GroupId groupId) newMsg sharedMsgId_ refAuthorId fwdMemberId) - `catchChatError` \e -> case e of - ChatErrorStore (SEDuplicateGroupMessage _ _ (Just authorGroupMemberId) Nothing) -> do - vr <- chatVersionRange - am@GroupMember {memberId = amMemberId} <- withStore $ \db -> getGroupMember db vr user groupId authorGroupMemberId - if sameMemberId refMemberId am - then forM_ (memberConn forwardingMember) $ \fmConn -> - void $ sendDirectMemberMessage fmConn (XGrpMemCon amMemberId) groupId - else toView $ CEvtMessageError user "error" "saveGroupFwdRcvMsg: referenced author member id doesn't match message member id" - throwError e - _ -> throwError e + -- TODO [channels fwd] TBC highlighting difference between deduplicated messages (useRelays branch) + withStore' (\db -> runExceptT $ createNewRcvMessage db (GroupId groupId) newMsg sharedMsgId_ refAuthorId fwdMemberId) >>= \case + Right msg -> pure $ Just msg + Left e@SEDuplicateGroupMessage {authorGroupMemberId, forwardedByGroupMemberId} + | isTrue useRelays -> pure Nothing -- with chat relays, duplicates are expected + | otherwise -> case (authorGroupMemberId, forwardedByGroupMemberId) of + (Just authorGMId, Nothing) -> do + vr <- chatVersionRange + am@GroupMember {memberId = amMemberId} <- withStore $ \db -> getGroupMember db vr user groupId authorGMId + if sameMemberId refMemberId am + then forM_ (memberConn forwardingMember) $ \fmConn -> + void $ sendDirectMemberMessage fmConn (XGrpMemCon amMemberId) groupId + else toView $ CEvtMessageError user "error" "saveGroupFwdRcvMsg: referenced author member id doesn't match message member id" + throwError $ ChatErrorStore e + _ -> throwError $ ChatErrorStore e + Left e -> throwError $ ChatErrorStore e saveSndChatItem :: ChatTypeI c => User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> CM (ChatItem c 'MDSnd) saveSndChatItem user cd msg content = saveSndChatItem' user cd msg content Nothing Nothing Nothing Nothing False @@ -2213,7 +2257,7 @@ deleteAgentConnectionAsync acId = deleteAgentConnectionAsync' acId False deleteAgentConnectionAsync' :: ConnId -> Bool -> CM () deleteAgentConnectionAsync' acId waitDelivery = do - withAgent (\a -> deleteConnectionAsync a waitDelivery acId) `catchChatError` eToView + withAgent (\a -> deleteConnectionAsync a waitDelivery acId) `catchAllErrors` eToView deleteAgentConnectionsAsync :: [ConnId] -> CM () deleteAgentConnectionsAsync acIds = deleteAgentConnectionsAsync' acIds False @@ -2222,7 +2266,7 @@ deleteAgentConnectionsAsync acIds = deleteAgentConnectionsAsync' acIds False deleteAgentConnectionsAsync' :: [ConnId] -> Bool -> CM () deleteAgentConnectionsAsync' [] _ = pure () deleteAgentConnectionsAsync' acIds waitDelivery = do - withAgent (\a -> deleteConnectionsAsync a waitDelivery acIds) `catchChatError` eToView + withAgent (\a -> deleteConnectionsAsync a waitDelivery acIds) `catchAllErrors` eToView agentXFTPDeleteRcvFile :: RcvFileId -> FileTransferId -> CM () agentXFTPDeleteRcvFile aFileId fileId = do @@ -2271,7 +2315,7 @@ agentXFTPDeleteSndFilesRemote user sndFiles = do case privateSndFileDescr of Nothing -> partitionSndDescr xsfs (aFileId : filesWithoutDescr) filesWithDescr Just sfdText -> - tryChatError' (parseFileDescription sfdText) >>= \case + tryAllErrors' (parseFileDescription sfdText) >>= \case Left _ -> partitionSndDescr xsfs (aFileId : filesWithoutDescr) filesWithDescr Right sfd -> partitionSndDescr xsfs filesWithoutDescr ((aFileId, sfd) : filesWithDescr) diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 3517f76152..5073da9c77 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -22,12 +22,14 @@ import Control.Monad import Control.Monad.Except import Control.Monad.IO.Unlift import Control.Monad.Reader +import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.Either (lefts, partitionEithers, rights) +import Data.Foldable (foldr') import Data.Functor (($>)) import Data.Int (Int64) -import Data.List (foldl') -import Data.List.NonEmpty (NonEmpty (..), (<|)) +import Data.List (find, foldl') +import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) import qualified Data.Map.Strict as M @@ -41,8 +43,10 @@ import qualified Data.UUID.V4 as V4 import Data.Word (Word32) import Simplex.Chat.Call import Simplex.Chat.Controller +import Simplex.Chat.Delivery import Simplex.Chat.Library.Internal import Simplex.Chat.Messages +import Simplex.Chat.Messages.Batch (batchDeliveryTasks1) import Simplex.Chat.Messages.CIContent import Simplex.Chat.Messages.CIContent.Events import Simplex.Chat.ProfileGenerator (generateRandomProfile) @@ -51,6 +55,7 @@ import Simplex.Chat.Store import Simplex.Chat.Store.Connections import Simplex.Chat.Store.ContactRequest import Simplex.Chat.Store.Direct +import Simplex.Chat.Store.Delivery import Simplex.Chat.Store.Files import Simplex.Chat.Store.Groups import Simplex.Chat.Store.Messages @@ -64,11 +69,13 @@ import qualified Simplex.FileTransfer.Description as FD import Simplex.FileTransfer.Protocol (FilePartyI) import qualified Simplex.FileTransfer.Transport as XFTP import Simplex.FileTransfer.Types (FileErrorType (..), RcvFileId, SndFileId) -import Simplex.Messaging.Agent as Agent +import Simplex.Messaging.Agent +import Simplex.Messaging.Agent.Client (getAgentWorker, waitForWork, withWork_, withWorkItems) +import Simplex.Messaging.Agent.Env.SQLite (Worker (..)) import Simplex.Messaging.Agent.Protocol import qualified Simplex.Messaging.Agent.Protocol as AP (AgentErrorType (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB -import Simplex.Messaging.Client (ProxyClientError (..)) +import Simplex.Messaging.Client (ProxyClientError (..), NetworkRequestMode (..)) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..)) import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport (..), pattern PQEncOff, pattern PQEncOn, pattern PQSupportOff, pattern PQSupportOn) @@ -82,6 +89,7 @@ import Simplex.Messaging.Util import Simplex.Messaging.Version import qualified System.FilePath as FP import Text.Read (readMaybe) +import UnliftIO.Concurrent (forkIO) import UnliftIO.Directory import UnliftIO.STM @@ -96,14 +104,14 @@ processAgentMessage _ _ (DEL_RCVQS delQs) = processAgentMessage _ _ (DEL_CONNS connIds) = toView $ CEvtAgentConnsDeleted $ L.map AgentConnId connIds processAgentMessage _ "" (ERR e) = - eToView $ ChatErrorAgent e Nothing + eToView $ ChatErrorAgent e (AgentConnId "") Nothing processAgentMessage corrId connId msg = do - lockEntity <- critical (withStore (`getChatLockEntity` AgentConnId connId)) + lockEntity <- critical connId (withStore (`getChatLockEntity` AgentConnId connId)) withEntityLock "processAgentMessage" lockEntity $ do vr <- chatVersionRange -- getUserByAConnId never throws logical errors, only SEDBBusyError can be thrown here - critical (withStore' (`getUserByAConnId` AgentConnId connId)) >>= \case - Just user -> processAgentMessageConn vr user corrId connId msg `catchChatError` eToView + critical connId (withStore' (`getUserByAConnId` AgentConnId connId)) >>= \case + Just user -> processAgentMessageConn vr user corrId connId msg `catchAllErrors` eToView _ -> throwChatError $ CENoConnectionUser (AgentConnId connId) -- CRITICAL error will be shown to the user as alert with restart button in Android/desktop apps. @@ -113,50 +121,35 @@ processAgentMessage corrId connId msg = do -- - without ACK the message delivery will be stuck, -- - with ACK message will be lost, as it failed to be saved. -- Full app restart is likely to resolve database condition and the message will be received and processed again. -critical :: CM a -> CM a -critical a = - a `catchChatError` \case - ChatErrorStore SEDBBusyError {message} -> throwError $ ChatErrorAgent (CRITICAL True message) Nothing +critical :: ConnId -> CM a -> CM a +critical agentConnId a = + a `catchAllErrors` \case + ChatErrorStore SEDBBusyError {message} -> throwError $ ChatErrorAgent (CRITICAL True message) (AgentConnId agentConnId) Nothing e -> throwError e processAgentMessageNoConn :: AEvent 'AENone -> CM () processAgentMessageNoConn = \case CONNECT p h -> hostEvent $ CEvtHostConnected p h DISCONNECT p h -> hostEvent $ CEvtHostDisconnected p h - DOWN srv conns -> serverEvent srv conns NSDisconnected CEvtContactsDisconnected - UP srv conns -> serverEvent srv conns NSConnected CEvtContactsSubscribed + DOWN srv conns -> serverEvent srv SSPending conns + UP srv conns -> serverEvent srv SSActive conns SUSPENDED -> toView CEvtChatSuspended DEL_USER agentUserId -> toView $ CEvtAgentUserDeleted agentUserId - ERRS cErrs -> errsEvent cErrs + ERRS cErrs -> errsEvent $ L.toList cErrs where hostEvent :: ChatEvent -> CM () hostEvent = whenM (asks $ hostEvents . config) . toView - serverEvent srv conns nsStatus event = do - chatModifyVar connNetworkStatuses $ \m -> foldl' (\m' cId -> M.insert cId nsStatus m') m connIds - ifM (asks $ coreApi . config) (notifyAPI connIds) notifyCLI - where - connIds = map AgentConnId conns - notifyAPI = toView . CEvtNetworkStatus nsStatus - notifyCLI = do - cs <- withStore' (`getConnectionsContacts` conns) - toView $ event srv cs + serverEvent :: SMPServer -> SubscriptionStatus -> [ConnId] -> CM () + serverEvent srv nsStatus conns = toView $ CEvtSubscriptionStatus srv nsStatus $ map AgentConnId conns errsEvent :: [(ConnId, AgentErrorType)] -> CM () - errsEvent cErrs = do - vr <- chatVersionRange - errs <- lift $ rights <$> withStoreBatch' (\db -> map (getChatErr vr db) cErrs) - toView $ CEvtChatErrors errs - where - getChatErr :: VersionRangeChat -> DB.Connection -> (ConnId, AgentErrorType) -> IO ChatError - getChatErr vr db (connId, err) = - let acId = AgentConnId connId - in ChatErrorAgent err <$> (getUserByAConnId db acId $>>= \user -> eitherToMaybe <$> runExceptT (getConnectionEntity db vr user acId)) + errsEvent = toView . CEvtChatErrors . map (\(cId, e) -> ChatErrorAgent e (AgentConnId cId) Nothing) processAgentMsgSndFile :: ACorrId -> SndFileId -> AEvent 'AESndFile -> CM () processAgentMsgSndFile _corrId aFileId msg = do (cRef_, fileId) <- withStore (`getXFTPSndFileDBIds` AgentSndFileId aFileId) withEntityLock_ cRef_ . withFileLock "processAgentMsgSndFile" fileId $ withStore' (`getUserByASndFileId` AgentSndFileId aFileId) >>= \case - Just user -> process user fileId `catchChatError` eToView + Just user -> process user fileId `catchAllErrors` eToView _ -> do lift $ withAgent' (`xftpDeleteSndFileInternal` aFileId) throwChatError $ CENoSndFileUser $ AgentSndFileId aFileId @@ -213,7 +206,8 @@ processAgentMsgSndFile _corrId aFileId msg = do Nothing -> eToView $ ChatError $ CEInternalError "SFDONE, sendFileDescriptions: expected at least 1 result" lift $ withAgent' (`xftpDeleteSndFileInternal` aFileId) (_, _, SMDSnd, GroupChat g@GroupInfo {groupId} _scope) -> do - ms <- withStore' $ \db -> getGroupMembers db vr user g + -- TODO [channels fwd] single description for all recipients + ms <- getRecipients let rfdsMemberFTs = zipWith (\rfd (conn, sft) -> (conn, sft, fileDescrText rfd)) rfds (memberFTs ms) extraRFDs = drop (length rfdsMemberFTs) rfds withStore' $ \db -> createExtraSndFTDescrs db user fileId (map fileDescrText extraRFDs) @@ -225,17 +219,14 @@ processAgentMsgSndFile _corrId aFileId msg = do lift $ withAgent' (`xftpDeleteSndFileInternal` aFileId) toView $ CEvtSndFileCompleteXFTP user ci' ft where + getRecipients + | isTrue (useRelays g) = withStore' $ \db -> getGroupRelays db vr user g + | otherwise = withStore' $ \db -> getGroupMembers db vr user g memberFTs :: [GroupMember] -> [(Connection, SndFileTransfer)] memberFTs ms = M.elems $ M.intersectionWith (,) (M.fromList mConns') (M.fromList sfts') where - mConns' = mapMaybe useMember ms + mConns' = mapMaybe readyMemberConn ms sfts' = mapMaybe (\sft@SndFileTransfer {groupMemberId} -> (,sft) <$> groupMemberId) sfts - -- Should match memberSendAction logic - useMember GroupMember {groupMemberId, activeConn = Just conn@Connection {connStatus}} - | (connStatus == ConnReady || connStatus == ConnSndReady) && not (connDisabled conn) && not (connInactive conn) = - Just (groupMemberId, conn) - | otherwise = Nothing - useMember _ = Nothing _ -> pure () _ -> pure () -- TODO error? SFWARN e -> do @@ -298,7 +289,7 @@ processAgentMsgRcvFile _corrId aFileId msg = do (cRef_, fileId) <- withStore (`getXFTPRcvFileDBIds` AgentRcvFileId aFileId) withEntityLock_ cRef_ . withFileLock "processAgentMsgRcvFile" fileId $ withStore' (`getUserByARcvFileId` AgentRcvFileId aFileId) >>= \case - Just user -> process user fileId `catchChatError` eToView + Just user -> process user fileId `catchAllErrors` eToView _ -> do lift $ withAgent' (`xftpDeleteRcvFile` aFileId) throwChatError $ CENoRcvFileUser $ AgentRcvFileId aFileId @@ -359,7 +350,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- as in this case no need to ACK message - we can't process messages for this connection anyway. -- SEDBException will be re-trown as CRITICAL as it is likely to indicate a temporary database condition -- that will be resolved with app restart. - entity <- critical $ withStore (\db -> getConnectionEntity db vr user $ AgentConnId agentConnId) >>= updateConnStatus + entity <- critical agentConnId $ withStore (\db -> getConnectionEntity db vr user $ AgentConnId agentConnId) >>= updateConnStatus case agentMessage of END -> case entity of RcvDirectMsgConnection _ (Just ct) -> toView $ CEvtContactAnotherClient user ct @@ -370,10 +361,6 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = processDirectMessage agentMessage entity conn contact_ RcvGroupMsgConnection conn gInfo m -> processGroupMessage agentMessage entity conn gInfo m - RcvFileConnection conn ft -> - processRcvFileConn agentMessage entity conn ft - SndFileConnection conn ft -> - processSndFileConn agentMessage entity conn ft UserContactConnection conn uc -> processUserContactRequest agentMessage entity conn uc where @@ -414,12 +401,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = CONF confId pqSupport _ connInfo -> do conn' <- processCONFpqSupport conn pqSupport -- [incognito] send saved profile - (conn'', inGroup) <- saveConnInfo conn' connInfo + (conn'', gInfo_) <- saveConnInfo conn' connInfo incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) - let profileToSend = - if inGroup - then userProfileInGroup user (fromLocalProfile <$> incognitoProfile) - else userProfileDirect user (fromLocalProfile <$> incognitoProfile) Nothing True + let profileToSend = case gInfo_ of + Just gInfo -> userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) + Nothing -> userProfileDirect user (fromLocalProfile <$> incognitoProfile) Nothing True -- [async agent commands] no continuation needed, but command should be asynchronous for stability allowAgentConnectionAsync user conn'' confId $ XInfo profileToSend INFO pqSupport connInfo -> do @@ -444,13 +430,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = MWARN _ err -> processConnMWARN connEntity conn err MERR _ err -> do - eToView (ChatErrorAgent err $ Just connEntity) + eToView $ ChatErrorAgent err (AgentConnId agentConnId) (Just connEntity) processConnMERR connEntity conn err MERRS _ err -> do -- error cannot be AUTH error here - eToView (ChatErrorAgent err $ Just connEntity) + eToView $ ChatErrorAgent err (AgentConnId agentConnId) (Just connEntity) ERR err -> do - eToView (ChatErrorAgent err $ Just connEntity) + eToView $ ChatErrorAgent err (AgentConnId agentConnId) (Just connEntity) when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () -- TODO add debugging output _ -> pure () @@ -460,22 +446,17 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- [async agent commands] XGrpMemIntro continuation on receiving INV withCompletedCommand conn agentMsg $ \_ -> case cReq of - directConnReq@(CRInvitationUri _ _) -> do - contData <- withStore' $ \db -> do - setConnConnReqInv db user connId cReq - getXGrpMemIntroContDirect db user ct - forM_ contData $ \(hostConnId, xGrpMemIntroCont) -> - sendXGrpMemInv hostConnId (Just directConnReq) xGrpMemIntroCont + CRInvitationUri _ _ -> withStore' $ \db -> setConnConnReqInv db user connId cReq CRContactUri _ -> throwChatError $ CECommandError "unexpected ConnectionRequestUri type" MSG msgMeta _msgFlags msgBody -> do tags <- newTVarIO [] withAckMessage "contact msg" agentConnId msgMeta True (Just tags) $ \eInfo -> do let MsgMeta {pqEncryption} = msgMeta (ct', conn') <- updateContactPQRcv user ct conn pqEncryption - checkIntegrityCreateItem (CDDirectRcv ct') msgMeta `catchChatError` \_ -> pure () + checkIntegrityCreateItem (CDDirectRcv ct') msgMeta `catchAllErrors` \_ -> pure () forM_ aChatMsgs $ \case Right (ACMsg _ chatMsg) -> - processEvent ct' conn' tags eInfo chatMsg `catchChatError` \e -> eToView e + processEvent ct' conn' tags eInfo chatMsg `catchAllErrors` \e -> eToView e Left e -> do atomically $ modifyTVar' tags ("error" :) logInfo $ "contact msg=error " <> eInfo <> " " <> tshow e @@ -537,7 +518,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- [async agent commands] no continuation needed, but command should be asynchronous for stability allowAgentConnectionAsync user conn'' confId XOk XInfo profile -> do - ct' <- processContactProfileUpdate ct profile False `catchChatError` const (pure ct) + ct' <- processContactProfileUpdate ct profile False `catchAllErrors` const (pure ct) -- [incognito] send incognito profile incognitoProfile <- forM customUserProfileId $ \profileId -> withStore $ \db -> getProfileById db userId profileId let p = userProfileDirect user (fromLocalProfile <$> incognitoProfile) (Just ct') True @@ -551,7 +532,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing (Just epochStart) -- [incognito] send saved profile incognitoProfile <- forM customUserProfileId $ \pId -> withStore (\db -> getProfileById db userId pId) - let profileToSend = userProfileInGroup user (fromLocalProfile <$> incognitoProfile) + let profileToSend = userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) allowAgentConnectionAsync user conn'' confId $ XInfo profileToSend toView $ CEvtBusinessLinkConnecting user gInfo host ct _ -> messageError "CONF for existing contact must have x.grp.mem.info or x.info" @@ -569,52 +550,42 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = void $ processContactProfileUpdate ct profile prepared XOk -> pure () _ -> messageError "INFO for existing contact must have x.grp.mem.info, x.info or x.ok" - CON pqEnc -> - withStore' (\db -> getViaGroupMember db vr user ct) >>= \case - Nothing -> do - when (pqEnc == PQEncOn) $ withStore' $ \db -> updateConnPQEnabledCON db connId pqEnc - let conn' = conn {pqSndEnabled = Just pqEnc, pqRcvEnabled = Just pqEnc} :: Connection - ct' = ct {activeConn = Just conn'} :: Contact - -- [incognito] print incognito profile used for this contact - incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) - lift $ setContactNetworkStatus ct' NSConnected - toView $ CEvtContactConnected user ct' (fmap fromLocalProfile incognitoProfile) - let createE2EItem = createInternalChatItem user (CDDirectRcv ct') (CIRcvDirectE2EEInfo $ E2EInfo $ Just pqEnc) Nothing - -- TODO [short links] get contact request by contactRequestId, check encryption (UserContactRequest.pqSupport)? - when (directOrUsed ct') $ case (preparedContact ct', contactRequestId' ct') of - (Nothing, Nothing) -> do - unlessM (withStore' $ \db -> checkContactHasItems db user ct') $ - createInternalChatItem user (CDDirectSnd ct') CIChatBanner (Just epochStart) - createE2EItem - createFeatureEnabledItems user ct' - (Just PreparedContact {connLinkToConnect = ACCL _ (CCLink cReq _)}, _) -> - unless (Just pqEnc == connRequestPQEncryption cReq) createE2EItem - (_, Just connReqId) -> - withStore' (\db -> getContactRequest' db user connReqId) >>= \case - Just UserContactRequest {pqSupport} | CR.pqSupportToEnc pqSupport == pqEnc -> pure () - _ -> createE2EItem - when (contactConnInitiated conn') $ do - let Connection {groupLinkId} = conn' - doProbeContacts = isJust groupLinkId - probeMatchingContactsAndMembers ct' (contactConnIncognito ct') doProbeContacts - withStore' $ \db -> resetContactConnInitiated db user conn' - forM_ viaUserContactLink $ \userContactLinkId -> do - (ucl, gli_) <- withStore $ \db -> getUserContactLinkById db userId userContactLinkId - -- let UserContactLink {addressSettings = AddressSettings {autoReply}} = ucl - when (connChatVersion < batchSend2Version) $ forM_ (autoReply $ addressSettings ucl) $ \mc -> sendAutoReply ct' mc Nothing -- old versions only - -- TODO REMOVE LEGACY vvv - forM_ gli_ $ \GroupLinkInfo {groupId, memberRole = gLinkMemRole} -> do - groupInfo <- withStore $ \db -> getGroupInfo db vr user groupId - subMode <- chatReadVar subscriptionMode - groupConnIds <- createAgentConnectionAsync user CFCreateConnGrpInv True SCMInvitation subMode - gVar <- asks random - withStore $ \db -> createNewContactMemberAsync db gVar user groupInfo ct' gLinkMemRole groupConnIds connChatVersion peerChatVRange subMode + CON pqEnc -> do + when (pqEnc == PQEncOn) $ withStore' $ \db -> updateConnPQEnabledCON db connId pqEnc + let conn' = conn {pqSndEnabled = Just pqEnc, pqRcvEnabled = Just pqEnc} :: Connection + ct' = ct {activeConn = Just conn'} :: Contact + -- [incognito] print incognito profile used for this contact + incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) + toView $ CEvtContactConnected user ct' (fmap fromLocalProfile incognitoProfile) + let createE2EItem = createInternalChatItem user (CDDirectRcv ct') (CIRcvDirectE2EEInfo $ E2EInfo $ Just pqEnc) Nothing + -- TODO [short links] get contact request by contactRequestId, check encryption (UserContactRequest.pqSupport)? + when (directOrUsed ct') $ case (preparedContact ct', contactRequestId' ct') of + (Nothing, Nothing) -> do + unlessM (withStore' $ \db -> checkContactHasItems db user ct') $ + createInternalChatItem user (CDDirectSnd ct') CIChatBanner (Just epochStart) + createE2EItem + createFeatureEnabledItems user ct' + (Just PreparedContact {connLinkToConnect = ACCL _ (CCLink cReq _)}, _) -> + unless (Just pqEnc == connRequestPQEncryption cReq) createE2EItem + (_, Just connReqId) -> + withStore' (\db -> getContactRequest' db user connReqId) >>= \case + Just UserContactRequest {pqSupport} | CR.pqSupportToEnc pqSupport == pqEnc -> pure () + _ -> createE2EItem + when (contactConnInitiated conn') $ do + probeMatchingMembers ct' (contactConnIncognito ct') + withStore' $ \db -> resetContactConnInitiated db user conn' + forM_ viaUserContactLink $ \userContactLinkId -> do + (ucl, gli_) <- withStore $ \db -> getUserContactLinkById db userId userContactLinkId + -- let UserContactLink {addressSettings = AddressSettings {autoReply}} = ucl + when (connChatVersion < batchSend2Version) $ forM_ (autoReply $ addressSettings ucl) $ \mc -> sendAutoReply ct' mc Nothing -- old versions only + -- TODO REMOVE LEGACY vvv + forM_ gli_ $ \GroupLinkInfo {groupId, memberRole = gLinkMemRole} -> do + groupInfo <- withStore $ \db -> getGroupInfo db vr user groupId + subMode <- chatReadVar subscriptionMode + groupConnIds <- createAgentConnectionAsync user CFCreateConnGrpInv True SCMInvitation subMode + gVar <- asks random + withStore $ \db -> createNewContactMemberAsync db gVar user groupInfo ct' gLinkMemRole groupConnIds connChatVersion peerChatVRange subMode -- TODO REMOVE LEGACY ^^^ - Just (gInfo, m@GroupMember {activeConn}) -> - when (maybe False ((== ConnReady) . connStatus) activeConn) $ do - notifyMemberConnected gInfo m $ Just ct - let connectedIncognito = contactConnIncognito ct || incognitoMembership gInfo - when (memberCategory m == GCPreMember) $ probeMatchingContactsAndMembers ct connectedIncognito True SENT msgId proxy -> do void $ continueSending connEntity conn sentMsgDeliveryEvent conn msgId @@ -665,7 +636,6 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- [async agent commands] continuation on receiving JOINED when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> when (directOrUsed ct && sqSecured) $ do - lift $ setContactNetworkStatus ct NSConnected toView $ CEvtContactSndReady user ct when (connChatVersion >= batchSend2Version) $ forM_ viaUserContactLink $ \userContactLinkId -> do (ucl, _) <- withStore $ \db -> getUserContactLinkById db userId userContactLinkId @@ -679,14 +649,14 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = processConnMWARN connEntity conn err MERR msgId err -> do updateDirectItemStatus ct conn msgId (CISSndError $ agentSndError err) - eToView (ChatErrorAgent err $ Just connEntity) + eToView $ ChatErrorAgent err (AgentConnId agentConnId) (Just connEntity) processConnMERR connEntity conn err MERRS msgIds err -> do -- error cannot be AUTH error here updateDirectItemsStatusMsgs ct conn (L.toList msgIds) (CISSndError $ agentSndError err) - eToView (ChatErrorAgent err $ Just connEntity) + eToView $ ChatErrorAgent err (AgentConnId agentConnId) (Just connEntity) ERR err -> do - eToView (ChatErrorAgent err $ Just connEntity) + eToView $ ChatErrorAgent err (AgentConnId agentConnId) (Just connEntity) when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () -- TODO add debugging output _ -> pure () @@ -766,7 +736,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = (gInfo', m') <- withStore $ \db -> updatePreparedUserAndHostMembersInvited db vr user gInfo m glInv -- [incognito] send saved profile incognitoProfile <- forM customUserProfileId $ \pId -> withStore (\db -> getProfileById db userId pId) - let profileToSend = userProfileInGroup user (fromLocalProfile <$> incognitoProfile) + let profileToSend = userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile) allowAgentConnectionAsync user conn' confId $ XInfo profileToSend toView $ CEvtGroupLinkConnecting user gInfo' m' XGrpLinkReject glRjct@GroupLinkRejection {rejectionReason} -> do @@ -779,7 +749,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = XGrpMemInfo memId _memProfile | sameMemberId memId m -> do let GroupMember {memberId = membershipMemId} = membership - membershipProfile = redactedMemberProfile $ fromLocalProfile $ memberProfile membership + allowSimplexLinks = groupFeatureUserAllowed SGFSimplexLinks gInfo + membershipProfile = redactedMemberProfile allowSimplexLinks $ fromLocalProfile $ memberProfile membership -- TODO update member profile -- [async agent commands] no continuation needed, but command should be asynchronous for stability allowAgentConnectionAsync user conn' confId $ XGrpMemInfo membershipMemId membershipProfile @@ -859,24 +830,15 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = where sendXGrpLinkMem gInfo'' = do let incognitoProfile = ExistingIncognito <$> incognitoMembershipProfile gInfo'' - profileToSend = userProfileInGroup user (fromIncognitoProfile <$> incognitoProfile) + profileToSend = userProfileInGroup user gInfo (fromIncognitoProfile <$> incognitoProfile) void $ sendDirectMemberMessage conn (XGrpLinkMem profileToSend) groupId _ -> do unless (memberPending m) $ withStore' $ \db -> updateGroupMemberStatus db userId m GSMemConnected + notifyMemberConnected gInfo m Nothing let memCategory = memberCategory m - withStore' (\db -> getViaGroupContact db vr user m) >>= \case - Nothing -> do - notifyMemberConnected gInfo m Nothing - let connectedIncognito = memberIncognito membership - when (memCategory == GCPreMember) $ - probeMatchingMemberContact m connectedIncognito - Just ct@Contact {activeConn} -> - forM_ activeConn $ \Connection {connStatus} -> - when (connStatus == ConnReady) $ do - notifyMemberConnected gInfo m $ Just ct - let connectedIncognito = contactConnIncognito ct || incognitoMembership gInfo - when (memCategory == GCPreMember && not (memberPending membership)) $ - probeMatchingContactsAndMembers ct connectedIncognito True + connectedIncognito = memberIncognito membership + when (memCategory == GCPreMember) $ + probeMatchingMemberContact m connectedIncognito sendXGrpMemCon memCategory where GroupMember {memberId} = m @@ -897,13 +859,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = withAckMessage "group msg" agentConnId msgMeta True (Just tags) $ \eInfo -> do -- possible improvement is to choose scope based on event (some events specify scope) (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m - checkIntegrityCreateItem (CDGroupRcv gInfo' scopeInfo m') msgMeta `catchChatError` \_ -> pure () - (fwdScopesMsgs, shouldDelConns) <- foldM (processAChatMsg gInfo' m' tags eInfo) (M.empty, False) aChatMsgs - when (isUserGrpFwdRelay gInfo') $ do - unless (blockedByAdmin m) $ - forM_ (M.assocs fwdScopesMsgs) $ \(groupForwardScope, fwdMsgs) -> - forwardMsgs groupForwardScope (L.reverse fwdMsgs) `catchChatError` eToView - when shouldDelConns $ deleteGroupConnections gInfo' True + checkIntegrityCreateItem (CDGroupRcv gInfo' scopeInfo m') msgMeta `catchAllErrors` \_ -> pure () + newDeliveryTasks <- reverse <$> foldM (processAChatMsg gInfo' m' tags eInfo) [] aChatMsgs + shouldDelConns <- + if isUserGrpFwdRelay gInfo' && not (blockedByAdmin m) + then createDeliveryTasks gInfo' m' newDeliveryTasks + else pure False withRcpt <- checkSendRcpt $ rights aChatMsgs pure (withRcpt, shouldDelConns) where @@ -914,72 +875,69 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -> GroupMember -> TVar [Text] -> Text - -> (Map GroupForwardScope (NonEmpty (ChatMessage 'Json)), ShouldDeleteGroupConns) + -> [NewMessageDeliveryTask] -> Either String AChatMessage - -> CM (Map GroupForwardScope (NonEmpty (ChatMessage 'Json)), ShouldDeleteGroupConns) - processAChatMsg gInfo' m' tags eInfo (fwdScopeMap, shouldDelConns) = \case + -> CM [NewMessageDeliveryTask] + processAChatMsg gInfo' m' tags eInfo newDeliveryTasks = \case Right (ACMsg SJson chatMsg) -> do - (cmFwdScope_, cmShouldDelConns) <- - processEvent gInfo' m' tags eInfo chatMsg `catchChatError` \e -> eToView e $> (Nothing, False) - let fwdScopeMap' = - case cmFwdScope_ of - Nothing -> fwdScopeMap - Just cmFwdScope -> M.alter (Just . maybe [chatMsg] (chatMsg <|)) cmFwdScope fwdScopeMap - shouldDelConns' = shouldDelConns || cmShouldDelConns - pure (fwdScopeMap', shouldDelConns') + newTask_ <- processEvent gInfo' m' tags eInfo chatMsg `catchAllErrors` \e -> eToView e $> Nothing + pure $ maybe newDeliveryTasks (: newDeliveryTasks) newTask_ Right (ACMsg SBinary chatMsg) -> do - void (processEvent gInfo' m' tags eInfo chatMsg) `catchChatError` \e -> eToView e - pure (fwdScopeMap, shouldDelConns) + void (processEvent gInfo' m' tags eInfo chatMsg) `catchAllErrors` \e -> eToView e + pure newDeliveryTasks Left e -> do atomically $ modifyTVar' tags ("error" :) logInfo $ "group msg=error " <> eInfo <> " " <> tshow e eToView (ChatError . CEException $ "error parsing chat message: " <> e) - pure (fwdScopeMap, shouldDelConns) - processEvent :: forall e. MsgEncodingI e => GroupInfo -> GroupMember -> TVar [Text] -> Text -> ChatMessage e -> CM (Maybe GroupForwardScope, ShouldDeleteGroupConns) + pure newDeliveryTasks + processEvent :: forall e. MsgEncodingI e => GroupInfo -> GroupMember -> TVar [Text] -> Text -> ChatMessage e -> CM (Maybe NewMessageDeliveryTask) processEvent gInfo' m' tags eInfo chatMsg@ChatMessage {chatMsgEvent} = do let tag = toCMEventTag chatMsgEvent atomically $ modifyTVar' tags (tshow tag :) logInfo $ "group msg=" <> tshow tag <> " " <> eInfo let body = chatMsgToBody chatMsg - (m'', conn', msg@RcvMessage {chatMsgEvent = ACME _ event}) <- saveGroupRcvMsg user groupId m' conn msgMeta body chatMsg - -- ! see isForwardedGroupMsg: processing functions should return GroupForwardScope for same events - case event of - XMsgNew mc -> memberCanSend m'' scope $ (,False) <$> newGroupContentMessage gInfo' m'' mc msg brokerTs False + (m'', conn', msg@RcvMessage {msgId, chatMsgEvent = ACME _ event}) <- saveGroupRcvMsg user groupId m' conn msgMeta body chatMsg + -- ! see isForwardedGroupMsg: processing functions should return DeliveryJobScope for same events + deliveryJobScope_ <- case event of + XMsgNew mc -> memberCanSend m'' scope $ newGroupContentMessage gInfo' m'' mc msg brokerTs False where ExtMsgContent {scope} = mcExtMsgContent mc -- file description is always allowed, to allow sending files to support scope - XMsgFileDescr sharedMsgId fileDescr -> (,False) <$> groupMessageFileDescription gInfo' m'' sharedMsgId fileDescr - XMsgUpdate sharedMsgId mContent mentions ttl live msgScope -> memberCanSend m'' msgScope $ (,False) <$> groupMessageUpdate gInfo' m'' sharedMsgId mContent mentions msgScope msg brokerTs ttl live - XMsgDel sharedMsgId memberId scope_ -> (,False) <$> groupMessageDelete gInfo' m'' sharedMsgId memberId scope_ msg brokerTs - XMsgReact sharedMsgId (Just memberId) scope_ reaction add -> (,False) <$> groupMsgReaction gInfo' m'' sharedMsgId memberId scope_ reaction add msg brokerTs + XMsgFileDescr sharedMsgId fileDescr -> groupMessageFileDescription gInfo' m'' sharedMsgId fileDescr + XMsgUpdate sharedMsgId mContent mentions ttl live msgScope -> memberCanSend m'' msgScope $ groupMessageUpdate gInfo' m'' sharedMsgId mContent mentions msgScope msg brokerTs ttl live + XMsgDel sharedMsgId memberId scope_ -> groupMessageDelete gInfo' m'' sharedMsgId memberId scope_ msg brokerTs + XMsgReact sharedMsgId (Just memberId) scope_ reaction add -> groupMsgReaction gInfo' m'' sharedMsgId memberId scope_ reaction add msg brokerTs -- TODO discontinue XFile - XFile fInv -> (Nothing, False) <$ processGroupFileInvitation' gInfo' m'' fInv msg brokerTs - XFileCancel sharedMsgId -> (,False) <$> xFileCancelGroup gInfo' m'' sharedMsgId - XFileAcptInv sharedMsgId fileConnReq_ fName -> (Nothing, False) <$ xFileAcptInvGroup gInfo' m'' sharedMsgId fileConnReq_ fName - XInfo p -> (,False) <$> xInfoMember gInfo' m'' p brokerTs - XGrpLinkMem p -> (Nothing, False) <$ xGrpLinkMem gInfo' m'' conn' p - XGrpLinkAcpt acceptance role memberId -> (Nothing, False) <$ xGrpLinkAcpt gInfo' m'' acceptance role memberId msg brokerTs - XGrpMemNew memInfo msgScope -> (,False) <$> xGrpMemNew gInfo' m'' memInfo msgScope msg brokerTs - XGrpMemIntro memInfo memRestrictions_ -> (Nothing, False) <$ xGrpMemIntro gInfo' m'' memInfo memRestrictions_ - XGrpMemInv memId introInv -> (Nothing, False) <$ xGrpMemInv gInfo' m'' memId introInv - XGrpMemFwd memInfo introInv -> (Nothing, False) <$ xGrpMemFwd gInfo' m'' memInfo introInv - XGrpMemRole memId memRole -> (,False) <$> xGrpMemRole gInfo' m'' memId memRole msg brokerTs - XGrpMemRestrict memId memRestrictions -> (,False) <$> xGrpMemRestrict gInfo' m'' memId memRestrictions msg brokerTs - XGrpMemCon memId -> (Nothing, False) <$ xGrpMemCon gInfo' m'' memId + XFile fInv -> Nothing <$ processGroupFileInvitation' gInfo' m'' fInv msg brokerTs + XFileCancel sharedMsgId -> xFileCancelGroup gInfo' m'' sharedMsgId + XFileAcptInv sharedMsgId fileConnReq_ fName -> Nothing <$ xFileAcptInvGroup gInfo' m'' sharedMsgId fileConnReq_ fName + XInfo p -> xInfoMember gInfo' m'' p brokerTs + XGrpLinkMem p -> Nothing <$ xGrpLinkMem gInfo' m'' conn' p + XGrpLinkAcpt acceptance role memberId -> Nothing <$ xGrpLinkAcpt gInfo' m'' acceptance role memberId msg brokerTs + XGrpMemNew memInfo msgScope -> xGrpMemNew gInfo' m'' memInfo msgScope msg brokerTs + XGrpMemIntro memInfo memRestrictions_ -> Nothing <$ xGrpMemIntro gInfo' m'' memInfo memRestrictions_ + XGrpMemInv memId introInv -> Nothing <$ xGrpMemInv gInfo' m'' memId introInv + XGrpMemFwd memInfo introInv -> Nothing <$ xGrpMemFwd gInfo' m'' memInfo introInv + XGrpMemRole memId memRole -> xGrpMemRole gInfo' m'' memId memRole msg brokerTs + XGrpMemRestrict memId memRestrictions -> xGrpMemRestrict gInfo' m'' memId memRestrictions msg brokerTs + XGrpMemCon memId -> Nothing <$ xGrpMemCon gInfo' m'' memId XGrpMemDel memId withMessages -> case encoding @e of SJson -> xGrpMemDel gInfo' m'' memId withMessages chatMsg msg brokerTs False - SBinary -> pure (Nothing, False) -- impossible - XGrpLeave -> (,False) <$> xGrpLeave gInfo' m'' msg brokerTs - XGrpDel -> (Just GFSAll, True) <$ xGrpDel gInfo' m'' msg brokerTs - XGrpInfo p' -> (,False) <$> xGrpInfo gInfo' m'' p' msg brokerTs - XGrpPrefs ps' -> (,False) <$> xGrpPrefs gInfo' m'' ps' + SBinary -> pure Nothing -- impossible + XGrpLeave -> xGrpLeave gInfo' m'' msg brokerTs + XGrpDel -> Just (DJSGroup {jobSpec = DJRelayRemoved}) <$ xGrpDel gInfo' m'' msg brokerTs + XGrpInfo p' -> xGrpInfo gInfo' m'' p' msg brokerTs + XGrpPrefs ps' -> xGrpPrefs gInfo' m'' ps' -- TODO [knocking] why don't we forward these messages? - XGrpDirectInv connReq mContent_ msgScope -> memberCanSend m'' msgScope $ (Nothing, False) <$ xGrpDirectInv gInfo' m'' conn' connReq mContent_ msg brokerTs - XGrpMsgForward memberId memberName msg' msgTs -> (Nothing, False) <$ xGrpMsgForward gInfo' m'' memberId memberName msg' msgTs - XInfoProbe probe -> (Nothing, False) <$ xInfoProbe (COMGroupMember m'') probe - XInfoProbeCheck probeHash -> (Nothing, False) <$ xInfoProbeCheck (COMGroupMember m'') probeHash - XInfoProbeOk probe -> (Nothing, False) <$ xInfoProbeOk (COMGroupMember m'') probe - BFileChunk sharedMsgId chunk -> (Nothing, False) <$ bFileChunkGroup gInfo' sharedMsgId chunk msgMeta - _ -> (Nothing, False) <$ messageError ("unsupported message: " <> tshow event) + XGrpDirectInv connReq mContent_ msgScope -> memberCanSend m'' msgScope $ Nothing <$ xGrpDirectInv gInfo' m'' conn' connReq mContent_ msg brokerTs + XGrpMsgForward memberId memberName msg' msgTs -> Nothing <$ xGrpMsgForward gInfo' m'' memberId memberName msg' msgTs brokerTs + XInfoProbe probe -> Nothing <$ xInfoProbe (COMGroupMember m'') probe + XInfoProbeCheck probeHash -> Nothing <$ xInfoProbeCheck (COMGroupMember m'') probeHash + XInfoProbeOk probe -> Nothing <$ xInfoProbeOk (COMGroupMember m'') probe + BFileChunk sharedMsgId chunk -> Nothing <$ bFileChunkGroup gInfo' sharedMsgId chunk msgMeta + _ -> Nothing <$ messageError ("unsupported message: " <> tshow event) + forM deliveryJobScope_ $ \jobScope -> + -- TODO [channels fwd] XMsgNew to return messageFromChannel + pure $ NewMessageDeliveryTask {messageId = msgId, jobScope, messageFromChannel = False} checkSendRcpt :: [AChatMessage] -> CM Bool checkSendRcpt aMsgs = do currentMemCount <- withStore' $ \db -> getGroupCurrentMembersCount db user gInfo @@ -991,58 +949,34 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = where aChatMsgHasReceipt (ACMsg _ ChatMessage {chatMsgEvent}) = hasDeliveryReceipt (toCMEventTag chatMsgEvent) - -- TODO forwardMsgs member retrieval can be further optimized: - -- - move remaining filters to SQL (memberCurrentOrPending, memberCurrent) - -- - create new GroupForwardScope for reports to avoid post-filtering moderators in msgsForwardedToMember - -- as an additional step, instead initially retrieve only moderators - -- (reuse getForwardIntroducedModerators, getForwardInvitedModerators + filters) - -- - new GroupForwardScope for excluding members on XGrpMemRestrict - forwardMsgs :: GroupForwardScope -> NonEmpty (ChatMessage 'Json) -> CM () - forwardMsgs groupForwardScope fwdMsgs = do - ms <- buildMemberList - let GroupMember {memberId} = m - memberName = Just $ memberShortenedName m - events = L.map (\cm -> XGrpMsgForward memberId memberName cm brokerTs) fwdMsgs - unless (null ms) $ void $ sendGroupMessages_ user gInfo ms events + createDeliveryTasks :: GroupInfo -> GroupMember -> [NewMessageDeliveryTask] -> CM ShouldDeleteGroupConns + createDeliveryTasks gInfo'@GroupInfo {groupId = gId} m' newDeliveryTasks = do + let relayRemovedTask_ = find (\NewMessageDeliveryTask {jobScope} -> isRelayRemoved jobScope) newDeliveryTasks + createdDeliveryTasks <- case relayRemovedTask_ of + Nothing -> do + withStore' $ \db -> + forM_ newDeliveryTasks $ \newTask -> + createMsgDeliveryTask db gInfo' m' newTask + pure newDeliveryTasks + Just relayRemovedTask -> do + -- if relay is removed, delete all other tasks and jobs + withStore' $ \db -> do + deleteGroupDeliveryTasks db gInfo' + deleteGroupDeliveryJobs db gInfo' + createMsgDeliveryTask db gInfo' m' relayRemovedTask + pure [relayRemovedTask] + lift $ forM_ (uniqueWorkerScopes createdDeliveryTasks) $ \workerScope -> + getDeliveryTaskWorker True (gId, workerScope) + pure $ isJust relayRemovedTask_ where - buildMemberList = case groupForwardScope of - GFSAll -> do - ms <- getAllIntroducedAndInvited - pure $ filter (\mem -> memberCurrentOrPending mem && msgsForwardedToMember fwdMsgs mem) ms - GFSMain -> do - ms <- getAllIntroducedAndInvited - pure $ filter (\mem -> memberCurrent mem && msgsForwardedToMember fwdMsgs mem) ms - GFSMemberSupport scopeGMId -> do - -- moderators introduced to this invited member - introducedModMs <- - if memberCategory m == GCInviteeMember - then withStore' $ \db -> getForwardIntroducedModerators db vr user m - else pure [] - -- invited moderators to which this member was introduced - invitedModMs <- withStore' $ \db -> getForwardInvitedModerators db vr user m - let modMs = introducedModMs <> invitedModMs - moderatorFilter mem = - memberCurrent mem - && maxVersion (memberChatVRange mem) >= groupKnockingVersion - && msgsForwardedToMember fwdMsgs mem - modMs' = filter moderatorFilter modMs - if scopeGMId == groupMemberId' m - then pure modMs' - else - withStore' (\db -> getForwardScopeMember db vr user m scopeGMId) >>= \case - Just scopeMem | msgsForwardedToMember fwdMsgs scopeMem -> pure $ scopeMem : modMs' - _ -> pure modMs' + uniqueWorkerScopes :: [NewMessageDeliveryTask] -> [DeliveryWorkerScope] + uniqueWorkerScopes createdDeliveryTasks = + let workerScopes = map (\NewMessageDeliveryTask {jobScope} -> toWorkerScope jobScope) createdDeliveryTasks + in foldr' addWorkerScope [] workerScopes where - getAllIntroducedAndInvited = do - ChatConfig {highlyAvailable} <- asks config - -- members introduced to this invited member - introducedMembers <- - if memberCategory m == GCInviteeMember - then withStore' $ \db -> getForwardIntroducedMembers db vr user m highlyAvailable - else pure [] - -- invited members to which this member was introduced - invitedMembers <- withStore' $ \db -> getForwardInvitedMembers db vr user m highlyAvailable - pure $ introducedMembers <> invitedMembers + addWorkerScope workerScope acc + | workerScope `elem` acc = acc + | otherwise = workerScope : acc RCVD msgMeta msgRcpt -> withAckMessage' "group rcvd" agentConnId msgMeta $ groupMsgReceived gInfo m conn msgMeta msgRcpt @@ -1107,16 +1041,16 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = MERR msgId err -> do withStore' $ \db -> updateGroupItemsErrorStatus db msgId (groupMemberId' m) (GSSError $ agentSndError err) -- group errors are silenced to reduce load on UI event log - -- eToView (ChatErrorAgent err $ Just connEntity) + -- eToView $ ChatErrorAgent err (AgentConnId agentConnId) (Just connEntity) processConnMERR connEntity conn err MERRS msgIds err -> do let newStatus = GSSError $ agentSndError err -- error cannot be AUTH error here withStore' $ \db -> forM_ msgIds $ \msgId -> updateGroupItemsErrorStatus db msgId (groupMemberId' m) newStatus `catchAll_` pure () - eToView (ChatErrorAgent err $ Just connEntity) + eToView $ ChatErrorAgent err (AgentConnId agentConnId) (Just connEntity) ERR err -> do - eToView (ChatErrorAgent err $ Just connEntity) + eToView $ ChatErrorAgent err (AgentConnId agentConnId) (Just connEntity) when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () -- TODO add debugging output _ -> pure () @@ -1165,117 +1099,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = r n'' = Just (ci, CIRcvDecryptionError mde n'') mdeUpdatedCI _ _ = Nothing - processSndFileConn :: AEvent e -> ConnectionEntity -> Connection -> SndFileTransfer -> CM () - processSndFileConn agentMsg connEntity conn ft@SndFileTransfer {fileId, fileName, fileStatus} = - case agentMsg of - -- SMP CONF for SndFileConnection happens for direct file protocol - -- when recipient of the file "joins" connection created by the sender - CONF confId _pqSupport _ connInfo -> do - ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo - conn' <- updatePeerChatVRange conn chatVRange - case chatMsgEvent of - -- TODO save XFileAcpt message - XFileAcpt name - | name == fileName -> do - withStore' $ \db -> updateSndFileStatus db ft FSAccepted - -- [async agent commands] no continuation needed, but command should be asynchronous for stability - allowAgentConnectionAsync user conn' confId XOk - | otherwise -> messageError "x.file.acpt: fileName is different from expected" - _ -> messageError "CONF from file connection must have x.file.acpt" - CON _ -> do - ci <- withStore $ \db -> do - liftIO $ updateSndFileStatus db ft FSConnected - updateDirectCIFileStatus db vr user fileId $ CIFSSndTransfer 0 1 - toView $ CEvtSndFileStart user ci ft - sendFileChunk user ft - SENT msgId _proxy -> do - withStore' $ \db -> updateSndFileChunkSent db ft msgId - unless (fileStatus == FSCancelled) $ sendFileChunk user ft - MERR _ err -> do - cancelSndFileTransfer user ft True >>= mapM_ deleteAgentConnectionAsync - case err of - SMP _ SMP.AUTH -> unless (fileStatus == FSCancelled) $ do - ci <- withStore $ \db -> do - liftIO (lookupChatRefByFileId db user fileId) >>= \case - Just (ChatRef CTDirect _ _) -> liftIO $ updateFileCancelled db user fileId CIFSSndCancelled - _ -> pure () - lookupChatItemByFileId db vr user fileId - toView $ CEvtSndFileRcvCancelled user ci ft - _ -> throwChatError $ CEFileSend fileId err - MSG meta _ _ -> - withAckMessage' "file msg" agentConnId meta $ pure () - OK -> - -- [async agent commands] continuation on receiving OK - when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () - -- TODO [certs rcv] - JOINED _ _serviceId-> - -- [async agent commands] continuation on receiving JOINED - when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () - ERR err -> do - eToView (ChatErrorAgent err $ Just connEntity) - when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () - -- TODO add debugging output - _ -> pure () - - processRcvFileConn :: AEvent e -> ConnectionEntity -> Connection -> RcvFileTransfer -> CM () - processRcvFileConn agentMsg connEntity conn ft@RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}, grpMemberId} = - case agentMsg of - -- TODO [certs rcv] - INV (ACR _ cReq) _serviceId -> - withCompletedCommand conn agentMsg $ \CommandData {cmdFunction} -> - case cReq of - fileInvConnReq@(CRInvitationUri _ _) -> case cmdFunction of - -- [async agent commands] direct XFileAcptInv continuation on receiving INV - CFCreateConnFileInvDirect -> do - ct <- withStore $ \db -> getContactByFileId db vr user fileId - sharedMsgId <- withStore $ \db -> getSharedMsgIdByFileId db userId fileId - void $ sendDirectContactMessage user ct (XFileAcptInv sharedMsgId (Just fileInvConnReq) fileName) - -- [async agent commands] group XFileAcptInv continuation on receiving INV - CFCreateConnFileInvGroup -> case grpMemberId of - Just gMemberId -> do - GroupMember {groupId, activeConn} <- withStore $ \db -> getGroupMemberById db vr user gMemberId - case activeConn of - Just gMemberConn -> do - sharedMsgId <- withStore $ \db -> getSharedMsgIdByFileId db userId fileId - void $ sendDirectMemberMessage gMemberConn (XFileAcptInv sharedMsgId (Just fileInvConnReq) fileName) groupId - _ -> throwChatError $ CECommandError "no GroupMember activeConn" - _ -> throwChatError $ CECommandError "no grpMemberId" - _ -> throwChatError $ CECommandError "unexpected cmdFunction" - CRContactUri _ -> throwChatError $ CECommandError "unexpected ConnectionRequestUri type" - -- SMP CONF for RcvFileConnection happens for group file protocol - -- when sender of the file "joins" connection created by the recipient - -- (sender doesn't create connections for all group members) - CONF confId _pqSupport _ connInfo -> do - ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo - conn' <- updatePeerChatVRange conn chatVRange - case chatMsgEvent of - XOk -> allowAgentConnectionAsync user conn' confId XOk -- [async agent commands] no continuation needed, but command should be asynchronous for stability - _ -> pure () - CON _ -> startReceivingFile user fileId - MSG meta _ msgBody -> do - -- XXX: not all branches do ACK - parseFileChunk msgBody >>= receiveFileChunk ft (Just conn) meta - OK -> - -- [async agent commands] continuation on receiving OK - when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () - -- TODO [certs rcv] - JOINED _ _serviceId -> - -- [async agent commands] continuation on receiving JOINED - when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () - MERR _ err -> do - eToView (ChatErrorAgent err $ Just connEntity) - processConnMERR connEntity conn err - ERR err -> do - eToView (ChatErrorAgent err $ Just connEntity) - when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () - -- TODO add debugging output - _ -> pure () - receiveFileChunk :: RcvFileTransfer -> Maybe Connection -> MsgMeta -> FileChunk -> CM () receiveFileChunk ft@RcvFileTransfer {fileId, chunkSize} conn_ meta@MsgMeta {recipient = (msgId, _), integrity} = \case FileChunkCancel -> unless (rcvFileCompleteOrCancelled ft) $ do - cancelRcvFileTransfer user ft >>= mapM_ deleteAgentConnectionAsync + cancelRcvFileTransfer user ft ci <- withStore $ \db -> getChatItemByFileId db vr user fileId toView $ CEvtRcvFileSndCancelled user ci ft FileChunk {chunkNo, chunkBytes = chunk} -> do @@ -1315,10 +1143,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- TODO show/log error, other events in contact request _ -> pure () MERR _ err -> do - eToView (ChatErrorAgent err $ Just connEntity) + eToView $ ChatErrorAgent err (AgentConnId agentConnId) (Just connEntity) processConnMERR connEntity conn err ERR err -> do - eToView (ChatErrorAgent err $ Just connEntity) + eToView $ ChatErrorAgent err (AgentConnId agentConnId) (Just connEntity) when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () -- TODO add debugging output _ -> pure () @@ -1484,13 +1312,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = memberCanSend :: GroupMember -> Maybe MsgScope -> - CM (Maybe GroupForwardScope, ShouldDeleteGroupConns) -> - CM (Maybe GroupForwardScope, ShouldDeleteGroupConns) + CM (Maybe DeliveryJobScope) -> + CM (Maybe DeliveryJobScope) memberCanSend m@GroupMember {memberRole} msgScope a = case msgScope of Just MSMember {} -> a Nothing | memberRole > GRObserver || memberPending m -> a - | otherwise -> messageError "member is not allowed to send messages" $> (Nothing, False) + | otherwise -> messageError "member is not allowed to send messages" $> Nothing processConnMERR :: ConnectionEntity -> Connection -> AgentErrorType -> CM () processConnMERR connEntity conn err = do @@ -1559,12 +1387,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- 3) show screen of death to the user asking to restart eInfo <- eventInfo logInfo $ label <> ": " <> eInfo - tryChatError (action eInfo) >>= \case + tryAllErrors (action eInfo) >>= \case Right (withRcpt, shouldDelConns) -> unless shouldDelConns $ withLog (eInfo <> " ok") $ ackMsg msgMeta $ if withRcpt then Just "" else Nothing -- If showCritical is True, then these errors don't result in ACK and show user visible alert -- This prevents losing the message that failed to be processed. - Left (ChatErrorStore SEDBBusyError {message}) | showCritical -> throwError $ ChatErrorAgent (CRITICAL True message) Nothing + Left (ChatErrorStore SEDBBusyError {message}) | showCritical -> throwError $ ChatErrorAgent (CRITICAL True message) (AgentConnId "") Nothing Left e -> do withLog (eInfo <> " error: " <> tshow e) $ ackMsg msgMeta Nothing throwError e @@ -1601,7 +1429,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = e -> SndErrOther $ tshow e where brokerError srvErr = \case - NETWORK -> SndErrExpired + NETWORK _ -> SndErrExpired TIMEOUT -> SndErrExpired HOST -> srvErr SrvErrHost SMP.TRANSPORT TEVersion -> srvErr SrvErrVersion @@ -1610,7 +1438,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = badRcvFileChunk :: RcvFileTransfer -> String -> CM () badRcvFileChunk ft err = unless (rcvFileCompleteOrCancelled ft) $ do - cancelRcvFileTransfer user ft >>= mapM_ deleteAgentConnectionAsync + cancelRcvFileTransfer user ft throwChatError $ CEFileRcvChunk err memberConnectedChatItem :: GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> CM () @@ -1622,11 +1450,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = notifyMemberConnected gInfo m ct_ = do (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m memberConnectedChatItem gInfo' scopeInfo m' - lift $ mapM_ (`setContactNetworkStatus` NSConnected) ct_ toView $ CEvtConnectedToGroupMember user gInfo' m' ct_ - probeMatchingContactsAndMembers :: Contact -> IncognitoEnabled -> Bool -> CM () - probeMatchingContactsAndMembers ct connectedIncognito doProbeContacts = do + probeMatchingMembers :: Contact -> IncognitoEnabled -> CM () + probeMatchingMembers ct connectedIncognito = do gVar <- asks random contactMerge <- readTVarIO =<< asks contactMergeEnabled if contactMerge && not connectedIncognito @@ -1637,12 +1464,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- sendProbe -> sendProbeHashes (currently) -- sendProbeHashes -> sendProbe (reversed - change order in code, may add delay) sendProbe probe - cs <- - if doProbeContacts - then map COMContact <$> withStore' (\db -> getMatchingContacts db vr user ct) - else pure [] ms <- map COMGroupMember <$> withStore' (\db -> getMatchingMembers db vr user ct) - sendProbeHashes (cs <> ms) probe probeId + sendProbeHashes ms probe probeId else sendProbe . Probe =<< liftIO (encodedRandomBytes gVar 32) where sendProbe :: Probe -> CM () @@ -1666,7 +1489,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = sendProbeHashes :: [ContactOrMember] -> Probe -> Int64 -> CM () sendProbeHashes cgms probe probeId = - forM_ cgms $ \cgm -> sendProbeHash cgm `catchChatError` \_ -> pure () + forM_ cgms $ \cgm -> sendProbeHash cgm `catchAllErrors` \_ -> pure () where probeHash = ProbeHash $ C.sha256Hash (unProbe probe) sendProbeHash :: ContactOrMember -> CM () @@ -1725,7 +1548,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = pure (fileId, aci) processFDMessage fileId aci fileDescr - groupMessageFileDescription :: GroupInfo -> GroupMember -> SharedMsgId -> FileDescr -> CM (Maybe GroupForwardScope) + groupMessageFileDescription :: GroupInfo -> GroupMember -> SharedMsgId -> FileDescr -> CM (Maybe DeliveryJobScope) groupMessageFileDescription g@GroupInfo {groupId} GroupMember {memberId} sharedMsgId fileDescr = do (fileId, aci) <- withStore $ \db -> do fileId <- getGroupFileIdBySharedMsgId db userId groupId sharedMsgId @@ -1738,8 +1561,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- in processFDMessage some paths are programmed as errors, -- for example failure on not approved relays (CEFileNotApproved). -- we catch error, so that even if processFDMessage fails, message can still be forwarded. - processFDMessage fileId aci fileDescr `catchChatError` \_ -> pure () - pure $ Just $ toGroupForwardScope g scopeInfo + processFDMessage fileId aci fileDescr `catchAllErrors` \_ -> pure () + pure $ Just $ infoToDeliveryScope g scopeInfo else messageError "x.msg.file.descr: file of another member" $> Nothing _ -> messageError "x.msg.file.descr: invalid file description part" $> Nothing @@ -1860,7 +1683,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = else pure Nothing mapM_ toView cEvt_ - groupMsgReaction :: GroupInfo -> GroupMember -> SharedMsgId -> MemberId -> Maybe MsgScope -> MsgReaction -> Bool -> RcvMessage -> UTCTime -> CM (Maybe GroupForwardScope) + groupMsgReaction :: GroupInfo -> GroupMember -> SharedMsgId -> MemberId -> Maybe MsgScope -> MsgReaction -> Bool -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) groupMsgReaction g m@GroupMember {memberRole} sharedMsgId itemMemberId scope_ reaction add RcvMessage {msgId} brokerTs | groupFeatureAllowed SGFReactions g = do rs <- withStore' $ \db -> getGroupReactions db g m itemMemberId sharedMsgId False @@ -1871,11 +1694,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | memberRole >= GRModerator || scopeMemberId == memberId' m -> withStore $ \db -> do liftIO $ setGroupReaction db g m itemMemberId sharedMsgId False reaction add msgId brokerTs - Just . GFSMemberSupport <$> getScopeMemberIdViaMemberId db user g m scopeMemberId + Just . DJSMemberSupport <$> getScopeMemberIdViaMemberId db user g m scopeMemberId | otherwise -> pure Nothing Nothing -> do withStore' $ \db -> setGroupReaction db g m itemMemberId sharedMsgId False reaction add msgId brokerTs - pure $ Just GFSMain + pure $ Just DJSGroup {jobSpec = DJDeliveryJob {includePending = False}} else pure Nothing | otherwise = pure Nothing where @@ -1892,7 +1715,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = let ci' = CChatItem md ci {reactions} r = ACIReaction SCTGroup SMDRcv (GroupChat g scopeInfo) $ CIReaction (CIGroupRcv m) ci' brokerTs reaction toView $ CEvtChatItemReaction user add r - pure $ Just $ toGroupForwardScope g scopeInfo + pure $ Just $ infoToDeliveryScope g scopeInfo else pure Nothing reactionAllowed :: Bool -> MsgReaction -> [MsgReaction] -> Bool @@ -1900,13 +1723,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = catchCINotFound :: CM a -> (SharedMsgId -> CM a) -> CM a catchCINotFound f handle = - f `catchChatError` \case + f `catchAllErrors` \case ChatErrorStore (SEChatItemSharedMsgIdNotFound sharedMsgId) -> handle sharedMsgId e -> throwError e - newGroupContentMessage :: GroupInfo -> GroupMember -> MsgContainer -> RcvMessage -> UTCTime -> Bool -> CM (Maybe GroupForwardScope) + newGroupContentMessage :: GroupInfo -> GroupMember -> MsgContainer -> RcvMessage -> UTCTime -> Bool -> CM (Maybe DeliveryJobScope) newGroupContentMessage gInfo m@GroupMember {memberId, memberRole} mc msg@RcvMessage {sharedMsgId_} brokerTs forwarded = do - (gInfo', m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m msgScope_ + (gInfo', m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m content msgScope_ if blockedByAdmin m' then createBlockedByAdmin gInfo' m' scopeInfo $> Nothing else @@ -1920,7 +1743,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = pure Nothing Nothing -> do createContentItem gInfo' m' scopeInfo - pure $ Just $ toGroupForwardScope gInfo scopeInfo + pure $ Just $ infoToDeliveryScope gInfo scopeInfo where rejected gInfo' m' scopeInfo f = newChatItem gInfo' m' scopeInfo (ciContentNoParse $ CIRcvGroupFeatureRejected f) Nothing Nothing False timed' gInfo' = if forwarded then rcvCITimed_ (Just Nothing) itemTTL else rcvGroupCITimed gInfo' itemTTL @@ -1966,7 +1789,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = reactions <- maybe (pure []) (\sharedMsgId -> withStore' $ \db -> getGroupCIReactions db gInfo' memberId sharedMsgId) sharedMsgId_ groupMsgToView cInfo ci' {reactions} - groupMessageUpdate :: GroupInfo -> GroupMember -> SharedMsgId -> MsgContent -> Map MemberName MsgMention -> Maybe MsgScope -> RcvMessage -> UTCTime -> Maybe Int -> Maybe Bool -> CM (Maybe GroupForwardScope) + groupMessageUpdate :: GroupInfo -> GroupMember -> SharedMsgId -> MsgContent -> Map MemberName MsgMention -> Maybe MsgScope -> RcvMessage -> UTCTime -> Maybe Int -> Maybe Bool -> CM (Maybe DeliveryJobScope) groupMessageUpdate gInfo@GroupInfo {groupId} m@GroupMember {groupMemberId, memberId} sharedMsgId mc mentions msgScope_ msg@RcvMessage {msgId} brokerTs ttl_ live_ | prohibitedSimplexLinks gInfo m ft_ = messageWarning ("x.msg.update ignored: feature not allowed " <> groupFeatureNameText GFSimplexLinks) $> Nothing @@ -1977,14 +1800,14 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- Chat item and update message which created it will have different sharedMsgId in this case... let timed_ = rcvGroupCITimed gInfo ttl_ mentions' = if memberBlocked m then [] else mentions - (gInfo', m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m msgScope_ + (gInfo', m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m mc msgScope_ (ci, cInfo) <- saveRcvChatItem' user (CDGroupRcv gInfo' scopeInfo m') msg (Just sharedMsgId) brokerTs (content, ts) Nothing timed_ live mentions' ci' <- withStore' $ \db -> do createChatItemVersion db (chatItemId' ci) brokerTs mc updateGroupChatItem db user groupId ci content True live Nothing ci'' <- blockedMemberCI gInfo' m' ci' toView $ CEvtChatItemUpdated user (AChatItem SCTGroup SMDRcv cInfo ci'') - pure $ Just $ toGroupForwardScope gInfo scopeInfo + pure $ Just $ infoToDeliveryScope gInfo scopeInfo where content = CIRcvMsgContent mc ts@(_, ft_) = msgContentTexts mc @@ -2009,14 +1832,14 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = updateGroupCIMentions db gInfo ci' ciMentions toView $ CEvtChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo scopeInfo) ci') startUpdatedTimedItemThread user (ChatRef CTGroup groupId $ toChatScope <$> scopeInfo) ci ci' - pure $ Just $ toGroupForwardScope gInfo scopeInfo + pure $ Just $ infoToDeliveryScope gInfo scopeInfo else do toView $ CEvtChatItemNotChanged user (AChatItem SCTGroup SMDRcv (GroupChat gInfo scopeInfo) ci) pure Nothing else messageError "x.msg.update: group member attempted to update a message of another member" $> Nothing _ -> messageError "x.msg.update: group member attempted invalid message update" $> Nothing - groupMessageDelete :: GroupInfo -> GroupMember -> SharedMsgId -> Maybe MemberId -> Maybe MsgScope -> RcvMessage -> UTCTime -> CM (Maybe GroupForwardScope) + groupMessageDelete :: GroupInfo -> GroupMember -> SharedMsgId -> Maybe MemberId -> Maybe MsgScope -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) groupMessageDelete gInfo@GroupInfo {membership} m@GroupMember {memberId, memberRole = senderRole} sharedMsgId sndMemberId_ scope_ RcvMessage {msgId} brokerTs = do let msgMemberId = fromMaybe memberId sndMemberId_ withStore' (\db -> runExceptT $ getGroupMemberCIBySharedMsgId db user gInfo msgMemberId sharedMsgId) >>= \case @@ -2045,32 +1868,32 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = Just (MSMember scopeMemberId) -> withStore $ \db -> do liftIO $ createCIModeration db gInfo m msgMemberId sharedMsgId msgId brokerTs - Just . GFSMemberSupport <$> getScopeMemberIdViaMemberId db user gInfo m scopeMemberId + Just . DJSMemberSupport <$> getScopeMemberIdViaMemberId db user gInfo m scopeMemberId Nothing -> do withStore' $ \db -> createCIModeration db gInfo m msgMemberId sharedMsgId msgId brokerTs - pure $ Just GFSMain + pure $ Just DJSGroup {jobSpec = DJDeliveryJob {includePending = False}} where - moderate :: GroupMember -> CChatItem 'CTGroup -> CM (Maybe GroupForwardScope) + moderate :: GroupMember -> CChatItem 'CTGroup -> CM (Maybe DeliveryJobScope) moderate mem cci = case sndMemberId_ of Just sndMemberId | sameMemberId sndMemberId mem -> checkRole mem $ do - groupForwardScope <- delete cci (Just m) + jobScope <- delete cci (Just m) archiveMessageReports cci m - pure $ Just groupForwardScope + pure $ Just jobScope | otherwise -> messageError "x.msg.del: message of another member with incorrect memberId" $> Nothing _ -> messageError "x.msg.del: message of another member without memberId" $> Nothing checkRole GroupMember {memberRole} a | senderRole < GRModerator || senderRole < memberRole = messageError "x.msg.del: message of another member with insufficient member permissions" $> Nothing | otherwise = a - delete :: CChatItem 'CTGroup -> Maybe GroupMember -> CM GroupForwardScope + delete :: CChatItem 'CTGroup -> Maybe GroupMember -> CM DeliveryJobScope delete cci byGroupMember = do scopeInfo <- withStore $ \db -> getGroupChatScopeInfoForItem db vr user gInfo (cChatItemId cci) deletions <- if groupFeatureMemberAllowed SGFFullDelete m gInfo then deleteGroupCIs user gInfo scopeInfo [cci] byGroupMember brokerTs else markGroupCIsDeleted user gInfo scopeInfo [cci] byGroupMember brokerTs toView $ CEvtChatItemsDeleted user deletions False False - pure $ toGroupForwardScope gInfo scopeInfo + pure $ infoToDeliveryScope gInfo scopeInfo archiveMessageReports :: CChatItem 'CTGroup -> GroupMember -> CM () archiveMessageReports (CChatItem _ ci) byMember = do ciIds <- withStore' $ \db -> markMessageReportsDeleted db user gInfo ci byMember brokerTs @@ -2127,7 +1950,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = fileId <- withStore $ \db -> getFileIdBySharedMsgId db userId contactId sharedMsgId ft <- withStore (\db -> getRcvFileTransfer db user fileId) unless (rcvFileCompleteOrCancelled ft) $ do - cancelRcvFileTransfer user ft >>= mapM_ deleteAgentConnectionAsync + cancelRcvFileTransfer user ft ci <- withStore $ \db -> getChatItemByFileId db vr user fileId toView $ CEvtRcvFileSndCancelled user ci ft @@ -2140,14 +1963,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- [async agent commands] no continuation needed, but command should be asynchronous for stability if fName == fileName then unless cancelled $ case fileConnReq_ of - -- receiving via a separate connection - Just fileConnReq -> do - subMode <- chatReadVar subscriptionMode - dm <- encodeConnInfo XOk - connIds <- joinAgentConnectionAsync user True fileConnReq dm subMode - withStore' $ \db -> createSndDirectFTConnection db vr user fileId connIds subMode -- receiving inline - _ -> do + Nothing -> do event <- withStore $ \db -> do ci' <- updateDirectCIFileStatus db vr user fileId $ CIFSSndTransfer 0 1 sft <- createSndDirectInlineFT db ct ft @@ -2157,6 +1974,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = (allowSendInline fileSize fileInline) (sendDirectFileInline user ct ft sharedMsgId) (messageError "x.file.acpt.inv: fileSize is bigger than allowed to send inline") + Just _fileConnReq -> messageError "x.file.acpt.inv: receiving file via a separate connection is deprecated" else messageError "x.file.acpt.inv: fileName is different from expected" assertSMPAcceptNotProhibited :: ChatItem c d -> CM () @@ -2176,7 +1994,6 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = forM_ sft_ $ \sft@SndFileTransfer {fileId} -> do ci@(AChatItem _ _ _ ChatItem {file}) <- withStore $ \db -> do liftIO $ updateSndFileStatus db sft FSComplete - liftIO $ deleteSndFileChunks db sft updateDirectCIFileStatus db vr user fileId CIFSSndComplete case file of Just CIFile {fileProtocol = FPXFTP} -> do @@ -2211,7 +2028,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = _ -> pure () receiveFileChunk ft Nothing meta chunk - xFileCancelGroup :: GroupInfo -> GroupMember -> SharedMsgId -> CM (Maybe GroupForwardScope) + xFileCancelGroup :: GroupInfo -> GroupMember -> SharedMsgId -> CM (Maybe DeliveryJobScope) xFileCancelGroup g@GroupInfo {groupId} GroupMember {memberId} sharedMsgId = do (fileId, aci) <- withStore $ \db -> do fileId <- getGroupFileIdBySharedMsgId db userId groupId sharedMsgId @@ -2223,9 +2040,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = then do ft <- withStore $ \db -> getRcvFileTransfer db user fileId unless (rcvFileCompleteOrCancelled ft) $ do - cancelRcvFileTransfer user ft >>= mapM_ deleteAgentConnectionAsync + cancelRcvFileTransfer user ft toView $ CEvtRcvFileSndCancelled user aci ft - pure $ Just $ toGroupForwardScope g scopeInfo + pure $ Just $ infoToDeliveryScope g scopeInfo else -- shouldn't happen now that query includes group member id messageError "x.file.cancel: group member attempted to cancel file of another member" $> Nothing @@ -2240,14 +2057,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = ft@FileTransferMeta {fileName, fileSize, fileInline, cancelled} <- withStore (\db -> getFileTransferMeta db user fileId) if fName == fileName then unless cancelled $ case (fileConnReq_, activeConn) of - (Just fileConnReq, _) -> do - subMode <- chatReadVar subscriptionMode - -- receiving via a separate connection - -- [async agent commands] no continuation needed, but command should be asynchronous for stability - dm <- encodeConnInfo XOk - connIds <- joinAgentConnectionAsync user True fileConnReq dm subMode - withStore' $ \db -> createSndGroupFileTransferConnection db vr user fileId connIds m subMode - (_, Just conn) -> do + (Nothing, Just conn) -> do -- receiving inline event <- withStore $ \db -> do ci' <- updateDirectCIFileStatus db vr user fileId $ CIFSSndTransfer 0 1 @@ -2258,6 +2068,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = (allowSendInline fileSize fileInline) (sendMemberFileInline m conn ft sharedMsgId) (messageError "x.file.acpt.inv: fileSize is bigger than allowed to send inline") + (Just _fileConnReq, _) -> messageError "x.file.acpt.inv: receiving file via a separate connection is deprecated" _ -> messageError "x.file.acpt.inv: member connection is not active" else messageError "x.file.acpt.inv: fileName is different from expected" @@ -2368,10 +2179,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = Profile {displayName = n, fullName = fn, shortDescr = sd, image = i, contactLink = cl} = p Profile {displayName = n', fullName = fn', shortDescr = sd', image = i', contactLink = cl'} = p' - xInfoMember :: GroupInfo -> GroupMember -> Profile -> UTCTime -> CM (Maybe GroupForwardScope) + xInfoMember :: GroupInfo -> GroupMember -> Profile -> UTCTime -> CM (Maybe DeliveryJobScope) xInfoMember gInfo m p' brokerTs = do void $ processMemberProfileUpdate gInfo m p' True (Just brokerTs) - pure $ memberEventForwardScope m + pure $ memberEventDeliveryScope m xGrpLinkMem :: GroupInfo -> GroupMember -> Connection -> Profile -> CM () xGrpLinkMem gInfo@GroupInfo {membership, businessChat} m@GroupMember {groupMemberId, memberCategory} Connection {viaGroupLink} p' = do @@ -2391,7 +2202,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memberId) >>= \case Left _ -> messageError "x.grp.link.acpt error: referenced member does not exist" Right referencedMember -> do - (referencedMember', gInfo') <- withFastStore' $ \db -> do + (referencedMember', gInfo') <- withStore' $ \db -> do referencedMember' <- updateGroupMemberAccepted db user referencedMember (newMemberStatus referencedMember) role gInfo' <- updateGroupMembersRequireAttention db user gInfo referencedMember referencedMember' pure (referencedMember', gInfo') @@ -2444,7 +2255,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = processMemberProfileUpdate :: GroupInfo -> GroupMember -> Profile -> Bool -> Maybe UTCTime -> CM GroupMember processMemberProfileUpdate gInfo m@GroupMember {memberProfile = p, memberContactId} p' createItems itemTs_ - | redactedMemberProfile (fromLocalProfile p) /= redactedMemberProfile p' = do + | redactedMemberProfile allowSimplexLinks (fromLocalProfile p) /= redactedMemberProfile allowSimplexLinks p' = do updateBusinessChatProfile gInfo case memberContactId of Nothing -> do @@ -2471,6 +2282,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | otherwise = pure m where + allowSimplexLinks = groupFeatureMemberAllowed SGFSimplexLinks m gInfo updateBusinessChatProfile g@GroupInfo {businessChat} = case businessChat of Just bc | isMainBusinessMember bc m -> do g' <- withStore $ \db -> updateGroupProfileFromMember db user g p' @@ -2497,7 +2309,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = probeMatches :: [ContactOrMember] -> ContactOrMember -> CM () probeMatches [] _ = pure () probeMatches (cgm1' : cgm1s') cgm2' = do - cgm2''_ <- probeMatch cgm1' cgm2' probe `catchChatError` \_ -> pure (Just cgm2') + cgm2''_ <- probeMatch cgm1' cgm2' probe `catchAllErrors` \_ -> pure (Just cgm2') let cgm2'' = fromMaybe cgm2' cgm2''_ probeMatches cgm1s' cgm2'' @@ -2514,25 +2326,23 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = probeMatch :: ContactOrMember -> ContactOrMember -> Probe -> CM (Maybe ContactOrMember) probeMatch cgm1 cgm2 probe = case cgm1 of - COMContact c1@Contact {contactId = cId1, profile = p1} -> + COMContact c1@Contact {profile = p1} -> case cgm2 of - COMContact c2@Contact {contactId = cId2, profile = p2} - | cId1 /= cId2 && profilesMatch p1 p2 -> do - void . sendDirectContactMessage user c1 $ XInfoProbeOk probe - COMContact <$$> mergeContacts c1 c2 - | otherwise -> messageWarning "probeMatch ignored: profiles don't match or same contact id" >> pure Nothing COMGroupMember m2@GroupMember {memberProfile = p2, memberContactId} | isNothing memberContactId && profilesMatch p1 p2 -> do void . sendDirectContactMessage user c1 $ XInfoProbeOk probe COMContact <$$> associateMemberAndContact c1 m2 | otherwise -> messageWarning "probeMatch ignored: profiles don't match or member already has contact" >> pure Nothing - COMGroupMember GroupMember {activeConn = Nothing} -> pure Nothing - COMGroupMember m1@GroupMember {groupId, memberProfile = p1, memberContactId, activeConn = Just conn} -> + COMContact _ -> messageWarning "probeMatch ignored: contacts are not merged" >> pure Nothing + COMGroupMember m1@GroupMember {groupId, memberProfile = p1, memberContactId} -> case cgm2 of COMContact c2@Contact {profile = p2} - | memberCurrent m1 && isNothing memberContactId && profilesMatch p1 p2 -> do - void $ sendDirectMemberMessage conn (XInfoProbeOk probe) groupId - COMContact <$$> associateMemberAndContact c2 m1 + | memberCurrent m1 && isNothing memberContactId && profilesMatch p1 p2 -> + case memberConn m1 of + Just conn -> do + void $ sendDirectMemberMessage conn (XInfoProbeOk probe) groupId + COMContact <$$> associateMemberAndContact c2 m1 + _ -> messageWarning "probeMatch ignored: matched member doesn't have connection" >> pure Nothing | otherwise -> messageWarning "probeMatch ignored: profiles don't match or member already has contact or member not current" >> pure Nothing COMGroupMember _ -> messageWarning "probeMatch ignored: members are not matched with members" >> pure Nothing @@ -2540,14 +2350,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = xInfoProbeOk cgm1 probe = do cgm2 <- withStore' $ \db -> matchSentProbe db vr user cgm1 probe case cgm1 of - COMContact c1@Contact {contactId = cId1} -> + COMContact c1 -> case cgm2 of - Just (COMContact c2@Contact {contactId = cId2}) - | cId1 /= cId2 -> void $ mergeContacts c1 c2 - | otherwise -> messageWarning "xInfoProbeOk ignored: same contact id" Just (COMGroupMember m2@GroupMember {memberContactId}) | isNothing memberContactId -> void $ associateMemberAndContact c1 m2 | otherwise -> messageWarning "xInfoProbeOk ignored: member already has contact" + Just (COMContact _) -> messageWarning "xInfoProbeOk ignored: contacts are not merged" _ -> pure () COMGroupMember m1@GroupMember {memberContactId} -> case cgm2 of @@ -2667,36 +2475,6 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = msgCallStateError eventName Call {callState} = messageError $ eventName <> ": wrong call state " <> T.pack (show $ callStateTag callState) - mergeContacts :: Contact -> Contact -> CM (Maybe Contact) - mergeContacts c1 c2 = do - let Contact {localDisplayName = cLDN1, profile = LocalProfile {displayName}} = c1 - Contact {localDisplayName = cLDN2} = c2 - case (suffixOrd displayName cLDN1, suffixOrd displayName cLDN2) of - (Just cOrd1, Just cOrd2) - | cOrd1 < cOrd2 -> merge c1 c2 - | cOrd2 < cOrd1 -> merge c2 c1 - | otherwise -> pure Nothing - _ -> pure Nothing - where - merge c1' c2' = do - c2'' <- withStore $ \db -> mergeContactRecords db vr user c1' c2' - toView $ CEvtContactsMerged user c1' c2' c2'' - when (directOrUsed c2'') $ showSecurityCodeChanged c2'' - pure $ Just c2'' - where - showSecurityCodeChanged mergedCt = do - let sc1_ = contactSecurityCode c1' - sc2_ = contactSecurityCode c2' - scMerged_ = contactSecurityCode mergedCt - case (sc1_, sc2_) of - (Just sc1, Nothing) - | scMerged_ /= Just sc1 -> securityCodeChanged mergedCt - | otherwise -> pure () - (Nothing, Just sc2) - | scMerged_ /= Just sc2 -> securityCodeChanged mergedCt - | otherwise -> pure () - _ -> pure () - associateMemberAndContact :: Contact -> GroupMember -> CM (Maybe Contact) associateMemberAndContact c m = do let Contact {localDisplayName = cLDN, profile = LocalProfile {displayName}} = c @@ -2729,7 +2507,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toView $ CEvtContactAndMemberAssociated user c2 g m1 c2' pure c2' - saveConnInfo :: Connection -> ConnInfo -> CM (Connection, Bool) + saveConnInfo :: Connection -> ConnInfo -> CM (Connection, Maybe GroupInfo) saveConnInfo activeConn connInfo = do ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage activeConn connInfo conn' <- updatePeerChatVRange activeConn chatVRange @@ -2737,20 +2515,20 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = XInfo p -> do ct <- withStore $ \db -> createDirectContact db vr user conn' p toView $ CEvtContactConnecting user ct - pure (conn', False) + pure (conn', Nothing) XGrpLinkInv glInv -> do (gInfo, host) <- withStore $ \db -> createGroupInvitedViaLink db vr user conn' glInv toView $ CEvtGroupLinkConnecting user gInfo host - pure (conn', True) + pure (conn', Just gInfo) XGrpLinkReject glRjct@GroupLinkRejection {rejectionReason} -> do (gInfo, host) <- withStore $ \db -> createGroupRejectedViaLink db vr user conn' glRjct toView $ CEvtGroupLinkConnecting user gInfo host toViewTE $ TEGroupLinkRejected user gInfo rejectionReason - pure (conn', True) + pure (conn', Just gInfo) -- TODO show/log error, other events in SMP confirmation - _ -> pure (conn', False) + _ -> pure (conn', Nothing) - xGrpMemNew :: GroupInfo -> GroupMember -> MemberInfo -> Maybe MsgScope -> RcvMessage -> UTCTime -> CM (Maybe GroupForwardScope) + xGrpMemNew :: GroupInfo -> GroupMember -> MemberInfo -> Maybe MsgScope -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) xGrpMemNew gInfo m memInfo@(MemberInfo memId memRole _ _) msgScope_ msg brokerTs = do checkHostRole m memRole if sameMemberId memId (membership gInfo) @@ -2766,7 +2544,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = pure (updatedMember, gInfo') toView $ CEvtUnknownMemberAnnounced user gInfo' m unknownMember updatedMember memberAnnouncedToView updatedMember gInfo' - pure $ forwardScope updatedMember + pure $ deliveryJobScope updatedMember Right _ -> messageError "x.grp.mem.new error: member already exists" $> Nothing Left _ -> do (newMember, gInfo') <- withStore $ \db -> do @@ -2776,15 +2554,15 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = else pure gInfo pure (newMember, gInfo') memberAnnouncedToView newMember gInfo' - pure $ forwardScope newMember + pure $ deliveryJobScope newMember where initialStatus = case msgScope_ of Just (MSMember _) -> GSMemPendingReview _ -> GSMemAnnounced - forwardScope GroupMember {groupMemberId, memberStatus} + deliveryJobScope GroupMember {groupMemberId, memberStatus} | memberStatus == GSMemPendingApproval = Nothing - | memberStatus == GSMemPendingReview = Just $ GFSMemberSupport groupMemberId - | otherwise = Just GFSMain + | memberStatus == GSMemPendingReview = Just $ DJSMemberSupport groupMemberId + | otherwise = Just DJSGroup {jobSpec = DJDeliveryJob {includePending = False}} memberAnnouncedToView announcedMember@GroupMember {groupMemberId, memberProfile} gInfo' = do (announcedMember', scopeInfo) <- getMemNewChatScope announcedMember let event = RGEMemberAdded groupMemberId (fromLocalProfile memberProfile) @@ -2837,14 +2615,17 @@ 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 - GroupMemberIntro {introId} <- withStore $ \db -> saveIntroInvitation db reMember m introInv - sendGroupMemberMessage gInfo reMember (XGrpMemFwd (memberInfo m) introInv) (Just introId) $ + 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 _ -> messageError "x.grp.mem.inv can be only sent by invitee member" xGrpMemFwd :: GroupInfo -> GroupMember -> MemberInfo -> IntroInvitation -> CM () - xGrpMemFwd gInfo@GroupInfo {membership, chatSettings} m memInfo@(MemberInfo memId memRole memChatVRange _) introInv@IntroInvitation {groupConnReq, directConnReq} = do + xGrpMemFwd gInfo@GroupInfo {membership, chatSettings} m memInfo@(MemberInfo memId memRole memChatVRange _) IntroInvitation {groupConnReq, directConnReq} = do let GroupMember {memberId = membershipMemId} = membership checkHostRole m memRole toMember <- @@ -2859,10 +2640,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- TODO add GSMemIntroInvitedPending, GSMemConnectedPending, etc.? -- TODO keep as is? (GSMemIntroInvited has no purpose) let newMemberStatus = if memberPending toMember then memberStatus toMember else GSMemIntroInvited - withStore' $ \db -> saveMemberInvitation db toMember introInv newMemberStatus + withStore' $ \db -> updateGroupMemberStatus db userId toMember newMemberStatus subMode <- chatReadVar subscriptionMode -- [incognito] send membership incognito profile, create direct connection as incognito - let membershipProfile = redactedMemberProfile $ fromLocalProfile $ memberProfile membership + let membershipProfile = redactedMemberProfile allowSimplexLinks $ fromLocalProfile $ memberProfile membership + allowSimplexLinks = groupFeatureUserAllowed SGFSimplexLinks gInfo dm <- encodeConnInfo $ XGrpMemInfo membershipMemId membershipProfile -- [async agent commands] no continuation needed, but commands should be asynchronous for stability groupConnIds <- joinAgentConnectionAsync user (chatHasNtfs chatSettings) groupConnReq dm subMode @@ -2872,7 +2654,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = chatV = vr `peerConnChatVersion` mcvr withStore' $ \db -> createIntroToMemberContact db user m toMember chatV mcvr groupConnIds directConnIds customUserProfileId subMode - xGrpMemRole :: GroupInfo -> GroupMember -> MemberId -> GroupMemberRole -> RcvMessage -> UTCTime -> CM (Maybe GroupForwardScope) + xGrpMemRole :: GroupInfo -> GroupMember -> MemberId -> GroupMemberRole -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) xGrpMemRole gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId memRole msg brokerTs | membershipMemId == memId = let gInfo' = gInfo {membership = membership {memberRole = memRole}} @@ -2892,13 +2674,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo'' scopeInfo m') msg brokerTs (CIRcvGroupEvent gEvent) groupMsgToView cInfo ci toView CEvtMemberRole {user, groupInfo = gInfo'', byMember = m', member = member {memberRole = memRole}, fromRole, toRole = memRole} - pure $ memberEventForwardScope member + pure $ memberEventDeliveryScope member checkHostRole :: GroupMember -> GroupMemberRole -> CM () checkHostRole GroupMember {memberRole, localDisplayName} memRole = when (memberRole < GRAdmin || memberRole < memRole) $ throwChatError (CEGroupContactRole localDisplayName) - xGrpMemRestrict :: GroupInfo -> GroupMember -> MemberId -> MemberRestrictions -> RcvMessage -> UTCTime -> CM (Maybe GroupForwardScope) + xGrpMemRestrict :: GroupInfo -> GroupMember -> MemberId -> MemberRestrictions -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) xGrpMemRestrict gInfo@GroupInfo {membership = GroupMember {memberId = membershipMemId}} m@GroupMember {memberRole = senderRole} @@ -2906,9 +2688,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = MemberRestrictions {restriction} msg brokerTs - | membershipMemId == memId = - -- member shouldn't receive this message about themselves - messageError "x.grp.mem.restrict: admin blocks you" $> Nothing + | membershipMemId == memId = pure Nothing -- ignore - XGrpMemRestrict can be sent to restricted member for efficiency | otherwise = withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case Right bm@GroupMember {groupMemberId = bmId, memberRole, blockedByAdmin, memberProfile = bmp} @@ -2923,12 +2703,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo' scopeInfo m') msg brokerTs ciContent groupMsgToView cInfo ci toView CEvtMemberBlockedForAll {user, groupInfo = gInfo', byMember = m', member = bm', blocked} - pure $ memberEventForwardScope bm + pure $ memberEventDeliveryScope bm Left (SEGroupMemberNotFoundByMemberId _) -> do bm <- createUnknownMember gInfo memId Nothing bm' <- setMemberBlocked bm toView $ CEvtUnknownMemberBlocked user gInfo m bm' - pure $ Just GFSMain + pure $ Just DJSGroup {jobSpec = DJDeliveryJob {includePending = False}} Left e -> throwError $ ChatErrorStore e where setMemberBlocked bm = withStore' $ \db -> updateGroupMemberBlocked db user gInfo restriction bm @@ -2975,23 +2755,26 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = _ -> updateStatus introId GMIntroReConnected updateStatus introId status = withStore' $ \db -> updateIntroStatus db introId status - xGrpMemDel :: GroupInfo -> GroupMember -> MemberId -> Bool -> ChatMessage 'Json -> RcvMessage -> UTCTime -> Bool -> CM (Maybe GroupForwardScope, ShouldDeleteGroupConns) + 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 let GroupMember {memberId = membershipMemId} = membership if membershipMemId == memId then checkRole membership $ do deleteGroupLinkIfExists user gInfo - unless (isUserGrpFwdRelay gInfo) $ deleteGroupConnections gInfo False + -- TODO [channels fwd] possible improvement is to immediately delete rcv queues if isUserGrpFwdRelay + unless (isUserGrpFwdRelay gInfo) $ deleteGroupConnections user gInfo False withStore' $ \db -> updateGroupMemberStatus db userId membership GSMemRemoved let membership' = membership {memberStatus = GSMemRemoved} when withMessages $ deleteMessages gInfo membership' SMDSnd deleteMemberItem RGEUserDeleted toView $ CEvtDeletedMemberUser user gInfo {membership = membership'} m withMessages - pure (Just GFSAll, True) + pure $ Just DJSGroup {jobSpec = DJRelayRemoved} else withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case - Left _ -> messageError "x.grp.mem.del with unknown member ID" $> (Just GFSAll, False) - Right deletedMember@GroupMember {groupMemberId, memberProfile} -> + Left _ -> do + messageError "x.grp.mem.del with unknown member ID" + pure $ Just DJSGroup {jobSpec = DJDeliveryJob {includePending = True}} + 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 @@ -3003,15 +2786,16 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = 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 wasDeleted = memberStatus == GSMemRemoved || memberStatus == GSMemLeft + deletedMember' = deletedMember {memberStatus = GSMemRemoved} when withMessages $ deleteMessages gInfo' deletedMember' SMDRcv - deleteMemberItem $ RGEMemberDeleted groupMemberId (fromLocalProfile memberProfile) + unless wasDeleted $ deleteMemberItem $ RGEMemberDeleted groupMemberId (fromLocalProfile memberProfile) toView $ CEvtDeletedMember user gInfo' m deletedMember' withMessages - pure (memberEventForwardScope deletedMember, False) + pure $ memberEventDeliveryScope deletedMember where checkRole GroupMember {memberRole} a | senderRole < GRAdmin || senderRole < memberRole = - messageError "x.grp.mem.del with insufficient member permissions" $> (Nothing, False) + messageError "x.grp.mem.del with insufficient member permissions" $> Nothing | otherwise = a deleteMemberItem gEvent = do (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m @@ -3028,17 +2812,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = event = XGrpMsgForward memberId memberName chatMsg brokerTs sendGroupMemberMessage gInfo member event Nothing (pure ()) + -- TODO [channels fwd] base on differentiation between groups and channels isUserGrpFwdRelay :: GroupInfo -> Bool - isUserGrpFwdRelay GroupInfo {membership = GroupMember {memberRole}} = - memberRole >= GRAdmin + isUserGrpFwdRelay GroupInfo {useRelays, membership = membership@GroupMember {memberRole}} + | isTrue useRelays = isMemberRelay membership + | otherwise = memberRole >= GRAdmin - deleteGroupConnections :: GroupInfo -> Bool -> CM () - deleteGroupConnections gInfo waitDelivery = do - -- member records are not deleted to keep history - members <- withStore' $ \db -> getGroupMembers db vr user gInfo - deleteMembersConnections' user members waitDelivery - - xGrpLeave :: GroupInfo -> GroupMember -> RcvMessage -> UTCTime -> CM (Maybe GroupForwardScope) + xGrpLeave :: GroupInfo -> GroupMember -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) xGrpLeave gInfo m msg brokerTs = do deleteMemberConnection m -- member record is not deleted to allow creation of "member left" chat item @@ -3051,19 +2831,20 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo'' scopeInfo m') msg brokerTs (CIRcvGroupEvent RGEMemberLeft) groupMsgToView cInfo ci toView $ CEvtLeftMember user gInfo'' m' {memberStatus = GSMemLeft} - pure $ memberEventForwardScope m + pure $ memberEventDeliveryScope m xGrpDel :: GroupInfo -> GroupMember -> RcvMessage -> UTCTime -> CM () xGrpDel gInfo@GroupInfo {membership} m@GroupMember {memberRole} msg brokerTs = do when (memberRole /= GROwner) $ throwChatError $ CEGroupUserRole gInfo GROwner withStore' $ \db -> updateGroupMemberStatus db userId membership GSMemGroupDeleted - unless (isUserGrpFwdRelay gInfo) $ deleteGroupConnections gInfo False + -- TODO [channels fwd] possible improvement is to immediately delete rcv queues if isUserGrpFwdRelay + unless (isUserGrpFwdRelay gInfo) $ deleteGroupConnections user gInfo False (gInfo'', m', scopeInfo) <- mkGroupChatScope gInfo m (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo'' scopeInfo m') msg brokerTs (CIRcvGroupEvent RGEGroupDeleted) groupMsgToView cInfo ci toView $ CEvtGroupDeleted user gInfo'' {membership = membership {memberStatus = GSMemGroupDeleted}} m' - xGrpInfo :: GroupInfo -> GroupMember -> GroupProfile -> RcvMessage -> UTCTime -> CM (Maybe GroupForwardScope) + xGrpInfo :: GroupInfo -> GroupMember -> GroupProfile -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) xGrpInfo g@GroupInfo {groupProfile = p, businessChat} m@GroupMember {memberRole} p' msg brokerTs | memberRole < GROwner = messageError "x.grp.info with insufficient member permissions" $> Nothing | otherwise = do @@ -3077,13 +2858,14 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = (ci, cInfo) <- saveRcvChatItemNoParse user cd msg brokerTs (CIRcvGroupEvent $ RGEGroupUpdated p') groupMsgToView cInfo ci createGroupFeatureChangedItems user cd CIRcvGroupFeature g g'' + void $ forkIO $ setGroupLinkData' NRMBackground user g'' Just _ -> updateGroupPrefs_ g m $ fromMaybe defaultBusinessGroupPrefs $ groupPreferences p' - pure $ Just GFSAll + pure $ Just DJSGroup {jobSpec = DJDeliveryJob {includePending = True}} - xGrpPrefs :: GroupInfo -> GroupMember -> GroupPreferences -> CM (Maybe GroupForwardScope) + xGrpPrefs :: GroupInfo -> GroupMember -> GroupPreferences -> CM (Maybe DeliveryJobScope) xGrpPrefs g m@GroupMember {memberRole} ps' | memberRole < GROwner = messageError "x.grp.prefs with insufficient member permissions" $> Nothing - | otherwise = updateGroupPrefs_ g m ps' $> Just GFSAll + | otherwise = updateGroupPrefs_ g m ps' $> Just DJSGroup {jobSpec = DJDeliveryJob {includePending = True}} updateGroupPrefs_ :: GroupInfo -> GroupMember -> GroupPreferences -> CM () updateGroupPrefs_ g@GroupInfo {groupProfile = p} m ps' = @@ -3183,8 +2965,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toViewTE $ TEContactVerificationReset user ct createInternalChatItem user (CDDirectRcv ct) (CIRcvConnEvent RCEVerificationCodeReset) Nothing - xGrpMsgForward :: GroupInfo -> GroupMember -> MemberId -> Maybe ContactName -> ChatMessage 'Json -> UTCTime -> CM () - xGrpMsgForward gInfo@GroupInfo {groupId} m@GroupMember {memberRole, localDisplayName} memberId memberName chatMsg msgTs = do + xGrpMsgForward :: GroupInfo -> GroupMember -> MemberId -> Maybe ContactName -> ChatMessage 'Json -> UTCTime -> UTCTime -> CM () + xGrpMsgForward gInfo m@GroupMember {memberRole, localDisplayName} memberId memberName chatMsg msgTs brokerTs = do when (memberRole < GRAdmin) $ throwChatError (CEGroupContactRole localDisplayName) withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memberId) >>= \case Right author -> processForwardedMsg author @@ -3198,13 +2980,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = processForwardedMsg :: GroupMember -> CM () processForwardedMsg author = do let body = chatMsgToBody chatMsg - rcvMsg@RcvMessage {chatMsgEvent = ACME _ event} <- saveGroupFwdRcvMsg user groupId m author body chatMsg - case event of - XMsgNew mc -> void $ memberCanSend author scope $ (,False) <$> newGroupContentMessage gInfo author mc rcvMsg msgTs True + rcvMsg_ <- saveGroupFwdRcvMsg user gInfo m author body chatMsg brokerTs + forM_ rcvMsg_ $ \rcvMsg@RcvMessage {chatMsgEvent = ACME _ event} -> case event of + XMsgNew mc -> void $ memberCanSend author scope $ (const Nothing) <$> newGroupContentMessage gInfo author mc rcvMsg msgTs True where ExtMsgContent {scope} = mcExtMsgContent mc -- file description is always allowed, to allow sending files to support scope XMsgFileDescr sharedMsgId fileDescr -> void $ groupMessageFileDescription gInfo author sharedMsgId fileDescr - XMsgUpdate sharedMsgId mContent mentions ttl live msgScope -> void $ memberCanSend author msgScope $ (,False) <$> groupMessageUpdate gInfo author sharedMsgId mContent mentions msgScope rcvMsg msgTs ttl live + XMsgUpdate sharedMsgId mContent mentions ttl live msgScope -> void $ memberCanSend author msgScope $ (const Nothing) <$> groupMessageUpdate gInfo author sharedMsgId mContent mentions msgScope rcvMsg msgTs ttl live XMsgDel sharedMsgId memId scope_ -> void $ groupMessageDelete gInfo author sharedMsgId memId scope_ rcvMsg msgTs XMsgReact sharedMsgId (Just memId) scope_ reaction add -> void $ groupMsgReaction gInfo author sharedMsgId memId scope_ reaction add rcvMsg msgTs XFileCancel sharedMsgId -> void $ xFileCancelGroup gInfo author sharedMsgId @@ -3225,7 +3007,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = directMsgReceived :: Contact -> Connection -> MsgMeta -> NonEmpty MsgReceipt -> CM () directMsgReceived ct conn@Connection {connId} msgMeta msgRcpts = do - checkIntegrityCreateItem (CDDirectRcv ct) msgMeta `catchChatError` \_ -> pure () + checkIntegrityCreateItem (CDDirectRcv ct) msgMeta `catchAllErrors` \_ -> pure () forM_ msgRcpts $ \MsgReceipt {agentMsgId, msgRcptStatus} -> do withStore' $ \db -> updateSndMsgDeliveryStatus db connId agentMsgId $ MDSSndRcvd msgRcptStatus updateDirectItemStatus ct conn agentMsgId $ CISSndRcvd msgRcptStatus SSPComplete @@ -3233,7 +3015,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = groupMsgReceived :: GroupInfo -> GroupMember -> Connection -> MsgMeta -> NonEmpty MsgReceipt -> CM () groupMsgReceived gInfo m conn@Connection {connId} msgMeta msgRcpts = do (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m - checkIntegrityCreateItem (CDGroupRcv gInfo' scopeInfo m') msgMeta `catchChatError` \_ -> pure () + checkIntegrityCreateItem (CDGroupRcv gInfo' scopeInfo m') msgMeta `catchAllErrors` \_ -> pure () forM_ msgRcpts $ \MsgReceipt {agentMsgId, msgRcptStatus} -> do withStore' $ \db -> updateSndMsgDeliveryStatus db connId agentMsgId $ MDSSndRcvd msgRcptStatus updateGroupItemsStatus gInfo' m' conn agentMsgId (GSSRcvd msgRcptStatus) Nothing @@ -3305,3 +3087,227 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = else pure Nothing else pure Nothing _ -> pure Nothing + +deleteGroupConnections :: User -> GroupInfo -> Bool -> CM () +deleteGroupConnections user gInfo waitDelivery = do + vr <- chatVersionRange + -- member records are not deleted to keep history + members <- getMembers vr + deleteMembersConnections' user members waitDelivery + where + getMembers vr + | isTrue (useRelays gInfo) = withStore' $ \db -> getGroupRelays db vr user gInfo + | otherwise = withStore' $ \db -> getGroupMembers db vr user gInfo + +startDeliveryTaskWorkers :: CM () +startDeliveryTaskWorkers = do + workerScopes <- withStore' $ \db -> getPendingDeliveryTaskScopes db + lift $ forM_ workerScopes resumeDeliveryTaskWork + +resumeDeliveryTaskWork :: DeliveryWorkerKey -> CM' () +resumeDeliveryTaskWork = void . getDeliveryTaskWorker False + +getDeliveryTaskWorker :: Bool -> DeliveryWorkerKey -> CM' Worker +getDeliveryTaskWorker hasWork deliveryKey = do + ws <- asks deliveryTaskWorkers + a <- asks smpAgent + getAgentWorker "delivery_task" hasWork a deliveryKey ws $ + runDeliveryTaskWorker a deliveryKey + +runDeliveryTaskWorker :: AgentClient -> DeliveryWorkerKey -> Worker -> CM () +runDeliveryTaskWorker a deliveryKey Worker {doWork} = do + delay <- asks $ deliveryWorkerDelay . config + vr <- chatVersionRange + -- TODO [channels fwd] in future may be required to read groupInfo and user on each iteration for up to date state + -- TODO - same for delivery jobs (runDeliveryJobWorker) + gInfo <- withStore $ \db -> do + user <- getUserByGroupId db groupId + getGroupInfo db vr user groupId + forever $ do + unless (delay == 0) $ liftIO $ threadDelay' delay + lift $ waitForWork doWork + runDeliveryTaskOperation vr gInfo + where + (groupId, workerScope) = deliveryKey + runDeliveryTaskOperation :: VersionRangeChat -> GroupInfo -> CM () + runDeliveryTaskOperation vr gInfo = do + withWork_ a doWork (withStore' $ \db -> getNextDeliveryTask db deliveryKey) $ \task -> + processDeliveryTask task + `catchAllErrors` \e -> do + withStore' $ \db -> setDeliveryTaskErrStatus db (deliveryTaskId task) (tshow e) + eToView e + where + processDeliveryTask :: MessageDeliveryTask -> CM () + processDeliveryTask task@MessageDeliveryTask {jobScope} = + case jobScopeImpliedSpec jobScope of + DJDeliveryJob _includePending -> + withWorkItems a doWork (withStore' $ \db -> getNextDeliveryTasks db gInfo task) $ \nextTasks -> do + let (body, taskIds, largeTaskIds) = batchDeliveryTasks1 vr maxEncodedMsgLength nextTasks + withStore' $ \db -> do + createMsgDeliveryJob db gInfo jobScope (singleSenderGMId_ nextTasks) body + forM_ taskIds $ \taskId -> updateDeliveryTaskStatus db taskId DTSProcessed + forM_ largeTaskIds $ \taskId -> setDeliveryTaskErrStatus db taskId "large" + lift . void $ getDeliveryJobWorker True deliveryKey + where + singleSenderGMId_ :: NonEmpty MessageDeliveryTask -> Maybe GroupMemberId + singleSenderGMId_ (MessageDeliveryTask {senderGMId = senderGMId'} :| ts) + | all (\MessageDeliveryTask {senderGMId} -> senderGMId == senderGMId') ts = Just senderGMId' + | otherwise = Nothing + DJRelayRemoved + | workerScope /= DWSGroup -> + throwChatError $ CEInternalError "delivery task worker: relay removed task in wrong worker scope" + | otherwise -> do + let MessageDeliveryTask {senderGMId, senderMemberId, senderMemberName, brokerTs, chatMessage} = task + fwdEvt = XGrpMsgForward senderMemberId (Just senderMemberName) chatMessage brokerTs + cm = ChatMessage {chatVRange = vr, msgId = Nothing, chatMsgEvent = fwdEvt} + body = chatMsgToBody cm + withStore' $ \db -> do + createMsgDeliveryJob db gInfo jobScope (Just senderGMId) body + updateDeliveryTaskStatus db (deliveryTaskId task) DTSProcessed + lift . void $ getDeliveryJobWorker True deliveryKey + +startDeliveryJobWorkers :: CM () +startDeliveryJobWorkers = do + workerScopes <- withStore' $ \db -> getPendingDeliveryJobScopes db + lift $ forM_ workerScopes resumeDeliveryJobWork + +resumeDeliveryJobWork :: DeliveryWorkerKey -> CM' () +resumeDeliveryJobWork = void . getDeliveryJobWorker False + +getDeliveryJobWorker :: Bool -> DeliveryWorkerKey -> CM' Worker +getDeliveryJobWorker hasWork deliveryKey = do + ws <- asks deliveryJobWorkers + a <- asks smpAgent + getAgentWorker "delivery_job" hasWork a deliveryKey ws $ + runDeliveryJobWorker a deliveryKey + +runDeliveryJobWorker :: AgentClient -> DeliveryWorkerKey -> Worker -> CM () +runDeliveryJobWorker a deliveryKey Worker {doWork} = do + delay <- asks $ deliveryWorkerDelay . config + vr <- chatVersionRange + (user, gInfo) <- withStore $ \db -> do + user <- getUserByGroupId db groupId + gInfo <- getGroupInfo db vr user groupId + pure (user, gInfo) + forever $ do + unless (delay == 0) $ liftIO $ threadDelay' delay + lift $ waitForWork doWork + runDeliveryJobOperation vr user gInfo + where + (groupId, workerScope) = deliveryKey + runDeliveryJobOperation :: VersionRangeChat -> User -> GroupInfo -> CM () + runDeliveryJobOperation vr user gInfo = do + withWork_ a doWork (withStore' $ \db -> getNextDeliveryJob db deliveryKey) $ \job -> + processDeliveryJob job + `catchAllErrors` \e -> do + withStore' $ \db -> setDeliveryJobErrStatus db (deliveryJobId job) (tshow e) + eToView e + where + processDeliveryJob :: MessageDeliveryJob -> CM () + processDeliveryJob job = + case jobScopeImpliedSpec jobScope of + DJDeliveryJob _includePending -> do + sendBodyToMembers + withStore' $ \db -> updateDeliveryJobStatus db jobId DJSComplete + DJRelayRemoved + | workerScope /= DWSGroup -> + throwChatError $ CEInternalError "delivery job worker: relay removed job in wrong worker scope" + | otherwise -> do + sendBodyToMembers + deleteGroupConnections user gInfo True + withStore' $ \db -> updateDeliveryJobStatus db jobId DJSComplete + where + MessageDeliveryJob {jobId, jobScope, singleSenderGMId_, body, cursorGMId_ = startingCursor} = job + sendBodyToMembers :: CM () + sendBodyToMembers + | isTrue (useRelays gInfo) = -- channel + case jobScope of + -- there's no member review in channels, so job spec includePending is ignored + DJSGroup {} -> do + bucketSize <- asks $ deliveryBucketSize . config + sendLoop bucketSize startingCursor + where + sendLoop :: Int -> Maybe GroupMemberId -> CM () + sendLoop bucketSize cursorGMId_ = do + mems <- withStore' $ \db -> getGroupMembersByCursor db vr user gInfo cursorGMId_ singleSenderGMId_ bucketSize + unless (null mems) $ do + deliver body mems + let cursorGMId' = groupMemberId' $ last mems + withStore' $ \db -> updateDeliveryJobCursor db jobId cursorGMId' + unless (length mems < bucketSize) $ sendLoop bucketSize (Just cursorGMId') + 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_ + modMs' = filter moderatorFilter modMs + mems <- + if Just scopeGMId == singleSenderGMId_ + then pure modMs' + else do + scopeMem <- withStore $ \db -> getGroupMemberById db vr user scopeGMId + pure $ scopeMem : modMs' + unless (null mems) $ deliver body mems + | otherwise = -- fully connected group + case singleSenderGMId_ of + 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 + 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 + where + deliver :: ByteString -> [GroupMember] -> CM () + deliver msgBody mems = + let mConns = mapMaybe (fmap snd . readyMemberConn) mems + msgReqs = foldMemConns mConns + in void $ withAgent (`sendMessages` msgReqs) + where + foldMemConns :: [Connection] -> [MsgReq] + foldMemConns mConns = snd $ foldr' addReq (lastMemIdx_, []) mConns + where + lastMemIdx_ = let len = length mConns in if len > 1 then Just len else Nothing + addReq :: Connection -> (Maybe Int, [MsgReq]) -> (Maybe Int, [MsgReq]) + addReq conn (memIdx_, reqs) = + (subtract 1 <$> memIdx_, req : reqs) + where + req = (aConnId conn, PQEncOff, MsgFlags False, vrValue_) + vrValue_ = case memIdx_ of + Nothing -> VRValue Nothing msgBody -- sending to one member, do not reference body + Just 1 -> VRValue (Just 1) msgBody + Just _ -> VRRef 1 diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 5a5f0922e7..f5d07d18af 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -31,7 +31,7 @@ import qualified Data.ByteString.Lazy.Char8 as LB import Data.Char (isSpace) import Data.Int (Int64) import Data.Kind (Constraint) -import Data.List.NonEmpty (NonEmpty) +import Data.List.NonEmpty (NonEmpty (..)) import Data.Map.Strict (Map) import Data.Maybe (fromMaybe, isJust, isNothing) import Data.Text (Text) @@ -49,7 +49,6 @@ import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared -import Simplex.Chat.Types.Util (textParseJSON) import Simplex.Messaging.Agent.Protocol (AgentMsgId, MsgMeta (..), MsgReceiptStatus (..)) import Simplex.Messaging.Agent.Store.DB (fromTextField_) import Simplex.Messaging.Crypto.File (CryptoFile (..)) @@ -185,24 +184,6 @@ toMsgScope :: GroupInfo -> GroupChatScopeInfo -> MsgScope toMsgScope GroupInfo {membership} = \case GCSIMemberSupport {groupMember_} -> MSMember $ memberId' $ fromMaybe membership groupMember_ -data GroupForwardScope - = GFSAll -- message should be forwarded to all group members, even pending (e.g. XGrpDel, XGrpInfo) - | GFSMain -- message should be forwarded to current group members only (e.g. regular messages in group) - | GFSMemberSupport GroupMemberId - deriving (Eq, Ord, Show) - -toGroupForwardScope :: GroupInfo -> Maybe GroupChatScopeInfo -> GroupForwardScope -toGroupForwardScope GroupInfo {membership} = \case - Nothing -> GFSMain - Just GCSIMemberSupport {groupMember_} -> GFSMemberSupport $ groupMemberId' $ fromMaybe membership groupMember_ - -memberEventForwardScope :: GroupMember -> Maybe GroupForwardScope -memberEventForwardScope m@GroupMember {memberRole, memberStatus} - | memberStatus == GSMemPendingApproval = Nothing - | memberStatus == GSMemPendingReview = Just $ GFSMemberSupport $ groupMemberId' m - | memberRole >= GRModerator = Just GFSAll - | otherwise = Just GFSMain - chatInfoToRef :: ChatInfo c -> Maybe ChatRef chatInfoToRef = \case DirectChat Contact {contactId} -> Just $ ChatRef CTDirect contactId Nothing @@ -1163,7 +1144,8 @@ data SndMessage = SndMessage data NewRcvMessage e = NewRcvMessage { chatMsgEvent :: ChatMsgEvent e, - msgBody :: MsgBody + msgBody :: MsgBody, + brokerTs :: UTCTime } deriving (Show) diff --git a/src/Simplex/Chat/Messages/Batch.hs b/src/Simplex/Chat/Messages/Batch.hs index c1c45d7b0a..2c3bd2b87d 100644 --- a/src/Simplex/Chat/Messages/Batch.hs +++ b/src/Simplex/Chat/Messages/Batch.hs @@ -7,13 +7,21 @@ module Simplex.Chat.Messages.Batch ( MsgBatch (..), batchMessages, + batchDeliveryTasks1, ) where import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B +import Data.Int (Int64) +import Data.List (foldl') +import Data.List.NonEmpty (NonEmpty (..)) +import qualified Data.List.NonEmpty as L import Simplex.Chat.Controller (ChatError (..), ChatErrorType (..)) +import Simplex.Chat.Delivery import Simplex.Chat.Messages +import Simplex.Chat.Protocol +import Simplex.Chat.Types (VersionRangeChat) data MsgBatch = MsgBatch ByteString [SndMessage] @@ -49,3 +57,38 @@ batchMessages maxLen = addBatch . foldr addToBatch ([], [], 0, 0) [msg] -> body msg msgs -> B.concat ["[", B.intercalate "," (map body msgs), "]"] body SndMessage {msgBody} = msgBody + +-- | Batches delivery tasks into (batch, [taskIds], [largeTaskIds]). +batchDeliveryTasks1 :: VersionRangeChat -> Int -> NonEmpty MessageDeliveryTask -> (ByteString, [Int64], [Int64]) +batchDeliveryTasks1 vr maxLen = toResult . foldl' addToBatch ([], [], [], 0, 0) . L.toList + where + addToBatch :: ([ByteString], [Int64], [Int64], Int, Int) -> MessageDeliveryTask -> ([ByteString], [Int64], [Int64], Int, Int) + addToBatch (msgBodies, taskIds, largeTaskIds, len, n) task + -- too large: skip msgBody, record taskId in largeTaskIds + | msgLen > maxLen = (msgBodies, taskIds, taskId : largeTaskIds, len, n) + -- fits: include in batch + | batchLen <= maxLen = (msgBody : msgBodies, taskId : taskIds, largeTaskIds, len', n + 1) + -- doesn’t fit: stop adding further messages + | otherwise = (msgBodies, taskIds, largeTaskIds, len, n) + where + MessageDeliveryTask {taskId, senderMemberId, senderMemberName, brokerTs, chatMessage, messageFromChannel = _messageFromChannel} = task + -- TODO [channels fwd] handle messageFromChannel (null memberId in XGrpMsgForward) + msgBody = + let fwdEvt = XGrpMsgForward senderMemberId (Just senderMemberName) chatMessage brokerTs + cm = ChatMessage {chatVRange = vr, msgId = Nothing, chatMsgEvent = fwdEvt} + in chatMsgToBody cm + msgLen = B.length msgBody + len' + | n == 0 = msgLen + | otherwise = msgLen + len + 1 -- 1 accounts for comma + batchLen + | n == 0 = len' + | otherwise = len' + 2 -- 2 accounts for opening and closing brackets + toResult :: ([ByteString], [Int64], [Int64], Int, Int) -> (ByteString, [Int64], [Int64]) + toResult (msgBodies, taskIds, largeTaskIds, _, _) = + (encodeMessages (reverse msgBodies), reverse taskIds, reverse largeTaskIds) + encodeMessages :: [ByteString] -> ByteString + encodeMessages = \case + [] -> mempty + [msg] -> msg + msgs -> B.concat ["[", B.intercalate "," msgs, "]"] diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index f18a3900da..b22cfebcdd 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -50,7 +50,7 @@ import Simplex.Chat.Types import Simplex.Messaging.Agent.Client (agentClientStore) import Simplex.Messaging.Agent.Env.SQLite (createAgentStore) import Simplex.Messaging.Agent.Store.Interface (closeDBStore, reopenDBStore) -import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation (..), MigrationError) +import Simplex.Messaging.Agent.Store.Shared (MigrationConfig (..), MigrationConfirmation (..), MigrationError) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, sumTypeJSON) @@ -254,7 +254,8 @@ mobileChatOpts dbOptions = tbqSize = 4096, deviceName = Nothing, highlyAvailable = False, - yesToUpMigrations = False + yesToUpMigrations = False, + migrationBackupPath = Just "" }, chatCmd = "", chatCmdDelay = 3, @@ -276,7 +277,6 @@ defaultMobileConfig = defaultChatConfig { confirmMigrations = MCYesUp, logLevel = CLLError, - coreApi = True, deviceNameForRemote = "Mobile" } @@ -294,8 +294,9 @@ chatMigrateInit dbFilePrefix dbKey confirm = do chatMigrateInitKey :: ChatDbOpts -> Bool -> String -> Bool -> IO (Either DBMigrationResult ChatController) chatMigrateInitKey chatDbOpts keepKey confirm backgroundMode = runExceptT $ do confirmMigrations <- liftEitherWith (const DBMInvalidConfirmation) $ strDecode $ B.pack confirm - chatStore <- migrate createChatStore (toDBOpts chatDbOpts chatSuffix keepKey) confirmMigrations - agentStore <- migrate createAgentStore (toDBOpts chatDbOpts agentSuffix keepKey) confirmMigrations + let migrationConfig = MigrationConfig confirmMigrations (Just "") + chatStore <- migrate createChatStore (toDBOpts chatDbOpts chatSuffix keepKey) migrationConfig + agentStore <- migrate createAgentStore (toDBOpts chatDbOpts agentSuffix keepKey) migrationConfig liftIO $ initialize chatStore ChatDatabase {chatStore, agentStore} where opts = mobileChatOpts $ removeDbKey chatDbOpts diff --git a/src/Simplex/Chat/Operators.hs b/src/Simplex/Chat/Operators.hs index c9b5e020d9..24baa37e4e 100644 --- a/src/Simplex/Chat/Operators.hs +++ b/src/Simplex/Chat/Operators.hs @@ -46,7 +46,6 @@ import Data.Time.Clock (UTCTime, nominalDay) import Language.Haskell.TH.Syntax (lift) import Simplex.Chat.Operators.Conditions import Simplex.Chat.Types (User) -import Simplex.Chat.Types.Util (textParseJSON) import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles) import Simplex.Messaging.Agent.Store.DB (FromField (..), ToField (..), fromTextField_) import Simplex.Messaging.Agent.Store.Entity diff --git a/src/Simplex/Chat/Options.hs b/src/Simplex/Chat/Options.hs index 9411c66175..afc01e0493 100644 --- a/src/Simplex/Chat/Options.hs +++ b/src/Simplex/Chat/Options.hs @@ -67,7 +67,8 @@ data CoreChatOpts = CoreChatOpts tbqSize :: Natural, deviceName :: Maybe Text, highlyAvailable :: Bool, - yesToUpMigrations :: Bool + yesToUpMigrations :: Bool, + migrationBackupPath :: Maybe FilePath } data CreateBotOpts = CreateBotOpts @@ -197,7 +198,7 @@ coreChatOptsP appDir defaultDbName = do switch ( long "connections" <> short 'c' - <> help "Log every contact and group connection on start (also with `-l info`)" + <> help "Log connections subscription errors on start (also with `-l info`)" ) logServerHosts <- switch @@ -243,6 +244,7 @@ coreChatOptsP appDir defaultDbName = do <> short 'y' <> help "Automatically confirm \"up\" database migrations" ) + migrationBackupPath <- migrationBackupPathP pure CoreChatOpts { dbOptions, @@ -268,7 +270,8 @@ coreChatOptsP appDir defaultDbName = do tbqSize, deviceName, highlyAvailable, - yesToUpMigrations + yesToUpMigrations, + migrationBackupPath } where useTcpTimeout p t = 1000000 * if t > 0 then t else maybe 7 (const 15) p diff --git a/src/Simplex/Chat/Options/Postgres.hs b/src/Simplex/Chat/Options/Postgres.hs index f7c429e93e..13af13b20a 100644 --- a/src/Simplex/Chat/Options/Postgres.hs +++ b/src/Simplex/Chat/Options/Postgres.hs @@ -42,7 +42,7 @@ chatDbOptsP _appDir defaultDbName = do ( long "pool-size" <> metavar "DB_POOL_SIZE" <> help "Database connection pool size" - <> value 1 + <> value 10 <> showDefault ) dbCreateSchema <- @@ -52,6 +52,9 @@ chatDbOptsP _appDir defaultDbName = do ) pure ChatDbOpts {dbConnstr, dbSchemaPrefix, dbPoolSize, dbCreateSchema} +migrationBackupPathP :: Parser (Maybe FilePath) +migrationBackupPathP = pure Nothing + dbString :: ChatDbOpts -> String dbString ChatDbOpts {dbConnstr} = dbConnstr @@ -78,7 +81,7 @@ mobileDbOpts schemaPrefix connstr = do ChatDbOpts { dbConnstr, dbSchemaPrefix, - dbPoolSize = 1, + dbPoolSize = 10, dbCreateSchema = True } diff --git a/src/Simplex/Chat/Options/SQLite.hs b/src/Simplex/Chat/Options/SQLite.hs index 0507076613..8591f0801c 100644 --- a/src/Simplex/Chat/Options/SQLite.hs +++ b/src/Simplex/Chat/Options/SQLite.hs @@ -54,6 +54,19 @@ chatDbOptsP appDir defaultDbName = do vacuumOnMigration = not disableVacuum } +migrationBackupPathP :: Parser (Maybe FilePath) +migrationBackupPathP = + flag' Nothing + ( long "disable-backup" + <> help "Disable backup when migrating database" + ) + <|> + (fmap Just . strOption) + ( long "backup-directory" + <> help "Directory to backup database for migration" + <> value "" + ) + dbString :: ChatDbOpts -> String dbString ChatDbOpts {dbFilePrefix} = dbFilePrefix <> "_chat.db, " <> dbFilePrefix <> "_agent.db" @@ -63,8 +76,8 @@ toDBOpts ChatDbOpts {dbFilePrefix, dbKey, trackQueries, vacuumOnMigration} dbSuf { dbFilePath = dbFilePrefix <> dbSuffix, dbKey, keepKey, - track = trackQueries, - vacuum = vacuumOnMigration + vacuum = vacuumOnMigration, + track = trackQueries } chatSuffix :: String diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index d6b826491e..2aafad94a1 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -34,11 +34,10 @@ import qualified Data.ByteString.Char8 as B import Data.ByteString.Internal (c2w, w2c) import qualified Data.ByteString.Lazy.Char8 as LB import Data.Either (fromRight) -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, mapMaybe) +import Data.Maybe (fromMaybe) import Data.String import Data.Text (Text) import qualified Data.Text as T @@ -53,7 +52,7 @@ import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Messaging.Agent.Protocol (VersionSMPA, pqdrSMPAgentVersion) -import Simplex.Messaging.Agent.Store.DB (fromTextField_) +import Simplex.Messaging.Agent.Store.DB (blobFieldDecoder, fromTextField_) import Simplex.Messaging.Compression (Compressed, compress1, decompress1) import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String @@ -154,8 +153,6 @@ agentToChatVersion v data ConnectionEntity = RcvDirectMsgConnection {entityConnection :: Connection, contact :: Maybe Contact} | RcvGroupMsgConnection {entityConnection :: Connection, groupInfo :: GroupInfo, groupMember :: GroupMember} - | SndFileConnection {entityConnection :: Connection, sndFileTransfer :: SndFileTransfer} - | RcvFileConnection {entityConnection :: Connection, rcvFileTransfer :: RcvFileTransfer} | UserContactConnection {entityConnection :: Connection, userContact :: UserContact} deriving (Eq, Show) @@ -165,8 +162,6 @@ connEntityInfo :: ConnectionEntity -> String connEntityInfo = \case RcvDirectMsgConnection c ct_ -> ctInfo ct_ <> ", status: " <> show (connStatus c) RcvGroupMsgConnection c g m -> mInfo g m <> ", status: " <> show (connStatus c) - SndFileConnection c _ft -> "snd file, status: " <> show (connStatus c) - RcvFileConnection c _ft -> "rcv file, status: " <> show (connStatus c) UserContactConnection c _uc -> "user address, status: " <> show (connStatus c) where ctInfo = maybe "connection" $ \Contact {contactId} -> "contact " <> show contactId @@ -176,8 +171,6 @@ updateEntityConnStatus :: ConnectionEntity -> ConnStatus -> ConnectionEntity updateEntityConnStatus connEntity connStatus = case connEntity of RcvDirectMsgConnection c ct_ -> RcvDirectMsgConnection (st c) ((\ct -> (ct :: Contact) {activeConn = Just $ st c}) <$> ct_) RcvGroupMsgConnection c gInfo m@GroupMember {activeConn = c'} -> RcvGroupMsgConnection (st c) gInfo m {activeConn = st <$> c'} - SndFileConnection c ft -> SndFileConnection (st c) ft - RcvFileConnection c ft -> RcvFileConnection (st c) ft UserContactConnection c uc -> UserContactConnection (st c) uc where st c = c {connStatus} @@ -313,6 +306,8 @@ data ChatMessage e = ChatMessage data AChatMessage = forall e. MsgEncodingI e => ACMsg (SMsgEncoding e) (ChatMessage e) +type MessageFromChannel = Bool + data ChatMsgEvent (e :: MsgEncoding) where XMsgNew :: MsgContainer -> ChatMsgEvent 'Json XMsgFileDescr :: {msgId :: SharedMsgId, fileDescr :: FileDescr} -> ChatMsgEvent 'Json @@ -392,32 +387,6 @@ isForwardedGroupMsg ev = case ev of XGrpPrefs _ -> True _ -> False --- applied after building list of messages to forward and building list of group members to forward to, see Chat; --- --- this filters out members if any of forwarded events in batch is an XGrpMemRestrict event referring to them, --- but practically XGrpMemRestrict is not batched with other events so it wouldn't prevent forwarding of other events --- to these members; --- --- same for reports (MCReport) - they are not batched with other events, so we can safely filter out --- members with role less than moderator when forwarding -msgsForwardedToMember :: NonEmpty (ChatMessage 'Json) -> GroupMember -> Bool -msgsForwardedToMember fwdMsgs GroupMember {memberId, memberRole} = - (memberId `notElem` restrictMemberIds) && (not hasReport || memberRole >= GRModerator) - where - restrictMemberIds = mapMaybe restrictMemberId $ L.toList fwdMsgs - restrictMemberId :: ChatMessage 'Json -> Maybe MemberId - restrictMemberId ChatMessage {chatMsgEvent} = - case chatMsgEvent of - XGrpMemRestrict mId _ -> Just mId - _ -> Nothing - hasReport = any isReportEvent fwdMsgs - isReportEvent ChatMessage {chatMsgEvent} = - case chatMsgEvent of - XMsgNew mc -> case mcExtMsgContent mc of - ExtMsgContent {content = MCReport {}} -> True - _ -> False - _ -> False - data MsgReaction = MREmoji {emoji :: MREmojiChar} | MRUnknown {tag :: Text, json :: J.Object} deriving (Eq, Show) @@ -1222,6 +1191,9 @@ instance ToJSON (ChatMessage 'Json) where instance FromJSON (ChatMessage 'Json) where parseJSON v = appJsonToCM <$?> parseJSON v +instance FromField (ChatMessage 'Json) where + fromField = blobFieldDecoder J.eitherDecodeStrict' + data ContactShortLinkData = ContactShortLinkData { profile :: Profile, message :: Maybe MsgContent, diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 7c25890bf2..a706bfc0de 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -75,11 +75,11 @@ remoteFilesFolder = "simplex_v1_files" -- when acting as host minRemoteCtrlVersion :: AppVersion -minRemoteCtrlVersion = AppVersion [6, 4, 3, 0] +minRemoteCtrlVersion = AppVersion [6, 4, 6, 0] -- when acting as controller minRemoteHostVersion :: AppVersion -minRemoteHostVersion = AppVersion [6, 4, 3, 0] +minRemoteHostVersion = AppVersion [6, 4, 6, 0] currentAppVersion :: AppVersion currentAppVersion = AppVersion SC.version @@ -175,13 +175,13 @@ startRemoteHost rh_ rcAddrPrefs_ port_ = do pure hostInfo handleConnectError :: RHKey -> SessionSeq -> CM a -> CM a handleConnectError rhKey sessSeq action = - action `catchChatError` \err -> do + action `catchAllErrors` \err -> do logError $ "startRemoteHost.rcConnectHost crashed: " <> tshow err cancelRemoteHostSession (Just (sessSeq, RHSRConnectionFailed err)) rhKey throwError err handleHostError :: SessionSeq -> TVar RHKey -> CM () -> CM () handleHostError sessSeq rhKeyVar action = - action `catchChatError` \err -> do + action `catchAllErrors` \err -> do logError $ "startRemoteHost.waitForHostSession crashed: " <> tshow err readTVarIO rhKeyVar >>= cancelRemoteHostSession (Just (sessSeq, RHSRCrashed err)) waitForHostSession :: Maybe RemoteHostInfo -> RHKey -> SessionSeq -> Maybe RCCtrlAddress -> TVar RHKey -> RCStepTMVar (ByteString, TLS 'TServer, RCStepTMVar (RCHostSession, RCHostHello, RCHostPairing)) -> CM () @@ -411,7 +411,7 @@ findKnownRemoteCtrl = do atomically $ takeTMVar cmdOk (RCCtrlPairing {ctrlFingerprint}, inv@(RCVerifiedInvitation RCInvitation {app})) <- timeoutThrow (ChatErrorRemoteCtrl RCETimeout) discoveryTimeout . withAgent $ \a -> rcDiscoverCtrl a pairings - ctrlAppInfo_ <- (Just <$> parseCtrlAppInfo app) `catchChatError` const (pure Nothing) + ctrlAppInfo_ <- (Just <$> parseCtrlAppInfo app) `catchAllErrors` const (pure Nothing) rc <- withStore' (`getRemoteCtrlByFingerprint` ctrlFingerprint) >>= \case Nothing -> throwChatError $ CEInternalError "connecting with a stored ctrl" @@ -500,11 +500,11 @@ parseCtrlAppInfo ctrlAppInfo = do handleRemoteCommand :: (ByteString -> Int -> CM' (Either ChatError ChatResponse)) -> RemoteCrypto -> TBQueue (Either ChatError ChatEvent) -> HTTP2Request -> CM' () handleRemoteCommand execCC encryption remoteOutputQ HTTP2Request {request, reqBody, sendResponse} = do logDebug "handleRemoteCommand" - liftIO (tryRemoteError' parseRequest) >>= \case + liftIO (tryAllErrors' parseRequest) >>= \case Right (rfKN, getNext, rc) -> do chatReadVar' currentUser >>= \case Nothing -> replyError $ ChatError CENoActiveUser - Just user -> processCommand user rfKN getNext rc `catchChatError'` replyError + Just user -> processCommand user rfKN getNext rc `catchAllErrors'` replyError Left e -> reply $ RRProtocolError e where parseRequest :: ExceptT RemoteProtocolError IO (C.SbKeyNonce, GetChunk, RemoteCommand) @@ -523,7 +523,7 @@ handleRemoteCommand execCC encryption remoteOutputQ HTTP2Request {request, reqBo replyWith :: Respond replyWith rr attach = do (corrId, cmdKN, sfKN) <- atomically $ getRemoteSndKeys encryption - liftIO (tryRemoteError' . encryptEncodeHTTP2Body corrId cmdKN encryption $ J.encode rr) >>= \case + liftIO (tryAllErrors' . encryptEncodeHTTP2Body corrId cmdKN encryption $ J.encode rr) >>= \case Right resp -> liftIO . sendResponse . responseStreaming N.status200 [] $ \send flush -> do send resp attach sfKN send @@ -531,7 +531,7 @@ handleRemoteCommand execCC encryption remoteOutputQ HTTP2Request {request, reqBo Left e -> eToView' $ ChatErrorRemoteCtrl $ RCEProtocolError e takeRCStep :: RCStepTMVar a -> CM a -takeRCStep = liftError' (\e -> ChatErrorAgent {agentError = RCP e, connectionEntity_ = Nothing}) . atomically . takeTMVar +takeRCStep = liftError' (\e -> ChatErrorAgent {agentError = RCP e, agentConnId = AgentConnId "", connectionEntity_ = Nothing}) . atomically . takeTMVar type GetChunk = Int -> IO ByteString @@ -542,14 +542,6 @@ type Respond = RemoteResponse -> (C.SbKeyNonce -> SendChunk -> IO ()) -> CM' () liftRC :: ExceptT RemoteProtocolError IO a -> CM a liftRC = liftError (ChatErrorRemoteCtrl . RCEProtocolError) -tryRemoteError :: ExceptT RemoteProtocolError IO a -> ExceptT RemoteProtocolError IO (Either RemoteProtocolError a) -tryRemoteError = tryAllErrors (RPEException . tshow) -{-# INLINE tryRemoteError #-} - -tryRemoteError' :: ExceptT RemoteProtocolError IO a -> IO (Either RemoteProtocolError a) -tryRemoteError' = tryAllErrors' (RPEException . tshow) -{-# INLINE tryRemoteError' #-} - handleSend :: (ByteString -> Int -> CM' (Either ChatError ChatResponse)) -> Text -> Int -> CM' RemoteResponse handleSend execCC command retryNum = do logDebug $ "Send: " <> tshow command @@ -573,7 +565,7 @@ handleStoreFile rfKN fileName fileSize fileDigest getChunk = Just ff -> takeFileName <$$> storeFileTo ff Nothing -> storeFileTo =<< getDefaultFilesFolder storeFileTo :: FilePath -> CM' (Either RemoteProtocolError FilePath) - storeFileTo dir = liftIO . tryRemoteError' $ do + storeFileTo dir = liftIO . tryAllErrors' $ do filePath <- liftIO $ dir `uniqueCombine` fileName receiveEncryptedFile rfKN getChunk fileSize fileDigest filePath pure filePath @@ -586,7 +578,7 @@ handleGetFile User {userId} RemoteFile {userId = commandUserId, fileId, sent, fi withStore $ \db -> do cf <- getLocalCryptoFile db commandUserId fileId sent unless (cf == cf') $ throwError $ SEFileNotFound fileId - liftRC (tryRemoteError $ getFileInfo path) >>= \case + liftRC (tryAllErrors $ getFileInfo path) >>= \case Left e -> lift $ reply (RRProtocolError e) $ \_ _ -> pure () Right (fileSize, fileDigest) -> lift . withFile path ReadMode $ \h -> do @@ -658,7 +650,7 @@ stopRemoteCtrl = cancelActiveRemoteCtrl Nothing handleCtrlError :: SessionSeq -> (ChatError -> RemoteCtrlStopReason) -> Text -> CM a -> CM a handleCtrlError sseq mkReason name action = - action `catchChatError` \e -> do + action `catchAllErrors` \e -> do logError $ name <> " remote ctrl error: " <> tshow e cancelActiveRemoteCtrl $ Just (sseq, mkReason e) throwError e diff --git a/src/Simplex/Chat/Remote/Types.hs b/src/Simplex/Chat/Remote/Types.hs index defbe7e72c..b7af624e9e 100644 --- a/src/Simplex/Chat/Remote/Types.hs +++ b/src/Simplex/Chat/Remote/Types.hs @@ -28,6 +28,7 @@ import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, sumTypeJSON import Simplex.Messaging.Transport (TLS (..), TSbChainKeys (..), TransportPeer (..)) import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client) import qualified Simplex.Messaging.TMap as TM +import Simplex.Messaging.Util (AnyError (..), tshow) import Simplex.RemoteControl.Client import Simplex.RemoteControl.Types @@ -155,6 +156,9 @@ data RemoteProtocolError | RPEException {someException :: Text} deriving (Show, Exception) +instance AnyError RemoteProtocolError where + fromSomeException = RPEException . tshow + type RemoteHostId = Int64 data RHKey = RHNew | RHId {remoteHostId :: RemoteHostId} diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index d3d3395ed1..e81ec0cc3a 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -6,7 +6,6 @@ module Simplex.Chat.Store ChatLockEntity (..), UserMsgReceiptSettings (..), UserContactLink (..), - GroupLink (..), GroupLinkInfo (..), AddressSettings (..), AutoAccept (..), @@ -16,17 +15,16 @@ module Simplex.Chat.Store ) where -import Simplex.Chat.Store.Groups (GroupLink (..)) import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared import Simplex.Messaging.Agent.Store.Common (DBStore (..), withTransaction) import Simplex.Messaging.Agent.Store.Interface (DBOpts, createDBStore) -import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation, MigrationError) +import Simplex.Messaging.Agent.Store.Shared (MigrationConfig, MigrationError) #if defined(dbPostgres) import Simplex.Chat.Store.Postgres.Migrations #else import Simplex.Chat.Store.SQLite.Migrations #endif -createChatStore :: DBOpts -> MigrationConfirmation -> IO (Either MigrationError DBStore) +createChatStore :: DBOpts -> MigrationConfig -> IO (Either MigrationError DBStore) createChatStore dbCreateOpts = createDBStore dbCreateOpts migrations diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 8930acf3c1..9467675272 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -15,27 +15,28 @@ module Simplex.Chat.Store.Connections getConnectionEntityByConnReq, getConnectionEntityViaShortLink, getContactConnEntityByConnReqHash, - getConnectionsToSubscribe, - unsetConnectionToSubscribe + getContactConnsToSub, + getUCLConnsToSub, + getMemberConnsToSub, + getPendingConnsToSub, + shouldSyncConnections, + setConnectionsSyncTs, ) where -import Control.Applicative ((<|>)) -import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class import Data.Bitraversable (bitraverse) import Data.Int (Int64) -import Data.Maybe (catMaybes, fromMaybe) +import Data.Maybe (fromMaybe) +import Data.Time.Clock (getCurrentTime) import Simplex.Chat.Protocol import Simplex.Chat.Store.Direct -import Simplex.Chat.Store.Files import Simplex.Chat.Store.Groups -import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared import Simplex.Chat.Types import Simplex.Messaging.Agent.Protocol (ConnId) -import Simplex.Messaging.Agent.Store.AgentStore (firstRow, firstRow', maybeFirstRow) +import Simplex.Messaging.Agent.Store.AgentStore (firstRow, firstRow', fromOnlyBI, maybeFirstRow) import Simplex.Messaging.Agent.Store.DB (BoolInt (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Util (eitherToMaybe) @@ -49,12 +50,12 @@ import Database.SQLite.Simple.QQ (sql) getChatLockEntity :: DB.Connection -> AgentConnId -> ExceptT StoreError IO ChatLockEntity getChatLockEntity db agentConnId = do - ((connId, connType) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId)) <- + ((connId, connType) :. (contactId, groupMemberId, userContactLinkId)) <- ExceptT . firstRow id (SEConnectionNotFound agentConnId) $ DB.query db [sql| - SELECT connection_id, conn_type, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id + SELECT connection_id, conn_type, contact_id, group_member_id, user_contact_link_id FROM connections WHERE agent_conn_id = ? |] @@ -63,8 +64,6 @@ getChatLockEntity db agentConnId = do case connType of ConnMember -> maybe err (fmap CLGroup . getMemberGroupId) groupMemberId ConnContact -> pure $ maybe (CLConnection connId) CLContact contactId - ConnSndFile -> maybe err (pure . CLFile) sndFileId - ConnRcvFile -> maybe err (pure . CLFile) rcvFileId ConnUserContact -> maybe err (pure . CLUserContact) userContactLinkId where getMemberGroupId :: GroupMemberId -> ExceptT StoreError IO GroupId @@ -84,8 +83,6 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do case connType of ConnMember -> uncurry (RcvGroupMsgConnection c) <$> getGroupAndMember_ entId c ConnContact -> RcvDirectMsgConnection c . Just <$> getContactRec_ entId c - ConnSndFile -> SndFileConnection c <$> getConnSndFileTransfer_ entId c - ConnRcvFile -> RcvFileConnection c <$> getRcvFileTransfer db user entId ConnUserContact -> UserContactConnection c <$> getUserContact_ entId where getConnection_ :: ExceptT StoreError IO Connection @@ -95,7 +92,7 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do db [sql| SELECT connection_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, group_link_id, xcontact_id, custom_user_profile_id, - conn_status, conn_type, contact_conn_initiated, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, + conn_status, conn_type, contact_conn_initiated, local_alias, contact_id, group_member_id, user_contact_link_id, created_at, security_code, security_code_verified_at, pq_support, pq_encryption, pq_snd_enabled, pq_rcv_enabled, auth_err_counter, quota_err_counter, conn_chat_version, peer_chat_min_version, peer_chat_max_version FROM connections @@ -110,7 +107,7 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do db [sql| SELECT - c.contact_profile_id, c.local_display_name, c.via_group, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, c.contact_used, c.contact_status, c.enable_ntfs, c.send_rcpts, c.favorite, + c.contact_profile_id, c.local_display_name, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, c.contact_used, c.contact_status, c.enable_ntfs, c.send_rcpts, c.favorite, p.preferences, c.user_preferences, c.created_at, c.updated_at, c.chat_ts, c.conn_full_link_to_connect, c.conn_short_link_to_connect, c.welcome_shared_msg_id, c.request_shared_msg_id, c.contact_request_id, c.contact_group_member_id, c.contact_grp_inv_sent, c.grp_direct_inv_link, c.grp_direct_inv_from_group_id, c.grp_direct_inv_from_group_member_id, c.grp_direct_inv_from_member_conn_id, c.grp_direct_inv_started_connection, c.ui_themes, c.chat_deleted, c.custom_data, c.chat_item_ttl @@ -120,14 +117,14 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do |] (userId, contactId, CSActive) toContact' :: Int64 -> Connection -> [ChatTagId] -> ContactRow' -> Contact - toContact' contactId conn chatTags ((profileId, localDisplayName, viaGroup, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL)) = + toContact' contactId conn chatTags ((profileId, localDisplayName, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL)) = let profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, peerType, preferences, localAlias} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito conn activeConn = Just conn preparedContact = toPreparedContact preparedContactRow groupDirectInv = toGroupDirectInvitation groupDirectInvRow - in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, preparedContact, contactRequestId, contactGroupMemberId, contactGrpInvSent, groupDirectInv, chatTags, chatItemTTL, uiThemes, chatDeleted, customData} + in Contact {contactId, localDisplayName, profile, activeConn, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, preparedContact, contactRequestId, contactGroupMemberId, contactGrpInvSent, groupDirectInv, chatTags, chatItemTTL, uiThemes, chatDeleted, customData} getGroupAndMember_ :: Int64 -> Connection -> ExceptT StoreError IO (GroupInfo, GroupMember) getGroupAndMember_ groupMemberId c = do gm <- @@ -143,7 +140,7 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, - g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + 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.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, @@ -172,26 +169,6 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do let groupInfo = toGroupInfo vr userContactId [] groupInfoRow member = toGroupMember userContactId memberRow in (groupInfo, (member :: GroupMember) {activeConn = Just c}) - getConnSndFileTransfer_ :: Int64 -> Connection -> ExceptT StoreError IO SndFileTransfer - getConnSndFileTransfer_ fileId Connection {connId} = - ExceptT $ - firstRow' (sndFileTransfer_ fileId connId) (SESndFileNotFound fileId) $ - DB.query - db - [sql| - SELECT s.file_status, f.file_name, f.file_size, f.chunk_size, f.file_path, s.file_descr_id, s.file_inline, s.group_member_id, cs.local_display_name, m.local_display_name - FROM snd_files s - JOIN files f USING (file_id) - LEFT JOIN contacts cs USING (contact_id) - LEFT JOIN group_members m USING (group_member_id) - WHERE f.user_id = ? AND f.file_id = ? AND s.connection_id = ? - |] - (userId, fileId, connId) - sndFileTransfer_ :: Int64 -> Int64 -> (FileStatus, String, Integer, Integer, FilePath, Maybe Int64, Maybe InlineFileMode, Maybe Int64, Maybe ContactName, Maybe ContactName) -> Either StoreError SndFileTransfer - sndFileTransfer_ fileId connId (fileStatus, fileName, fileSize, chunkSize, filePath, fileDescrId, fileInline, groupMemberId, contactName_, memberName_) = - case contactName_ <|> memberName_ of - Just recipientDisplayName -> Right SndFileTransfer {fileId, fileStatus, fileName, fileSize, chunkSize, filePath, fileDescrId, fileInline, recipientDisplayName, connId, agentConnId, groupMemberId} - Nothing -> Left $ SESndFileInvalid fileId getUserContact_ :: Int64 -> ExceptT StoreError IO UserContact getUserContact_ userContactLinkId = ExceptT $ do userContact_ @@ -259,16 +236,108 @@ getContactConnEntityByConnReqHash db vr user@User {userId} (cReqHash1, cReqHash2 (userId, cReqHash1, cReqHash2, ConnDeleted) maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getConnectionEntity db vr user) connId_ -getConnectionsToSubscribe :: DB.Connection -> VersionRangeChat -> IO ([ConnId], [ConnectionEntity]) -getConnectionsToSubscribe db vr = do - aConnIds <- map fromOnly <$> DB.query_ db "SELECT agent_conn_id FROM connections where to_subscribe = 1" - entities <- forM aConnIds $ \acId -> do - getUserByAConnId db acId >>= \case - Just user -> eitherToMaybe <$> runExceptT (getConnectionEntity db vr user acId) - Nothing -> pure Nothing - unsetConnectionToSubscribe db - let connIds = map (\(AgentConnId connId) -> connId) aConnIds - pure (connIds, catMaybes entities) +getContactConnsToSub :: DB.Connection -> User -> Bool -> IO [ConnId] +getContactConnsToSub db User {userId} filterToSubscribe = + map fromOnly <$> DB.query db query (userId, ConnDeleted, CSActive) + where + query + | filterToSubscribe = baseQuery <> " AND c.to_subscribe = 1 " <> cond + | otherwise = baseQuery <> " " <> cond + baseQuery = + [sql| + SELECT c.agent_conn_id + FROM connections c + JOIN contacts ct ON ct.contact_id = c.contact_id + WHERE c.user_id = ? + |] + cond = + [sql| + AND c.conn_status != ? + AND ct.contact_status = ? AND ct.deleted = 0 + |] -unsetConnectionToSubscribe :: DB.Connection -> IO () -unsetConnectionToSubscribe db = DB.execute_ db "UPDATE connections SET to_subscribe = 0 WHERE to_subscribe = 1" +getUCLConnsToSub :: DB.Connection -> User -> Bool -> IO [ConnId] +getUCLConnsToSub db User {userId} filterToSubscribe = + map fromOnly <$> DB.query db query (userId, ConnDeleted) + where + query + | filterToSubscribe = baseQuery <> " AND c.to_subscribe = 1 " <> cond + | otherwise = baseQuery <> " " <> cond + baseQuery = + [sql| + SELECT c.agent_conn_id + FROM connections c + JOIN user_contact_links ucl ON ucl.user_contact_link_id = c.user_contact_link_id + WHERE c.user_id = ? + |] + cond = " AND c.conn_status != ?" + +getMemberConnsToSub :: DB.Connection -> User -> Bool -> IO [ConnId] +getMemberConnsToSub db User {userId, userContactId} filterToSubscribe = + map fromOnly <$> + DB.query + db + query + ((userId, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted) + :. (userId, ConnDeleted, GSMemRemoved, GSMemLeft, GSMemGroupDeleted)) + where + query + | filterToSubscribe = baseQuery <> " AND c.to_subscribe = 1" + | otherwise = baseQuery + baseQuery = + [sql| + WITH user_groups AS MATERIALIZED ( + SELECT g.group_id + FROM groups g + JOIN group_members mu ON mu.group_id = g.group_id + WHERE g.user_id = ? + AND mu.contact_id = ? + AND mu.member_status NOT IN (?,?,?) + ) + SELECT c.agent_conn_id + FROM connections c + JOIN group_members m ON m.group_member_id = c.group_member_id + JOIN user_groups ug ON ug.group_id = m.group_id + WHERE c.user_id = ? + AND c.conn_status != ? + AND m.member_status NOT IN (?,?,?) + |] + +getPendingConnsToSub :: DB.Connection -> User -> Bool -> IO [ConnId] +getPendingConnsToSub db User {userId} filterToSubscribe = + map fromOnly <$> DB.query db query (userId, ConnContact, ConnDeleted) + where + query + | filterToSubscribe = baseQuery <> " AND to_subscribe = 1 " <> cond + | otherwise = baseQuery <> " " <> cond + baseQuery = + [sql| + SELECT agent_conn_id + FROM connections + WHERE user_id = ? + |] + cond = + [sql| + AND conn_type = ? + AND contact_id IS NULL + AND conn_status != ? + |] + +shouldSyncConnections :: DB.Connection -> IO Bool +shouldSyncConnections db = + fromOnlyBI . head + <$> DB.query_ + db + "SELECT should_sync FROM connections_sync WHERE connections_sync_id = 1" + +setConnectionsSyncTs :: DB.Connection -> IO () +setConnectionsSyncTs db = do + currentTs <- getCurrentTime + DB.execute + db + [sql| + UPDATE connections_sync + SET should_sync = 0, last_sync_ts = ? + WHERE connections_sync_id = 1 + |] + (Only currentTs) diff --git a/src/Simplex/Chat/Store/ContactRequest.hs b/src/Simplex/Chat/Store/ContactRequest.hs index d9ad9ec763..1e0ca8bdc5 100644 --- a/src/Simplex/Chat/Store/ContactRequest.hs +++ b/src/Simplex/Chat/Store/ContactRequest.hs @@ -14,9 +14,9 @@ module Simplex.Chat.Store.ContactRequest setContactAcceptedXContactId, setBusinessChatAcceptedXContactId, setRequestSharedMsgIdForContact, - setRequestSharedMsgIdForGroup + setRequestSharedMsgIdForGroup, ) - where +where import Control.Monad import Control.Monad.Except @@ -24,11 +24,11 @@ import Control.Monad.IO.Class import Crypto.Random (ChaChaDRG) import Data.Int (Int64) import Data.Time.Clock (getCurrentTime) -import Simplex.Chat.Protocol (businessChatsVersion, MsgContent) +import Simplex.Chat.Protocol (MsgContent, businessChatsVersion) import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Groups -import Simplex.Chat.Store.Shared import Simplex.Chat.Store.Profiles +import Simplex.Chat.Store.Shared import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Messaging.Agent.Protocol (InvitationId) @@ -47,21 +47,21 @@ import Database.SQLite.Simple.QQ (sql) #endif createOrUpdateContactRequest :: - DB.Connection - -> TVar ChaChaDRG - -> VersionRangeChat - -> User - -> Int64 - -> UserContactLink - -> Bool - -> InvitationId - -> VersionRangeChat - -> Profile - -> Maybe XContactId - -> Maybe SharedMsgId - -> Maybe (SharedMsgId, MsgContent) - -> PQSupport - -> ExceptT StoreError IO RequestStage + DB.Connection -> + TVar ChaChaDRG -> + VersionRangeChat -> + User -> + Int64 -> + UserContactLink -> + Bool -> + InvitationId -> + VersionRangeChat -> + Profile -> + Maybe XContactId -> + Maybe SharedMsgId -> + Maybe (SharedMsgId, MsgContent) -> + PQSupport -> + ExceptT StoreError IO RequestStage createOrUpdateContactRequest db gVar @@ -86,147 +86,146 @@ createOrUpdateContactRequest Just ct -> do cr <- liftIO $ getContactRequestByXContactId xContactId pure $ RSAcceptedRequest cr (REContact ct) - Nothing -> liftIO (getAcceptedBusinessChat xContactId) >>= \case - Just gInfo@GroupInfo {businessChat = Just BusinessChatInfo {customerId}} -> do - clientMember <- getGroupMemberByMemberId db vr user gInfo customerId - cr <- liftIO $ getContactRequestByXContactId xContactId - pure $ RSAcceptedRequest cr (REBusinessChat gInfo clientMember) - Just GroupInfo {businessChat = Nothing} -> throwError SEInvalidBusinessChatContactRequest - -- 2) if no legacy accepted contact or business chat was found, next we try to find an existing request - Nothing -> - liftIO (getContactRequestByXContactId xContactId) >>= \case - -- 3a) if request was found, we update it - Just cr -> updateContactRequest cr - -- 3b) if no request was found, we create a new contact request - Nothing -> createContactRequest - where - getAcceptedContact :: XContactId -> IO (Maybe Contact) - getAcceptedContact xContactId = do - ct_ <- - maybeFirstRow (toContact vr user []) $ + Nothing -> + liftIO (getAcceptedBusinessChat xContactId) >>= \case + Just gInfo@GroupInfo {businessChat = Just BusinessChatInfo {customerId}} -> do + clientMember <- getGroupMemberByMemberId db vr user gInfo customerId + cr <- liftIO $ getContactRequestByXContactId xContactId + pure $ RSAcceptedRequest cr (REBusinessChat gInfo clientMember) + Just GroupInfo {businessChat = Nothing} -> throwError SEInvalidBusinessChatContactRequest + -- 2) if no legacy accepted contact or business chat was found, next we try to find an existing request + Nothing -> + liftIO (getContactRequestByXContactId xContactId) >>= \case + -- 3a) if request was found, we update it + Just cr -> updateContactRequest cr + -- 3b) if no request was found, we create a new contact request + Nothing -> createContactRequest + where + getAcceptedContact :: XContactId -> IO (Maybe Contact) + getAcceptedContact xContactId = do + ct_ <- + maybeFirstRow (toContact vr user []) $ + DB.query + db + [sql| + SELECT + -- Contact + ct.contact_id, ct.contact_profile_id, ct.local_display_name, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, + ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, + ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + -- Connection + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.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 contacts ct + JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id + LEFT JOIN connections c ON c.contact_id = ct.contact_id + WHERE ct.user_id = ? AND ct.xcontact_id = ? AND ct.deleted = 0 + |] + (userId, xContactId) + mapM (addDirectChatTags db) ct_ + getAcceptedBusinessChat :: XContactId -> IO (Maybe GroupInfo) + getAcceptedBusinessChat xContactId = do + g_ <- + maybeFirstRow (toGroupInfo vr userContactId []) $ + DB.query + db + (groupInfoQuery <> " WHERE g.business_xcontact_id = ? AND g.user_id = ? AND mu.contact_id = ?") + (xContactId, userId, userContactId) + mapM (addGroupChatTags db) g_ + getContactRequestByXContactId :: XContactId -> IO (Maybe UserContactRequest) + getContactRequestByXContactId xContactId = + maybeFirstRow toContactRequest $ DB.query db [sql| SELECT - -- Contact - ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, - cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, - ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, - ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, - -- Connection - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.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.snd_file_id, c.rcv_file_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 contacts ct - JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id - LEFT JOIN connections c ON c.contact_id = ct.contact_id - WHERE ct.user_id = ? AND ct.xcontact_id = ? AND ct.deleted = 0 - ORDER BY c.created_at DESC + cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, + cr.contact_id, cr.business_group_id, cr.user_contact_link_id, + cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, cr.xcontact_id, + cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, + cr.created_at, cr.updated_at, + cr.peer_chat_min_version, cr.peer_chat_max_version + FROM contact_requests cr + JOIN contact_profiles p USING (contact_profile_id) + WHERE cr.user_id = ? + AND cr.xcontact_id = ? LIMIT 1 |] (userId, xContactId) - mapM (addDirectChatTags db) ct_ - getAcceptedBusinessChat :: XContactId -> IO (Maybe GroupInfo) - getAcceptedBusinessChat xContactId = do - g_ <- - maybeFirstRow (toGroupInfo vr userContactId []) $ - DB.query - db - (groupInfoQuery <> " WHERE g.business_xcontact_id = ? AND g.user_id = ? AND mu.contact_id = ?") - (xContactId, userId, userContactId) - mapM (addGroupChatTags db) g_ - getContactRequestByXContactId :: XContactId -> IO (Maybe UserContactRequest) - getContactRequestByXContactId xContactId = - maybeFirstRow toContactRequest $ - DB.query - db - [sql| - SELECT - cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, - cr.contact_id, cr.business_group_id, cr.user_contact_link_id, - cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, cr.xcontact_id, - cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, - cr.created_at, cr.updated_at, - cr.peer_chat_min_version, cr.peer_chat_max_version - FROM contact_requests cr - JOIN contact_profiles p USING (contact_profile_id) - WHERE cr.user_id = ? - AND cr.xcontact_id = ? - LIMIT 1 - |] - (userId, xContactId) - createContactRequest :: ExceptT StoreError IO RequestStage - createContactRequest = do - currentTs <- liftIO $ getCurrentTime - ExceptT $ withLocalDisplayName db userId displayName $ \ldn -> runExceptT $ do - liftIO $ - DB.execute - db - "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 - liftIO $ - DB.execute - db - [sql| + createContactRequest :: ExceptT StoreError IO RequestStage + createContactRequest = do + currentTs <- liftIO $ getCurrentTime + ExceptT $ withLocalDisplayName db userId displayName $ \ldn -> runExceptT $ do + liftIO $ + DB.execute + db + "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 + liftIO $ + DB.execute + db + [sql| INSERT INTO contact_requests (user_contact_link_id, agent_invitation_id, peer_chat_min_version, peer_chat_max_version, contact_profile_id, local_display_name, user_id, created_at, updated_at, xcontact_id, welcome_shared_msg_id, request_shared_msg_id, pq_support) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (uclId, Binary invId, minV, maxV, profileId, ldn, userId) - :. (currentTs, currentTs, xContactId_, welcomeMsgId_, fst <$> requestMsg_, pqSup) - ) - contactRequestId <- liftIO $ insertedRowId db - createRequestEntity ldn profileId contactRequestId currentTs - where - createRequestEntity ldn profileId contactRequestId currentTs - | businessAddress = - if isSimplexTeam && maxV < businessChatsVersion - then createContact' - else createBusinessChat - | otherwise = createContact' - where - createContact' = do - let ctUserPreferences = newContactUserPrefs user profile - liftIO $ - DB.execute - db - "INSERT INTO contacts (contact_profile_id, user_preferences, local_display_name, user_id, created_at, updated_at, chat_ts, contact_used, contact_request_id) VALUES (?,?,?,?,?,?,?,?,?)" - (profileId, ctUserPreferences, ldn, userId, currentTs, currentTs, currentTs, BI True, contactRequestId) - contactId <- liftIO $ insertedRowId db - liftIO $ - DB.execute - db - "UPDATE contact_requests SET contact_id = ? WHERE contact_request_id = ?" - (contactId, contactRequestId) - ucr <- getContactRequest db user contactRequestId - ct <- getContact db vr user contactId - pure $ RSCurrentRequest Nothing ucr (Just $ REContact ct) - createBusinessChat = do - let groupPreferences = maybe defaultBusinessGroupPrefs businessGroupPrefs $ preferences' user - (gInfo@GroupInfo {groupId}, clientMember) <- - createBusinessRequestGroup db vr gVar user cReqChatVRange profile profileId ldn groupPreferences - liftIO $ - DB.execute - db - "UPDATE contact_requests SET business_group_id = ? WHERE contact_request_id = ?" - (groupId, contactRequestId) - ucr <- getContactRequest db user contactRequestId - pure $ RSCurrentRequest Nothing ucr (Just $ REBusinessChat gInfo clientMember) - updateContactRequest :: UserContactRequest -> ExceptT StoreError IO RequestStage - updateContactRequest ucr@UserContactRequest {contactRequestId, contactId_, localDisplayName = oldLdn, profile = Profile {displayName = oldDisplayName}} = do - currentTs <- liftIO getCurrentTime - liftIO $ updateProfile currentTs - updateRequest currentTs - ucr' <- getContactRequest db user contactRequestId - re_ <- getRequestEntity ucr' - pure $ RSCurrentRequest (Just ucr) ucr' re_ - where - updateProfile currentTs = - DB.execute - db - [sql| + ( (uclId, Binary invId, minV, maxV, profileId, ldn, userId) + :. (currentTs, currentTs, xContactId_, welcomeMsgId_, fst <$> requestMsg_, pqSup) + ) + contactRequestId <- liftIO $ insertedRowId db + createRequestEntity ldn profileId contactRequestId currentTs + where + createRequestEntity ldn profileId contactRequestId currentTs + | businessAddress = + if isSimplexTeam && maxV < businessChatsVersion + then createContact' + else createBusinessChat + | otherwise = createContact' + where + createContact' = do + let ctUserPreferences = newContactUserPrefs user profile + liftIO $ + DB.execute + db + "INSERT INTO contacts (contact_profile_id, user_preferences, local_display_name, user_id, created_at, updated_at, chat_ts, contact_used, contact_request_id) VALUES (?,?,?,?,?,?,?,?,?)" + (profileId, ctUserPreferences, ldn, userId, currentTs, currentTs, currentTs, BI True, contactRequestId) + contactId <- liftIO $ insertedRowId db + liftIO $ + DB.execute + db + "UPDATE contact_requests SET contact_id = ? WHERE contact_request_id = ?" + (contactId, contactRequestId) + ucr <- getContactRequest db user contactRequestId + ct <- getContact db vr user contactId + pure $ RSCurrentRequest Nothing ucr (Just $ REContact ct) + createBusinessChat = do + let groupPreferences = maybe defaultBusinessGroupPrefs businessGroupPrefs $ preferences' user + (gInfo@GroupInfo {groupId}, clientMember) <- + createBusinessRequestGroup db vr gVar user cReqChatVRange profile profileId ldn groupPreferences + liftIO $ + DB.execute + db + "UPDATE contact_requests SET business_group_id = ? WHERE contact_request_id = ?" + (groupId, contactRequestId) + ucr <- getContactRequest db user contactRequestId + pure $ RSCurrentRequest Nothing ucr (Just $ REBusinessChat gInfo clientMember) + updateContactRequest :: UserContactRequest -> ExceptT StoreError IO RequestStage + updateContactRequest ucr@UserContactRequest {contactRequestId, contactId_, localDisplayName = oldLdn, profile = Profile {displayName = oldDisplayName}} = do + currentTs <- liftIO getCurrentTime + liftIO $ updateProfile currentTs + updateRequest currentTs + ucr' <- getContactRequest db user contactRequestId + re_ <- getRequestEntity ucr' + pure $ RSCurrentRequest (Just ucr) ucr' re_ + where + updateProfile currentTs = + DB.execute + db + [sql| UPDATE contact_profiles SET display_name = ?, full_name = ?, @@ -241,21 +240,20 @@ createOrUpdateContactRequest AND contact_request_id = ? ) |] - (displayName, fullName, shortDescr, image, contactLink, currentTs, userId, contactRequestId) - updateRequest currentTs = - if displayName == oldDisplayName - then - liftIO $ - DB.execute - db - [sql| + (displayName, fullName, shortDescr, image, contactLink, currentTs, userId, contactRequestId) + updateRequest currentTs = + if displayName == oldDisplayName + then + liftIO $ + DB.execute + db + [sql| UPDATE contact_requests SET agent_invitation_id = ?, pq_support = ?, peer_chat_min_version = ?, peer_chat_max_version = ?, updated_at = ? WHERE user_id = ? AND contact_request_id = ? |] - (Binary invId, pqSup, minV, maxV, currentTs, userId, contactRequestId) - else - ExceptT $ withLocalDisplayName db userId displayName $ \ldn -> runExceptT $ do + (Binary invId, pqSup, minV, maxV, currentTs, userId, contactRequestId) + else ExceptT $ withLocalDisplayName db userId displayName $ \ldn -> runExceptT $ do liftIO $ do DB.execute db @@ -276,21 +274,21 @@ createOrUpdateContactRequest |] (ldn, currentTs, contactId) safeDeleteLDN db user oldLdn - getRequestEntity :: UserContactRequest -> ExceptT StoreError IO (Maybe RequestEntity) - getRequestEntity UserContactRequest {contactRequestId, contactId_, businessGroupId_} = - case (contactId_, businessGroupId_) of - (Just contactId, Nothing) -> do - ct <- getContact db vr user contactId - pure $ Just (REContact ct) - (Nothing, Just businessGroupId) -> do - gInfo <- getGroupInfo db vr user businessGroupId - case gInfo of - GroupInfo {businessChat = Just BusinessChatInfo {customerId}} -> do - clientMember <- getGroupMemberByMemberId db vr user gInfo customerId - pure $ Just (REBusinessChat gInfo clientMember) - _ -> throwError SEInvalidBusinessChatContactRequest - (Nothing, Nothing) -> pure Nothing - _ -> throwError $ SEInvalidContactRequestEntity contactRequestId + getRequestEntity :: UserContactRequest -> ExceptT StoreError IO (Maybe RequestEntity) + getRequestEntity UserContactRequest {contactRequestId, contactId_, businessGroupId_} = + case (contactId_, businessGroupId_) of + (Just contactId, Nothing) -> do + ct <- getContact db vr user contactId + pure $ Just (REContact ct) + (Nothing, Just businessGroupId) -> do + gInfo <- getGroupInfo db vr user businessGroupId + case gInfo of + GroupInfo {businessChat = Just BusinessChatInfo {customerId}} -> do + clientMember <- getGroupMemberByMemberId db vr user gInfo customerId + pure $ Just (REBusinessChat gInfo clientMember) + _ -> throwError SEInvalidBusinessChatContactRequest + (Nothing, Nothing) -> pure Nothing + _ -> throwError $ SEInvalidContactRequestEntity contactRequestId setContactAcceptedXContactId :: DB.Connection -> Contact -> XContactId -> IO () setContactAcceptedXContactId db Contact {contactId} xContactId = diff --git a/src/Simplex/Chat/Store/Delivery.hs b/src/Simplex/Chat/Store/Delivery.hs new file mode 100644 index 0000000000..30fffac0e7 --- /dev/null +++ b/src/Simplex/Chat/Store/Delivery.hs @@ -0,0 +1,376 @@ +{-# LANGUAGE CPP #-} +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} +{-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TypeOperators #-} + +module Simplex.Chat.Store.Delivery + ( createMsgDeliveryTask, + deleteGroupDeliveryTasks, + deleteGroupDeliveryJobs, + getPendingDeliveryTaskScopes, + getNextDeliveryTask, + getNextDeliveryTasks, + updateDeliveryTaskStatus, + setDeliveryTaskErrStatus, + deleteDoneDeliveryTasks, + createMsgDeliveryJob, + getPendingDeliveryJobScopes, + getNextDeliveryJob, + updateDeliveryJobStatus, + setDeliveryJobErrStatus, + getGroupMembersByCursor, + updateDeliveryJobCursor, + deleteDoneDeliveryJobs, + ) +where + +import Data.ByteString.Char8 (ByteString) +import Data.Int (Int64) +import Data.Text (Text) +import Data.Time.Clock (UTCTime, getCurrentTime) +import Simplex.Chat.Delivery +import Simplex.Chat.Protocol hiding (Binary) +import Simplex.Chat.Store.Shared +import Simplex.Chat.Types +import Simplex.Messaging.Agent.Store.AgentStore (getWorkItem, getWorkItems, maybeFirstRow) +import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..)) +import qualified Simplex.Messaging.Agent.Store.DB as DB +import Simplex.Messaging.Util (firstRow') +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (In (..), Only (..), (:.) (..)) +import Database.PostgreSQL.Simple.SqlQQ (sql) +#else +import Control.Monad.Except +import Data.Either (rights) +import Database.SQLite.Simple (Only (..), (:.) (..)) +import Database.SQLite.Simple.QQ (sql) +import Simplex.Chat.Store.Groups (getGroupMemberById) +#endif + +type DeliveryJobScopeRow = (DeliveryWorkerScope, Maybe DeliveryJobSpecTag, Maybe BoolInt, Maybe GroupMemberId) + +jobScopeRow_ :: DeliveryJobScope -> DeliveryJobScopeRow +jobScopeRow_ = \case + DJSGroup {jobSpec} -> case jobSpec of + DJDeliveryJob {includePending} -> (DWSGroup, Just DJSTDeliveryJob, Just (BI includePending), Nothing) + DJRelayRemoved -> (DWSGroup, Just DJSTRelayRemoved, Nothing, Nothing) + DJSMemberSupport {supportGMId} -> (DWSMemberSupport, Nothing, Nothing, Just supportGMId) + +toJobScope_ :: DeliveryJobScopeRow -> Maybe DeliveryJobScope +toJobScope_ = \case + (DWSGroup, Just DJSTDeliveryJob, Just (BI includePending), Nothing) -> Just $ DJSGroup {jobSpec = DJDeliveryJob {includePending}} + (DWSGroup, Just DJSTRelayRemoved, Nothing, Nothing) -> Just $ DJSGroup {jobSpec = DJRelayRemoved} + (DWSMemberSupport, Nothing, Nothing, Just supportGMId) -> Just $ DJSMemberSupport {supportGMId} + _ -> Nothing + +createMsgDeliveryTask :: DB.Connection -> GroupInfo -> GroupMember -> NewMessageDeliveryTask -> IO () +createMsgDeliveryTask db gInfo sender newTask = do + currentTs <- getCurrentTime + DB.execute + db + [sql| + INSERT INTO delivery_tasks ( + group_id, + worker_scope, job_scope_spec_tag, job_scope_include_pending, job_scope_support_gm_id, + sender_group_member_id, message_id, message_from_channel, task_status, + created_at, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?) + |] + ((Only groupId) :. jobScopeRow_ jobScope :. (groupMemberId' sender, messageId, BI messageFromChannel, DTSNew, currentTs, currentTs)) + where + GroupInfo {groupId} = gInfo + NewMessageDeliveryTask {messageId, jobScope, messageFromChannel} = newTask + +deleteGroupDeliveryTasks :: DB.Connection -> GroupInfo -> IO () +deleteGroupDeliveryTasks db GroupInfo {groupId} = + DB.execute db "DELETE FROM delivery_tasks WHERE group_id = ?" (Only groupId) + +deleteGroupDeliveryJobs :: DB.Connection -> GroupInfo -> IO () +deleteGroupDeliveryJobs db GroupInfo {groupId} = + DB.execute db "DELETE FROM delivery_jobs WHERE group_id = ?" (Only groupId) + +getPendingDeliveryTaskScopes :: DB.Connection -> IO [DeliveryWorkerKey] +getPendingDeliveryTaskScopes db = + DB.query + db + [sql| + SELECT DISTINCT group_id, worker_scope + FROM delivery_tasks + WHERE failed = 0 AND task_status = ? + |] + (Only DTSNew) + +getNextDeliveryTask :: DB.Connection -> DeliveryWorkerKey -> IO (Either StoreError (Maybe MessageDeliveryTask)) +getNextDeliveryTask db deliveryKey = do + getWorkItem "delivery task" getTaskId (getMsgDeliveryTask_ db) (markDeliveryTaskFailed_ db) + where + (groupId, workerScope) = deliveryKey + getTaskId :: IO (Maybe Int64) + getTaskId = + maybeFirstRow fromOnly $ + DB.query + db + [sql| + SELECT delivery_task_id + FROM delivery_tasks + WHERE group_id = ? AND worker_scope = ? + AND failed = 0 AND task_status = ? + ORDER BY delivery_task_id ASC + LIMIT 1 + |] + (groupId, workerScope, DTSNew) + +type MessageDeliveryTaskRow = (Only Int64) :. DeliveryJobScopeRow :. (GroupMemberId, MemberId, ContactName, UTCTime, ChatMessage 'Json, BoolInt) + +getMsgDeliveryTask_ :: DB.Connection -> Int64 -> IO (Either StoreError MessageDeliveryTask) +getMsgDeliveryTask_ db taskId = + firstRow' toTask (SEDeliveryTaskNotFound taskId) $ + DB.query + db + [sql| + SELECT + t.delivery_task_id, + t.worker_scope, t.job_scope_spec_tag, t.job_scope_include_pending, t.job_scope_support_gm_id, + m.group_member_id, m.member_id, p.display_name, msg.broker_ts, msg.msg_body, t.message_from_channel + FROM delivery_tasks t + JOIN messages msg ON msg.message_id = t.message_id + JOIN group_members m ON m.group_member_id = t.sender_group_member_id + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + WHERE t.delivery_task_id = ? + |] + (Only taskId) + where + toTask :: MessageDeliveryTaskRow -> Either StoreError MessageDeliveryTask + toTask ((Only taskId') :. jobScopeRow :. (senderGMId, senderMemberId, senderMemberName, brokerTs, chatMessage, BI messageFromChannel)) = + case toJobScope_ jobScopeRow of + Just jobScope -> Right $ MessageDeliveryTask {taskId = taskId', jobScope, senderGMId, senderMemberId, senderMemberName, brokerTs, chatMessage, messageFromChannel} + Nothing -> Left $ SEInvalidDeliveryTask taskId' + +markDeliveryTaskFailed_ :: DB.Connection -> Int64 -> IO () +markDeliveryTaskFailed_ db taskId = + DB.execute db "UPDATE delivery_tasks SET failed = 1 where delivery_task_id = ?" (Only taskId) + +-- TODO [channels fwd] possible optimization is to read and add tasks to batch iteratively to avoid reading too many tasks +-- passed MessageDeliveryTask defines the jobScope to search for +getNextDeliveryTasks :: DB.Connection -> GroupInfo -> MessageDeliveryTask -> IO (Either StoreError [Either StoreError MessageDeliveryTask]) +getNextDeliveryTasks db gInfo task = + getWorkItems "message delivery task" getTaskIds (getMsgDeliveryTask_ db) (markDeliveryTaskFailed_ db) + where + GroupInfo {groupId, useRelays} = gInfo + MessageDeliveryTask {jobScope, senderGMId} = task + getTaskIds :: IO [Int64] + getTaskIds + | isTrue useRelays = + map fromOnly + <$> DB.query + db + [sql| + SELECT delivery_task_id + FROM delivery_tasks + WHERE group_id = ? + AND worker_scope = ? + AND job_scope_spec_tag IS NOT DISTINCT FROM ? + AND job_scope_include_pending IS NOT DISTINCT FROM ? + AND job_scope_support_gm_id IS NOT DISTINCT FROM ? + AND failed = 0 + AND task_status = ? + ORDER BY delivery_task_id ASC + |] + ((Only groupId) :. jobScopeRow_ jobScope :. (Only DTSNew)) + | 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.). + -- We do this optimization in the job to decrease load on admins using mobile devices for clients. + map fromOnly + <$> DB.query + db + [sql| + SELECT delivery_task_id + FROM delivery_tasks + WHERE group_id = ? + AND worker_scope = ? + AND job_scope_spec_tag IS NOT DISTINCT FROM ? + AND job_scope_include_pending IS NOT DISTINCT FROM ? + AND job_scope_support_gm_id IS NOT DISTINCT FROM ? + AND sender_group_member_id = ? + AND failed = 0 + AND task_status = ? + ORDER BY delivery_task_id ASC + |] + ((Only groupId) :. jobScopeRow_ jobScope :. (senderGMId, DTSNew)) + +updateDeliveryTaskStatus :: DB.Connection -> Int64 -> DeliveryTaskStatus -> IO () +updateDeliveryTaskStatus db taskId status = updateDeliveryTaskStatus_ db taskId status Nothing + +setDeliveryTaskErrStatus :: DB.Connection -> Int64 -> Text -> IO () +setDeliveryTaskErrStatus db taskId errReason = updateDeliveryTaskStatus_ db taskId DTSError (Just errReason) + +updateDeliveryTaskStatus_ :: DB.Connection -> Int64 -> DeliveryTaskStatus -> Maybe Text -> IO () +updateDeliveryTaskStatus_ db taskId status errReason_ = do + currentTs <- getCurrentTime + DB.execute + db + "UPDATE delivery_tasks SET task_status = ?, task_err_reason = ?, updated_at = ? WHERE delivery_task_id = ?" + (status, errReason_, currentTs, taskId) + +deleteDoneDeliveryTasks :: DB.Connection -> UTCTime -> IO () +deleteDoneDeliveryTasks db createdAtCutoff = do + DB.execute + db + [sql| + DELETE FROM delivery_tasks + WHERE created_at <= ? + AND (task_status IN (?,?) OR failed = 1) + |] + (createdAtCutoff, DTSProcessed, DTSError) + +createMsgDeliveryJob :: DB.Connection -> GroupInfo -> DeliveryJobScope -> Maybe GroupMemberId -> ByteString -> IO () +createMsgDeliveryJob db gInfo jobScope singleSenderGMId_ body = do + currentTs <- getCurrentTime + DB.execute + db + [sql| + INSERT INTO delivery_jobs ( + group_id, + worker_scope, job_scope_spec_tag, job_scope_include_pending, job_scope_support_gm_id, + single_sender_group_member_id, body, job_status, created_at, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?) + |] + ((Only groupId) :. jobScopeRow_ jobScope :. (singleSenderGMId_, Binary body, DJSPending, currentTs, currentTs)) + where + GroupInfo {groupId} = gInfo + +getPendingDeliveryJobScopes :: DB.Connection -> IO [DeliveryWorkerKey] +getPendingDeliveryJobScopes db = + DB.query + db + [sql| + SELECT DISTINCT group_id, worker_scope + FROM delivery_jobs + WHERE failed = 0 AND job_status = ? + |] + (Only DJSPending) + +type MessageDeliveryJobRow = (Only Int64) :. DeliveryJobScopeRow :. (Maybe GroupMemberId, Binary ByteString, Maybe GroupMemberId) + +getNextDeliveryJob :: DB.Connection -> DeliveryWorkerKey -> IO (Either StoreError (Maybe MessageDeliveryJob)) +getNextDeliveryJob db deliveryKey = do + getWorkItem "delivery job" getJobId getJob markJobFailed + where + (groupId, workerScope) = deliveryKey + getJobId :: IO (Maybe Int64) + getJobId = + maybeFirstRow fromOnly $ + DB.query + db + [sql| + SELECT delivery_job_id + FROM delivery_jobs + WHERE group_id = ? AND worker_scope = ? + AND failed = 0 AND job_status = ? + ORDER BY delivery_job_id ASC + LIMIT 1 + |] + (groupId, workerScope, DJSPending) + getJob :: Int64 -> IO (Either StoreError MessageDeliveryJob) + getJob jobId = + firstRow' toDeliveryJob (SEDeliveryJobNotFound jobId) $ + DB.query + db + [sql| + SELECT + delivery_job_id, + worker_scope, job_scope_spec_tag, job_scope_include_pending, job_scope_support_gm_id, + single_sender_group_member_id, body, cursor_group_member_id + FROM delivery_jobs + WHERE delivery_job_id = ? + |] + (Only jobId) + where + toDeliveryJob :: MessageDeliveryJobRow -> Either StoreError MessageDeliveryJob + toDeliveryJob ((Only jobId') :. jobScopeRow :. (singleSenderGMId_, Binary body, cursorGMId_)) = + case toJobScope_ jobScopeRow of + Just jobScope -> Right $ MessageDeliveryJob {jobId = jobId', jobScope, singleSenderGMId_, body, cursorGMId_} + Nothing -> Left $ SEInvalidDeliveryJob jobId' + markJobFailed :: Int64 -> IO () + markJobFailed jobId = + DB.execute db "UPDATE delivery_jobs SET failed = 1 where delivery_job_id = ?" (Only jobId) + +updateDeliveryJobStatus :: DB.Connection -> Int64 -> DeliveryJobStatus -> IO () +updateDeliveryJobStatus db jobId status = updateDeliveryJobStatus_ db jobId status Nothing + +setDeliveryJobErrStatus :: DB.Connection -> Int64 -> Text -> IO () +setDeliveryJobErrStatus db jobId errReason = updateDeliveryJobStatus_ db jobId DJSError (Just errReason) + +updateDeliveryJobStatus_ :: DB.Connection -> Int64 -> DeliveryJobStatus -> Maybe Text -> IO () +updateDeliveryJobStatus_ db jobId status errReason_ = do + currentTs <- getCurrentTime + DB.execute + db + "UPDATE delivery_jobs SET job_status = ?, job_err_reason = ?, updated_at = ? WHERE delivery_job_id = ?" + (status, errReason_, currentTs, jobId) + +-- TODO [channels fwd] possible improvement is to prioritize owners and "active" members +getGroupMembersByCursor :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> Maybe GroupMemberId -> Maybe GroupMemberId -> Int -> IO [GroupMember] +getGroupMembersByCursor db vr user@User {userContactId} GroupInfo {groupId} cursorGMId_ singleSenderGMId_ count = do + gmIds :: [Int64] <- + map fromOnly <$> case cursorGMId_ of + Nothing -> + DB.query + db + (query <> orderLimit) + ( (groupId, userContactId, singleSenderGMId_, GSMemIntroduced, GSMemIntroInvited, GSMemAccepted, GSMemAnnounced, GSMemConnected, GSMemComplete) + :. (Only count) + ) + Just cursorGMId -> + DB.query + db + (query <> " AND group_member_id > ?" <> orderLimit) + ( (groupId, userContactId, singleSenderGMId_, GSMemIntroduced, GSMemIntroInvited, GSMemAccepted, GSMemAnnounced, GSMemConnected, GSMemComplete) + :. (cursorGMId, count) + ) +#if defined(dbPostgres) + map (toContactMember vr user) <$> + DB.query + db + (groupMemberQuery <> " WHERE m.group_member_id IN ?") + (Only (In gmIds)) +#else + rights <$> mapM (runExceptT . getGroupMemberById db vr user) gmIds +#endif + where + query = + [sql| + SELECT group_member_id + FROM group_members + WHERE group_id = ? + AND contact_id IS DISTINCT FROM ? + AND group_member_id IS DISTINCT FROM ? + AND member_status IN (?,?,?,?,?,?) + |] + orderLimit = " ORDER BY group_member_id ASC LIMIT ?" + +updateDeliveryJobCursor :: DB.Connection -> Int64 -> GroupMemberId -> IO () +updateDeliveryJobCursor db jobId cursorGMId = do + currentTs <- getCurrentTime + DB.execute + db + "UPDATE delivery_jobs SET cursor_group_member_id = ?, updated_at = ? WHERE delivery_job_id = ?" + (cursorGMId, currentTs, jobId) + +deleteDoneDeliveryJobs :: DB.Connection -> UTCTime -> IO () +deleteDoneDeliveryJobs db createdAtCutoff = do + DB.execute + db + [sql| + DELETE FROM delivery_jobs + WHERE created_at <= ? + AND (job_status IN (?,?) OR failed = 1) + |] + (createdAtCutoff, DJSComplete, DJSError) diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index f25f7f2562..a517a7365b 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -71,7 +71,6 @@ module Simplex.Chat.Store.Direct createAcceptedContactConn, updateContactAccepted, getUserByContactRequestId, - getPendingContactConnections, getContactConnections, getConnectionById, getConnectionsContacts, @@ -244,7 +243,7 @@ getConnReqContactXContactId db vr user@User {userId} cReqHash1 cReqHash2 = db [sql| SELECT connection_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, group_link_id, xcontact_id, custom_user_profile_id, conn_status, conn_type, contact_conn_initiated, local_alias, - contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, security_code, security_code_verified_at, pq_support, pq_encryption, pq_snd_enabled, pq_rcv_enabled, auth_err_counter, quota_err_counter, + contact_id, group_member_id, user_contact_link_id, created_at, security_code, security_code_verified_at, pq_support, pq_encryption, pq_snd_enabled, pq_rcv_enabled, auth_err_counter, quota_err_counter, conn_chat_version, peer_chat_min_version, peer_chat_max_version FROM connections WHERE (user_id = ? AND via_contact_uri_hash = ?) @@ -262,13 +261,13 @@ getContactByConnReqHash db vr user@User {userId} cReqHash1 cReqHash2 = do [sql| SELECT -- Contact - ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + ct.contact_id, ct.contact_profile_id, ct.local_display_name, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.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.snd_file_id, c.rcv_file_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.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 contacts ct JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id @@ -277,8 +276,6 @@ getContactByConnReqHash db vr user@User {userId} cReqHash1 cReqHash2 = do ( (c.user_id = ? AND c.via_contact_uri_hash = ?) OR (c.user_id = ? AND c.via_contact_uri_hash = ?) ) AND ct.contact_status = ? AND ct.deleted = 0 - ORDER BY c.created_at DESC - LIMIT 1 |] (userId, cReqHash1, userId, cReqHash2, CSActive) mapM (addDirectChatTags db) ct @@ -349,7 +346,7 @@ createPreparedContact db vr user p connLinkToConnect welcomeSharedMsgId = do currentTs <- liftIO getCurrentTime let prepared = Just (connLinkToConnect, welcomeSharedMsgId) ctUserPreferences = newContactUserPrefs user p - contactId <- createContact_ db user p ctUserPreferences prepared "" Nothing currentTs + contactId <- createContact_ db user p ctUserPreferences prepared "" currentTs getContact db vr user contactId updatePreparedContactUser :: DB.Connection -> VersionRangeChat -> User -> Contact -> User -> ExceptT StoreError IO Contact @@ -386,7 +383,7 @@ createDirectContact :: DB.Connection -> VersionRangeChat -> User -> Connection - createDirectContact db vr user Connection {connId, localAlias} p = do currentTs <- liftIO getCurrentTime let ctUserPreferences = newContactUserPrefs user p - contactId <- createContact_ db user p ctUserPreferences Nothing localAlias Nothing currentTs + contactId <- createContact_ db user p ctUserPreferences Nothing localAlias currentTs liftIO $ DB.execute db "UPDATE connections SET contact_id = ?, updated_at = ? WHERE connection_id = ?" (contactId, currentTs, connId) getContact db vr user contactId @@ -792,7 +789,6 @@ createContactFromRequest db user@User {userId, profile = LocalProfile {preferenc localDisplayName, profile = toLocalProfile profileId profile "", activeConn = Just conn, - viaGroup = Nothing, contactUsed, contactStatus = CSActive, chatSettings = defaultChatSettings, @@ -854,56 +850,27 @@ getContact_ db vr user@User {userId} contactId deleted = do [sql| SELECT -- Contact - ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + ct.contact_id, ct.contact_profile_id, ct.local_display_name, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.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.snd_file_id, c.rcv_file_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.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 contacts ct JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id LEFT JOIN connections c ON c.contact_id = ct.contact_id WHERE ct.user_id = ? AND ct.contact_id = ? AND ct.deleted = ? - AND ( - c.connection_id = ( - SELECT cc_connection_id FROM ( - SELECT - cc.connection_id AS cc_connection_id, - cc.created_at AS cc_created_at, - (CASE WHEN cc.conn_status = ? OR cc.conn_status = ? THEN 1 ELSE 0 END) AS cc_conn_status_ord - FROM connections cc - WHERE cc.user_id = ct.user_id AND cc.contact_id = ct.contact_id - ORDER BY cc_conn_status_ord DESC, cc_created_at DESC - LIMIT 1 - ) cc - ) - OR c.connection_id IS NULL - ) |] - (userId, contactId, BI deleted, ConnReady, ConnSndReady) + (userId, contactId, BI deleted) getUserByContactRequestId :: DB.Connection -> Int64 -> ExceptT StoreError IO User getUserByContactRequestId db contactRequestId = ExceptT . firstRow toUser (SEUserNotFoundByContactRequestId contactRequestId) $ DB.query db (userQuery <> " JOIN contact_requests cr ON cr.user_id = u.user_id WHERE cr.contact_request_id = ?") (Only contactRequestId) -getPendingContactConnections :: DB.Connection -> User -> IO [PendingContactConnection] -getPendingContactConnections db User {userId} = do - map toPendingContactConnection - <$> DB.query - db - [sql| - SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, group_link_id, custom_user_profile_id, conn_req_inv, short_link_inv, local_alias, created_at, updated_at - FROM connections - WHERE user_id = ? - AND conn_type = ? - AND contact_id IS NULL - |] - (userId, ConnContact) - getContactConnections :: DB.Connection -> VersionRangeChat -> UserId -> Contact -> IO [Connection] getContactConnections db vr userId Contact {contactId} = connections =<< liftIO getConnections_ @@ -913,7 +880,7 @@ getContactConnections db vr userId Contact {contactId} = db [sql| SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.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.snd_file_id, c.rcv_file_id, c.user_contact_link_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 connections c @@ -931,7 +898,7 @@ getConnectionById db vr User {userId} connId = ExceptT $ do db [sql| SELECT connection_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, group_link_id, xcontact_id, custom_user_profile_id, - conn_status, conn_type, contact_conn_initiated, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, + conn_status, conn_type, contact_conn_initiated, local_alias, contact_id, group_member_id, user_contact_link_id, created_at, security_code, security_code_verified_at, pq_support, pq_encryption, pq_snd_enabled, pq_rcv_enabled, auth_err_counter, quota_err_counter, conn_chat_version, peer_chat_min_version, peer_chat_max_version FROM connections diff --git a/src/Simplex/Chat/Store/Files.hs b/src/Simplex/Chat/Store/Files.hs index 76d14ca689..eac046666b 100644 --- a/src/Simplex/Chat/Store/Files.hs +++ b/src/Simplex/Chat/Store/Files.hs @@ -12,13 +12,7 @@ {-# LANGUAGE TypeOperators #-} module Simplex.Chat.Store.Files - ( getLiveSndFileTransfers, - getLiveRcvFileTransfers, - getPendingSndChunks, - createSndDirectFTConnection, - createSndGroupFileTransfer, - createSndGroupFileTransferConnection, - createSndDirectInlineFT, + ( createSndDirectInlineFT, createSndGroupInlineFT, updateSndDirectFTDelivery, updateSndGroupFTDelivery, @@ -41,10 +35,6 @@ module Simplex.Chat.Store.Files getChatRefByFileId, lookupChatRefByFileId, updateSndFileStatus, - createSndFileChunk, - updateSndFileChunkMsg, - updateSndFileChunkSent, - deleteSndFileChunks, createRcvFileTransfer, createRcvGroupFileTransfer, createRcvStandaloneFileTransfer, @@ -54,8 +44,6 @@ module Simplex.Chat.Store.Files updateRcvFileAgentId, getRcvFileTransferById, getRcvFileTransfer, - acceptRcvFileTransfer, - getContactByFileId, acceptRcvInlineFT, startRcvInlineFT, xftpAcceptRcvFT, @@ -74,12 +62,10 @@ module Simplex.Chat.Store.Files getFileTransferMeta, lookupFileTransferRedirectMeta, getSndFileTransfer, - getSndFileTransfers, getContactFileInfo, getNoteFolderFileInfo, createLocalFile, getLocalCryptoFile, - getLocalFileMeta, updateDirectCIFileStatus, ) where @@ -99,22 +85,17 @@ import Data.Type.Equality import Data.Word (Word32) import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent -import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Messages import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared import Simplex.Chat.Types -import Simplex.Chat.Util (week) -import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, UserId) +import Simplex.Messaging.Agent.Protocol (AgentMsgId, UserId) import Simplex.Messaging.Agent.Store.AgentStore (firstRow, firstRow', maybeFirstRow) import Simplex.Messaging.Agent.Store.DB (BoolInt (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF -import Simplex.Messaging.Crypto.Ratchet as CR -import Simplex.Messaging.Protocol (SubscriptionMode (..)) -import Simplex.Messaging.Version import System.FilePath (takeFileName) #if defined(dbPostgres) import Database.PostgreSQL.Simple (Only (..), (:.) (..)) @@ -126,91 +107,6 @@ import Database.SQLite.Simple.QQ (sql) import Database.SQLite.Simple.ToField (ToField) #endif -getLiveSndFileTransfers :: DB.Connection -> User -> IO [SndFileTransfer] -getLiveSndFileTransfers db User {userId} = do - cutoffTs <- addUTCTime (-week) <$> getCurrentTime - fileIds :: [Int64] <- - map fromOnly - <$> DB.query - db - [sql| - SELECT DISTINCT f.file_id - FROM files f - JOIN snd_files s USING (file_id) - WHERE f.user_id = ? - AND s.file_status IN (?, ?, ?) - AND s.file_descr_id IS NULL - AND s.file_inline IS NULL - AND s.created_at > ? - |] - (userId, FSNew, FSAccepted, FSConnected, cutoffTs) - concatMap (filter liveTransfer) . rights <$> mapM (getSndFileTransfers_ db userId) fileIds - where - liveTransfer :: SndFileTransfer -> Bool - liveTransfer SndFileTransfer {fileStatus} = fileStatus `elem` [FSNew, FSAccepted, FSConnected] - -getLiveRcvFileTransfers :: DB.Connection -> User -> IO [RcvFileTransfer] -getLiveRcvFileTransfers db user@User {userId} = do - cutoffTs <- addUTCTime (-week) <$> getCurrentTime - fileIds :: [Int64] <- - map fromOnly - <$> DB.query - db - [sql| - SELECT f.file_id - FROM files f - JOIN rcv_files r USING (file_id) - WHERE f.user_id = ? AND r.file_status IN (?, ?) - AND r.rcv_file_inline IS NULL - AND r.file_descr_id IS NULL - AND r.created_at > ? - |] - (userId, FSAccepted, FSConnected, cutoffTs) - rights <$> mapM (runExceptT . getRcvFileTransfer db user) fileIds - -getPendingSndChunks :: DB.Connection -> Int64 -> Int64 -> IO [Integer] -getPendingSndChunks db fileId connId = - map fromOnly - <$> DB.query - db - [sql| - SELECT chunk_number - FROM snd_file_chunks - WHERE file_id = ? AND connection_id = ? AND chunk_agent_msg_id IS NULL - ORDER BY chunk_number - |] - (fileId, connId) - -createSndDirectFTConnection :: DB.Connection -> VersionRangeChat -> User -> Int64 -> (CommandId, ConnId) -> SubscriptionMode -> IO () -createSndDirectFTConnection db vr user@User {userId} fileId (cmdId, acId) subMode = do - currentTs <- getCurrentTime - Connection {connId} <- createSndFileConnection_ db vr userId fileId acId subMode - setCommandConnId db user cmdId connId - DB.execute - db - "INSERT INTO snd_files (file_id, file_status, connection_id, created_at, updated_at) VALUES (?,?,?,?,?)" - (fileId, FSAccepted, connId, currentTs, currentTs) - -createSndGroupFileTransfer :: DB.Connection -> UserId -> GroupInfo -> FilePath -> FileInvitation -> Integer -> IO FileTransferMeta -createSndGroupFileTransfer db userId GroupInfo {groupId} filePath FileInvitation {fileName, fileSize, fileInline} chunkSize = do - currentTs <- getCurrentTime - DB.execute - db - "INSERT INTO files (user_id, group_id, file_name, file_path, file_size, chunk_size, file_inline, ci_file_status, protocol, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)" - ((userId, groupId, fileName, filePath, fileSize, chunkSize) :. (fileInline, CIFSSndStored, FPSMP, currentTs, currentTs)) - fileId <- insertedRowId db - pure FileTransferMeta {fileId, xftpSndFile = Nothing, xftpRedirectFor = Nothing, fileName, filePath, fileSize, fileInline, chunkSize, cancelled = False} - -createSndGroupFileTransferConnection :: DB.Connection -> VersionRangeChat -> User -> Int64 -> (CommandId, ConnId) -> GroupMember -> SubscriptionMode -> IO () -createSndGroupFileTransferConnection db vr user@User {userId} fileId (cmdId, acId) GroupMember {groupMemberId} subMode = do - currentTs <- getCurrentTime - Connection {connId} <- createSndFileConnection_ db vr userId fileId acId subMode - setCommandConnId db user cmdId connId - DB.execute - db - "INSERT INTO snd_files (file_id, file_status, connection_id, group_member_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" - (fileId, FSAccepted, connId, groupMemberId, currentTs, currentTs) - createSndDirectInlineFT :: DB.Connection -> Contact -> FileTransferMeta -> ExceptT StoreError IO SndFileTransfer createSndDirectInlineFT _ Contact {localDisplayName, activeConn = Nothing} _ = throwError $ SEContactNotReady localDisplayName createSndDirectInlineFT db Contact {localDisplayName = n, activeConn = Just Connection {connId, agentConnId}} FileTransferMeta {fileId, fileName, filePath, fileSize, chunkSize, fileInline} = liftIO $ do @@ -458,63 +354,11 @@ lookupChatRefByFileId db User {userId} fileId = |] (userId, fileId) --- TODO v6.0 remove -createSndFileConnection_ :: DB.Connection -> VersionRangeChat -> UserId -> Int64 -> ConnId -> SubscriptionMode -> IO Connection -createSndFileConnection_ db vr userId fileId agentConnId subMode = do - currentTs <- getCurrentTime - createConnection_ db userId ConnSndFile (Just fileId) agentConnId ConnNew (minVersion vr) chatInitialVRange Nothing Nothing Nothing 0 currentTs subMode CR.PQSupportOff - updateSndFileStatus :: DB.Connection -> SndFileTransfer -> FileStatus -> IO () updateSndFileStatus db SndFileTransfer {fileId, connId} status = do currentTs <- getCurrentTime DB.execute db "UPDATE snd_files SET file_status = ?, updated_at = ? WHERE file_id = ? AND connection_id = ?" (status, currentTs, fileId, connId) -createSndFileChunk :: DB.Connection -> SndFileTransfer -> IO (Maybe Integer) -createSndFileChunk db SndFileTransfer {fileId, connId, fileSize, chunkSize} = do - chunkNo <- getLastChunkNo - insertChunk chunkNo - pure chunkNo - where - getLastChunkNo = do - ns <- DB.query db "SELECT chunk_number FROM snd_file_chunks WHERE file_id = ? AND connection_id = ? AND chunk_sent = 1 ORDER BY chunk_number DESC LIMIT 1" (fileId, connId) - pure $ case map fromOnly ns of - [] -> Just 1 - n : _ -> if n * chunkSize >= fileSize then Nothing else Just (n + 1) - insertChunk chunkNo_ = forM_ chunkNo_ $ \chunkNo -> do - currentTs <- getCurrentTime - DB.execute - db - "INSERT OR REPLACE INTO snd_file_chunks (file_id, connection_id, chunk_number, created_at, updated_at) VALUES (?,?,?,?,?)" - (fileId, connId, chunkNo, currentTs, currentTs) - -updateSndFileChunkMsg :: DB.Connection -> SndFileTransfer -> Integer -> AgentMsgId -> IO () -updateSndFileChunkMsg db SndFileTransfer {fileId, connId} chunkNo msgId = do - currentTs <- getCurrentTime - DB.execute - db - [sql| - UPDATE snd_file_chunks - SET chunk_agent_msg_id = ?, updated_at = ? - WHERE file_id = ? AND connection_id = ? AND chunk_number = ? - |] - (msgId, currentTs, fileId, connId, chunkNo) - -updateSndFileChunkSent :: DB.Connection -> SndFileTransfer -> AgentMsgId -> IO () -updateSndFileChunkSent db SndFileTransfer {fileId, connId} msgId = do - currentTs <- getCurrentTime - DB.execute - db - [sql| - UPDATE snd_file_chunks - SET chunk_sent = 1, updated_at = ? - WHERE file_id = ? AND connection_id = ? AND chunk_agent_msg_id = ? - |] - (currentTs, fileId, connId, msgId) - -deleteSndFileChunks :: DB.Connection -> SndFileTransfer -> IO () -deleteSndFileChunks db SndFileTransfer {fileId, connId} = - DB.execute db "DELETE FROM snd_file_chunks WHERE file_id = ? AND connection_id = ?" (fileId, connId) - createRcvFileTransfer :: DB.Connection -> UserId -> Contact -> FileInvitation -> Maybe InlineFileMode -> Integer -> ExceptT StoreError IO RcvFileTransfer createRcvFileTransfer db userId Contact {contactId, localDisplayName = c} f@FileInvitation {fileName, fileSize, fileConnReq, fileInline, fileDescr} rcvFileInline chunkSize = do currentTs <- liftIO getCurrentTime @@ -684,11 +528,9 @@ getRcvFileTransfer_ db userId fileId = do SELECT r.file_status, r.file_queue_info, r.group_member_id, f.file_name, f.file_size, f.chunk_size, f.cancelled, cs.local_display_name, m.local_display_name, f.file_path, f.file_crypto_key, f.file_crypto_nonce, r.file_inline, r.rcv_file_inline, - r.agent_rcv_file_id, r.agent_rcv_file_deleted, r.user_approved_relays, - c.connection_id, c.agent_conn_id + r.agent_rcv_file_id, r.agent_rcv_file_deleted, r.user_approved_relays FROM rcv_files r JOIN files f USING (file_id) - LEFT JOIN connections c ON r.file_id = c.rcv_file_id LEFT JOIN contacts cs ON cs.contact_id = f.contact_id LEFT JOIN group_members m ON m.group_member_id = r.group_member_id WHERE f.user_id = ? AND f.file_id = ? @@ -699,54 +541,32 @@ getRcvFileTransfer_ db userId fileId = do where rcvFileTransfer :: Maybe RcvFileDescr -> - (FileStatus, Maybe ConnReqInvitation, Maybe Int64, String, Integer, Integer, Maybe BoolInt) :. (Maybe ContactName, Maybe ContactName, Maybe FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe InlineFileMode, Maybe InlineFileMode, Maybe AgentRcvFileId, BoolInt, BoolInt) :. (Maybe Int64, Maybe AgentConnId) -> + (FileStatus, Maybe ConnReqInvitation, Maybe Int64, String, Integer, Integer, Maybe BoolInt) :. (Maybe ContactName, Maybe ContactName, Maybe FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe InlineFileMode, Maybe InlineFileMode, Maybe AgentRcvFileId, BoolInt, BoolInt) -> ExceptT StoreError IO RcvFileTransfer - rcvFileTransfer rfd_ ((fileStatus', fileConnReq, grpMemberId, fileName, fileSize, chunkSize, cancelled_) :. (contactName_, memberName_, filePath_, fileKey, fileNonce, fileInline, rcvFileInline, agentRcvFileId, BI agentRcvFileDeleted, BI userApprovedRelays) :. (connId_, agentConnId_)) = + rcvFileTransfer rfd_ ((fileStatus', fileConnReq, grpMemberId, fileName, fileSize, chunkSize, cancelled_) :. (contactName_, memberName_, filePath_, fileKey, fileNonce, fileInline, rcvFileInline, agentRcvFileId, BI agentRcvFileDeleted, BI userApprovedRelays)) = case contactName_ <|> memberName_ <|> standaloneName_ of Nothing -> throwError $ SERcvFileInvalid fileId Just name -> case fileStatus' of FSNew -> pure $ ft name RFSNew - FSAccepted -> ft name . RFSAccepted <$> rfi - FSConnected -> ft name . RFSConnected <$> rfi - FSComplete -> ft name . RFSComplete <$> rfi - FSCancelled -> ft name . RFSCancelled <$> rfi_ + FSAccepted -> ft name . RFSAccepted <$> filePath + FSConnected -> ft name . RFSConnected <$> filePath + FSComplete -> ft name . RFSComplete <$> filePath + FSCancelled -> pure $ ft name (RFSCancelled filePath_) where - standaloneName_ = case (connId_, agentRcvFileId, filePath_) of - (Nothing, Just _, Just _) -> Just "" -- filePath marks files that are accepted from contact or, in this case, set by createRcvDirectFileTransfer + standaloneName_ = case (agentRcvFileId, filePath_) of + (Just _, Just _) -> Just "" -- filePath marks files that are accepted from contact or, in this case, set by createRcvDirectFileTransfer _ -> Nothing ft senderDisplayName fileStatus = let fileInvitation = FileInvitation {fileName, fileSize, fileDigest = Nothing, fileConnReq, fileInline, fileDescr = Nothing} cryptoArgs = CFArgs <$> fileKey <*> fileNonce xftpRcvFile = (\rfd -> XFTPRcvFile {rcvFileDescription = rfd, agentRcvFileId, agentRcvFileDeleted, userApprovedRelays}) <$> rfd_ in RcvFileTransfer {fileId, xftpRcvFile, fileInvitation, fileStatus, rcvFileInline, senderDisplayName, chunkSize, cancelled, grpMemberId, cryptoArgs} - rfi = maybe (throwError $ SERcvFileInvalid fileId) pure =<< rfi_ - rfi_ = case (filePath_, connId_, agentConnId_) of - (Just filePath, connId, agentConnId) -> pure $ Just RcvFileInfo {filePath, connId, agentConnId} - _ -> pure Nothing + filePath = case filePath_ of + Nothing -> throwError $ SERcvFileInvalid fileId + Just fp -> pure fp cancelled = maybe False unBI cancelled_ -acceptRcvFileTransfer :: DB.Connection -> VersionRangeChat -> User -> Int64 -> (CommandId, ConnId) -> ConnStatus -> FilePath -> SubscriptionMode -> ExceptT StoreError IO AChatItem -acceptRcvFileTransfer db vr user@User {userId} fileId (cmdId, acId) connStatus filePath subMode = ExceptT $ do - currentTs <- getCurrentTime - acceptRcvFT_ db user fileId filePath False Nothing currentTs - DB.execute - db - "INSERT INTO connections (agent_conn_id, conn_status, conn_type, rcv_file_id, user_id, created_at, updated_at, to_subscribe) VALUES (?,?,?,?,?,?,?,?)" - (acId, connStatus, ConnRcvFile, fileId, userId, currentTs, currentTs, BI (subMode == SMOnlyCreate)) - connId <- insertedRowId db - setCommandConnId db user cmdId connId - runExceptT $ getChatItemByFileId db vr user fileId - -getContactByFileId :: DB.Connection -> VersionRangeChat -> User -> FileTransferId -> ExceptT StoreError IO Contact -getContactByFileId db vr user@User {userId} fileId = do - cId <- getContactIdByFileId - getContact db vr user cId - where - getContactIdByFileId = - ExceptT . firstRow fromOnly (SEContactNotFoundByFileId fileId) $ - DB.query db "SELECT contact_id FROM files WHERE user_id = ? AND file_id = ?" (userId, fileId) - acceptRcvInlineFT :: DB.Connection -> VersionRangeChat -> User -> FileTransferId -> FilePath -> ExceptT StoreError IO AChatItem acceptRcvInlineFT db vr user fileId filePath = do liftIO $ acceptRcvFT_ db user fileId filePath False (Just IFMOffer) =<< getCurrentTime @@ -887,8 +707,7 @@ getFileTransferProgress db user fileId = do ft <- getFileTransfer db user fileId liftIO $ (ft,) . map fromOnly <$> case ft of - FTSnd _ [] -> pure [Only 0] - FTSnd _ _ -> DB.query db "SELECT COUNT(*) FROM snd_file_chunks WHERE file_id = ? and chunk_sent = 1 GROUP BY connection_id" (Only fileId) + FTSnd _ _ -> pure [Only 0] FTRcv _ -> DB.query db "SELECT COUNT(*) FROM rcv_file_chunks WHERE file_id = ? AND chunk_stored = 1" (Only fileId) getFileTransfer :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO FileTransfer @@ -1027,7 +846,7 @@ getLocalCryptoFile db userId fileId sent = when sent $ throwError $ SEFileNotFound fileId RcvFileTransfer {fileStatus, cryptoArgs} <- getRcvFileTransfer_ db userId fileId case fileStatus of - RFSComplete RcvFileInfo {filePath} -> pure $ CryptoFile filePath cryptoArgs + RFSComplete filePath -> pure $ CryptoFile filePath cryptoArgs _ -> throwError $ SEFileNotFound fileId [(Just _, Nothing, _)] -> do unless sent $ throwError $ SEFileNotFound fileId diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index f7b07daad7..00aa527603 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -18,7 +18,6 @@ module Simplex.Chat.Store.Groups GroupInfoRow, GroupMemberRow, MaybeGroupMemberRow, - GroupLink (..), toGroupInfo, toGroupMember, toMaybeGroupMember, @@ -64,16 +63,14 @@ module Simplex.Chat.Store.Groups getScopeMemberIdViaMemberId, getGroupMembers, getGroupModerators, + getGroupRelays, getGroupMembersForExpiration, getGroupCurrentMembersCount, deleteGroupChatItems, deleteGroupMembers, cleanupHostGroupLinkConn, deleteGroup, - getUserGroupsToSubscribe, - getUserGroupDetails, - getUserGroupsWithSummary, - getGroupSummary, + getBaseGroupDetails, getContactGroupPreferences, getGroupInvitation, createNewContactMember, @@ -101,7 +98,6 @@ module Simplex.Chat.Store.Groups updateGroupMemberRole, createIntroductions, updateIntroStatus, - saveIntroInvitation, getIntroduction, getIntroducedGroupMemberIds, getForwardIntroducedMembers, @@ -111,9 +107,6 @@ module Simplex.Chat.Store.Groups getForwardScopeMember, createIntroReMember, createIntroToMemberContact, - saveMemberInvitation, - getViaGroupMember, - getViaGroupContact, getMatchingContacts, getMatchingMembers, getMatchingMemberContacts, @@ -122,14 +115,12 @@ module Simplex.Chat.Store.Groups matchReceivedProbe, matchReceivedProbeHash, matchSentProbe, - mergeContactRecords, associateMemberWithContactRecord, associateContactWithMemberRecord, deleteOldProbes, updateGroupSettings, updateGroupMemberSettings, updateGroupMemberBlocked, - getXGrpMemIntroContDirect, getHostConnId, createMemberContact, getMemberContact, @@ -162,9 +153,7 @@ import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class import Crypto.Random (ChaChaDRG) -import qualified Data.Aeson.TH as J import Data.Bifunctor (second) -import Data.Bitraversable (bitraverse) import Data.Char (toLower) import Data.Either (rights) import Data.Int (Int64) @@ -172,10 +161,11 @@ import Data.List (partition, sortOn) import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing) import Data.Ord (Down (..)) import Data.Text (Text) +import qualified Data.Text as T import Data.Time.Clock (UTCTime (..), getCurrentTime) import Data.Text.Encoding (encodeUtf8) import Simplex.Chat.Messages -import Simplex.Chat.Protocol (MsgMention (..), groupForwardVersion) +import Simplex.Chat.Protocol hiding (Binary) import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Shared import Simplex.Chat.Types @@ -188,7 +178,6 @@ import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..)) 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.Parsers (defaultJSON) import Simplex.Messaging.Protocol (SubscriptionMode (..)) import Simplex.Messaging.Util (eitherToMaybe, firstRow', safeDecodeUtf8, ($>>), ($>>=), (<$$>)) import Simplex.Messaging.Version @@ -230,7 +219,7 @@ getGroupLinkConnection db vr User {userId} groupInfo@GroupInfo {groupId} = db [sql| SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.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.snd_file_id, c.rcv_file_id, c.user_contact_link_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 connections c @@ -280,16 +269,6 @@ deleteGroupLink db User {userId} GroupInfo {groupId} = do (userId, groupId) DB.execute db "DELETE FROM user_contact_links WHERE user_id = ? AND group_id = ?" (userId, groupId) -data GroupLink = GroupLink - { userContactLinkId :: Int64, - connLinkContact :: CreatedLinkContact, - shortLinkDataSet :: Bool, - shortLinkLargeDataSet :: BoolDef, - groupLinkId :: GroupLinkId, - acceptMemberRole :: GroupMemberRole - } - deriving (Show) - getGroupLink :: DB.Connection -> User -> GroupInfo -> ExceptT StoreError IO GroupLink getGroupLink db User {userId} gInfo@GroupInfo {groupId} = ExceptT . firstRow toGroupLink (SEGroupLinkNotFound gInfo) $ @@ -359,6 +338,7 @@ createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = Exc pure GroupInfo { groupId, + useRelays = BoolDef False, localDisplayName = ldn, groupProfile, localAlias = "", @@ -374,6 +354,7 @@ createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = Exc chatTags = [], chatItemTTL = Nothing, uiThemes = Nothing, + groupSummary = GroupSummary 1, customData = Nothing, membersRequireAttention = 0, viaGroupLinkUri = Nothing @@ -432,6 +413,7 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ pure ( GroupInfo { groupId, + useRelays = BoolDef False, localDisplayName, groupProfile, localAlias = "", @@ -447,6 +429,7 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ chatTags = [], chatItemTTL = Nothing, uiThemes = Nothing, + groupSummary = GroupSummary 2, customData = Nothing, membersRequireAttention = 0, viaGroupLinkUri = Nothing @@ -804,51 +787,6 @@ getGroup db vr user groupId = do members <- liftIO $ getGroupMembers db vr user gInfo pure $ Group gInfo members -getGroupToSubscribe :: DB.Connection -> User -> GroupId -> ExceptT StoreError IO ShortGroup -getGroupToSubscribe db User {userId, userContactId} groupId = do - shortInfo <- getGroupInfoToSubscribe - members <- liftIO getGroupMembersToSubscribe - pure $ ShortGroup shortInfo members - where - getGroupInfoToSubscribe :: ExceptT StoreError IO ShortGroupInfo - getGroupInfoToSubscribe = ExceptT $ do - firstRow toInfo (SEGroupNotFound groupId) $ - DB.query - db - [sql| - SELECT g.local_display_name, mu.member_status - FROM groups g - JOIN group_members mu ON mu.group_id = g.group_id - WHERE g.group_id = ? AND g.user_id = ? AND mu.contact_id = ? - AND mu.member_status NOT IN (?,?,?) - |] - (groupId, userId, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted) - where - toInfo :: (GroupName, GroupMemberStatus) -> ShortGroupInfo - toInfo (groupName, membershipStatus) = - ShortGroupInfo groupId groupName membershipStatus - getGroupMembersToSubscribe :: IO [ShortGroupMember] - getGroupMembersToSubscribe = do - map toShortMember - <$> DB.query - db - [sql| - SELECT m.group_member_id, m.local_display_name, c.agent_conn_id - FROM group_members m - JOIN connections c ON c.connection_id = ( - SELECT max(cc.connection_id) - FROM connections cc - WHERE cc.user_id = ? AND cc.group_member_id = m.group_member_id - ) - WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) - AND m.member_status NOT IN (?,?,?) - |] - (userId, userId, groupId, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted) - where - toShortMember :: (GroupMemberId, ContactName, AgentConnId) -> ShortGroupMember - toShortMember (groupMemberId, localDisplayName, agentConnId) = - ShortGroupMember groupMemberId groupId localDisplayName agentConnId - deleteGroupChatItems :: DB.Connection -> User -> GroupInfo -> IO () deleteGroupChatItems db User {userId} GroupInfo {groupId} = DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND group_id = ?" (userId, groupId) @@ -940,67 +878,21 @@ deleteGroupProfile_ db userId groupId = |] (userId, groupId) -getUserGroupsToSubscribe :: DB.Connection -> User -> IO [ShortGroup] -getUserGroupsToSubscribe db user@User {userId} = do - groupIds <- map fromOnly <$> DB.query db "SELECT group_id FROM groups WHERE user_id = ?" (Only userId) - rights <$> mapM (runExceptT . getGroupToSubscribe db user) groupIds - -getUserGroupDetails :: DB.Connection -> VersionRangeChat -> User -> Maybe ContactId -> Maybe String -> IO [GroupInfo] -getUserGroupDetails db vr User {userId, userContactId} _contactId_ search_ = do - g_ <- - map (toGroupInfo vr userContactId []) - <$> DB.query - db - [sql| - SELECT - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, - g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, - g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, - g.business_chat, g.business_member_id, g.customer_member_id, - g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, - mu.group_member_id, g.group_id, 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, - 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 groups g - JOIN group_profiles gp USING (group_profile_id) - JOIN group_members mu USING (group_id) - JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) - WHERE g.user_id = ? AND mu.contact_id = ? - AND (LOWER(gp.display_name) LIKE '%' || ? || '%' - OR LOWER(gp.full_name) LIKE '%' || ? || '%' - OR LOWER(gp.short_descr) LIKE '%' || ? || '%' - OR LOWER(gp.description) LIKE '%' || ? || '%' - ) - |] - (userId, userContactId, search, search, search, search) - mapM (addGroupChatTags db) g_ +getBaseGroupDetails :: DB.Connection -> VersionRangeChat -> User -> Maybe ContactId -> Maybe Text -> IO [GroupInfo] +getBaseGroupDetails db vr User {userId, userContactId} _contactId_ search_ = do + map (toGroupInfo vr userContactId []) + <$> DB.query db (groupInfoQuery <> " " <> condition) (userId, userContactId, search, search, search, search) where - search = maybe "" (map toLower) search_ - -getUserGroupsWithSummary :: DB.Connection -> VersionRangeChat -> User -> Maybe ContactId -> Maybe String -> IO [GroupInfoSummary] -getUserGroupsWithSummary db vr user _contactId_ search_ = - getUserGroupDetails db vr user _contactId_ search_ - >>= mapM (\g@GroupInfo {groupId} -> GIS g <$> getGroupSummary db user groupId) - --- the statuses on non-current members should match memberCurrent' function -getGroupSummary :: DB.Connection -> User -> GroupId -> IO GroupSummary -getGroupSummary db User {userId} groupId = do - currentMembers_ <- - maybeFirstRow fromOnly $ - DB.query - db - [sql| - SELECT count (m.group_member_id) - FROM groups g - JOIN group_members m USING (group_id) - WHERE g.user_id = ? - AND g.group_id = ? - AND m.member_status NOT IN (?,?,?,?,?) - |] - (userId, groupId, GSMemRejected, GSMemRemoved, GSMemLeft, GSMemUnknown, GSMemInvited) - pure GroupSummary {currentMembers = fromMaybe 0 currentMembers_} + condition = + [sql| + WHERE g.user_id = ? AND mu.contact_id = ? + AND (LOWER(gp.display_name) LIKE '%' || ? || '%' + OR LOWER(gp.full_name) LIKE '%' || ? || '%' + OR LOWER(gp.short_descr) LIKE '%' || ? || '%' + OR LOWER(gp.description) LIKE '%' || ? || '%' + ) + |] + search = maybe "" (T.map toLower) search_ getContactGroupPreferences :: DB.Connection -> User -> Contact -> IO [(GroupMemberRole, FullGroupPreferences)] getContactGroupPreferences db User {userId} Contact {contactId} = do @@ -1021,42 +913,21 @@ getGroupInfoByName db vr user gName = do gId <- getGroupIdByName db user gName getGroupInfo db vr user gId -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.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.snd_file_id, c.rcv_file_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.connection_id = ( - SELECT max(cc.connection_id) - FROM connections cc - WHERE cc.user_id = ? AND cc.group_member_id = m.group_member_id - ) - |] - getGroupMember :: DB.Connection -> VersionRangeChat -> User -> GroupId -> GroupMemberId -> ExceptT StoreError IO GroupMember getGroupMember db vr user@User {userId} groupId groupMemberId = ExceptT . firstRow (toContactMember vr user) (SEGroupMemberNotFound groupMemberId) $ DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.group_member_id = ? AND m.user_id = ?") - (userId, groupId, groupMemberId, userId) + (groupId, groupMemberId, userId) getHostMember :: DB.Connection -> VersionRangeChat -> User -> GroupId -> ExceptT StoreError IO GroupMember -getHostMember db vr user@User {userId} groupId = +getHostMember db vr user groupId = ExceptT . firstRow (toContactMember vr user) (SEGroupHostMemberNotFound groupId) $ DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.member_category = ?") - (userId, groupId, GCHostMember) + (groupId, GCHostMember) getMentionedGroupMember :: DB.Connection -> User -> GroupId -> GroupMemberId -> ExceptT StoreError IO CIMention getMentionedGroupMember db User {userId} groupId gmId = @@ -1097,15 +968,15 @@ getGroupMemberById db vr user@User {userId} groupMemberId = DB.query db (groupMemberQuery <> " WHERE m.group_member_id = ? AND m.user_id = ?") - (userId, groupMemberId, userId) + (groupMemberId, userId) getGroupMemberByMemberId :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> MemberId -> ExceptT StoreError IO GroupMember -getGroupMemberByMemberId db vr user@User {userId} GroupInfo {groupId} memberId = +getGroupMemberByMemberId db vr user GroupInfo {groupId} memberId = ExceptT . firstRow (toContactMember vr user) (SEGroupMemberNotFoundByMemberId memberId) $ DB.query db (groupMemberQuery <> " WHERE m.group_id = ? AND m.member_id = ?") - (userId, groupId, memberId) + (groupId, memberId) getScopeMemberIdViaMemberId :: DB.Connection -> User -> GroupInfo -> GroupMember -> MemberId -> ExceptT StoreError IO GroupMemberId getScopeMemberIdViaMemberId db user g@GroupInfo {membership} sender scopeMemberId @@ -1127,7 +998,7 @@ getGroupMembers db vr user@User {userId, userContactId} GroupInfo {groupId} = do <$> DB.query db (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?)") - (userId, userId, groupId, userContactId) + (userId, groupId, userContactId) getGroupModerators :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember] getGroupModerators db vr user@User {userId, userContactId} GroupInfo {groupId} = do @@ -1135,7 +1006,16 @@ getGroupModerators db vr user@User {userId, userContactId} GroupInfo {groupId} = <$> DB.query db (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?,?)") - (userId, userId, groupId, userContactId, GRModerator, GRAdmin, GROwner) + (userId, groupId, userContactId, GRModerator, GRAdmin, GROwner) + +-- TODO [channels fwd] retrieve relays based on knowledge about member from protocol, not role (isMemberRelay) +getGroupRelays :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember] +getGroupRelays db vr user@User {userId, userContactId} GroupInfo {groupId} = do + map (toContactMember vr user) + <$> DB.query + db + (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND m.contact_id IS DISTINCT FROM ? AND m.member_role = ?") + (userId, groupId, userContactId, GRAdmin) getGroupMembersForExpiration :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember] getGroupMembersForExpiration db vr user@User {userId, userContactId} GroupInfo {groupId} = do @@ -1143,6 +1023,7 @@ getGroupMembersForExpiration db vr user@User {userId, userContactId} GroupInfo { <$> DB.query db ( groupMemberQuery + <> " " <> [sql| WHERE m.group_id = ? AND m.user_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_status IN (?, ?, ?, ?) @@ -1151,11 +1032,7 @@ getGroupMembersForExpiration db vr user@User {userId, userContactId} GroupInfo { ) |] ) - (userId, groupId, userId, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted, GSMemUnknown) - -toContactMember :: VersionRangeChat -> User -> (GroupMemberRow :. MaybeConnectionRow) -> GroupMember -toContactMember vr User {userContactId} (memberRow :. connRow) = - (toGroupMember userContactId memberRow) {activeConn = toMaybeConnection vr connRow} + (groupId, userId, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted, GSMemUnknown) getGroupCurrentMembersCount :: DB.Connection -> User -> GroupInfo -> IO Int getGroupCurrentMembersCount db User {userId} GroupInfo {groupId} = do @@ -1495,13 +1372,21 @@ decreaseGroupMembersRequireAttention :: DB.Connection -> User -> GroupInfo -> IO decreaseGroupMembersRequireAttention db User {userId} g@GroupInfo {groupId, membersRequireAttention} = do DB.execute db +#if defined(dbPostgres) [sql| UPDATE groups - SET members_require_attention = members_require_attention - 1 + SET members_require_attention = GREATEST(0, members_require_attention - 1) WHERE user_id = ? AND group_id = ? |] +#else + [sql| + UPDATE groups + SET members_require_attention = MAX(0, members_require_attention - 1) + WHERE user_id = ? AND group_id = ? + |] +#endif (userId, groupId) - pure g {membersRequireAttention = membersRequireAttention - 1} + pure g {membersRequireAttention = max 0 (membersRequireAttention - 1)} increaseGroupMembersRequireAttention :: DB.Connection -> User -> GroupInfo -> IO GroupInfo increaseGroupMembersRequireAttention db User {userId} g@GroupInfo {groupId, membersRequireAttention} = do @@ -1656,7 +1541,7 @@ createIntroductions db chatV members toMember = do |] (groupMemberId' reMember, groupMemberId' toMember, GMIntroPending, chatV, ts, ts) introId <- insertedRowId db - pure $ Just GroupMemberIntro {introId, reMember, toMember, introStatus = GMIntroPending, introInvitation = Nothing} + pure $ Just GroupMemberIntro {introId, reMember, toMember, introStatus = GMIntroPending} where checkInverseIntro :: IO (Maybe Int64) checkInverseIntro = @@ -1678,56 +1563,21 @@ updateIntroStatus db introId introStatus = do |] (introStatus, currentTs, introId) -saveIntroInvitation :: DB.Connection -> GroupMember -> GroupMember -> IntroInvitation -> ExceptT StoreError IO GroupMemberIntro -saveIntroInvitation db reMember toMember introInv@IntroInvitation {groupConnReq} = do - intro <- getIntroduction db reMember toMember - liftIO $ do - currentTs <- getCurrentTime - DB.execute - db - [sql| - UPDATE group_member_intros - SET intro_status = ?, - group_queue_info = ?, - direct_queue_info = ?, - updated_at = ? - WHERE group_member_intro_id = ? - |] - (GMIntroInvReceived, groupConnReq, directConnReq introInv, currentTs, introId intro) - pure intro {introInvitation = Just introInv, introStatus = GMIntroInvReceived} - -saveMemberInvitation :: DB.Connection -> GroupMember -> IntroInvitation -> GroupMemberStatus -> IO () -saveMemberInvitation db GroupMember {groupMemberId} IntroInvitation {groupConnReq, directConnReq} newMemberStatus = do - currentTs <- getCurrentTime - DB.execute - db - [sql| - UPDATE group_members - SET member_status = ?, - group_queue_info = ?, - direct_queue_info = ?, - updated_at = ? - WHERE group_member_id = ? - |] - (newMemberStatus, groupConnReq, directConnReq, currentTs, groupMemberId) - getIntroduction :: DB.Connection -> GroupMember -> GroupMember -> ExceptT StoreError IO GroupMemberIntro -getIntroduction db reMember toMember = ExceptT $ do - toIntro - <$> DB.query +getIntroduction db reMember toMember = ExceptT $ + firstRow toIntro SEIntroNotFound $ + DB.query db [sql| - SELECT group_member_intro_id, group_queue_info, direct_queue_info, intro_status + SELECT group_member_intro_id, intro_status FROM group_member_intros WHERE re_group_member_id = ? AND to_group_member_id = ? |] (groupMemberId' reMember, groupMemberId' toMember) where - toIntro :: [(Int64, Maybe ConnReqInvitation, Maybe ConnReqInvitation, GroupMemberIntroStatus)] -> Either StoreError GroupMemberIntro - toIntro [(introId, groupConnReq, directConnReq, introStatus)] = - let introInvitation = IntroInvitation <$> groupConnReq <*> pure directConnReq - in Right GroupMemberIntro {introId, reMember, toMember, introStatus, introInvitation} - toIntro _ = Left SEIntroNotFound + toIntro :: (Int64, GroupMemberIntroStatus) -> GroupMemberIntro + toIntro (introId, introStatus) = + GroupMemberIntro {introId, reMember, toMember, introStatus} getIntroducedGroupMemberIds :: DB.Connection -> GroupMember -> IO [GroupMemberId] getIntroducedGroupMemberIds db invitee = @@ -1877,8 +1727,8 @@ createIntroToMemberContact db user@User {userId} GroupMember {memberContactId = DB.execute db [sql| - INSERT INTO contacts (contact_profile_id, via_group, local_display_name, user_id, created_at, updated_at, chat_ts) - SELECT contact_profile_id, group_id, ?, ?, ?, ?, ? + INSERT INTO contacts (contact_profile_id local_display_name, user_id, created_at, updated_at, chat_ts) + SELECT contact_profile_id, ?, ?, ?, ?, ? FROM group_members WHERE group_member_id = ? |] @@ -1901,77 +1751,6 @@ createMemberConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> Version createMemberConnection_ db userId groupMemberId agentConnId chatV peerChatVRange viaContact connLevel currentTs subMode = createConnection_ db userId ConnMember (Just groupMemberId) agentConnId ConnNew chatV peerChatVRange viaContact Nothing Nothing connLevel currentTs subMode PQSupportOff -getViaGroupMember :: DB.Connection -> VersionRangeChat -> User -> Contact -> IO (Maybe (GroupInfo, GroupMember)) -getViaGroupMember db vr User {userId, userContactId} Contact {contactId} = do - gm_ <- - maybeFirstRow toGroupAndMember $ - DB.query - db - [sql| - SELECT - -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, - g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, - g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, - g.business_chat, g.business_member_id, g.customer_member_id, - g.ui_themes, 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.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, - -- via 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.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.snd_file_id, c.rcv_file_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 contacts ct ON ct.contact_id = m.contact_id - JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) - JOIN groups g ON g.group_id = m.group_id AND g.group_id = ct.via_group - JOIN group_profiles gp USING (group_profile_id) - JOIN group_members mu ON g.group_id = mu.group_id - JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) - LEFT JOIN connections c ON c.connection_id = ( - SELECT max(cc.connection_id) - FROM connections cc - where cc.user_id = ? AND cc.group_member_id = m.group_member_id - ) - WHERE ct.user_id = ? AND ct.contact_id = ? AND mu.contact_id = ? AND ct.deleted = 0 - |] - (userId, userId, contactId, userContactId) - mapM (bitraverse (addGroupChatTags db) pure) gm_ - where - toGroupAndMember :: (GroupInfoRow :. GroupMemberRow :. MaybeConnectionRow) -> (GroupInfo, GroupMember) - toGroupAndMember (groupInfoRow :. memberRow :. connRow) = - let groupInfo = toGroupInfo vr userContactId [] groupInfoRow - member = toGroupMember userContactId memberRow - in (groupInfo, (member :: GroupMember) {activeConn = toMaybeConnection vr connRow}) - -getViaGroupContact :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> IO (Maybe Contact) -getViaGroupContact db vr user@User {userId} GroupMember {groupMemberId} = do - contactId_ <- - maybeFirstRow fromOnly $ - DB.query - db - [sql| - SELECT ct.contact_id - FROM group_members m - JOIN groups g ON g.group_id = m.group_id - JOIN contacts ct ON ct.contact_id = m.contact_id AND ct.via_group = g.group_id - WHERE m.user_id = ? AND m.group_member_id = ? AND ct.deleted = 0 - LIMIT 1 - |] - (userId, groupMemberId) - maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getContact db vr user) contactId_ - updateGroupProfile :: DB.Connection -> User -> GroupInfo -> GroupProfile -> ExceptT StoreError IO GroupInfo updateGroupProfile db user@User {userId} g@GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName}} p'@GroupProfile {displayName = newName, fullName, shortDescr, description, image, groupPreferences, memberAdmission} | displayName == newName = liftIO $ do @@ -2298,69 +2077,6 @@ getContactOrMember_ db vr user ids = (_, Just gId, Just gmId) -> COMGroupMember <$> getGroupMember db vr user gId gmId _ -> throwError $ SEInternalError "" --- if requested merge direction is overruled (toFromContacts), keepLDN is kept -mergeContactRecords :: DB.Connection -> VersionRangeChat -> User -> Contact -> Contact -> ExceptT StoreError IO Contact -mergeContactRecords db vr user@User {userId} to@Contact {localDisplayName = keepLDN} from = do - let (toCt, fromCt) = toFromContacts to from - Contact {contactId = toContactId, localDisplayName = toLDN} = toCt - Contact {contactId = fromContactId, localDisplayName = fromLDN} = fromCt - assertNotUser db user toCt - assertNotUser db user fromCt - liftIO $ do - currentTs <- getCurrentTime - DB.execute - db - "UPDATE connections SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ?" - (toContactId, currentTs, fromContactId, userId) - DB.execute - db - "UPDATE connections SET via_contact = ?, updated_at = ? WHERE via_contact = ? AND user_id = ?" - (toContactId, currentTs, fromContactId, userId) - DB.execute - db - "UPDATE group_members SET invited_by = ?, updated_at = ? WHERE invited_by = ? AND user_id = ?" - (toContactId, currentTs, fromContactId, userId) - DB.execute - db - "UPDATE chat_items SET contact_id = ?, updated_at = ? WHERE contact_id = ? AND user_id = ?" - (toContactId, currentTs, fromContactId, userId) - DB.execute - db - [sql| - UPDATE group_members - SET contact_id = ?, - local_display_name = (SELECT local_display_name FROM contacts WHERE contact_id = ?), - contact_profile_id = (SELECT contact_profile_id FROM contacts WHERE contact_id = ?), - updated_at = ? - WHERE contact_id = ? - AND user_id = ? - |] - (toContactId, toContactId, toContactId, currentTs, fromContactId, userId) - deleteContactProfile_ db userId fromContactId - DB.execute db "DELETE FROM contacts WHERE contact_id = ? AND user_id = ?" (fromContactId, userId) - deleteUnusedDisplayName_ db userId fromLDN - when (keepLDN /= toLDN && keepLDN == fromLDN) $ - DB.execute - db - [sql| - UPDATE display_names - SET local_display_name = ?, updated_at = ? - WHERE user_id = ? AND local_display_name = ? - |] - (keepLDN, currentTs, userId, toLDN) - getContact db vr user toContactId - where - toFromContacts :: Contact -> Contact -> (Contact, Contact) - toFromContacts c1 c2 - | d1 && not d2 = (c1, c2) - | d2 && not d1 = (c2, c1) - | ctCreatedAt c1 <= ctCreatedAt c2 = (c1, c2) - | otherwise = (c2, c1) - where - d1 = directOrUsed c1 - d2 = directOrUsed c2 - ctCreatedAt Contact {createdAt} = createdAt - associateMemberWithContactRecord :: DB.Connection -> User -> Contact -> GroupMember -> IO () associateMemberWithContactRecord db @@ -2484,36 +2200,6 @@ updateGroupMemberBlocked db User {userId} GroupInfo {groupId} mrs m@GroupMember (mrs, currentTs, userId, groupId, groupMemberId) pure m {blockedByAdmin = mrsBlocked mrs} -getXGrpMemIntroContDirect :: DB.Connection -> User -> Contact -> IO (Maybe (Int64, XGrpMemIntroCont)) -getXGrpMemIntroContDirect db User {userId} Contact {contactId} = do - fmap join . maybeFirstRow toCont $ - DB.query - db - [sql| - SELECT ch.connection_id, g.group_id, m.group_member_id, m.member_id, c.conn_req_inv - FROM contacts ct - JOIN group_members m ON m.contact_id = ct.contact_id - LEFT JOIN connections c ON c.connection_id = ( - SELECT MAX(cc.connection_id) - FROM connections cc - WHERE cc.group_member_id = m.group_member_id - ) - JOIN groups g ON g.group_id = m.group_id AND g.group_id = ct.via_group - JOIN group_members mh ON mh.group_id = g.group_id - LEFT JOIN connections ch ON ch.connection_id = ( - SELECT max(cc.connection_id) - FROM connections cc - where cc.user_id = ? AND cc.group_member_id = mh.group_member_id - ) - WHERE ct.user_id = ? AND ct.contact_id = ? AND ct.deleted = 0 AND mh.member_category = ? - |] - (userId, userId, contactId, GCHostMember) - where - toCont :: (Int64, GroupId, GroupMemberId, MemberId, Maybe ConnReqInvitation) -> Maybe (Int64, XGrpMemIntroCont) - toCont (hostConnId, groupId, groupMemberId, memberId, connReq_) = case connReq_ of - Just groupConnReq -> Just (hostConnId, XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq}) - _ -> Nothing - getHostConnId :: DB.Connection -> User -> GroupId -> ExceptT StoreError IO GroupMemberId getHostConnId db user@User {userId} groupId = do hostMemberId <- getHostMemberId_ db user groupId @@ -2590,7 +2276,7 @@ createMemberContact quotaErrCounter = 0 } mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito ctConn - pure Contact {contactId, localDisplayName, profile = memberProfile, activeConn = Just ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, preparedContact = Nothing, contactRequestId = Nothing, contactGroupMemberId = Just groupMemberId, contactGrpInvSent = False, groupDirectInv = Nothing, chatTags = [], chatItemTTL = Nothing, uiThemes = Nothing, chatDeleted = False, customData = Nothing} + pure Contact {contactId, localDisplayName, profile = memberProfile, activeConn = Just ctConn, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, preparedContact = Nothing, contactRequestId = Nothing, contactGroupMemberId = Just groupMemberId, contactGrpInvSent = False, groupDirectInv = Nothing, chatTags = [], chatItemTTL = Nothing, uiThemes = Nothing, chatDeleted = False, customData = Nothing} getMemberContact :: DB.Connection -> VersionRangeChat -> User -> ContactId -> ExceptT StoreError IO (GroupInfo, GroupMember, Contact, ConnReqInvitation) getMemberContact db vr user contactId = do @@ -2895,5 +2581,3 @@ updateGroupAlias db userId g@GroupInfo {groupId} localAlias = do updatedAt <- getCurrentTime DB.execute db "UPDATE groups SET local_alias = ?, updated_at = ? WHERE user_id = ? AND group_id = ?" (localAlias, updatedAt, userId, groupId) pure (g :: GroupInfo) {localAlias = localAlias} - -$(J.deriveJSON defaultJSON ''GroupLink) diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index d1c147d43c..47aabd16ee 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -38,6 +38,7 @@ module Simplex.Chat.Store.Messages MemberAttention (..), updateChatTsStats, setSupportChatTs, + setSupportChatMemberAttention, createNewSndChatItem, createNewRcvChatItem, createNewChatItemNoMsg, @@ -79,6 +80,7 @@ module Simplex.Chat.Store.Messages setDirectChatItemRead, setDirectChatItemsDeleteAt, updateGroupChatItemsRead, + updateSupportChatItemsRead, getGroupUnreadTimedItems, updateGroupChatItemsReadList, updateGroupScopeUnreadStats, @@ -284,7 +286,7 @@ getLastRcvMsgInfo db connId = RcvMsgInfo {msgId, msgDeliveryId, msgDeliveryStatus, agentMsgId, agentMsgMeta} createNewRcvMessage :: forall e. MsgEncodingI e => DB.Connection -> ConnOrGroupId -> NewRcvMessage e -> Maybe SharedMsgId -> Maybe GroupMemberId -> Maybe GroupMemberId -> ExceptT StoreError IO RcvMessage -createNewRcvMessage db connOrGroupId NewRcvMessage {chatMsgEvent, msgBody} sharedMsgId_ authorMember forwardedByMember = +createNewRcvMessage db connOrGroupId NewRcvMessage {chatMsgEvent, msgBody, brokerTs} sharedMsgId_ authorMember forwardedByMember = case connOrGroupId of ConnectionId connId -> liftIO $ insertRcvMsg (Just connId) Nothing GroupId groupId -> case sharedMsgId_ of @@ -312,10 +314,12 @@ createNewRcvMessage db connOrGroupId NewRcvMessage {chatMsgEvent, msgBody} share db [sql| INSERT INTO messages - (msg_sent, chat_msg_event, msg_body, created_at, updated_at, connection_id, group_id, shared_msg_id, author_group_member_id, forwarded_by_group_member_id) - VALUES (?,?,?,?,?,?,?,?,?,?) + (msg_sent, chat_msg_event, msg_body, broker_ts, created_at, updated_at, connection_id, group_id, + shared_msg_id, author_group_member_id, forwarded_by_group_member_id) + VALUES (?,?,?,?,?,?,?,?,?,?,?) |] - (MDRcv, toCMEventTag chatMsgEvent, DB.Binary msgBody, currentTs, currentTs, connId_, groupId_, sharedMsgId_, authorMember, forwardedByMember) + ((MDRcv, toCMEventTag chatMsgEvent, DB.Binary msgBody, brokerTs, currentTs, currentTs, connId_, groupId_) + :. (sharedMsgId_, authorMember, forwardedByMember)) msgId <- insertedRowId db pure RcvMessage {msgId, chatMsgEvent = ACME (encoding @e) chatMsgEvent, sharedMsgId_, msgBody, authorMember, forwardedByMember} @@ -423,14 +427,23 @@ updateChatTsStats db vr user@User {userId} chatDirection chatTs chatStats_ = cas | not nowRequires && didRequire -> do DB.execute db +#if defined(dbPostgres) [sql| UPDATE groups SET chat_ts = ?, - members_require_attention = members_require_attention - 1 + members_require_attention = GREATEST(0, members_require_attention - 1) WHERE user_id = ? AND group_id = ? |] +#else + [sql| + UPDATE groups + SET chat_ts = ?, + members_require_attention = MAX(0, members_require_attention - 1) + WHERE user_id = ? AND group_id = ? + |] +#endif (chatTs, userId, groupId) - pure $ GroupChat g {membersRequireAttention = membersRequireAttention - 1, chatTs = Just chatTs} (Just $ GCSIMemberSupport (Just member')) + pure $ GroupChat g {membersRequireAttention = max 0 (membersRequireAttention - 1), chatTs = Just chatTs} (Just $ GCSIMemberSupport (Just member')) | otherwise -> do DB.execute db @@ -496,6 +509,21 @@ setSupportChatTs :: DB.Connection -> GroupMemberId -> UTCTime -> IO () setSupportChatTs db groupMemberId chatTs = DB.execute db "UPDATE group_members SET support_chat_ts = ? WHERE group_member_id = ?" (chatTs, groupMemberId) +setSupportChatMemberAttention :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupMember -> Int64 -> IO (GroupInfo, GroupMember) +setSupportChatMemberAttention db vr user g m memberAttention = do + m' <- updateGMAttention + g' <- updateGroupMembersRequireAttention db user g m m' + pure (g', m') + where + updateGMAttention = do + currentTs <- getCurrentTime + DB.execute + db + "UPDATE group_members SET support_chat_items_member_attention = ?, updated_at = ? WHERE group_member_id = ?" + (memberAttention, currentTs, groupMemberId' m) + m_ <- runExceptT $ getGroupMemberById db vr user (groupMemberId' m) + pure $ either (const m) id m_ -- Left shouldn't happen, but types require it + createNewSndChatItem :: DB.Connection -> User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> Maybe (CIQuote c) -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> UTCTime -> IO ChatItemId createNewSndChatItem db user chatDirection SndMessage {msgId, sharedMsgId} ciContent quotedItem itemForwarded timed live createdAt = createNewChatItem_ db user chatDirection False createdByMsgId (Just sharedMsgId) ciContent quoteRow itemForwarded timed live False createdAt Nothing createdAt @@ -1139,7 +1167,7 @@ checkContactHasItems db User {userId} Contact {contactId} = "SELECT EXISTS (SELECT 1 FROM chat_items WHERE user_id = ? AND contact_id = ?)" (userId, contactId) -getDirectChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTDirect, Maybe NavigationInfo) +getDirectChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ChatPagination -> Maybe Text -> ExceptT StoreError IO (Chat 'CTDirect, Maybe NavigationInfo) getDirectChat db vr user contactId pagination search_ = do let search = fromMaybe "" search_ ct <- getContact db vr user contactId @@ -1149,18 +1177,18 @@ getDirectChat db vr user contactId pagination search_ = do CPBefore beforeId count -> (,Nothing) <$> getDirectChatBefore_ db user ct beforeId count search CPAround aroundId count -> getDirectChatAround_ db user ct aroundId count search CPInitial count -> do - unless (null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search" + unless (T.null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search" getDirectChatInitial_ db user ct count -- the last items in reverse order (the last item in the conversation is the first in the returned list) -getDirectChatLast_ :: DB.Connection -> User -> Contact -> Int -> String -> IO (Chat 'CTDirect) +getDirectChatLast_ :: DB.Connection -> User -> Contact -> Int -> Text -> IO (Chat 'CTDirect) getDirectChatLast_ db user ct count search = do ciIds <- getDirectChatItemIdsLast_ db user ct count search ts <- getCurrentTime cis <- mapM (safeGetDirectItem db user ct ts) ciIds pure $ Chat (DirectChat ct) (reverse cis) emptyChatStats -getDirectChatItemIdsLast_ :: DB.Connection -> User -> Contact -> Int -> String -> IO [ChatItemId] +getDirectChatItemIdsLast_ :: DB.Connection -> User -> Contact -> Int -> Text -> IO [ChatItemId] getDirectChatItemIdsLast_ db User {userId} Contact {contactId} count search = map fromOnly <$> DB.query @@ -1217,7 +1245,7 @@ getDirectChatItemLast db user@User {userId} contactId = do (userId, contactId) getDirectChatItem db user contactId chatItemId -getDirectChatAfter_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect) +getDirectChatAfter_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> Text -> ExceptT StoreError IO (Chat 'CTDirect) getDirectChatAfter_ db user ct@Contact {contactId} afterId count search = do afterCI <- getDirectChatItem db user contactId afterId ciIds <- liftIO $ getDirectCIsAfter_ db user ct afterCI count search @@ -1225,7 +1253,7 @@ getDirectChatAfter_ db user ct@Contact {contactId} afterId count search = do cis <- liftIO $ mapM (safeGetDirectItem db user ct ts) ciIds pure $ Chat (DirectChat ct) cis emptyChatStats -getDirectCIsAfter_ :: DB.Connection -> User -> Contact -> CChatItem 'CTDirect -> Int -> String -> IO [ChatItemId] +getDirectCIsAfter_ :: DB.Connection -> User -> Contact -> CChatItem 'CTDirect -> Int -> Text -> IO [ChatItemId] getDirectCIsAfter_ db User {userId} Contact {contactId} afterCI count search = map fromOnly <$> DB.query @@ -1240,7 +1268,7 @@ getDirectCIsAfter_ db User {userId} Contact {contactId} afterCI count search = |] (userId, contactId, search, ciCreatedAt afterCI, ciCreatedAt afterCI, cChatItemId afterCI, count) -getDirectChatBefore_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect) +getDirectChatBefore_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> Text -> ExceptT StoreError IO (Chat 'CTDirect) getDirectChatBefore_ db user ct@Contact {contactId} beforeId count search = do beforeCI <- getDirectChatItem db user contactId beforeId ciIds <- liftIO $ getDirectCIsBefore_ db user ct beforeCI count search @@ -1248,7 +1276,7 @@ getDirectChatBefore_ db user ct@Contact {contactId} beforeId count search = do cis <- liftIO $ mapM (safeGetDirectItem db user ct ts) ciIds pure $ Chat (DirectChat ct) (reverse cis) emptyChatStats -getDirectCIsBefore_ :: DB.Connection -> User -> Contact -> CChatItem 'CTDirect -> Int -> String -> IO [ChatItemId] +getDirectCIsBefore_ :: DB.Connection -> User -> Contact -> CChatItem 'CTDirect -> Int -> Text -> IO [ChatItemId] getDirectCIsBefore_ db User {userId} Contact {contactId} beforeCI count search = map fromOnly <$> DB.query @@ -1263,12 +1291,12 @@ getDirectCIsBefore_ db User {userId} Contact {contactId} beforeCI count search = |] (userId, contactId, search, ciCreatedAt beforeCI, ciCreatedAt beforeCI, cChatItemId beforeCI, count) -getDirectChatAround_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect, Maybe NavigationInfo) +getDirectChatAround_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> Text -> ExceptT StoreError IO (Chat 'CTDirect, Maybe NavigationInfo) getDirectChatAround_ db user ct aroundId count search = do stats <- liftIO $ getContactStats_ db user ct getDirectChatAround' db user ct aroundId count search stats -getDirectChatAround' :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ChatStats -> ExceptT StoreError IO (Chat 'CTDirect, Maybe NavigationInfo) +getDirectChatAround' :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> Text -> ChatStats -> ExceptT StoreError IO (Chat 'CTDirect, Maybe NavigationInfo) getDirectChatAround' db user ct@Contact {contactId} aroundId count search stats = do aroundCI <- getDirectChatItem db user contactId aroundId beforeIds <- liftIO $ getDirectCIsBefore_ db user ct aroundCI count search @@ -1376,7 +1404,7 @@ getContactNavInfo_ db User {userId} Contact {contactId} afterCI = do :. (userId, contactId, ciCreatedAt afterCI, cChatItemId afterCI) ) -getGroupChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> Maybe GroupChatScope -> Maybe MsgContentTag -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) +getGroupChat :: DB.Connection -> VersionRangeChat -> User -> Int64 -> Maybe GroupChatScope -> Maybe MsgContentTag -> ChatPagination -> Maybe Text -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) getGroupChat db vr user groupId scope_ contentFilter pagination search_ = do let search = fromMaybe "" search_ g <- getGroupInfo db vr user groupId @@ -1387,7 +1415,7 @@ getGroupChat db vr user groupId scope_ contentFilter pagination search_ = do CPBefore beforeId count -> (,Nothing) <$> getGroupChatBefore_ db user g scopeInfo contentFilter beforeId count search CPAround aroundId count -> getGroupChatAround_ db user g scopeInfo contentFilter aroundId count search CPInitial count -> do - unless (null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search" + unless (T.null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search" getGroupChatInitial_ db user g scopeInfo contentFilter count getCreateGroupChatScopeInfo :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupChatScope -> ExceptT StoreError IO GroupChatScopeInfo @@ -1438,7 +1466,7 @@ getGroupChatScopeForItem_ db itemId = (Nothing, Nothing) -> Nothing (Nothing, Just _) -> Nothing -- shouldn't happen -getGroupChatLast_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> Int -> String -> ChatStats -> ExceptT StoreError IO (Chat 'CTGroup) +getGroupChatLast_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> Int -> Text -> ChatStats -> ExceptT StoreError IO (Chat 'CTGroup) getGroupChatLast_ db user g scopeInfo_ contentFilter count search stats = do ciIds <- getGroupChatItemIDs db user g scopeInfo_ contentFilter GRLast count search ts <- liftIO getCurrentTime @@ -1447,7 +1475,7 @@ getGroupChatLast_ db user g scopeInfo_ contentFilter count search stats = do data GroupItemIDsRange = GRLast | GRAfter UTCTime ChatItemId | GRBefore UTCTime ChatItemId -getGroupChatItemIDs :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> GroupItemIDsRange -> Int -> String -> ExceptT StoreError IO [ChatItemId] +getGroupChatItemIDs :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> GroupItemIDsRange -> Int -> Text -> ExceptT StoreError IO [ChatItemId] getGroupChatItemIDs db User {userId} GroupInfo {groupId} scopeInfo_ contentFilter range count search = case (scopeInfo_, contentFilter) of (Nothing, Nothing) -> liftIO $ @@ -1492,7 +1520,7 @@ getGroupChatItemIDs db User {userId} GroupInfo {groupId} scopeInfo_ contentFilte orParams ts itemId = (p :. (Only ts) :. p :. (ts, itemId)) rangeQuery :: ToRow p => Query -> p -> Query -> IO [ChatItemId] rangeQuery c p ob - | null search = searchQuery "" () + | T.null search = searchQuery "" () | otherwise = searchQuery " AND LOWER(item_text) LIKE '%' || LOWER(?) || '%' " (Only search) where searchQuery :: ToRow p' => Query -> p' -> IO [ChatItemId] @@ -1542,7 +1570,7 @@ getGroupMemberChatItemLast db user@User {userId} groupId groupMemberId = do (userId, groupId, groupMemberId) getGroupChatItem db user groupId chatItemId -getGroupChatAfter_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup) +getGroupChatAfter_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> Text -> ExceptT StoreError IO (Chat 'CTGroup) getGroupChatAfter_ db user g@GroupInfo {groupId} scopeInfo contentFilter afterId count search = do afterCI <- getGroupChatItem db user groupId afterId let range = GRAfter (chatItemTs afterCI) (cChatItemId afterCI) @@ -1551,7 +1579,7 @@ getGroupChatAfter_ db user g@GroupInfo {groupId} scopeInfo contentFilter afterId cis <- liftIO $ mapM (safeGetGroupItem db user g ts) ciIds pure $ Chat (GroupChat g scopeInfo) cis emptyChatStats -getGroupChatBefore_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup) +getGroupChatBefore_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> Text -> ExceptT StoreError IO (Chat 'CTGroup) getGroupChatBefore_ db user g@GroupInfo {groupId} scopeInfo contentFilter beforeId count search = do beforeCI <- getGroupChatItem db user groupId beforeId let range = GRBefore (chatItemTs beforeCI) (cChatItemId beforeCI) @@ -1560,12 +1588,12 @@ getGroupChatBefore_ db user g@GroupInfo {groupId} scopeInfo contentFilter before cis <- liftIO $ mapM (safeGetGroupItem db user g ts) ciIds pure $ Chat (GroupChat g scopeInfo) (reverse cis) emptyChatStats -getGroupChatAround_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) +getGroupChatAround_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> Text -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) getGroupChatAround_ db user g scopeInfo contentFilter aroundId count search = do stats <- getGroupStats_ db user g scopeInfo getGroupChatAround' db user g scopeInfo contentFilter aroundId count search stats -getGroupChatAround' :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> String -> ChatStats -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) +getGroupChatAround' :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ChatItemId -> Int -> Text -> ChatStats -> ExceptT StoreError IO (Chat 'CTGroup, Maybe NavigationInfo) getGroupChatAround' db user g scopeInfo contentFilter aroundId count search stats = do aroundCI <- getGroupCIWithReactions db user g aroundId let beforeRange = GRBefore (chatItemTs aroundCI) (cChatItemId aroundCI) @@ -1708,7 +1736,7 @@ getGroupNavInfo_ db User {userId} GroupInfo {groupId} afterCI = do :. (userId, groupId, chatItemTs afterCI, cChatItemId afterCI) ) -getLocalChat :: DB.Connection -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTLocal, Maybe NavigationInfo) +getLocalChat :: DB.Connection -> User -> Int64 -> ChatPagination -> Maybe Text -> ExceptT StoreError IO (Chat 'CTLocal, Maybe NavigationInfo) getLocalChat db user folderId pagination search_ = do let search = fromMaybe "" search_ nf <- getNoteFolder db user folderId @@ -1718,17 +1746,17 @@ getLocalChat db user folderId pagination search_ = do CPBefore beforeId count -> (,Nothing) <$> getLocalChatBefore_ db user nf beforeId count search CPAround aroundId count -> getLocalChatAround_ db user nf aroundId count search CPInitial count -> do - unless (null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search" + unless (T.null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search" getLocalChatInitial_ db user nf count -getLocalChatLast_ :: DB.Connection -> User -> NoteFolder -> Int -> String -> IO (Chat 'CTLocal) +getLocalChatLast_ :: DB.Connection -> User -> NoteFolder -> Int -> Text -> IO (Chat 'CTLocal) getLocalChatLast_ db user nf count search = do ciIds <- getLocalChatItemIdsLast_ db user nf count search ts <- getCurrentTime cis <- mapM (safeGetLocalItem db user nf ts) ciIds pure $ Chat (LocalChat nf) (reverse cis) emptyChatStats -getLocalChatItemIdsLast_ :: DB.Connection -> User -> NoteFolder -> Int -> String -> IO [ChatItemId] +getLocalChatItemIdsLast_ :: DB.Connection -> User -> NoteFolder -> Int -> Text -> IO [ChatItemId] getLocalChatItemIdsLast_ db User {userId} NoteFolder {noteFolderId} count search = map fromOnly <$> DB.query @@ -1769,7 +1797,7 @@ safeToLocalItem currentTs itemId = \case file = Nothing } -getLocalChatAfter_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal) +getLocalChatAfter_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> Text -> ExceptT StoreError IO (Chat 'CTLocal) getLocalChatAfter_ db user nf@NoteFolder {noteFolderId} afterId count search = do afterCI <- getLocalChatItem db user noteFolderId afterId ciIds <- liftIO $ getLocalCIsAfter_ db user nf afterCI count search @@ -1777,7 +1805,7 @@ getLocalChatAfter_ db user nf@NoteFolder {noteFolderId} afterId count search = d cis <- liftIO $ mapM (safeGetLocalItem db user nf ts) ciIds pure $ Chat (LocalChat nf) cis emptyChatStats -getLocalCIsAfter_ :: DB.Connection -> User -> NoteFolder -> CChatItem 'CTLocal -> Int -> String -> IO [ChatItemId] +getLocalCIsAfter_ :: DB.Connection -> User -> NoteFolder -> CChatItem 'CTLocal -> Int -> Text -> IO [ChatItemId] getLocalCIsAfter_ db User {userId} NoteFolder {noteFolderId} afterCI count search = map fromOnly <$> DB.query @@ -1792,7 +1820,7 @@ getLocalCIsAfter_ db User {userId} NoteFolder {noteFolderId} afterCI count searc |] (userId, noteFolderId, search, ciCreatedAt afterCI, ciCreatedAt afterCI, cChatItemId afterCI, count) -getLocalChatBefore_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal) +getLocalChatBefore_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> Text -> ExceptT StoreError IO (Chat 'CTLocal) getLocalChatBefore_ db user nf@NoteFolder {noteFolderId} beforeId count search = do beforeCI <- getLocalChatItem db user noteFolderId beforeId ciIds <- liftIO $ getLocalCIsBefore_ db user nf beforeCI count search @@ -1800,7 +1828,7 @@ getLocalChatBefore_ db user nf@NoteFolder {noteFolderId} beforeId count search = cis <- liftIO $ mapM (safeGetLocalItem db user nf ts) ciIds pure $ Chat (LocalChat nf) (reverse cis) emptyChatStats -getLocalCIsBefore_ :: DB.Connection -> User -> NoteFolder -> CChatItem 'CTLocal -> Int -> String -> IO [ChatItemId] +getLocalCIsBefore_ :: DB.Connection -> User -> NoteFolder -> CChatItem 'CTLocal -> Int -> Text -> IO [ChatItemId] getLocalCIsBefore_ db User {userId} NoteFolder {noteFolderId} beforeCI count search = map fromOnly <$> DB.query @@ -1815,12 +1843,12 @@ getLocalCIsBefore_ db User {userId} NoteFolder {noteFolderId} beforeCI count sea |] (userId, noteFolderId, search, ciCreatedAt beforeCI, ciCreatedAt beforeCI, cChatItemId beforeCI, count) -getLocalChatAround_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTLocal, Maybe NavigationInfo) +getLocalChatAround_ :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> Text -> ExceptT StoreError IO (Chat 'CTLocal, Maybe NavigationInfo) getLocalChatAround_ db user nf aroundId count search = do stats <- liftIO $ getLocalStats_ db user nf getLocalChatAround' db user nf aroundId count search stats -getLocalChatAround' :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> String -> ChatStats -> ExceptT StoreError IO (Chat 'CTLocal, Maybe NavigationInfo) +getLocalChatAround' :: DB.Connection -> User -> NoteFolder -> ChatItemId -> Int -> Text -> ChatStats -> ExceptT StoreError IO (Chat 'CTLocal, Maybe NavigationInfo) getLocalChatAround' db user nf@NoteFolder {noteFolderId} aroundId count search stats = do aroundCI <- getLocalChatItem db user noteFolderId aroundId beforeIds <- liftIO $ getLocalCIsBefore_ db user nf aroundCI count search @@ -2010,41 +2038,84 @@ setDirectChatItemsDeleteAt db User {userId} contactId itemIds currentTs = forM i (deleteAt, userId, contactId, chatItemId) pure (chatItemId, deleteAt) -updateGroupChatItemsRead :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScope -> IO () -updateGroupChatItemsRead db User {userId} GroupInfo {groupId, membership} scope = do +updateGroupChatItemsRead :: DB.Connection -> User -> GroupInfo -> IO () +updateGroupChatItemsRead db User {userId} GroupInfo {groupId} = do currentTs <- getCurrentTime DB.execute db [sql| UPDATE chat_items SET item_status = ?, updated_at = ? - WHERE user_id = ? AND group_id = ? AND item_status = ? + WHERE user_id = ? AND group_id = ? + AND item_status = ? |] (CISRcvRead, currentTs, userId, groupId, CISRcvNew) - case scope of - Nothing -> pure () - Just GCSMemberSupport {groupMemberId_} -> do - let gmId = fromMaybe (groupMemberId' membership) groupMemberId_ + +updateSupportChatItemsRead :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupChatScopeInfo -> IO (GroupInfo, GroupMember) +updateSupportChatItemsRead db vr user@User {userId} g@GroupInfo {groupId, membership} scopeInfo = do + currentTs <- getCurrentTime + case scopeInfo of + GCSIMemberSupport {groupMember_} -> do + DB.execute + db + [sql| + UPDATE chat_items SET item_status = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? + AND group_scope_tag = ? AND group_scope_group_member_id IS NOT DISTINCT FROM ? + AND item_status = ? + |] + (CISRcvRead, currentTs, userId, groupId, GCSTMemberSupport_, groupMemberId' <$> groupMember_, CISRcvNew) + case groupMember_ of + Nothing -> do + membership' <- updateGMStats membership + pure (g {membership = membership'}, membership') + Just member -> do + member' <- updateGMStats member + let didRequire = gmRequiresAttention member + nowRequires = gmRequiresAttention member' + if (not nowRequires && didRequire) + then (,member') <$> decreaseGroupMembersRequireAttention db user g + else pure (g, member') + where + updateGMStats m@GroupMember {groupMemberId} = do + currentTs <- getCurrentTime DB.execute db [sql| UPDATE group_members SET support_chat_items_unread = 0, support_chat_items_member_attention = 0, - support_chat_items_mentions = 0 + support_chat_items_mentions = 0, + updated_at = ? WHERE group_member_id = ? |] - (Only gmId) + (currentTs, groupMemberId) + m_ <- runExceptT $ getGroupMemberById db vr user groupMemberId + pure $ either (const m) id m_ -- Left shouldn't happen, but types require it -getGroupUnreadTimedItems :: DB.Connection -> User -> GroupId -> IO [(ChatItemId, Int)] -getGroupUnreadTimedItems db User {userId} groupId = - DB.query - db - [sql| - SELECT chat_item_id, timed_ttl - FROM chat_items - WHERE user_id = ? AND group_id = ? AND item_status = ? AND timed_ttl IS NOT NULL AND timed_delete_at IS NULL - |] - (userId, groupId, CISRcvNew) +getGroupUnreadTimedItems :: DB.Connection -> User -> GroupId -> Maybe GroupChatScope -> IO [(ChatItemId, Int)] +getGroupUnreadTimedItems db User {userId} groupId scope = + case scope of + Nothing -> + DB.query + db + [sql| + SELECT chat_item_id, timed_ttl + FROM chat_items + WHERE user_id = ? AND group_id = ? + AND item_status = ? AND timed_ttl IS NOT NULL AND timed_delete_at IS NULL + |] + (userId, groupId, CISRcvNew) + Just GCSMemberSupport {groupMemberId_} -> + DB.query + db + [sql| + SELECT chat_item_id, timed_ttl + FROM chat_items + WHERE user_id = ? AND group_id = ? + AND group_scope_tag = ? AND group_scope_group_member_id IS NOT DISTINCT FROM ? + AND item_status = ? AND timed_ttl IS NOT NULL AND timed_delete_at IS NULL + |] + (userId, groupId, GCSTMemberSupport_, groupMemberId_, CISRcvNew) updateGroupChatItemsReadList :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> NonEmpty ChatItemId -> ExceptT StoreError IO ([(ChatItemId, Int)], GroupInfo) updateGroupChatItemsReadList db vr user@User {userId} g@GroupInfo {groupId} scopeInfo_ itemIds = do @@ -2110,20 +2181,29 @@ updateGroupScopeUnreadStats db vr user g@GroupInfo {membership} scopeInfo (unrea currentTs <- getCurrentTime DB.execute db +#if defined(dbPostgres) [sql| UPDATE group_members - SET support_chat_items_unread = support_chat_items_unread - ?, - support_chat_items_member_attention = support_chat_items_member_attention - ?, - support_chat_items_mentions = support_chat_items_mentions - ?, + SET support_chat_items_unread = GREATEST(0, support_chat_items_unread - ?), + support_chat_items_member_attention = GREATEST(0, support_chat_items_member_attention - ?), + support_chat_items_mentions = GREATEST(0, support_chat_items_mentions - ?), updated_at = ? WHERE group_member_id = ? |] +#else + [sql| + UPDATE group_members + SET support_chat_items_unread = MAX(0, support_chat_items_unread - ?), + support_chat_items_member_attention = MAX(0, support_chat_items_member_attention - ?), + support_chat_items_mentions = MAX(0, support_chat_items_mentions - ?), + updated_at = ? + WHERE group_member_id = ? + |] +#endif (unread, unanswered, mentions, currentTs, groupMemberId) m_ <- runExceptT $ getGroupMemberById db vr user groupMemberId pure $ either (const m) id m_ -- Left shouldn't happen, but types require it -deriving instance Show BoolInt - setGroupChatItemsDeleteAt :: DB.Connection -> User -> GroupId -> [(ChatItemId, Int)] -> UTCTime -> IO [(ChatItemId, UTCTime)] setGroupChatItemsDeleteAt db User {userId} groupId itemIds currentTs = forM itemIds $ \(chatItemId, ttl) -> do let deleteAt = addUTCTime (realToFrac ttl) currentTs @@ -2290,7 +2370,7 @@ toGroupChatItem ciTimed :: Maybe CITimed ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} -getAllChatItems :: DB.Connection -> VersionRangeChat -> User -> ChatPagination -> Maybe String -> ExceptT StoreError IO [AChatItem] +getAllChatItems :: DB.Connection -> VersionRangeChat -> User -> ChatPagination -> Maybe Text -> ExceptT StoreError IO [AChatItem] getAllChatItems db vr user@User {userId} pagination search_ = do itemRefs <- rights . map toChatItemRef <$> case pagination of @@ -2299,7 +2379,7 @@ getAllChatItems db vr user@User {userId} pagination search_ = do CPBefore beforeId count -> liftIO . getAllChatItemsBefore_ beforeId count . aChatItemTs =<< getAChatItem_ beforeId CPAround aroundId count -> liftIO . getAllChatItemsAround_ aroundId count . aChatItemTs =<< getAChatItem_ aroundId CPInitial count -> do - unless (null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search" + unless (T.null search) $ throwError $ SEInternalError "initial chat pagination doesn't support search" liftIO getFirstUnreadItemId_ >>= \case Just itemId -> liftIO . getAllChatItemsAround_ itemId count . aChatItemTs =<< getAChatItem_ itemId Nothing -> liftIO $ getAllChatItemsLast_ count diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index ce1987db15..89f8f6070b 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -16,6 +16,11 @@ import Simplex.Chat.Store.Postgres.Migrations.M20250721_indexes import Simplex.Chat.Store.Postgres.Migrations.M20250729_member_contact_requests import Simplex.Chat.Store.Postgres.Migrations.M20250801_via_group_link_uri import Simplex.Chat.Store.Postgres.Migrations.M20250802_chat_peer_type +import Simplex.Chat.Store.Postgres.Migrations.M20250813_delivery_tasks +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.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -31,7 +36,12 @@ schemaMigrations = ("20250721_indexes", m20250721_indexes, Just down_m20250721_indexes), ("20250729_member_contact_requests", m20250729_member_contact_requests, Just down_m20250729_member_contact_requests), ("20250801_via_group_link_uri", m20250801_via_group_link_uri, Just down_m20250801_via_group_link_uri), - ("20250802_chat_peer_type", m20250802_chat_peer_type, Just down_m20250802_chat_peer_type) + ("20250802_chat_peer_type", m20250802_chat_peer_type, Just down_m20250802_chat_peer_type), + ("20250813_delivery_tasks", m20250813_delivery_tasks, Just down_m20250813_delivery_tasks), + ("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) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20250813_delivery_tasks.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20250813_delivery_tasks.hs new file mode 100644 index 0000000000..688c0aee76 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20250813_delivery_tasks.hs @@ -0,0 +1,128 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20250813_delivery_tasks where + +import Data.Text (Text) +import qualified Data.Text as T +import Text.RawString.QQ (r) + +m20250813_delivery_tasks :: Text +m20250813_delivery_tasks = + T.pack + [r| +CREATE TABLE delivery_tasks ( + delivery_task_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + group_id BIGINT NOT NULL REFERENCES groups ON DELETE CASCADE, + worker_scope TEXT NOT NULL, + job_scope_spec_tag TEXT, + job_scope_include_pending SMALLINT, + job_scope_support_gm_id BIGINT REFERENCES group_members(group_member_id) ON DELETE CASCADE, + sender_group_member_id BIGINT NOT NULL REFERENCES group_members(group_member_id) ON DELETE CASCADE, + message_id BIGINT REFERENCES messages ON DELETE CASCADE, + message_from_channel SMALLINT NOT NULL DEFAULT 0, + task_status TEXT NOT NULL, + task_err_reason TEXT, + failed SMALLINT DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + updated_at TIMESTAMPTZ NOT NULL DEFAULT (now()) +); + +CREATE INDEX idx_delivery_tasks_group_id ON delivery_tasks(group_id); +CREATE INDEX idx_delivery_tasks_job_scope_support_gm_id ON delivery_tasks(job_scope_support_gm_id); +CREATE INDEX idx_delivery_tasks_sender_group_member_id ON delivery_tasks(sender_group_member_id); +CREATE INDEX idx_delivery_tasks_message_id ON delivery_tasks(message_id); + +CREATE INDEX idx_delivery_tasks_next ON delivery_tasks( + group_id, + worker_scope, + failed, + task_status +); +CREATE INDEX idx_delivery_tasks_next_for_job_scope ON delivery_tasks( + group_id, + worker_scope, + job_scope_spec_tag, + job_scope_include_pending, + job_scope_support_gm_id, + failed, + task_status +); +CREATE INDEX idx_delivery_tasks_next_for_job_scope_sender ON delivery_tasks( + group_id, + worker_scope, + job_scope_spec_tag, + job_scope_include_pending, + job_scope_support_gm_id, + sender_group_member_id, + failed, + task_status +); +CREATE INDEX idx_delivery_tasks_created_at ON delivery_tasks(created_at); + + + +CREATE TABLE delivery_jobs ( + delivery_job_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + group_id BIGINT NOT NULL REFERENCES groups ON DELETE CASCADE, + worker_scope TEXT NOT NULL, + job_scope_spec_tag TEXT, + job_scope_include_pending SMALLINT, + job_scope_support_gm_id BIGINT REFERENCES group_members(group_member_id) ON DELETE CASCADE, + single_sender_group_member_id BIGINT REFERENCES group_members(group_member_id) ON DELETE CASCADE, + body BYTEA, + cursor_group_member_id BIGINT, + job_status TEXT NOT NULL, + job_err_reason TEXT, + failed SMALLINT DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT (now()), + updated_at TIMESTAMPTZ NOT NULL DEFAULT (now()) +); + +CREATE INDEX idx_delivery_jobs_group_id ON delivery_jobs(group_id); +CREATE INDEX idx_delivery_jobs_job_scope_support_gm_id ON delivery_jobs(job_scope_support_gm_id); +CREATE INDEX idx_delivery_jobs_single_sender_group_member_id ON delivery_jobs(single_sender_group_member_id); + +CREATE INDEX idx_delivery_jobs_next ON delivery_jobs( + group_id, + worker_scope, + failed, + job_status +); +CREATE INDEX idx_delivery_jobs_created_at ON delivery_jobs(created_at); + + + +ALTER TABLE messages ADD COLUMN broker_ts TIMESTAMPTZ; +|] + +down_m20250813_delivery_tasks :: Text +down_m20250813_delivery_tasks = + T.pack + [r| +ALTER TABLE messages DROP COLUMN broker_ts; + + + +DROP INDEX idx_delivery_jobs_group_id; +DROP INDEX idx_delivery_jobs_job_scope_support_gm_id; +DROP INDEX idx_delivery_jobs_single_sender_group_member_id; + +DROP INDEX idx_delivery_jobs_next; +DROP INDEX idx_delivery_jobs_created_at; + +DROP TABLE delivery_jobs; + + + +DROP INDEX idx_delivery_tasks_group_id; +DROP INDEX idx_delivery_tasks_job_scope_support_gm_id; +DROP INDEX idx_delivery_tasks_sender_group_member_id; +DROP INDEX idx_delivery_tasks_message_id; + +DROP INDEX idx_delivery_tasks_next; +DROP INDEX idx_delivery_tasks_next_for_job_scope; +DROP INDEX idx_delivery_tasks_next_for_job_scope_sender; +DROP INDEX idx_delivery_tasks_created_at; + +DROP TABLE delivery_tasks; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20250919_group_summary.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20250919_group_summary.hs new file mode 100644 index 0000000000..915781d2c0 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20250919_group_summary.hs @@ -0,0 +1,116 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20250919_group_summary where + +import Data.Text (Text) +import qualified Data.Text as T +import Text.RawString.QQ (r) + +m20250919_group_summary :: Text +m20250919_group_summary = + T.pack + [r| +ALTER TABLE groups ADD COLUMN summary_current_members_count BIGINT NOT NULL DEFAULT 0; +CREATE INDEX idx_groups_summary_current_members_count ON groups(summary_current_members_count); + +CREATE FUNCTION is_current_member(p_status TEXT) RETURNS BOOLEAN +LANGUAGE plpgsql AS $$ +BEGIN + RETURN p_status IN ( + 'introduced', + 'intro-inv', + 'accepted', + 'announced', + 'connected', + 'complete', + 'creator' + ); +END; +$$; + +UPDATE groups g +SET summary_current_members_count = COALESCE(c.cnt, 0) +FROM ( + SELECT group_id, COUNT(group_member_id) AS cnt + FROM group_members + WHERE is_current_member(member_status) = TRUE + GROUP BY group_id +) c +WHERE g.group_id = c.group_id; + +CREATE FUNCTION on_group_members_insert_update_summary() RETURNS TRIGGER +LANGUAGE plpgsql AS $$ +BEGIN + IF is_current_member(NEW.member_status) THEN + UPDATE groups + SET summary_current_members_count = summary_current_members_count + 1 + WHERE group_id = NEW.group_id; + END IF; + RETURN NEW; +END; +$$; + +CREATE FUNCTION on_group_members_delete_update_summary() RETURNS TRIGGER +LANGUAGE plpgsql AS $$ +BEGIN + IF is_current_member(OLD.member_status) THEN + UPDATE groups + SET summary_current_members_count = summary_current_members_count - 1 + WHERE group_id = OLD.group_id; + END IF; + RETURN OLD; +END; +$$; + +CREATE FUNCTION on_group_members_update_update_summary() RETURNS TRIGGER +LANGUAGE plpgsql AS $$ +DECLARE + was_active BOOLEAN; + is_active BOOLEAN; +BEGIN + was_active := is_current_member(OLD.member_status); + is_active := is_current_member(NEW.member_status); + + IF was_active != is_active THEN + UPDATE groups + SET summary_current_members_count = summary_current_members_count + + (CASE WHEN is_active THEN 1 ELSE -1 END) + WHERE group_id = NEW.group_id; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER tr_group_members_insert_update_summary +AFTER INSERT ON group_members +FOR EACH ROW +EXECUTE FUNCTION on_group_members_insert_update_summary(); + +CREATE TRIGGER tr_group_members_delete_update_summary +AFTER DELETE ON group_members +FOR EACH ROW +EXECUTE FUNCTION on_group_members_delete_update_summary(); + +CREATE TRIGGER tr_group_members_update_update_summary +AFTER UPDATE ON group_members +FOR EACH ROW +EXECUTE FUNCTION on_group_members_update_update_summary(); +|] + +down_m20250919_group_summary :: Text +down_m20250919_group_summary = + T.pack + [r| +DROP TRIGGER tr_group_members_insert_update_summary ON group_members; +DROP TRIGGER tr_group_members_delete_update_summary ON group_members; +DROP TRIGGER tr_group_members_update_update_summary ON group_members; + +DROP FUNCTION on_group_members_insert_update_summary; +DROP FUNCTION on_group_members_delete_update_summary; +DROP FUNCTION on_group_members_update_update_summary; + +DROP FUNCTION is_current_member; + +DROP INDEX idx_groups_summary_current_members_count; +ALTER TABLE groups DROP COLUMN summary_current_members_count; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20250922_remove_unused_connections.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20250922_remove_unused_connections.hs new file mode 100644 index 0000000000..7ebba83177 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20250922_remove_unused_connections.hs @@ -0,0 +1,96 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20250922_remove_unused_connections where + +import Data.Text (Text) +import qualified Data.Text as T +import Text.RawString.QQ (r) + +m20250922_remove_unused_connections :: Text +m20250922_remove_unused_connections = + T.pack + [r| +DELETE FROM connections WHERE snd_file_id IS NOT NULL; +DELETE FROM connections WHERE rcv_file_id IS NOT NULL; + +DROP TABLE snd_file_chunks; + +WITH ranked_contact_connections AS ( + SELECT + c.connection_id, + ROW_NUMBER() OVER ( + PARTITION BY c.user_id, c.contact_id + ORDER BY + CASE WHEN c.conn_status = 'ready' OR c.conn_status = 'snd-ready' THEN 1 ELSE 0 END DESC, + c.created_at DESC + ) AS rn + FROM connections c + WHERE c.contact_id IS NOT NULL +) +DELETE FROM connections +WHERE connection_id IN ( + SELECT connection_id + FROM ranked_contact_connections + WHERE rn > 1 +); + +WITH ranked_group_member_connections AS ( + SELECT + c.connection_id, + ROW_NUMBER() OVER ( + PARTITION BY c.user_id, c.group_member_id + ORDER BY c.connection_id DESC + ) AS rn + FROM connections c + WHERE c.group_member_id IS NOT NULL +) +DELETE FROM connections +WHERE connection_id IN ( + SELECT connection_id + FROM ranked_group_member_connections + WHERE rn > 1 +); + +DROP INDEX idx_connections_contact_id; +DROP INDEX idx_connections_group_member_id; + +CREATE UNIQUE INDEX idx_connections_contact_id ON connections(contact_id); +CREATE UNIQUE INDEX idx_connections_group_member_id ON connections(group_member_id); + +DROP INDEX idx_connections_to_subscribe; +CREATE INDEX idx_connections_to_subscribe ON connections(user_id, to_subscribe); + +DROP INDEX idx_contacts_via_group; +ALTER TABLE contacts DROP COLUMN via_group; +|] + +down_m20250922_remove_unused_connections :: Text +down_m20250922_remove_unused_connections = + T.pack + [r| +CREATE TABLE snd_file_chunks( + file_id BIGINT NOT NULL, + connection_id BIGINT NOT NULL, + chunk_number BIGINT NOT NULL, + chunk_agent_msg_id BIGINT, + chunk_sent SMALLINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + FOREIGN KEY(file_id, connection_id) REFERENCES snd_files ON DELETE CASCADE, + PRIMARY KEY(file_id, connection_id, chunk_number) +); + +CREATE INDEX idx_snd_file_chunks_file_id_connection_id ON snd_file_chunks(file_id, connection_id); + +DROP INDEX idx_connections_contact_id; +DROP INDEX idx_connections_group_member_id; + +CREATE INDEX idx_connections_contact_id ON connections(contact_id); +CREATE INDEX idx_connections_group_member_id ON connections(group_member_id); + +DROP INDEX idx_connections_to_subscribe; +CREATE INDEX idx_connections_to_subscribe ON connections(to_subscribe); + +ALTER TABLE contacts ADD COLUMN via_group INTEGER REFERENCES groups(group_id) ON DELETE SET NULL; +CREATE INDEX idx_contacts_via_group ON contacts(via_group); +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20251007_connections_sync.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20251007_connections_sync.hs new file mode 100644 index 0000000000..08d88eeea8 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20251007_connections_sync.hs @@ -0,0 +1,27 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20251007_connections_sync where + +import Data.Text (Text) +import qualified Data.Text as T +import Text.RawString.QQ (r) + +m20251007_connections_sync :: Text +m20251007_connections_sync = + T.pack + [r| +CREATE TABLE connections_sync( + connections_sync_id BIGINT PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, + should_sync SMALLINT NOT NULL DEFAULT 0, + last_sync_ts TIMESTAMPTZ +); + +INSERT INTO connections_sync (connections_sync_id, should_sync, last_sync_ts) VALUES (1, 1, NULL); +|] + +down_m20251007_connections_sync :: Text +down_m20251007_connections_sync = + T.pack + [r| +DROP TABLE connections_sync; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20251017_chat_tags_cascade.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20251017_chat_tags_cascade.hs new file mode 100644 index 0000000000..f5a227a3c5 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20251017_chat_tags_cascade.hs @@ -0,0 +1,32 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20251017_chat_tags_cascade where + +import Data.Text (Text) +import qualified Data.Text as T +import Text.RawString.QQ (r) + +m20251017_chat_tags_cascade :: Text +m20251017_chat_tags_cascade = + T.pack + [r| +ALTER TABLE chat_tags DROP CONSTRAINT chat_tags_user_id_fkey; + +ALTER TABLE chat_tags + ADD CONSTRAINT chat_tags_user_id_fkey + FOREIGN KEY (user_id) + REFERENCES users(user_id) + ON DELETE CASCADE; +|] + +down_m20251017_chat_tags_cascade :: Text +down_m20251017_chat_tags_cascade = + T.pack + [r| +ALTER TABLE chat_tags DROP CONSTRAINT chat_tags_user_id_fkey; + +ALTER TABLE chat_tags + ADD CONSTRAINT chat_tags_user_id_fkey + FOREIGN KEY (user_id) + REFERENCES users(user_id); +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index c0560de376..601dd97a6e 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -15,6 +15,76 @@ SET row_security = off; CREATE SCHEMA test_chat_schema; + +CREATE FUNCTION test_chat_schema.is_current_member(p_status text) RETURNS boolean + LANGUAGE plpgsql + AS $$ +BEGIN + RETURN p_status IN ( + 'introduced', + 'intro-inv', + 'accepted', + 'announced', + 'connected', + 'complete', + 'creator' + ); +END; +$$; + + + +CREATE FUNCTION test_chat_schema.on_group_members_delete_update_summary() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + IF is_current_member(OLD.member_status) THEN + UPDATE groups + SET summary_current_members_count = summary_current_members_count - 1 + WHERE group_id = OLD.group_id; + END IF; + RETURN OLD; +END; +$$; + + + +CREATE FUNCTION test_chat_schema.on_group_members_insert_update_summary() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + IF is_current_member(NEW.member_status) THEN + UPDATE groups + SET summary_current_members_count = summary_current_members_count + 1 + WHERE group_id = NEW.group_id; + END IF; + RETURN NEW; +END; +$$; + + + +CREATE FUNCTION test_chat_schema.on_group_members_update_update_summary() RETURNS trigger + LANGUAGE plpgsql + AS $$ +DECLARE + was_active BOOLEAN; + is_active BOOLEAN; +BEGIN + was_active := is_current_member(OLD.member_status); + is_active := is_current_member(NEW.member_status); + + IF was_active != is_active THEN + UPDATE groups + SET summary_current_members_count = summary_current_members_count + + (CASE WHEN is_active THEN 1 ELSE -1 END) + WHERE group_id = NEW.group_id; + END IF; + RETURN NEW; +END; +$$; + + SET default_table_access_method = heap; @@ -320,6 +390,25 @@ ALTER TABLE test_chat_schema.connections ALTER COLUMN connection_id ADD GENERATE +CREATE TABLE test_chat_schema.connections_sync ( + connections_sync_id bigint NOT NULL, + should_sync smallint DEFAULT 0 NOT NULL, + last_sync_ts timestamp with time zone +); + + + +ALTER TABLE test_chat_schema.connections_sync ALTER COLUMN connections_sync_id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME test_chat_schema.connections_sync_connections_sync_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + + CREATE TABLE test_chat_schema.contact_profiles ( contact_profile_id bigint NOT NULL, display_name text NOT NULL, @@ -388,7 +477,6 @@ CREATE TABLE test_chat_schema.contacts ( user_id bigint NOT NULL, local_display_name text NOT NULL, is_user smallint DEFAULT 0 NOT NULL, - via_group bigint, created_at timestamp with time zone DEFAULT now() NOT NULL, updated_at timestamp with time zone NOT NULL, xcontact_id bytea, @@ -432,6 +520,66 @@ ALTER TABLE test_chat_schema.contacts ALTER COLUMN contact_id ADD GENERATED ALWA +CREATE TABLE test_chat_schema.delivery_jobs ( + delivery_job_id bigint NOT NULL, + group_id bigint NOT NULL, + worker_scope text NOT NULL, + job_scope_spec_tag text, + job_scope_include_pending smallint, + job_scope_support_gm_id bigint, + single_sender_group_member_id bigint, + body bytea, + cursor_group_member_id bigint, + job_status text NOT NULL, + job_err_reason text, + failed smallint DEFAULT 0, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL +); + + + +ALTER TABLE test_chat_schema.delivery_jobs ALTER COLUMN delivery_job_id ADD GENERATED ALWAYS AS IDENTITY ( + SEQUENCE NAME test_chat_schema.delivery_jobs_delivery_job_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + + +CREATE TABLE test_chat_schema.delivery_tasks ( + delivery_task_id bigint NOT NULL, + group_id bigint NOT NULL, + worker_scope text NOT NULL, + job_scope_spec_tag text, + job_scope_include_pending smallint, + job_scope_support_gm_id bigint, + sender_group_member_id bigint NOT NULL, + message_id bigint, + message_from_channel smallint DEFAULT 0 NOT NULL, + task_status text NOT NULL, + task_err_reason text, + failed smallint DEFAULT 0, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL +); + + + +ALTER TABLE test_chat_schema.delivery_tasks ALTER COLUMN delivery_task_id ADD GENERATED ALWAYS AS IDENTITY ( + SEQUENCE NAME test_chat_schema.delivery_tasks_delivery_task_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + + CREATE TABLE test_chat_schema.display_names ( user_id bigint NOT NULL, local_display_name text NOT NULL, @@ -656,7 +804,8 @@ CREATE TABLE test_chat_schema.groups ( welcome_shared_msg_id bytea, request_shared_msg_id bytea, conn_link_prepared_connection smallint DEFAULT 0 NOT NULL, - via_group_link_uri bytea + via_group_link_uri bytea, + summary_current_members_count bigint DEFAULT 0 NOT NULL ); @@ -707,7 +856,8 @@ CREATE TABLE test_chat_schema.messages ( shared_msg_id bytea, shared_msg_id_user smallint, author_group_member_id bigint, - forwarded_by_group_member_id bigint + forwarded_by_group_member_id bigint, + broker_ts timestamp with time zone ); @@ -1051,18 +1201,6 @@ ALTER TABLE test_chat_schema.settings ALTER COLUMN settings_id ADD GENERATED ALW -CREATE TABLE test_chat_schema.snd_file_chunks ( - file_id bigint NOT NULL, - connection_id bigint NOT NULL, - chunk_number bigint NOT NULL, - chunk_agent_msg_id bigint, - chunk_sent smallint DEFAULT 0 NOT NULL, - created_at timestamp with time zone NOT NULL, - updated_at timestamp with time zone NOT NULL -); - - - CREATE TABLE test_chat_schema.snd_files ( file_id bigint NOT NULL, connection_id bigint NOT NULL, @@ -1239,6 +1377,11 @@ ALTER TABLE ONLY test_chat_schema.connections +ALTER TABLE ONLY test_chat_schema.connections_sync + ADD CONSTRAINT connections_sync_pkey PRIMARY KEY (connections_sync_id); + + + ALTER TABLE ONLY test_chat_schema.contact_profiles ADD CONSTRAINT contact_profiles_pkey PRIMARY KEY (contact_profile_id); @@ -1274,6 +1417,16 @@ ALTER TABLE ONLY test_chat_schema.contacts +ALTER TABLE ONLY test_chat_schema.delivery_jobs + ADD CONSTRAINT delivery_jobs_pkey PRIMARY KEY (delivery_job_id); + + + +ALTER TABLE ONLY test_chat_schema.delivery_tasks + ADD CONSTRAINT delivery_tasks_pkey PRIMARY KEY (delivery_task_id); + + + ALTER TABLE ONLY test_chat_schema.display_names ADD CONSTRAINT display_names_pkey PRIMARY KEY (user_id, local_display_name); @@ -1439,11 +1592,6 @@ ALTER TABLE ONLY test_chat_schema.settings -ALTER TABLE ONLY test_chat_schema.snd_file_chunks - ADD CONSTRAINT snd_file_chunks_pkey PRIMARY KEY (file_id, connection_id, chunk_number); - - - ALTER TABLE ONLY test_chat_schema.snd_files ADD CONSTRAINT snd_files_pkey PRIMARY KEY (file_id, connection_id); @@ -1725,7 +1873,7 @@ CREATE INDEX idx_connections_conn_req_inv ON test_chat_schema.connections USING -CREATE INDEX idx_connections_contact_id ON test_chat_schema.connections USING btree (contact_id); +CREATE UNIQUE INDEX idx_connections_contact_id ON test_chat_schema.connections USING btree (contact_id); @@ -1737,7 +1885,7 @@ CREATE INDEX idx_connections_group_member ON test_chat_schema.connections USING -CREATE INDEX idx_connections_group_member_id ON test_chat_schema.connections USING btree (group_member_id); +CREATE UNIQUE INDEX idx_connections_group_member_id ON test_chat_schema.connections USING btree (group_member_id); @@ -1745,7 +1893,7 @@ CREATE INDEX idx_connections_rcv_file_id ON test_chat_schema.connections USING b -CREATE INDEX idx_connections_to_subscribe ON test_chat_schema.connections USING btree (to_subscribe); +CREATE INDEX idx_connections_to_subscribe ON test_chat_schema.connections USING btree (user_id, to_subscribe); @@ -1829,14 +1977,62 @@ CREATE INDEX idx_contacts_grp_direct_inv_from_member_conn_id ON test_chat_schema -CREATE INDEX idx_contacts_via_group ON test_chat_schema.contacts USING btree (via_group); - - - CREATE INDEX idx_contacts_xcontact_id ON test_chat_schema.contacts USING btree (xcontact_id); +CREATE INDEX idx_delivery_jobs_created_at ON test_chat_schema.delivery_jobs USING btree (created_at); + + + +CREATE INDEX idx_delivery_jobs_group_id ON test_chat_schema.delivery_jobs USING btree (group_id); + + + +CREATE INDEX idx_delivery_jobs_job_scope_support_gm_id ON test_chat_schema.delivery_jobs USING btree (job_scope_support_gm_id); + + + +CREATE INDEX idx_delivery_jobs_next ON test_chat_schema.delivery_jobs USING btree (group_id, worker_scope, failed, job_status); + + + +CREATE INDEX idx_delivery_jobs_single_sender_group_member_id ON test_chat_schema.delivery_jobs USING btree (single_sender_group_member_id); + + + +CREATE INDEX idx_delivery_tasks_created_at ON test_chat_schema.delivery_tasks USING btree (created_at); + + + +CREATE INDEX idx_delivery_tasks_group_id ON test_chat_schema.delivery_tasks USING btree (group_id); + + + +CREATE INDEX idx_delivery_tasks_job_scope_support_gm_id ON test_chat_schema.delivery_tasks USING btree (job_scope_support_gm_id); + + + +CREATE INDEX idx_delivery_tasks_message_id ON test_chat_schema.delivery_tasks USING btree (message_id); + + + +CREATE INDEX idx_delivery_tasks_next ON test_chat_schema.delivery_tasks USING btree (group_id, worker_scope, failed, task_status); + + + +CREATE INDEX idx_delivery_tasks_next_for_job_scope ON test_chat_schema.delivery_tasks USING btree (group_id, worker_scope, job_scope_spec_tag, job_scope_include_pending, job_scope_support_gm_id, failed, task_status); + + + +CREATE INDEX idx_delivery_tasks_next_for_job_scope_sender ON test_chat_schema.delivery_tasks USING btree (group_id, worker_scope, job_scope_spec_tag, job_scope_include_pending, job_scope_support_gm_id, sender_group_member_id, failed, task_status); + + + +CREATE INDEX idx_delivery_tasks_sender_group_member_id ON test_chat_schema.delivery_tasks USING btree (sender_group_member_id); + + + CREATE INDEX idx_extra_xftp_file_descriptions_file_id ON test_chat_schema.extra_xftp_file_descriptions USING btree (file_id); @@ -1941,6 +2137,10 @@ CREATE INDEX idx_groups_inv_queue_info ON test_chat_schema.groups USING btree (i +CREATE INDEX idx_groups_summary_current_members_count ON test_chat_schema.groups USING btree (summary_current_members_count); + + + CREATE INDEX idx_groups_via_group_link_uri_hash ON test_chat_schema.groups USING btree (user_id, via_group_link_uri_hash); @@ -2089,10 +2289,6 @@ CREATE INDEX idx_smp_servers_user_id ON test_chat_schema.protocol_servers USING -CREATE INDEX idx_snd_file_chunks_file_id_connection_id ON test_chat_schema.snd_file_chunks USING btree (file_id, connection_id); - - - CREATE INDEX idx_snd_files_connection_id ON test_chat_schema.snd_files USING btree (connection_id); @@ -2125,6 +2321,18 @@ CREATE INDEX note_folders_user_id ON test_chat_schema.note_folders USING btree ( +CREATE TRIGGER tr_group_members_delete_update_summary AFTER DELETE ON test_chat_schema.group_members FOR EACH ROW EXECUTE FUNCTION test_chat_schema.on_group_members_delete_update_summary(); + + + +CREATE TRIGGER tr_group_members_insert_update_summary AFTER INSERT ON test_chat_schema.group_members FOR EACH ROW EXECUTE FUNCTION test_chat_schema.on_group_members_insert_update_summary(); + + + +CREATE TRIGGER tr_group_members_update_update_summary AFTER UPDATE ON test_chat_schema.group_members FOR EACH ROW EXECUTE FUNCTION test_chat_schema.on_group_members_update_update_summary(); + + + ALTER TABLE ONLY test_chat_schema.calls ADD CONSTRAINT calls_chat_item_id_fkey FOREIGN KEY (chat_item_id) REFERENCES test_chat_schema.chat_items(chat_item_id) ON DELETE CASCADE; @@ -2271,7 +2479,7 @@ ALTER TABLE ONLY test_chat_schema.chat_tags_chats ALTER TABLE ONLY test_chat_schema.chat_tags - ADD CONSTRAINT chat_tags_user_id_fkey FOREIGN KEY (user_id) REFERENCES test_chat_schema.users(user_id); + ADD CONSTRAINT chat_tags_user_id_fkey FOREIGN KEY (user_id) REFERENCES test_chat_schema.users(user_id) ON DELETE CASCADE; @@ -2390,6 +2598,41 @@ ALTER TABLE ONLY test_chat_schema.contacts +ALTER TABLE ONLY test_chat_schema.delivery_jobs + ADD CONSTRAINT delivery_jobs_group_id_fkey FOREIGN KEY (group_id) REFERENCES test_chat_schema.groups(group_id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY test_chat_schema.delivery_jobs + ADD CONSTRAINT delivery_jobs_job_scope_support_gm_id_fkey FOREIGN KEY (job_scope_support_gm_id) REFERENCES test_chat_schema.group_members(group_member_id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY test_chat_schema.delivery_jobs + ADD CONSTRAINT delivery_jobs_single_sender_group_member_id_fkey FOREIGN KEY (single_sender_group_member_id) REFERENCES test_chat_schema.group_members(group_member_id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY test_chat_schema.delivery_tasks + ADD CONSTRAINT delivery_tasks_group_id_fkey FOREIGN KEY (group_id) REFERENCES test_chat_schema.groups(group_id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY test_chat_schema.delivery_tasks + ADD CONSTRAINT delivery_tasks_job_scope_support_gm_id_fkey FOREIGN KEY (job_scope_support_gm_id) REFERENCES test_chat_schema.group_members(group_member_id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY test_chat_schema.delivery_tasks + ADD CONSTRAINT delivery_tasks_message_id_fkey FOREIGN KEY (message_id) REFERENCES test_chat_schema.messages(message_id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY test_chat_schema.delivery_tasks + ADD CONSTRAINT delivery_tasks_sender_group_member_id_fkey FOREIGN KEY (sender_group_member_id) REFERENCES test_chat_schema.group_members(group_member_id) ON DELETE CASCADE; + + + ALTER TABLE ONLY test_chat_schema.display_names ADD CONSTRAINT display_names_user_id_fkey FOREIGN KEY (user_id) REFERENCES test_chat_schema.users(user_id) ON DELETE CASCADE; @@ -2445,11 +2688,6 @@ ALTER TABLE ONLY test_chat_schema.contacts -ALTER TABLE ONLY test_chat_schema.contacts - ADD CONSTRAINT fk_contacts_groups FOREIGN KEY (via_group) REFERENCES test_chat_schema.groups(group_id) ON DELETE SET NULL; - - - ALTER TABLE ONLY test_chat_schema.files ADD CONSTRAINT fk_files_chat_items FOREIGN KEY (chat_item_id) REFERENCES test_chat_schema.chat_items(chat_item_id) ON DELETE CASCADE; @@ -2705,11 +2943,6 @@ ALTER TABLE ONLY test_chat_schema.settings -ALTER TABLE ONLY test_chat_schema.snd_file_chunks - ADD CONSTRAINT snd_file_chunks_file_id_connection_id_fkey FOREIGN KEY (file_id, connection_id) REFERENCES test_chat_schema.snd_files(file_id, connection_id) ON DELETE CASCADE; - - - ALTER TABLE ONLY test_chat_schema.snd_files ADD CONSTRAINT snd_files_file_id_fkey FOREIGN KEY (file_id) REFERENCES test_chat_schema.files(file_id) ON DELETE CASCADE; diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 64fbbddda0..af46c14b83 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -46,7 +46,6 @@ module Simplex.Chat.Store.Profiles getUserContactProfiles, createUserContactLink, getUserAddressConnection, - getUserContactLinks, deleteUserAddress, getUserAddress, getUserContactLinkById, @@ -322,9 +321,9 @@ updateUserProfile db user p' DB.execute db "UPDATE users SET user_member_profile_updated_at = ? WHERE user_id = ?" (currentTs, userId) pure $ Just currentTs | otherwise = pure userMemberProfileUpdatedAt - userMemberProfileChanged = newName /= displayName || newFullName /= fullName || newImage /= image - User {userId, userContactId, localDisplayName, profile = LocalProfile {profileId, displayName, fullName, image, localAlias}, userMemberProfileUpdatedAt} = user - Profile {displayName = newName, fullName = newFullName, image = newImage, preferences} = p' + userMemberProfileChanged = newName /= displayName || fn' /= fullName || d' /= shortDescr || img' /= image + User {userId, userContactId, localDisplayName, profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, localAlias}, userMemberProfileUpdatedAt} = user + Profile {displayName = newName, fullName = fn', shortDescr = d', image = img', preferences} = p' profile = toLocalProfile profileId p' localAlias fullPreferences = fullPreferences' preferences @@ -378,7 +377,7 @@ getUserAddressConnection db vr User {userId} = do db [sql| SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.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.snd_file_id, c.rcv_file_id, c.user_contact_link_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 connections c @@ -387,26 +386,6 @@ getUserAddressConnection db vr User {userId} = do |] (userId, userId) -getUserContactLinks :: DB.Connection -> VersionRangeChat -> User -> IO [(Connection, UserContact)] -getUserContactLinks db vr User {userId} = - map toUserContactConnection - <$> DB.query - db - [sql| - SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.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.snd_file_id, c.rcv_file_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, - uc.user_contact_link_id, uc.conn_req_contact, uc.group_id - FROM connections c - JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id - WHERE c.user_id = ? AND uc.user_id = ? - |] - (userId, userId) - where - toUserContactConnection :: (ConnectionRow :. (Int64, ConnReqContact, Maybe GroupId)) -> (Connection, UserContact) - toUserContactConnection (connRow :. (userContactLinkId, connReqContact, groupId)) = (toConnection vr connRow, UserContact {userContactLinkId, connReqContact, groupId}) - deleteUserAddress :: DB.Connection -> User -> IO () deleteUserAddress db user@User {userId} = do DB.execute diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 3d5fc307ee..1c819e6537 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -139,6 +139,11 @@ import Simplex.Chat.Store.SQLite.Migrations.M20250721_indexes import Simplex.Chat.Store.SQLite.Migrations.M20250729_member_contact_requests import Simplex.Chat.Store.SQLite.Migrations.M20250801_via_group_link_uri import Simplex.Chat.Store.SQLite.Migrations.M20250802_chat_peer_type +import Simplex.Chat.Store.SQLite.Migrations.M20250813_delivery_tasks +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.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -277,7 +282,12 @@ schemaMigrations = ("20250721_indexes", m20250721_indexes, Just down_m20250721_indexes), ("20250729_member_contact_requests", m20250729_member_contact_requests, Just down_m20250729_member_contact_requests), ("20250801_via_group_link_uri", m20250801_via_group_link_uri, Just down_m20250801_via_group_link_uri), - ("20250802_chat_peer_type", m20250802_chat_peer_type, Just down_m20250802_chat_peer_type) + ("20250802_chat_peer_type", m20250802_chat_peer_type, Just down_m20250802_chat_peer_type), + ("20250813_delivery_tasks", m20250813_delivery_tasks, Just down_m20250813_delivery_tasks), + ("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) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250813_delivery_tasks.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250813_delivery_tasks.hs new file mode 100644 index 0000000000..0b70fb9dcb --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250813_delivery_tasks.hs @@ -0,0 +1,164 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20250813_delivery_tasks where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +-- TODO [channels fwd] add later in new migration for MemberProfileUpdate delivery jobs: +-- TODO - ALTER TABLE group_members ADD COLUMN last_profile_delivery_ts TEXT; +-- TODO - ALTER TABLE group_members ADD COLUMN join_ts TEXT; + +-- How columns correspond to types: + +-- both tables: +-- - <-> DeliveryWorkerKey = (GroupId, DeliveryWorkerScope), +-- - job_scope_spec_tag <-> Maybe DeliveryJobSpecTag (for DJSGroup job scope), +-- - job_scope_include_pending <-> Maybe Bool (for DJDeliveryJob job spec), +-- - job_scope_support_gm_id <-> Maybe GroupMemberId (for DJSMemberSupport job scope), +-- - failed <-> Bool (for internal worker use, to mark failed work items). + +-- delivery_tasks table: +-- - sender_group_member_id <-> GroupMemberId (sender of the original message that created task), +-- - message_id <-> MessageId (reference to the original message that created task), +-- - message_from_channel <-> Maybe MessageFromChannel (for MessageDeliveryTask), +-- - task_status <-> DeliveryTaskStatus, +-- - task_err_reason <-> Maybe Text (set when task status is DTSError, not encoded in status to allow filtering by DTSError in queries). + +-- delivery_jobs table: +-- - single_sender_group_member_id <-> Maybe GroupMemberId (set when all messages in job's delivery body are from the same sender), +-- - body <-> ByteString (JSON encoded batch of messages or single message packed with forwarding metadata (XGrpMsgForward)), +-- - cursor_group_member_id <-> Maybe GroupMemberId (for tracking progress of job processing buckets of recipient members), +-- - job_status <-> DeliveryJobStatus, +-- - job_err_reason <-> Maybe Text (set when job status is DJSError, not encoded in status to allow filtering by DJSError in queries). +-- +-- Pair of columns defines the scope of work for a worker. +-- +-- Both tasks and jobs are defined by the same range of worker scopes, with a delivery task worker +-- converting tasks into jobs for a delivery job worker of the same scope. +-- Each group can have up to 1 task worker and 1 job worker for each worker scope. +-- See DeliveryWorkerScope. +-- +-- Columns job_scope_spec_tag, job_scope_include_pending, job_scope_support_gm_id narrow down delivery scope for a job. +-- See DeliveryJobScope. +-- +-- In some cases multiple tasks of the same job scope may be converted into a single job. +-- For example, messages referenced in DJDeliveryJob tasks of the same scope/spec can be batched for a single delivery. +-- Some tasks, for example of DJRelayRemoved specialization, are converted one-to-one into jobs. +-- +-- Delivery scopes can be expanded to create more specialized jobs. +-- For example DWSMemberProfileUpdate workers are planned to deliver senders' profiles to +-- all members satisfying criteria: sender last_profile_delivery_ts < recipient join_ts. +-- Tasks for these jobs are planned to be created based on sender and group state, rather than per message, +-- so member profile updates will be delivered separately from message deliveries. +-- See rfc doc for more details. +m20250813_delivery_tasks :: Query +m20250813_delivery_tasks = + [sql| +CREATE TABLE delivery_tasks ( + delivery_task_id INTEGER PRIMARY KEY AUTOINCREMENT, + group_id INTEGER NOT NULL REFERENCES groups ON DELETE CASCADE, + worker_scope TEXT NOT NULL, + job_scope_spec_tag TEXT, + job_scope_include_pending INTEGER, + job_scope_support_gm_id INTEGER REFERENCES group_members(group_member_id) ON DELETE CASCADE, + sender_group_member_id INTEGER NOT NULL REFERENCES group_members(group_member_id) ON DELETE CASCADE, + message_id INTEGER REFERENCES messages ON DELETE CASCADE, + message_from_channel INTEGER NOT NULL DEFAULT 0, + task_status TEXT NOT NULL, + task_err_reason TEXT, + failed INTEGER DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX idx_delivery_tasks_group_id ON delivery_tasks(group_id); +CREATE INDEX idx_delivery_tasks_job_scope_support_gm_id ON delivery_tasks(job_scope_support_gm_id); +CREATE INDEX idx_delivery_tasks_sender_group_member_id ON delivery_tasks(sender_group_member_id); +CREATE INDEX idx_delivery_tasks_message_id ON delivery_tasks(message_id); + +CREATE INDEX idx_delivery_tasks_next ON delivery_tasks( + group_id, + worker_scope, + failed, + task_status +); +CREATE INDEX idx_delivery_tasks_next_for_job_scope ON delivery_tasks( + group_id, + worker_scope, + job_scope_spec_tag, + job_scope_include_pending, + job_scope_support_gm_id, + failed, + task_status +); +CREATE INDEX idx_delivery_tasks_next_for_job_scope_sender ON delivery_tasks( + group_id, + worker_scope, + job_scope_spec_tag, + job_scope_include_pending, + job_scope_support_gm_id, + sender_group_member_id, + failed, + task_status +); +CREATE INDEX idx_delivery_tasks_created_at ON delivery_tasks(created_at); + +CREATE TABLE delivery_jobs ( + delivery_job_id INTEGER PRIMARY KEY AUTOINCREMENT, + group_id INTEGER NOT NULL REFERENCES groups ON DELETE CASCADE, + worker_scope TEXT NOT NULL, + job_scope_spec_tag TEXT, + job_scope_include_pending INTEGER, + job_scope_support_gm_id INTEGER REFERENCES group_members(group_member_id) ON DELETE CASCADE, + single_sender_group_member_id INTEGER REFERENCES group_members(group_member_id) ON DELETE CASCADE, + body BLOB, + cursor_group_member_id INTEGER, + job_status TEXT NOT NULL, + job_err_reason TEXT, + failed INTEGER DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX idx_delivery_jobs_group_id ON delivery_jobs(group_id); +CREATE INDEX idx_delivery_jobs_job_scope_support_gm_id ON delivery_jobs(job_scope_support_gm_id); +CREATE INDEX idx_delivery_jobs_single_sender_group_member_id ON delivery_jobs(single_sender_group_member_id); + +CREATE INDEX idx_delivery_jobs_next ON delivery_jobs( + group_id, + worker_scope, + failed, + job_status +); +CREATE INDEX idx_delivery_jobs_created_at ON delivery_jobs(created_at); + +ALTER TABLE messages ADD COLUMN broker_ts TEXT; +|] + +down_m20250813_delivery_tasks :: Query +down_m20250813_delivery_tasks = + [sql| +ALTER TABLE messages DROP COLUMN broker_ts; + +DROP INDEX idx_delivery_jobs_group_id; +DROP INDEX idx_delivery_jobs_job_scope_support_gm_id; +DROP INDEX idx_delivery_jobs_single_sender_group_member_id; + +DROP INDEX idx_delivery_jobs_next; +DROP INDEX idx_delivery_jobs_created_at; + +DROP TABLE delivery_jobs; + +DROP INDEX idx_delivery_tasks_group_id; +DROP INDEX idx_delivery_tasks_job_scope_support_gm_id; +DROP INDEX idx_delivery_tasks_sender_group_member_id; +DROP INDEX idx_delivery_tasks_message_id; + +DROP INDEX idx_delivery_tasks_next; +DROP INDEX idx_delivery_tasks_next_for_job_scope; +DROP INDEX idx_delivery_tasks_next_for_job_scope_sender; +DROP INDEX idx_delivery_tasks_created_at; + +DROP TABLE delivery_tasks; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250919_group_summary.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250919_group_summary.hs new file mode 100644 index 0000000000..0a9e7451e3 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250919_group_summary.hs @@ -0,0 +1,95 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20250919_group_summary where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20250919_group_summary :: Query +m20250919_group_summary = + [sql| +ALTER TABLE groups ADD COLUMN summary_current_members_count INTEGER NOT NULL DEFAULT 0; +CREATE INDEX idx_groups_summary_current_members_count ON groups(summary_current_members_count); + +CREATE TABLE group_member_status_predicates( + member_status TEXT NOT NULL PRIMARY KEY, + current_member INTEGER NOT NULL DEFAULT 0 +); + +INSERT INTO group_member_status_predicates(member_status, current_member) +VALUES + ('rejected', 0), + ('removed', 0), + ('left', 0), + ('deleted', 0), + ('unknown', 0), + ('invited', 0), + ('pending_approval', 0), + ('pending_review', 0), + ('introduced', 1), + ('intro-inv', 1), + ('accepted', 1), + ('announced', 1), + ('connected', 1), + ('complete', 1), + ('creator', 1); + +UPDATE groups +SET summary_current_members_count = c.cnt +FROM ( + SELECT m.group_id, COUNT(m.group_member_id) AS cnt + FROM group_members m + JOIN group_member_status_predicates p ON m.member_status = p.member_status + WHERE p.current_member = 1 + GROUP BY m.group_id +) AS c +WHERE groups.group_id = c.group_id; + +CREATE TRIGGER on_group_members_insert_update_summary +AFTER INSERT ON group_members +FOR EACH ROW +WHEN EXISTS (SELECT 1 FROM group_member_status_predicates WHERE member_status = NEW.member_status AND current_member = 1) +BEGIN + UPDATE groups + SET summary_current_members_count = summary_current_members_count + 1 + WHERE group_id = NEW.group_id; +END; + +CREATE TRIGGER on_group_members_delete_update_summary +AFTER DELETE ON group_members +FOR EACH ROW +WHEN EXISTS (SELECT 1 FROM group_member_status_predicates WHERE member_status = OLD.member_status AND current_member = 1) +BEGIN + UPDATE groups + SET summary_current_members_count = summary_current_members_count - 1 + WHERE group_id = OLD.group_id; +END; + +CREATE TRIGGER on_group_members_update_update_summary +AFTER UPDATE ON group_members +FOR EACH ROW +WHEN EXISTS (SELECT 1 FROM group_member_status_predicates WHERE member_status = OLD.member_status AND current_member = 1) + != EXISTS (SELECT 1 FROM group_member_status_predicates WHERE member_status = NEW.member_status AND current_member = 1) +BEGIN + UPDATE groups + SET summary_current_members_count = summary_current_members_count + + ( + CASE WHEN EXISTS (SELECT 1 FROM group_member_status_predicates WHERE member_status = NEW.member_status AND current_member = 1) + THEN 1 ELSE -1 END + ) + WHERE group_id = NEW.group_id; +END; +|] + +down_m20250919_group_summary :: Query +down_m20250919_group_summary = + [sql| +DROP TRIGGER on_group_members_insert_update_summary; +DROP TRIGGER on_group_members_delete_update_summary; +DROP TRIGGER on_group_members_update_update_summary; + +DROP TABLE group_member_status_predicates; + +DROP INDEX idx_groups_summary_current_members_count; +ALTER TABLE groups DROP COLUMN summary_current_members_count; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250922_remove_unused_connections.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250922_remove_unused_connections.hs new file mode 100644 index 0000000000..d159065b13 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250922_remove_unused_connections.hs @@ -0,0 +1,93 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20250922_remove_unused_connections where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20250922_remove_unused_connections :: Query +m20250922_remove_unused_connections = + [sql| +DELETE FROM connections WHERE snd_file_id IS NOT NULL; +DELETE FROM connections WHERE rcv_file_id IS NOT NULL; + +DROP TABLE snd_file_chunks; + +WITH ranked_contact_connections AS ( + SELECT + c.connection_id, + ROW_NUMBER() OVER ( + PARTITION BY c.user_id, c.contact_id + ORDER BY + CASE WHEN c.conn_status = 'ready' OR c.conn_status = 'snd-ready' THEN 1 ELSE 0 END DESC, + c.created_at DESC + ) AS rn + FROM connections c + WHERE c.contact_id IS NOT NULL +) +DELETE FROM connections +WHERE connection_id IN ( + SELECT connection_id + FROM ranked_contact_connections + WHERE rn > 1 +); + +WITH ranked_group_member_connections AS ( + SELECT + c.connection_id, + ROW_NUMBER() OVER ( + PARTITION BY c.user_id, c.group_member_id + ORDER BY c.connection_id DESC + ) AS rn + FROM connections c + WHERE c.group_member_id IS NOT NULL +) +DELETE FROM connections +WHERE connection_id IN ( + SELECT connection_id + FROM ranked_group_member_connections + WHERE rn > 1 +); + +DROP INDEX idx_connections_contact_id; +DROP INDEX idx_connections_group_member_id; + +CREATE UNIQUE INDEX idx_connections_contact_id ON connections(contact_id); +CREATE UNIQUE INDEX idx_connections_group_member_id ON connections(group_member_id); + +DROP INDEX idx_connections_to_subscribe; +CREATE INDEX idx_connections_to_subscribe ON connections(user_id, to_subscribe); + +DROP INDEX idx_contacts_via_group; +ALTER TABLE contacts DROP COLUMN via_group; +|] + +down_m20250922_remove_unused_connections :: Query +down_m20250922_remove_unused_connections = + [sql| +CREATE TABLE snd_file_chunks( + file_id INTEGER NOT NULL, + connection_id INTEGER NOT NULL, + chunk_number INTEGER NOT NULL, + chunk_agent_msg_id INTEGER, + chunk_sent INTEGER NOT NULL DEFAULT 0, + created_at TEXT CHECK(created_at NOT NULL), + updated_at TEXT CHECK(updated_at NOT NULL), + FOREIGN KEY(file_id, connection_id) REFERENCES snd_files ON DELETE CASCADE, + PRIMARY KEY(file_id, connection_id, chunk_number) +) WITHOUT ROWID; + +CREATE INDEX idx_snd_file_chunks_file_id_connection_id ON snd_file_chunks(file_id, connection_id); + +DROP INDEX idx_connections_contact_id; +DROP INDEX idx_connections_group_member_id; + +CREATE INDEX idx_connections_contact_id ON connections(contact_id); +CREATE INDEX idx_connections_group_member_id ON connections(group_member_id); + +DROP INDEX idx_connections_to_subscribe; +CREATE INDEX idx_connections_to_subscribe ON connections(to_subscribe); + +ALTER TABLE contacts ADD COLUMN via_group INTEGER REFERENCES groups(group_id) ON DELETE SET NULL; +CREATE INDEX idx_contacts_via_group ON contacts(via_group); +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20251007_connections_sync.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20251007_connections_sync.hs new file mode 100644 index 0000000000..239f0aeec1 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20251007_connections_sync.hs @@ -0,0 +1,25 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20251007_connections_sync where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +-- should_sync should be set manually when required +m20251007_connections_sync :: Query +m20251007_connections_sync = + [sql| +CREATE TABLE connections_sync( + connections_sync_id INTEGER PRIMARY KEY AUTOINCREMENT, + should_sync INTEGER NOT NULL DEFAULT 0, + last_sync_ts TEXT +); + +INSERT INTO connections_sync (connections_sync_id, should_sync, last_sync_ts) VALUES (1, 1, NULL); +|] + +down_m20251007_connections_sync :: Query +down_m20251007_connections_sync = + [sql| +DROP TABLE connections_sync; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20251017_chat_tags_cascade.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20251017_chat_tags_cascade.hs new file mode 100644 index 0000000000..1f82831404 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20251017_chat_tags_cascade.hs @@ -0,0 +1,30 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20251017_chat_tags_cascade where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20251017_chat_tags_cascade :: Query +m20251017_chat_tags_cascade = + [sql| +PRAGMA writable_schema=1; + +UPDATE sqlite_master +SET sql = replace(sql, 'user_id INTEGER REFERENCES users', 'user_id INTEGER REFERENCES users ON DELETE CASCADE') +WHERE name = 'chat_tags' AND type = 'table'; + +PRAGMA writable_schema=0; +|] + +down_m20251017_chat_tags_cascade :: Query +down_m20251017_chat_tags_cascade = + [sql| +PRAGMA writable_schema=1; + +UPDATE sqlite_master +SET sql = replace(sql, 'user_id INTEGER REFERENCES users ON DELETE CASCADE', 'user_id INTEGER REFERENCES users') +WHERE name = 'chat_tags' AND type = 'table'; + +PRAGMA writable_schema=0; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt index 41f01b3b51..3c15a4303c 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt @@ -63,9 +63,11 @@ Query: SELECT rcv_file_chunk_id, chunk_no, chunk_size, digest, tmp_path FROM rcv_file_chunks WHERE rcv_file_id = ? + ORDER BY chunk_no ASC Plan: SEARCH rcv_file_chunks USING INDEX idx_rcv_file_chunks_rcv_file_id (rcv_file_id=?) +USE TEMP B-TREE FOR ORDER BY Query: SELECT snd_file_chunk_id, chunk_no, chunk_offset, chunk_size, digest @@ -327,16 +329,6 @@ Plan: SEARCH r USING INTEGER PRIMARY KEY (rowid=?) SEARCH s USING INTEGER PRIMARY KEY (rowid=?) -Query: - SELECT - user_id, conn_id, conn_mode, smp_agent_version, enable_ntfs, - last_external_snd_msg_id, deleted, ratchet_sync_state, pq_support - FROM connections - WHERE conn_id = ? - -Plan: -SEARCH connections USING PRIMARY KEY (conn_id=?) - Query: SELECT DISTINCT s.xftp_host, s.xftp_port, s.xftp_key_hash @@ -384,16 +376,42 @@ SEARCH s USING INTEGER PRIMARY KEY (rowid=?) USE TEMP B-TREE FOR DISTINCT Query: - SELECT DISTINCT c.host, c.port, COALESCE(c.server_key_hash, s.key_hash) + SELECT DISTINCT c.conn_id, c.host, c.port, COALESCE(c.server_key_hash, s.key_hash) FROM commands c + JOIN connections cs ON c.conn_id = cs.conn_id LEFT JOIN servers s ON s.host = c.host AND s.port = c.port - WHERE conn_id = ? + WHERE cs.deleted = 0 Plan: -SEARCH c USING INDEX idx_commands_conn_id (conn_id=?) +SCAN c USING INDEX idx_commands_conn_id +SEARCH cs USING PRIMARY KEY (conn_id=?) SEARCH s USING PRIMARY KEY (host=? AND port=?) LEFT-JOIN USE TEMP B-TREE FOR DISTINCT +Query: + SELECT DISTINCT c.user_id, q.host, q.port, COALESCE(q.server_key_hash, s.key_hash) + FROM rcv_queues q + JOIN servers s ON q.host = s.host AND q.port = s.port + JOIN connections c ON q.conn_id = c.conn_id + WHERE c.deleted = 0 AND q.deleted = 0 +Plan: +SCAN q USING INDEX idx_rcv_queues_client_notice_id +SEARCH c USING PRIMARY KEY (conn_id=?) +SEARCH s USING PRIMARY KEY (host=? AND port=?) +USE TEMP B-TREE FOR DISTINCT + +Query: + SELECT DISTINCT c.user_id, q.host, q.port, COALESCE(q.server_key_hash, s.key_hash) + FROM rcv_queues q + JOIN servers s ON q.host = s.host AND q.port = s.port + JOIN connections c ON q.conn_id = c.conn_id + WHERE q.to_subscribe = 1 AND c.deleted = 0 AND q.deleted = 0 +Plan: +SEARCH q USING INDEX idx_rcv_queues_to_subscribe (to_subscribe=?) +SEARCH s USING PRIMARY KEY (host=? AND port=?) +SEARCH c USING PRIMARY KEY (conn_id=?) +USE TEMP B-TREE FOR DISTINCT + Query: SELECT DISTINCT ntf_host, ntf_port, ntf_key_hash FROM ntf_tokens_to_delete @@ -459,6 +477,16 @@ Query: Plan: SEARCH inv_short_links USING INDEX idx_inv_short_links_link_id (host=? AND port=? AND link_id=?) +Query: + SELECT n.host, n.port, n.entity_id, COALESCE(n.server_key_hash, s.key_hash), n.created_at, n.notice_ttl + FROM client_notices n + JOIN servers s ON n.host = s.host AND n.port = s.port + WHERE n.protocol = 'smp' + +Plan: +SEARCH n USING INDEX idx_client_notices_entity (protocol=?) +SEARCH s USING PRIMARY KEY (host=? AND port=?) + Query: SELECT s.internal_id, m.msg_type, s.internal_hash, s.rcpt_internal_id, s.rcpt_status FROM snd_messages s @@ -469,6 +497,15 @@ Plan: SEARCH s USING PRIMARY KEY (conn_id=? AND internal_snd_id=?) SEARCH m USING PRIMARY KEY (conn_id=? AND internal_id=?) +Query: + SELECT user_id, conn_id, conn_mode, smp_agent_version, enable_ntfs, + last_external_snd_msg_id, deleted, ratchet_sync_state, pq_support + FROM connections + WHERE conn_id = ? AND deleted = ? + +Plan: +SEARCH connections USING PRIMARY KEY (conn_id=?) + Query: DELETE FROM conn_confirmations WHERE conn_id = ? @@ -554,10 +591,10 @@ SEARCH messages USING COVERING INDEX idx_messages_conn_id_internal_rcv_id (conn_ Query: INSERT INTO rcv_queues ( host, port, rcv_id, conn_id, rcv_private_key, rcv_dh_secret, e2e_priv_key, e2e_dh_secret, - snd_id, queue_mode, status, rcv_queue_id, rcv_primary, replace_rcv_queue_id, smp_client_version, server_key_hash, + snd_id, queue_mode, status, to_subscribe, rcv_queue_id, rcv_primary, replace_rcv_queue_id, smp_client_version, server_key_hash, link_id, link_key, link_priv_sig_key, link_enc_fixed_data, ntf_public_key, ntf_private_key, ntf_id, rcv_ntf_dh_secret - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?); + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?); Plan: @@ -579,16 +616,15 @@ SEARCH messages USING COVERING INDEX idx_messages_conn_id_internal_snd_id (conn_ Query: INSERT INTO snd_queues - (host, port, snd_id, queue_mode, conn_id, snd_public_key, snd_private_key, e2e_pub_key, e2e_dh_secret, + (host, port, snd_id, queue_mode, conn_id, snd_private_key, e2e_pub_key, e2e_dh_secret, status, snd_queue_id, snd_primary, replace_snd_queue_id, smp_client_version, server_key_hash) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT (host, port, snd_id) DO UPDATE SET host=EXCLUDED.host, port=EXCLUDED.port, snd_id=EXCLUDED.snd_id, queue_mode=EXCLUDED.queue_mode, conn_id=EXCLUDED.conn_id, - snd_public_key=EXCLUDED.snd_public_key, snd_private_key=EXCLUDED.snd_private_key, e2e_pub_key=EXCLUDED.e2e_pub_key, e2e_dh_secret=EXCLUDED.e2e_dh_secret, @@ -733,7 +769,28 @@ SEARCH snd_queues USING PRIMARY KEY (host=? AND port=? AND snd_id=?) Query: SELECT c.user_id, COALESCE(q.server_key_hash, s.key_hash), q.conn_id, q.host, q.port, q.snd_id, q.queue_mode, - q.snd_public_key, q.snd_private_key, q.e2e_pub_key, q.e2e_dh_secret, q.status, + q.snd_private_key, q.e2e_pub_key, q.e2e_dh_secret, q.status, + q.snd_queue_id, q.snd_primary, q.replace_snd_queue_id, q.switch_status, q.smp_client_version + FROM snd_queues q + JOIN servers s ON q.host = s.host AND q.port = s.port + JOIN connections c ON q.conn_id = c.conn_id + + JOIN (SELECT DISTINCT conn_id, snd_queue_id FROM snd_message_deliveries WHERE failed = 0) d + ON d.conn_id = q.conn_id AND d.snd_queue_id = q.snd_queue_id + WHERE c.deleted = 0 + +Plan: +MATERIALIZE d +SCAN snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries_expired +SCAN d +SEARCH q USING INDEX idx_snd_queue_id (conn_id=? AND snd_queue_id=?) +SEARCH s USING PRIMARY KEY (host=? AND port=?) +SEARCH c USING PRIMARY KEY (conn_id=?) + +Query: + SELECT + c.user_id, COALESCE(q.server_key_hash, s.key_hash), q.conn_id, q.host, q.port, q.snd_id, q.queue_mode, + q.snd_private_key, q.e2e_pub_key, q.e2e_dh_secret, q.status, q.snd_queue_id, q.snd_primary, q.replace_snd_queue_id, q.switch_status, q.smp_client_version FROM snd_queues q JOIN servers s ON q.host = s.host AND q.port = s.port @@ -746,7 +803,7 @@ SEARCH s USING PRIMARY KEY (host=? AND port=?) Query: SELECT c.user_id, COALESCE(q.server_key_hash, s.key_hash), q.conn_id, q.host, q.port, q.rcv_id, q.rcv_private_key, q.rcv_dh_secret, - q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.queue_mode, q.status, + q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.queue_mode, q.status, c.enable_ntfs, q.client_notice_id, q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id, q.switch_status, q.smp_client_version, q.delete_errors, q.ntf_public_key, q.ntf_private_key, q.ntf_id, q.rcv_ntf_dh_secret, q.link_id, q.link_key, q.link_priv_sig_key, q.link_enc_fixed_data @@ -761,7 +818,7 @@ SEARCH s USING PRIMARY KEY (host=? AND port=?) Query: SELECT c.user_id, COALESCE(q.server_key_hash, s.key_hash), q.conn_id, q.host, q.port, q.rcv_id, q.rcv_private_key, q.rcv_dh_secret, - q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.queue_mode, q.status, + q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.queue_mode, q.status, c.enable_ntfs, q.client_notice_id, q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id, q.switch_status, q.smp_client_version, q.delete_errors, q.ntf_public_key, q.ntf_private_key, q.ntf_id, q.rcv_ntf_dh_secret, q.link_id, q.link_key, q.link_priv_sig_key, q.link_enc_fixed_data @@ -776,7 +833,7 @@ SEARCH c USING PRIMARY KEY (conn_id=?) Query: SELECT c.user_id, COALESCE(q.server_key_hash, s.key_hash), q.conn_id, q.host, q.port, q.rcv_id, q.rcv_private_key, q.rcv_dh_secret, - q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.queue_mode, q.status, + q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.queue_mode, q.status, c.enable_ntfs, q.client_notice_id, q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id, q.switch_status, q.smp_client_version, q.delete_errors, q.ntf_public_key, q.ntf_private_key, q.ntf_id, q.rcv_ntf_dh_secret, q.link_id, q.link_key, q.link_priv_sig_key, q.link_enc_fixed_data @@ -791,7 +848,7 @@ SEARCH c USING PRIMARY KEY (conn_id=?) Query: SELECT c.user_id, COALESCE(q.server_key_hash, s.key_hash), q.conn_id, q.host, q.port, q.rcv_id, q.rcv_private_key, q.rcv_dh_secret, - q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.queue_mode, q.status, + q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.queue_mode, q.status, c.enable_ntfs, q.client_notice_id, q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id, q.switch_status, q.smp_client_version, q.delete_errors, q.ntf_public_key, q.ntf_private_key, q.ntf_id, q.rcv_ntf_dh_secret, q.link_id, q.link_key, q.link_priv_sig_key, q.link_enc_fixed_data @@ -806,7 +863,7 @@ SEARCH s USING PRIMARY KEY (host=? AND port=?) Query: SELECT c.user_id, COALESCE(q.server_key_hash, s.key_hash), q.conn_id, q.host, q.port, q.rcv_id, q.rcv_private_key, q.rcv_dh_secret, - q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.queue_mode, q.status, + q.e2e_priv_key, q.e2e_dh_secret, q.snd_id, q.queue_mode, q.status, c.enable_ntfs, q.client_notice_id, q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id, q.switch_status, q.smp_client_version, q.delete_errors, q.ntf_public_key, q.ntf_private_key, q.ntf_id, q.rcv_ntf_dh_secret, q.link_id, q.link_key, q.link_priv_sig_key, q.link_enc_fixed_data @@ -819,6 +876,30 @@ SEARCH q USING PRIMARY KEY (host=? AND port=? AND rcv_id=?) SEARCH s USING PRIMARY KEY (host=? AND port=?) SEARCH c USING PRIMARY KEY (conn_id=?) +Query: + SELECT c.user_id, q.conn_id, q.host, q.port, COALESCE(q.server_key_hash, s.key_hash), q.rcv_id, q.rcv_private_key, q.status, c.enable_ntfs, q.client_notice_id, + q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id + FROM rcv_queues q + JOIN servers s ON q.host = s.host AND q.port = s.port + JOIN connections c ON q.conn_id = c.conn_id + WHERE c.deleted = 0 AND q.deleted = 0 AND c.user_id = ? AND q.host = ? AND q.port = ? +Plan: +SEARCH s USING PRIMARY KEY (host=? AND port=?) +SEARCH q USING PRIMARY KEY (host=? AND port=?) +SEARCH c USING PRIMARY KEY (conn_id=?) + +Query: + SELECT c.user_id, q.conn_id, q.host, q.port, COALESCE(q.server_key_hash, s.key_hash), q.rcv_id, q.rcv_private_key, q.status, c.enable_ntfs, q.client_notice_id, + q.rcv_queue_id, q.rcv_primary, q.replace_rcv_queue_id + FROM rcv_queues q + JOIN servers s ON q.host = s.host AND q.port = s.port + JOIN connections c ON q.conn_id = c.conn_id + WHERE q.to_subscribe = 1 AND c.deleted = 0 AND q.deleted = 0 AND c.user_id = ? AND q.host = ? AND q.port = ? +Plan: +SEARCH q USING INDEX idx_rcv_queues_to_subscribe (to_subscribe=? AND host=? AND port=?) +SEARCH c USING PRIMARY KEY (conn_id=?) +SEARCH s USING PRIMARY KEY (host=? AND port=?) + Query: DELETE FROM commands WHERE command_id = ? Plan: SEARCH commands USING INTEGER PRIMARY KEY (rowid=?) @@ -841,6 +922,20 @@ SEARCH messages USING COVERING INDEX idx_messages_conn_id (conn_id=?) SEARCH snd_queues USING COVERING INDEX idx_snd_queue_id (conn_id=?) SEARCH rcv_queues USING COVERING INDEX idx_rcv_queue_id (conn_id=?) +Query: DELETE FROM connections WHERE user_id = 2 +Plan: +SEARCH connections USING COVERING INDEX idx_connections_user (user_id=?) +SEARCH processed_ratchet_key_hashes USING COVERING INDEX idx_processed_ratchet_key_hashes_hash (conn_id=?) +SEARCH encrypted_rcv_message_hashes USING COVERING INDEX idx_encrypted_rcv_message_hashes_hash (conn_id=?) +SEARCH snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries_conn_id_internal_id (conn_id=?) +SEARCH commands USING COVERING INDEX idx_commands_conn_id (conn_id=?) +SEARCH ratchets USING PRIMARY KEY (conn_id=?) +SEARCH conn_invitations USING COVERING INDEX idx_conn_invitations_contact_conn_id (contact_conn_id=?) +SEARCH conn_confirmations USING COVERING INDEX idx_conn_confirmations_conn_id (conn_id=?) +SEARCH messages USING COVERING INDEX idx_messages_conn_id (conn_id=?) +SEARCH snd_queues USING COVERING INDEX idx_snd_queue_id (conn_id=?) +SEARCH rcv_queues USING COVERING INDEX idx_rcv_queue_id (conn_id=?) + Query: DELETE FROM deleted_snd_chunk_replicas WHERE deleted_snd_chunk_replica_id = ? Plan: SEARCH deleted_snd_chunk_replicas USING INTEGER PRIMARY KEY (rowid=?) @@ -892,6 +987,14 @@ Query: DELETE FROM snd_queues WHERE conn_id = ? AND snd_queue_id = ? Plan: SEARCH snd_queues USING COVERING INDEX idx_snd_queue_id (conn_id=? AND snd_queue_id=?) +Query: DELETE FROM users WHERE user_id = 2 +Plan: +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) +SEARCH deleted_snd_chunk_replicas USING COVERING INDEX idx_deleted_snd_chunk_replicas_user_id (user_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_user_id (user_id=?) +SEARCH rcv_files USING COVERING INDEX idx_rcv_files_user_id (user_id=?) +SEARCH connections USING COVERING INDEX idx_connections_user (user_id=?) + Query: DELETE FROM users WHERE user_id = ? Plan: SEARCH users USING INTEGER PRIMARY KEY (rowid=?) @@ -964,18 +1067,26 @@ Query: SELECT 1 FROM snd_message_deliveries WHERE conn_id = ? AND failed = 0 LIM Plan: SEARCH snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries_expired (conn_id=?) -Query: SELECT DISTINCT conn_id FROM snd_message_deliveries WHERE failed = 0 +Query: SELECT conn_id FROM connections WHERE deleted = 0 Plan: -SCAN snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries_expired +SCAN connections Query: SELECT conn_id FROM connections WHERE user_id = ? Plan: SEARCH connections USING COVERING INDEX idx_connections_user (user_id=?) +Query: SELECT count(1) FROM connections +Plan: +SCAN connections USING COVERING INDEX idx_connections_user + Query: SELECT count(1) FROM snd_message_bodies Plan: SCAN snd_message_bodies +Query: SELECT count(1) FROM users +Plan: +SCAN users + Query: SELECT deleted FROM snd_files WHERE snd_file_id = ? Plan: SEARCH snd_files USING INTEGER PRIMARY KEY (rowid=?) @@ -1036,6 +1147,10 @@ Query: SELECT started_at, servers_stats FROM servers_stats WHERE servers_stats_i Plan: SEARCH servers_stats USING INTEGER PRIMARY KEY (rowid=?) +Query: SELECT user_id FROM users WHERE deleted = 0 +Plan: +SCAN users + Query: SELECT user_id FROM users WHERE user_id = ? AND deleted = ? Plan: SEARCH users USING INTEGER PRIMARY KEY (rowid=?) @@ -1128,6 +1243,10 @@ Query: UPDATE rcv_queues SET rcv_primary = ?, replace_rcv_queue_id = ? WHERE con Plan: SEARCH rcv_queues USING COVERING INDEX idx_rcv_queue_id (conn_id=? AND rcv_queue_id=?) +Query: UPDATE rcv_queues SET to_subscribe = 0 WHERE to_subscribe = 1 +Plan: +SEARCH rcv_queues USING COVERING INDEX idx_rcv_queues_to_subscribe (to_subscribe=?) + Query: UPDATE servers_stats SET servers_stats = ?, updated_at = ? WHERE servers_stats_id = 1 Plan: SEARCH servers_stats USING INTEGER PRIMARY KEY (rowid=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index 7eacc2337b..dbb4823024 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -25,7 +25,7 @@ SEARCH contact_requests USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE groups SET chat_ts = ?, - members_require_attention = members_require_attention + 1 + members_require_attention = MAX(0, members_require_attention - 1) WHERE user_id = ? AND group_id = ? Plan: @@ -34,7 +34,7 @@ SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE groups SET chat_ts = ?, - members_require_attention = members_require_attention - 1 + members_require_attention = members_require_attention + 1 WHERE user_id = ? AND group_id = ? Plan: @@ -48,6 +48,31 @@ Query: 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 groups @@ -57,6 +82,27 @@ Query: Plan: +Query: + SELECT + -- Contact + ct.contact_id, ct.contact_profile_id, ct.local_display_name, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, + ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, + ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + -- Connection + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.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 contacts ct + JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id + LEFT JOIN connections c ON c.contact_id = ct.contact_id + WHERE ct.user_id = ? AND ct.xcontact_id = ? AND ct.deleted = 0 + +Plan: +SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=?) +SEARCH cp USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_contact_id (contact_id=?) LEFT-JOIN + Query: SELECT -- GroupInfo @@ -65,7 +111,7 @@ Query: g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, - g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + 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.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, @@ -95,6 +141,22 @@ SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?) SEARCH pu USING INTEGER PRIMARY KEY (rowid=?) +Query: + SELECT delivery_task_id + FROM delivery_tasks + WHERE group_id = ? + AND worker_scope = ? + AND job_scope_spec_tag IS NOT DISTINCT FROM ? + AND job_scope_include_pending IS NOT DISTINCT FROM ? + AND job_scope_support_gm_id IS NOT DISTINCT FROM ? + AND sender_group_member_id = ? + AND failed = 0 + AND task_status = ? + ORDER BY delivery_task_id ASC + +Plan: +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_next_for_job_scope_sender (group_id=? AND worker_scope=? AND job_scope_spec_tag=? AND job_scope_include_pending=? AND job_scope_support_gm_id=? AND sender_group_member_id=? AND failed=? AND task_status=?) + Query: UPDATE contact_profiles SET user_id = ?, updated_at = ? @@ -176,6 +238,31 @@ Query: 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 @@ -185,6 +272,31 @@ Query: 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 groups @@ -196,27 +308,21 @@ Plan: Query: SELECT - -- Contact - ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, - cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, - ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, - ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, - -- Connection - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.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.snd_file_id, c.rcv_file_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 contacts ct - JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id - LEFT JOIN connections c ON c.contact_id = ct.contact_id - WHERE ct.user_id = ? AND ct.xcontact_id = ? AND ct.deleted = 0 - ORDER BY c.created_at DESC + cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, + cr.contact_id, cr.business_group_id, cr.user_contact_link_id, + cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, cr.xcontact_id, + cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, + cr.created_at, cr.updated_at, + cr.peer_chat_min_version, cr.peer_chat_max_version + FROM contact_requests cr + JOIN contact_profiles p USING (contact_profile_id) + WHERE cr.user_id = ? + AND cr.xcontact_id = ? LIMIT 1 Plan: -SEARCH ct USING INDEX idx_contacts_chat_ts (user_id=?) -SEARCH cp USING INTEGER PRIMARY KEY (rowid=?) -SEARCH c USING INDEX idx_connections_contact_id (contact_id=?) LEFT-JOIN -USE TEMP B-TREE FOR ORDER BY +SEARCH cr USING INDEX idx_contact_requests_xcontact_id (user_id=? AND xcontact_id=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT COUNT(1) @@ -249,34 +355,6 @@ Query: Plan: SEARCH operator_usage_conditions USING INDEX idx_operator_usage_conditions_server_operator_id (server_operator_id=?) -Query: - SELECT g.local_display_name, mu.member_status - FROM groups g - JOIN group_members mu ON mu.group_id = g.group_id - WHERE g.group_id = ? AND g.user_id = ? AND mu.contact_id = ? - AND mu.member_status NOT IN (?,?,?) - -Plan: -SEARCH g USING INTEGER PRIMARY KEY (rowid=?) -SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?) - -Query: - SELECT m.group_member_id, m.local_display_name, c.agent_conn_id - FROM group_members m - JOIN connections c ON c.connection_id = ( - SELECT max(cc.connection_id) - FROM connections cc - WHERE cc.user_id = ? AND cc.group_member_id = m.group_member_id - ) - WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) - AND m.member_status NOT IN (?,?,?) - -Plan: -SEARCH m USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) -SEARCH c USING INTEGER PRIMARY KEY (rowid=?) -CORRELATED SCALAR SUBQUERY 1 -SEARCH cc USING COVERING INDEX idx_connections_group_member (user_id=? AND group_member_id=?) - Query: SELECT timed_ttl FROM chat_items @@ -358,6 +436,31 @@ Query: 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 @@ -367,6 +470,31 @@ Query: 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 @@ -376,6 +504,31 @@ Query: 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 messages ( @@ -387,7 +540,7 @@ Plan: Query: SELECT - c.contact_profile_id, c.local_display_name, c.via_group, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, c.contact_used, c.contact_status, c.enable_ntfs, c.send_rcpts, c.favorite, + c.contact_profile_id, c.local_display_name, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, c.contact_used, c.contact_status, c.enable_ntfs, c.send_rcpts, c.favorite, p.preferences, c.user_preferences, c.created_at, c.updated_at, c.chat_ts, c.conn_full_link_to_connect, c.conn_short_link_to_connect, c.welcome_shared_msg_id, c.request_shared_msg_id, c.contact_request_id, c.contact_group_member_id, c.contact_grp_inv_sent, c.grp_direct_inv_link, c.grp_direct_inv_from_group_id, c.grp_direct_inv_from_group_member_id, c.grp_direct_inv_from_member_conn_id, c.grp_direct_inv_started_connection, c.ui_themes, c.chat_deleted, c.custom_data, c.chat_item_ttl @@ -401,21 +554,14 @@ SEARCH p USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT - cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, - cr.contact_id, cr.business_group_id, cr.user_contact_link_id, - cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, cr.xcontact_id, - cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences, - cr.created_at, cr.updated_at, - cr.peer_chat_min_version, cr.peer_chat_max_version - FROM contact_requests cr - JOIN contact_profiles p USING (contact_profile_id) - WHERE cr.user_id = ? - AND cr.xcontact_id = ? - LIMIT 1 + delivery_job_id, + worker_scope, job_scope_spec_tag, job_scope_include_pending, job_scope_support_gm_id, + single_sender_group_member_id, body, cursor_group_member_id + FROM delivery_jobs + WHERE delivery_job_id = ? Plan: -SEARCH cr USING INDEX idx_contact_requests_xcontact_id (user_id=? AND xcontact_id=?) -SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH delivery_jobs USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT COUNT(1) @@ -608,11 +754,11 @@ Query: WHERE user_id = ? AND short_link_inv = ? LIMIT 1 Plan: -SEARCH connections USING INDEX idx_connections_updated_at (user_id=?) +SEARCH connections USING INDEX idx_connections_to_subscribe (user_id=?) Query: SELECT connection_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, group_link_id, xcontact_id, custom_user_profile_id, - conn_status, conn_type, contact_conn_initiated, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, + conn_status, conn_type, contact_conn_initiated, local_alias, contact_id, group_member_id, user_contact_link_id, created_at, security_code, security_code_verified_at, pq_support, pq_encryption, pq_snd_enabled, pq_rcv_enabled, auth_err_counter, quota_err_counter, conn_chat_version, peer_chat_min_version, peer_chat_max_version FROM connections @@ -623,7 +769,7 @@ SEARCH connections USING INDEX sqlite_autoindex_connections_1 (agent_conn_id=?) Query: SELECT connection_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, group_link_id, xcontact_id, custom_user_profile_id, conn_status, conn_type, contact_conn_initiated, local_alias, - contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, security_code, security_code_verified_at, pq_support, pq_encryption, pq_snd_enabled, pq_rcv_enabled, auth_err_counter, quota_err_counter, + contact_id, group_member_id, user_contact_link_id, created_at, security_code, security_code_verified_at, pq_support, pq_encryption, pq_snd_enabled, pq_rcv_enabled, auth_err_counter, quota_err_counter, conn_chat_version, peer_chat_min_version, peer_chat_max_version FROM connections WHERE (user_id = ? AND via_contact_uri_hash = ?) @@ -648,6 +794,28 @@ Plan: SEARCH m USING INTEGER PRIMARY KEY (rowid=?) SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) +Query: + SELECT delivery_job_id + FROM delivery_jobs + WHERE group_id = ? AND worker_scope = ? + AND failed = 0 AND job_status = ? + ORDER BY delivery_job_id ASC + LIMIT 1 + +Plan: +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_next (group_id=? AND worker_scope=? AND failed=? AND job_status=?) + +Query: + SELECT delivery_task_id + FROM delivery_tasks + WHERE group_id = ? AND worker_scope = ? + AND failed = 0 AND task_status = ? + ORDER BY delivery_task_id ASC + LIMIT 1 + +Plan: +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_next (group_id=? AND worker_scope=? AND failed=? AND task_status=?) + Query: SELECT gp.display_name, gp.full_name, gp.short_descr, gp.description, gp.image, gp.preferences, gp.member_admission FROM group_profiles gp @@ -802,6 +970,31 @@ Query: 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 groups @@ -822,8 +1015,9 @@ Plan: Query: INSERT INTO messages - (msg_sent, chat_msg_event, msg_body, created_at, updated_at, connection_id, group_id, shared_msg_id, author_group_member_id, forwarded_by_group_member_id) - VALUES (?,?,?,?,?,?,?,?,?,?) + (msg_sent, chat_msg_event, msg_body, broker_ts, created_at, updated_at, connection_id, group_id, + shared_msg_id, author_group_member_id, forwarded_by_group_member_id) + VALUES (?,?,?,?,?,?,?,?,?,?,?) Plan: @@ -938,13 +1132,13 @@ SEARCH ri USING COVERING INDEX idx_chat_items_direct_shared_msg_id (user_id=? AN Query: SELECT -- Contact - ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + ct.contact_id, ct.contact_profile_id, ct.local_display_name, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.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.snd_file_id, c.rcv_file_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.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 contacts ct JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id @@ -953,8 +1147,6 @@ Query: ( (c.user_id = ? AND c.via_contact_uri_hash = ?) OR (c.user_id = ? AND c.via_contact_uri_hash = ?) ) AND ct.contact_status = ? AND ct.deleted = 0 - ORDER BY c.created_at DESC - LIMIT 1 Plan: MULTI-INDEX OR @@ -964,87 +1156,6 @@ INDEX 2 SEARCH c USING INDEX idx_connections_via_contact_uri_hash (user_id=? AND via_contact_uri_hash=?) SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) SEARCH cp USING INTEGER PRIMARY KEY (rowid=?) -USE TEMP B-TREE FOR ORDER BY - -Query: - SELECT - -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, - g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, - g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, - g.business_chat, g.business_member_id, g.customer_member_id, - g.ui_themes, 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.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, - -- via 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.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.snd_file_id, c.rcv_file_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 contacts ct ON ct.contact_id = m.contact_id - JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) - JOIN groups g ON g.group_id = m.group_id AND g.group_id = ct.via_group - JOIN group_profiles gp USING (group_profile_id) - JOIN group_members mu ON g.group_id = mu.group_id - JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) - LEFT JOIN connections c ON c.connection_id = ( - SELECT max(cc.connection_id) - FROM connections cc - where cc.user_id = ? AND cc.group_member_id = m.group_member_id - ) - WHERE ct.user_id = ? AND ct.contact_id = ? AND mu.contact_id = ? AND ct.deleted = 0 - -Plan: -SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) -SEARCH g USING INTEGER PRIMARY KEY (rowid=?) -SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) -SEARCH m USING INDEX sqlite_autoindex_group_members_1 (group_id=?) -SEARCH p USING INTEGER PRIMARY KEY (rowid=?) -SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?) -SEARCH pu USING INTEGER PRIMARY KEY (rowid=?) -SEARCH c USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN -CORRELATED SCALAR SUBQUERY 1 -SEARCH cc USING COVERING INDEX idx_connections_group_member (user_id=? AND group_member_id=?) - -Query: - SELECT - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, - g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, - g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, - g.business_chat, g.business_member_id, g.customer_member_id, - g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, - mu.group_member_id, g.group_id, 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, - 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 groups g - JOIN group_profiles gp USING (group_profile_id) - JOIN group_members mu USING (group_id) - JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id) - WHERE g.user_id = ? AND mu.contact_id = ? - AND (LOWER(gp.display_name) LIKE '%' || ? || '%' - OR LOWER(gp.full_name) LIKE '%' || ? || '%' - OR LOWER(gp.short_descr) LIKE '%' || ? || '%' - OR LOWER(gp.description) LIKE '%' || ? || '%' - ) - -Plan: -SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?) -SEARCH g USING INTEGER PRIMARY KEY (rowid=?) -SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) -SEARCH pu USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT 1 @@ -1077,20 +1188,6 @@ SEARCH users USING INTEGER PRIMARY KEY (rowid=?) INDEX 2 SEARCH users USING INDEX sqlite_autoindex_users_1 (contact_id=?) -Query: - SELECT DISTINCT f.file_id - FROM files f - JOIN snd_files s USING (file_id) - WHERE f.user_id = ? - AND s.file_status IN (?, ?, ?) - AND s.file_descr_id IS NULL - AND s.file_inline IS NULL - AND s.created_at > ? - -Plan: -SEARCH f USING COVERING INDEX idx_files_user_id (user_id=?) -SEARCH s USING INDEX idx_snd_files_file_descr_id (file_descr_id=? AND file_id=?) - Query: SELECT agent_conn_id FROM ( SELECT @@ -1108,7 +1205,7 @@ USE TEMP B-TREE FOR ORDER BY Query: SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.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.snd_file_id, c.rcv_file_id, c.user_contact_link_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 connections c @@ -1116,8 +1213,8 @@ Query: WHERE c.user_id = ? AND ct.user_id = ? AND ct.contact_id = ? Plan: -SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) SEARCH c USING INDEX idx_connections_contact_id (contact_id=?) +SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT chat_item_id @@ -1189,6 +1286,25 @@ Plan: SEARCH chat_items USING INDEX idx_chat_items_group_scope_item_ts (user_id=?) USE TEMP B-TREE FOR ORDER BY +Query: + SELECT chat_item_id, timed_ttl + FROM chat_items + WHERE user_id = ? AND group_id = ? + AND group_scope_tag = ? AND group_scope_group_member_id IS NOT DISTINCT FROM ? + AND item_status = ? AND timed_ttl IS NOT NULL AND timed_delete_at IS NULL + +Plan: +SEARCH chat_items USING 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=?) + +Query: + SELECT chat_item_id, timed_ttl + FROM chat_items + WHERE user_id = ? AND group_id = ? + AND item_status = ? AND timed_ttl IS NOT NULL AND timed_delete_at IS NULL + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=? AND item_status=?) + Query: SELECT chat_item_moderation_id, moderator_member_id, created_by_msg_id, moderated_at FROM chat_item_moderations @@ -1199,7 +1315,7 @@ Plan: SEARCH chat_item_moderations USING INDEX idx_chat_item_moderations_group (group_id=? AND item_member_id=? AND shared_msg_id=?) Query: - SELECT connection_id, conn_type, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id + SELECT connection_id, conn_type, contact_id, group_member_id, user_contact_link_id FROM connections WHERE agent_conn_id = ? @@ -1215,18 +1331,6 @@ Query: Plan: SEARCH files USING INTEGER PRIMARY KEY (rowid=?) -Query: - SELECT count (m.group_member_id) - FROM groups g - JOIN group_members m USING (group_id) - WHERE g.user_id = ? - AND g.group_id = ? - AND m.member_status NOT IN (?,?,?,?,?) - -Plan: -SEARCH g USING INTEGER PRIMARY KEY (rowid=?) -SEARCH m USING INDEX sqlite_autoindex_group_members_1 (group_id=?) - Query: SELECT ct.contact_id FROM contacts ct @@ -1251,46 +1355,6 @@ SEARCH cp USING COVERING INDEX idx_contact_profiles_contact_link (user_id=? AND SEARCH ct USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) SEARCH c USING COVERING INDEX idx_connections_contact_id (contact_id=?) LEFT-JOIN -Query: - SELECT ct.contact_id - FROM group_members m - JOIN groups g ON g.group_id = m.group_id - JOIN contacts ct ON ct.contact_id = m.contact_id AND ct.via_group = g.group_id - WHERE m.user_id = ? AND m.group_member_id = ? AND ct.deleted = 0 - LIMIT 1 - -Plan: -SEARCH m USING INTEGER PRIMARY KEY (rowid=?) -SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) -SEARCH g USING INTEGER PRIMARY KEY (rowid=?) - -Query: - SELECT ct.contact_id, c.connection_id, c.agent_conn_id, ct.local_display_name - FROM contacts ct - JOIN connections c ON c.contact_id = ct.contact_id - WHERE c.agent_conn_id IN (SELECT conn_id FROM temp_conn_ids) - AND c.conn_type = ? - AND ct.deleted = 0 - -Plan: -SEARCH c USING INDEX sqlite_autoindex_connections_1 (agent_conn_id=?) -LIST SUBQUERY 1 -SCAN temp_conn_ids -SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) - -Query: - SELECT f.file_id - FROM files f - JOIN rcv_files r USING (file_id) - WHERE f.user_id = ? AND r.file_status IN (?, ?) - AND r.rcv_file_inline IS NULL - AND r.file_descr_id IS NULL - AND r.created_at > ? - -Plan: -SEARCH f USING COVERING INDEX idx_files_user_id (user_id=?) -SEARCH r USING INTEGER PRIMARY KEY (rowid=?) - Query: SELECT g.group_id FROM groups g @@ -1402,11 +1466,9 @@ Query: SELECT r.file_status, r.file_queue_info, r.group_member_id, f.file_name, f.file_size, f.chunk_size, f.cancelled, cs.local_display_name, m.local_display_name, f.file_path, f.file_crypto_key, f.file_crypto_nonce, r.file_inline, r.rcv_file_inline, - r.agent_rcv_file_id, r.agent_rcv_file_deleted, r.user_approved_relays, - c.connection_id, c.agent_conn_id + r.agent_rcv_file_id, r.agent_rcv_file_deleted, r.user_approved_relays FROM rcv_files r JOIN files f USING (file_id) - LEFT JOIN connections c ON r.file_id = c.rcv_file_id LEFT JOIN contacts cs ON cs.contact_id = f.contact_id LEFT JOIN group_members m ON m.group_member_id = r.group_member_id WHERE f.user_id = ? AND f.file_id = ? @@ -1414,7 +1476,6 @@ Query: Plan: SEARCH f USING INTEGER PRIMARY KEY (rowid=?) SEARCH r USING INTEGER PRIMARY KEY (rowid=?) -SEARCH c USING INDEX idx_connections_rcv_file_id (rcv_file_id=?) LEFT-JOIN SEARCH cs USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN SEARCH m USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN @@ -1431,6 +1492,15 @@ SEARCH r USING INDEX idx_received_probes_user_id (user_id=?) SEARCH m USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN SEARCH g USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +Query: + UPDATE chat_items SET item_status = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? + AND group_scope_tag = ? AND group_scope_group_member_id IS NOT DISTINCT FROM ? + AND item_status = ? + +Plan: +SEARCH chat_items USING 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=?) + Query: UPDATE connections SET via_contact_uri = NULL, via_contact_uri_hash = NULL, xcontact_id = NULL WHERE user_id = ? AND via_group_link = 1 AND contact_id IN ( @@ -1440,7 +1510,7 @@ Query: ) Plan: -SEARCH connections USING INDEX idx_connections_updated_at (user_id=?) +SEARCH connections USING INDEX idx_connections_to_subscribe (user_id=?) LIST SUBQUERY 1 SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) @@ -1474,7 +1544,8 @@ Query: UPDATE group_members SET support_chat_items_unread = 0, support_chat_items_member_attention = 0, - support_chat_items_mentions = 0 + support_chat_items_mentions = 0, + updated_at = ? WHERE group_member_id = ? Plan: @@ -1482,9 +1553,9 @@ SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE group_members - SET support_chat_items_unread = support_chat_items_unread - ?, - support_chat_items_member_attention = support_chat_items_member_attention - ?, - support_chat_items_mentions = support_chat_items_mentions - ?, + SET support_chat_items_unread = MAX(0, support_chat_items_unread - ?), + support_chat_items_member_attention = MAX(0, support_chat_items_member_attention - ?), + support_chat_items_mentions = MAX(0, support_chat_items_mentions - ?), updated_at = ? WHERE group_member_id = ? @@ -1546,6 +1617,31 @@ Query: 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 @@ -1555,6 +1651,31 @@ Query: 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 msg_deliveries @@ -1581,44 +1702,24 @@ Plan: Query: SELECT -- Contact - ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, + ct.contact_id, ct.contact_profile_id, ct.local_display_name, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.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.snd_file_id, c.rcv_file_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.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 contacts ct JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id LEFT JOIN connections c ON c.contact_id = ct.contact_id WHERE ct.user_id = ? AND ct.contact_id = ? AND ct.deleted = ? - AND ( - c.connection_id = ( - SELECT cc_connection_id FROM ( - SELECT - cc.connection_id AS cc_connection_id, - cc.created_at AS cc_created_at, - (CASE WHEN cc.conn_status = ? OR cc.conn_status = ? THEN 1 ELSE 0 END) AS cc_conn_status_ord - FROM connections cc - WHERE cc.user_id = ct.user_id AND cc.contact_id = ct.contact_id - ORDER BY cc_conn_status_ord DESC, cc_created_at DESC - LIMIT 1 - ) cc - ) - OR c.connection_id IS NULL - ) Plan: SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) SEARCH cp USING INTEGER PRIMARY KEY (rowid=?) SEARCH c USING INDEX idx_connections_contact_id (contact_id=?) LEFT-JOIN -CORRELATED SCALAR SUBQUERY 2 -CO-ROUTINE cc -SEARCH cc USING INDEX idx_connections_contact_id (contact_id=?) -USE TEMP B-TREE FOR ORDER BY -SCAN cc Query: SELECT @@ -1669,7 +1770,8 @@ Query: AND LOWER(local_alias) LIKE '%' || LOWER(?) || '%' ORDER BY updated_at DESC LIMIT ? Plan: -SEARCH connections USING INDEX idx_connections_updated_at (user_id=?) +SEARCH connections USING INDEX idx_connections_contact_id (contact_id=?) +USE TEMP B-TREE FOR ORDER BY Query: SELECT @@ -2968,6 +3070,23 @@ CORRELATED SCALAR SUBQUERY 1 SEARCH ci USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) USE TEMP B-TREE FOR ORDER BY +Query: + SELECT + t.delivery_task_id, + t.worker_scope, t.job_scope_spec_tag, t.job_scope_include_pending, t.job_scope_support_gm_id, + m.group_member_id, m.member_id, p.display_name, msg.broker_ts, msg.msg_body, t.message_from_channel + FROM delivery_tasks t + JOIN messages msg ON msg.message_id = t.message_id + JOIN group_members m ON m.group_member_id = t.sender_group_member_id + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + WHERE t.delivery_task_id = ? + +Plan: +SEARCH t USING INTEGER PRIMARY KEY (rowid=?) +SEARCH msg USING INTEGER PRIMARY KEY (rowid=?) +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) + Query: SELECT COUNT(1) FROM chat_items @@ -2984,9 +3103,44 @@ Query: Plan: SEARCH chat_items USING COVERING INDEX idx_chat_items_notes (user_id=? AND note_folder_id=? AND item_status=?) +Query: + SELECT agent_conn_id + FROM connections + WHERE user_id = ? + + AND conn_type = ? + AND contact_id IS NULL + AND conn_status != ? + +Plan: +SEARCH connections USING INDEX idx_connections_contact_id (contact_id=?) + +Query: + SELECT c.agent_conn_id + FROM connections c + JOIN contacts ct ON ct.contact_id = c.contact_id + WHERE c.user_id = ? + + AND c.conn_status != ? + AND ct.contact_status = ? AND ct.deleted = 0 + +Plan: +SEARCH c USING INDEX idx_connections_to_subscribe (user_id=?) +SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT c.agent_conn_id + FROM connections c + JOIN user_contact_links ucl ON ucl.user_contact_link_id = c.user_contact_link_id + WHERE c.user_id = ? + AND c.conn_status != ? +Plan: +SEARCH c USING INDEX idx_connections_to_subscribe (user_id=?) +SEARCH ucl USING INTEGER PRIMARY KEY (rowid=?) + Query: SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.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.snd_file_id, c.rcv_file_id, c.user_contact_link_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 connections c @@ -2999,7 +3153,7 @@ SEARCH c USING INDEX idx_connections_user_contact_link_id (user_contact_link_id= Query: SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.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.snd_file_id, c.rcv_file_id, c.user_contact_link_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 connections c @@ -3010,20 +3164,6 @@ Plan: SEARCH uc USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=? AND local_display_name=?) SEARCH c USING INDEX idx_connections_user_contact_link_id (user_contact_link_id=?) -Query: - SELECT c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.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.snd_file_id, c.rcv_file_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, - uc.user_contact_link_id, uc.conn_req_contact, uc.group_id - FROM connections c - JOIN user_contact_links uc ON c.user_contact_link_id = uc.user_contact_link_id - WHERE c.user_id = ? AND uc.user_id = ? - -Plan: -SEARCH c USING INDEX idx_connections_updated_at (user_id=?) -SEARCH uc USING INTEGER PRIMARY KEY (rowid=?) - Query: SELECT chat_item_id FROM chat_item_messages @@ -3189,6 +3329,16 @@ Query: Plan: SEARCH chat_item_versions USING INDEX idx_chat_item_versions_chat_item_id (chat_item_id=?) +Query: + SELECT chat_tag_id, chat_tag_emoji, chat_tag_text + FROM chat_tags + WHERE user_id = ? + ORDER BY tag_order + +Plan: +SEARCH chat_tags USING INDEX idx_chat_tags_user_id (user_id=?) +USE TEMP B-TREE FOR ORDER BY + Query: SELECT command_id, connection_id, command_function, command_status FROM commands @@ -3199,7 +3349,7 @@ SEARCH commands USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT connection_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, group_link_id, xcontact_id, custom_user_profile_id, - conn_status, conn_type, contact_conn_initiated, local_alias, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, + conn_status, conn_type, contact_conn_initiated, local_alias, contact_id, group_member_id, user_contact_link_id, created_at, security_code, security_code_verified_at, pq_support, pq_encryption, pq_snd_enabled, pq_rcv_enabled, auth_err_counter, quota_err_counter, conn_chat_version, peer_chat_min_version, peer_chat_max_version FROM connections @@ -3208,16 +3358,6 @@ Query: Plan: SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) -Query: - SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, group_link_id, custom_user_profile_id, conn_req_inv, short_link_inv, local_alias, created_at, updated_at - FROM connections - WHERE user_id = ? - AND conn_type = ? - AND contact_id IS NULL - -Plan: -SEARCH connections USING INDEX idx_connections_updated_at (user_id=?) - Query: SELECT connection_id, agent_conn_id, conn_status, via_contact_uri_hash, via_user_contact_link, group_link_id, custom_user_profile_id, conn_req_inv, short_link_inv, local_alias, created_at, updated_at FROM connections @@ -3335,7 +3475,7 @@ Plan: SEARCH chat_item_reactions USING INDEX idx_chat_item_reactions_group (group_id=? AND shared_msg_id=?) Query: - SELECT group_member_intro_id, group_queue_info, direct_queue_info, intro_status + SELECT group_member_intro_id, intro_status FROM group_member_intros WHERE re_group_member_id = ? AND to_group_member_id = ? @@ -3630,17 +3770,6 @@ SEARCH connections USING INDEX idx_connections_group_member_id (group_member_id= LIST SUBQUERY 1 SCAN group_members USING COVERING INDEX idx_group_members_user_id_local_display_name -Query: - UPDATE group_member_intros - SET intro_status = ?, - group_queue_info = ?, - direct_queue_info = ?, - updated_at = ? - WHERE group_member_intro_id = ? - -Plan: -SEARCH group_member_intros USING INTEGER PRIMARY KEY (rowid=?) - Query: UPDATE group_member_intros SET intro_status='fwd' WHERE re_group_member_id IN (SELECT group_member_id FROM group_members WHERE local_display_name = ?) @@ -3669,6 +3798,31 @@ Query: Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) +Query: + WITH user_groups AS MATERIALIZED ( + SELECT g.group_id + FROM groups g + JOIN group_members mu ON mu.group_id = g.group_id + WHERE g.user_id = ? + AND mu.contact_id = ? + AND mu.member_status NOT IN (?,?,?) + ) + SELECT c.agent_conn_id + FROM connections c + JOIN group_members m ON m.group_member_id = c.group_member_id + JOIN user_groups ug ON ug.group_id = m.group_id + WHERE c.user_id = ? + AND c.conn_status != ? + AND m.member_status NOT IN (?,?,?) + +Plan: +MATERIALIZE user_groups +SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?) +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_to_subscribe (user_id=?) +SEARCH m USING INTEGER PRIMARY KEY (rowid=?) +SEARCH ug USING AUTOMATIC COVERING INDEX (group_id=?) + Query: DELETE FROM chat_items WHERE group_scope_group_member_id = ? @@ -3757,8 +3911,8 @@ Query: Plan: SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) LIST SUBQUERY 1 -SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) SEARCH c USING COVERING INDEX idx_connections_contact_id (contact_id=?) +SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) SEARCH msg_deliveries USING COVERING INDEX idx_msg_deliveries_agent_msg_id (connection_id=?) SEARCH commands USING COVERING INDEX idx_commands_connection_id (connection_id=?) SEARCH messages USING COVERING INDEX idx_messages_connection_id (connection_id=?) @@ -3962,6 +4116,22 @@ SEARCH group_members USING COVERING INDEX idx_group_members_member_profile_id (m SEARCH group_members USING COVERING INDEX idx_group_members_contact_profile_id (contact_profile_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_contact_profile_id (contact_profile_id=?) +Query: + DELETE FROM delivery_jobs + WHERE created_at <= ? + AND (job_status IN (?,?) OR failed = 1) + +Plan: +SEARCH delivery_jobs USING INDEX idx_delivery_jobs_created_at (created_at 0 OR chat_item_ttl IS NULL Plan: @@ -6001,10 +6217,6 @@ Query: SELECT group_id FROM groups WHERE inv_queue_info = ? AND user_id = ? LIMI Plan: SEARCH groups USING INDEX idx_groups_chat_ts (user_id=?) -Query: SELECT group_id FROM groups WHERE user_id = ? -Plan: -SEARCH groups USING COVERING INDEX sqlite_autoindex_groups_2 (user_id=?) - Query: SELECT group_id FROM groups WHERE user_id = ? AND chat_item_ttl > 0 OR chat_item_ttl IS NULL Plan: SCAN groups @@ -6069,6 +6281,10 @@ Query: SELECT sent_inv_queue_info FROM group_members WHERE group_member_id = ? A Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: SELECT should_sync FROM connections_sync WHERE connections_sync_id = 1 +Plan: +SEARCH connections_sync USING INTEGER PRIMARY KEY (rowid=?) + Query: SELECT user_contact_link_id FROM contact_requests WHERE contact_request_id = ? Plan: SEARCH contact_requests USING INTEGER PRIMARY KEY (rowid=?) @@ -6141,9 +6357,9 @@ Query: UPDATE connections SET security_code = ?, security_code_verified_at = ?, Plan: SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) -Query: UPDATE connections SET to_subscribe = 0 WHERE to_subscribe = 1 +Query: UPDATE connections_sync SET should_sync = 1 WHERE connections_sync_id = 1 Plan: -SEARCH connections USING INDEX idx_connections_to_subscribe (to_subscribe=?) +SEARCH connections_sync USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE contact_requests SET business_group_id = ? WHERE contact_request_id = ? Plan: @@ -6213,6 +6429,14 @@ Query: UPDATE contacts SET xcontact_id = ? WHERE contact_id = ? Plan: SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE delivery_jobs SET job_status = ?, job_err_reason = ?, updated_at = ? WHERE delivery_job_id = ? +Plan: +SEARCH delivery_jobs USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE delivery_tasks SET task_status = ?, task_err_reason = ?, updated_at = ? WHERE delivery_task_id = ? +Plan: +SEARCH delivery_tasks USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE files SET agent_snd_file_deleted = 1, updated_at = ? WHERE user_id = ? AND file_id = ? Plan: SEARCH files USING INTEGER PRIMARY KEY (rowid=?) @@ -6277,6 +6501,14 @@ Query: UPDATE group_members SET member_role = ? WHERE user_id = ? AND group_memb Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE group_members SET support_chat_items_member_attention = ?, updated_at = ? WHERE group_member_id = ? +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE group_members SET support_chat_items_member_attention=100 WHERE group_member_id=? +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE group_members SET support_chat_ts = ? WHERE group_member_id = ? Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) @@ -6317,6 +6549,10 @@ Query: UPDATE groups SET local_display_name = ?, updated_at = ? WHERE user_id = Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE groups SET members_require_attention=1 WHERE group_id=? +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE groups SET request_shared_msg_id = ? WHERE group_id = ? Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 952d0541d9..4b12246a3f 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -1,8 +1,7 @@ CREATE TABLE migrations( - name TEXT NOT NULL, + name TEXT NOT NULL PRIMARY KEY, ts TEXT NOT NULL, - down TEXT, - PRIMARY KEY(name) + down TEXT ); CREATE TABLE contact_profiles( -- remote user profile @@ -62,7 +61,6 @@ CREATE TABLE contacts( user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, local_display_name TEXT NOT NULL, is_user INTEGER NOT NULL DEFAULT 0, -- 1 if this contact is a user - via_group INTEGER REFERENCES groups(group_id) ON DELETE SET NULL, created_at TEXT NOT NULL DEFAULT(datetime('now')), updated_at TEXT CHECK(updated_at NOT NULL), xcontact_id BLOB, @@ -156,7 +154,8 @@ CREATE TABLE groups( welcome_shared_msg_id BLOB, request_shared_msg_id BLOB, conn_link_prepared_connection INTEGER NOT NULL DEFAULT 0, - via_group_link_uri BLOB, -- received + via_group_link_uri BLOB, + summary_current_members_count INTEGER NOT NULL DEFAULT 0, -- received FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE @@ -269,17 +268,6 @@ CREATE TABLE rcv_files( to_receive INTEGER, user_approved_relays INTEGER NOT NULL DEFAULT 0 ); -CREATE TABLE snd_file_chunks( - file_id INTEGER NOT NULL, - connection_id INTEGER NOT NULL, - chunk_number INTEGER NOT NULL, - chunk_agent_msg_id INTEGER, - chunk_sent INTEGER NOT NULL DEFAULT 0, - created_at TEXT CHECK(created_at NOT NULL), - updated_at TEXT CHECK(updated_at NOT NULL), -- 0(sent to agent), 1(sent to server) - FOREIGN KEY(file_id, connection_id) REFERENCES snd_files ON DELETE CASCADE, - PRIMARY KEY(file_id, connection_id, chunk_number) -) WITHOUT ROWID; CREATE TABLE rcv_file_chunks( file_id INTEGER NOT NULL REFERENCES rcv_files ON DELETE CASCADE, chunk_number INTEGER NOT NULL, @@ -395,7 +383,8 @@ CREATE TABLE messages( shared_msg_id BLOB, shared_msg_id_user INTEGER, author_group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL, - forwarded_by_group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL + forwarded_by_group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL, + broker_ts TEXT ); CREATE TABLE pending_group_messages( pending_group_message_id INTEGER PRIMARY KEY, @@ -674,7 +663,7 @@ CREATE TABLE operator_usage_conditions( ); CREATE TABLE chat_tags( chat_tag_id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER REFERENCES users, + user_id INTEGER REFERENCES users ON DELETE CASCADE, chat_tag_text TEXT NOT NULL, chat_tag_emoji TEXT, tag_order INTEGER NOT NULL @@ -691,6 +680,47 @@ CREATE TABLE chat_item_mentions( chat_item_id INTEGER NOT NULL REFERENCES chat_items ON DELETE CASCADE, display_name TEXT NOT NULL ); +CREATE TABLE delivery_tasks( + delivery_task_id INTEGER PRIMARY KEY AUTOINCREMENT, + group_id INTEGER NOT NULL REFERENCES groups ON DELETE CASCADE, + worker_scope TEXT NOT NULL, + job_scope_spec_tag TEXT, + job_scope_include_pending INTEGER, + job_scope_support_gm_id INTEGER REFERENCES group_members(group_member_id) ON DELETE CASCADE, + sender_group_member_id INTEGER NOT NULL REFERENCES group_members(group_member_id) ON DELETE CASCADE, + message_id INTEGER REFERENCES messages ON DELETE CASCADE, + message_from_channel INTEGER NOT NULL DEFAULT 0, + task_status TEXT NOT NULL, + task_err_reason TEXT, + failed INTEGER DEFAULT 0, + created_at TEXT NOT NULL DEFAULT(datetime('now')), + updated_at TEXT NOT NULL DEFAULT(datetime('now')) +); +CREATE TABLE delivery_jobs( + delivery_job_id INTEGER PRIMARY KEY AUTOINCREMENT, + group_id INTEGER NOT NULL REFERENCES groups ON DELETE CASCADE, + worker_scope TEXT NOT NULL, + job_scope_spec_tag TEXT, + job_scope_include_pending INTEGER, + job_scope_support_gm_id INTEGER REFERENCES group_members(group_member_id) ON DELETE CASCADE, + single_sender_group_member_id INTEGER REFERENCES group_members(group_member_id) ON DELETE CASCADE, + body BLOB, + cursor_group_member_id INTEGER, + job_status TEXT NOT NULL, + job_err_reason TEXT, + failed INTEGER DEFAULT 0, + created_at TEXT NOT NULL DEFAULT(datetime('now')), + updated_at TEXT NOT NULL DEFAULT(datetime('now')) +); +CREATE TABLE group_member_status_predicates( + member_status TEXT NOT NULL PRIMARY KEY, + current_member INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE connections_sync( + connections_sync_id INTEGER PRIMARY KEY AUTOINCREMENT, + should_sync INTEGER NOT NULL DEFAULT 0, + last_sync_ts TEXT +); CREATE INDEX contact_profiles_index ON contact_profiles( display_name, full_name @@ -735,7 +765,6 @@ CREATE INDEX idx_connections_via_user_contact_link ON connections( via_user_contact_link ); CREATE INDEX idx_connections_rcv_file_id ON connections(rcv_file_id); -CREATE INDEX idx_connections_contact_id ON connections(contact_id); CREATE INDEX idx_connections_user_contact_link_id ON connections( user_contact_link_id ); @@ -747,7 +776,6 @@ CREATE INDEX idx_contact_requests_contact_profile_id ON contact_requests( CREATE INDEX idx_contact_requests_user_contact_link_id ON contact_requests( user_contact_link_id ); -CREATE INDEX idx_contacts_via_group ON contacts(via_group); CREATE INDEX idx_contacts_contact_profile_id ON contacts(contact_profile_id); CREATE INDEX idx_files_chat_item_id ON files(chat_item_id); CREATE INDEX idx_files_user_id ON files(user_id); @@ -785,10 +813,6 @@ CREATE INDEX idx_pending_group_messages_group_member_id ON pending_group_message CREATE INDEX idx_rcv_file_chunks_file_id ON rcv_file_chunks(file_id); CREATE INDEX idx_rcv_files_group_member_id ON rcv_files(group_member_id); CREATE INDEX idx_settings_user_id ON settings(user_id); -CREATE INDEX idx_snd_file_chunks_file_id_connection_id ON snd_file_chunks( - file_id, - connection_id -); CREATE INDEX idx_snd_files_group_member_id ON snd_files(group_member_id); CREATE INDEX idx_snd_files_connection_id ON snd_files(connection_id); CREATE INDEX idx_snd_files_file_id ON snd_files(file_id); @@ -861,7 +885,6 @@ CREATE INDEX idx_chat_items_user_id_item_status ON chat_items( user_id, item_status ); -CREATE INDEX idx_connections_to_subscribe ON connections(to_subscribe); CREATE INDEX idx_contacts_contact_group_member_id ON contacts( contact_group_member_id ); @@ -1054,7 +1077,6 @@ CREATE INDEX idx_chat_items_groups_user_mention ON chat_items( user_mention ); CREATE INDEX idx_chat_items_group_id ON chat_items(group_id); -CREATE INDEX idx_connections_group_member_id ON connections(group_member_id); CREATE INDEX idx_chat_items_group_id_shared_msg_id ON chat_items( group_id, shared_msg_id @@ -1103,3 +1125,94 @@ CREATE INDEX idx_contacts_grp_direct_inv_from_group_member_id ON contacts( CREATE INDEX idx_contacts_grp_direct_inv_from_member_conn_id ON contacts( grp_direct_inv_from_member_conn_id ); +CREATE INDEX idx_delivery_tasks_group_id ON delivery_tasks(group_id); +CREATE INDEX idx_delivery_tasks_job_scope_support_gm_id ON delivery_tasks( + job_scope_support_gm_id +); +CREATE INDEX idx_delivery_tasks_sender_group_member_id ON delivery_tasks( + sender_group_member_id +); +CREATE INDEX idx_delivery_tasks_message_id ON delivery_tasks(message_id); +CREATE INDEX idx_delivery_tasks_next ON delivery_tasks( + group_id, + worker_scope, + failed, + task_status +); +CREATE INDEX idx_delivery_tasks_next_for_job_scope ON delivery_tasks( + group_id, + worker_scope, + job_scope_spec_tag, + job_scope_include_pending, + job_scope_support_gm_id, + failed, + task_status +); +CREATE INDEX idx_delivery_tasks_next_for_job_scope_sender ON delivery_tasks( + group_id, + worker_scope, + job_scope_spec_tag, + job_scope_include_pending, + job_scope_support_gm_id, + sender_group_member_id, + failed, + task_status +); +CREATE INDEX idx_delivery_tasks_created_at ON delivery_tasks(created_at); +CREATE INDEX idx_delivery_jobs_group_id ON delivery_jobs(group_id); +CREATE INDEX idx_delivery_jobs_job_scope_support_gm_id ON delivery_jobs( + job_scope_support_gm_id +); +CREATE INDEX idx_delivery_jobs_single_sender_group_member_id ON delivery_jobs( + single_sender_group_member_id +); +CREATE INDEX idx_delivery_jobs_next ON delivery_jobs( + group_id, + worker_scope, + failed, + job_status +); +CREATE INDEX idx_delivery_jobs_created_at ON delivery_jobs(created_at); +CREATE INDEX idx_groups_summary_current_members_count ON groups( + summary_current_members_count +); +CREATE UNIQUE INDEX idx_connections_contact_id ON connections(contact_id); +CREATE UNIQUE INDEX idx_connections_group_member_id ON connections( + group_member_id +); +CREATE INDEX idx_connections_to_subscribe ON connections( + user_id, + to_subscribe +); +CREATE TRIGGER on_group_members_insert_update_summary +AFTER INSERT ON group_members +FOR EACH ROW +WHEN EXISTS (SELECT 1 FROM group_member_status_predicates WHERE member_status = NEW.member_status AND current_member = 1) +BEGIN + UPDATE groups + SET summary_current_members_count = summary_current_members_count + 1 + WHERE group_id = NEW.group_id; +END; +CREATE TRIGGER on_group_members_delete_update_summary +AFTER DELETE ON group_members +FOR EACH ROW +WHEN EXISTS (SELECT 1 FROM group_member_status_predicates WHERE member_status = OLD.member_status AND current_member = 1) +BEGIN + UPDATE groups + SET summary_current_members_count = summary_current_members_count - 1 + WHERE group_id = OLD.group_id; +END; +CREATE TRIGGER on_group_members_update_update_summary +AFTER UPDATE ON group_members +FOR EACH ROW +WHEN EXISTS (SELECT 1 FROM group_member_status_predicates WHERE member_status = OLD.member_status AND current_member = 1) + != EXISTS (SELECT 1 FROM group_member_status_predicates WHERE member_status = NEW.member_status AND current_member = 1) +BEGIN + UPDATE groups + SET summary_current_members_count = summary_current_members_count + + ( + CASE WHEN EXISTS (SELECT 1 FROM group_member_status_predicates WHERE member_status = NEW.member_status AND current_member = 1) + THEN 1 ELSE -1 END + ) + WHERE group_id = NEW.group_id; +END; diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 1caf034f2a..243db84da7 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -37,7 +37,8 @@ import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme -import Simplex.Messaging.Agent.Protocol (AConnectionRequestUri (..), AConnShortLink (..), ACreatedConnLink (..), ConnId, ConnShortLink, ConnectionRequestUri, CreatedConnLink (..), UserId, connMode) +import Simplex.Messaging.Agent.Protocol (AConnShortLink (..), AConnectionRequestUri (..), ACreatedConnLink (..), ConnId, ConnShortLink, ConnectionRequestUri, CreatedConnLink (..), UserId, connMode) +import Simplex.Messaging.Agent.Store (AnyStoreError (..)) import Simplex.Messaging.Agent.Store.AgentStore (firstRow, maybeFirstRow) import Simplex.Messaging.Agent.Store.DB (BoolInt (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB @@ -46,6 +47,7 @@ import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport (..)) import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) import Simplex.Messaging.Protocol (SubscriptionMode (..)) +import Simplex.Messaging.Util (AnyError (..)) import Simplex.Messaging.Version import UnliftIO.STM #if defined(dbPostgres) @@ -147,8 +149,24 @@ data StoreError | SEUsageConditionsNotFound | SEInvalidQuote | SEInvalidMention + | SEInvalidDeliveryTask {taskId :: Int64} + | SEDeliveryTaskNotFound {taskId :: Int64} + | SEInvalidDeliveryJob {jobId :: Int64} + | SEDeliveryJobNotFound {jobId :: Int64} + | -- | Error when reading work item that suspends worker - do not use! + SEWorkItemError {errContext :: String} deriving (Show, Exception) +instance AnyError StoreError where + fromSomeException = SEInternalError . show + {-# INLINE fromSomeException #-} + +instance AnyStoreError StoreError where + isWorkItemError = \case + SEWorkItemError {} -> True + _ -> False + mkWorkItemError errContext = SEWorkItemError {errContext} + $(J.deriveJSON (sumTypeJSON $ dropPrefix "SE") ''StoreError) insertedRowId :: DB.Connection -> IO Int64 @@ -195,14 +213,14 @@ fileInfoQuery = toFileInfo :: (Int64, Maybe ACIFileStatus, Maybe FilePath) -> CIFileInfo toFileInfo (fileId, fileStatus, filePath) = CIFileInfo {fileId, fileStatus, filePath} -type EntityIdsRow = (Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64) +type EntityIdsRow = (Maybe Int64, Maybe Int64, Maybe Int64) type ConnectionRow = (Int64, ConnId, Int, Maybe Int64, Maybe Int64, BoolInt, Maybe GroupLinkId, Maybe XContactId) :. (Maybe Int64, ConnStatus, ConnType, BoolInt, LocalAlias) :. EntityIdsRow :. (UTCTime, Maybe Text, Maybe UTCTime, PQSupport, PQEncryption, Maybe PQEncryption, Maybe PQEncryption, Int, Int, Maybe VersionChat, VersionChat, VersionChat) type MaybeConnectionRow = (Maybe Int64, Maybe ConnId, Maybe Int, Maybe Int64, Maybe Int64, Maybe BoolInt, Maybe GroupLinkId, Maybe XContactId) :. (Maybe Int64, Maybe ConnStatus, Maybe ConnType, Maybe BoolInt, Maybe LocalAlias) :. EntityIdsRow :. (Maybe UTCTime, Maybe Text, Maybe UTCTime, Maybe PQSupport, Maybe PQEncryption, Maybe PQEncryption, Maybe PQEncryption, Maybe Int, Maybe Int, Maybe VersionChat, Maybe VersionChat, Maybe VersionChat) toConnection :: VersionRangeChat -> ConnectionRow -> Connection -toConnection vr ((connId, acId, connLevel, viaContact, viaUserContactLink, BI viaGroupLink, groupLinkId, xContactId) :. (customUserProfileId, connStatus, connType, BI contactConnInitiated, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, pqSupport, pqEncryption, pqSndEnabled, pqRcvEnabled, authErrCounter, quotaErrCounter, chatV, minVer, maxVer)) = +toConnection vr ((connId, acId, connLevel, viaContact, viaUserContactLink, BI viaGroupLink, groupLinkId, xContactId) :. (customUserProfileId, connStatus, connType, BI contactConnInitiated, localAlias) :. (contactId, groupMemberId, userContactLinkId) :. (createdAt, code_, verifiedAt_, pqSupport, pqEncryption, pqSndEnabled, pqRcvEnabled, authErrCounter, quotaErrCounter, chatV, minVer, maxVer)) = Connection { connId, agentConnId = AgentConnId acId, @@ -234,13 +252,11 @@ toConnection vr ((connId, acId, connLevel, viaContact, viaUserContactLink, BI vi entityId_ :: ConnType -> Maybe Int64 entityId_ ConnContact = contactId entityId_ ConnMember = groupMemberId - entityId_ ConnRcvFile = rcvFileId - entityId_ ConnSndFile = sndFileId entityId_ ConnUserContact = userContactLinkId toMaybeConnection :: VersionRangeChat -> MaybeConnectionRow -> Maybe Connection -toMaybeConnection vr ((Just connId, Just agentConnId, Just connLevel, viaContact, viaUserContactLink, Just viaGroupLink, groupLinkId, xContactId) :. (customUserProfileId, Just connStatus, Just connType, Just contactConnInitiated, Just localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (Just createdAt, code_, verifiedAt_, Just pqSupport, Just pqEncryption, pqSndEnabled_, pqRcvEnabled_, Just authErrCounter, Just quotaErrCounter, connChatVersion, Just minVer, Just maxVer)) = - Just $ toConnection vr ((connId, agentConnId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, xContactId) :. (customUserProfileId, connStatus, connType, contactConnInitiated, localAlias) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId) :. (createdAt, code_, verifiedAt_, pqSupport, pqEncryption, pqSndEnabled_, pqRcvEnabled_, authErrCounter, quotaErrCounter, connChatVersion, minVer, maxVer)) +toMaybeConnection vr ((Just connId, Just agentConnId, Just connLevel, viaContact, viaUserContactLink, Just viaGroupLink, groupLinkId, xContactId) :. (customUserProfileId, Just connStatus, Just connType, Just contactConnInitiated, Just localAlias) :. (contactId, groupMemberId, userContactLinkId) :. (Just createdAt, code_, verifiedAt_, Just pqSupport, Just pqEncryption, pqSndEnabled_, pqRcvEnabled_, Just authErrCounter, Just quotaErrCounter, connChatVersion, Just minVer, Just maxVer)) = + Just $ toConnection vr ((connId, agentConnId, connLevel, viaContact, viaUserContactLink, viaGroupLink, groupLinkId, xContactId) :. (customUserProfileId, connStatus, connType, contactConnInitiated, localAlias) :. (contactId, groupMemberId, userContactLinkId) :. (createdAt, code_, verifiedAt_, pqSupport, pqEncryption, pqSndEnabled_, pqRcvEnabled_, authErrCounter, quotaErrCounter, connChatVersion, minVer, maxVer)) toMaybeConnection _ _ = Nothing createConnection_ :: DB.Connection -> UserId -> ConnType -> Maybe Int64 -> ConnId -> ConnStatus -> VersionChat -> VersionRangeChat -> Maybe ContactId -> Maybe Int64 -> Maybe ProfileId -> Int -> UTCTime -> SubscriptionMode -> PQSupport -> IO Connection @@ -253,12 +269,12 @@ createConnection_ db userId connType entityId acId connStatus connChatVersion pe [sql| INSERT INTO connections ( user_id, agent_conn_id, conn_level, via_contact, via_user_contact_link, via_group_link, custom_user_profile_id, conn_status, conn_type, - contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id, created_at, updated_at, + contact_id, group_member_id, user_contact_link_id, created_at, updated_at, conn_chat_version, peer_chat_min_version, peer_chat_max_version, to_subscribe, pq_support, pq_encryption - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ( (userId, acId, connLevel, viaContact, viaUserContactLink, BI viaGroupLink, customUserProfileId, connStatus, connType) - :. (ent ConnContact, ent ConnMember, ent ConnSndFile, ent ConnRcvFile, ent ConnUserContact, currentTs, currentTs) + :. (ent ConnContact, ent ConnMember, ent ConnUserContact, currentTs, currentTs) :. (connChatVersion, minV, maxV, BI (subMode == SMOnlyCreate), pqSup, pqSup) ) connId <- insertedRowId db @@ -384,10 +400,10 @@ setCommandConnId db User {userId} cmdId connId = do createContact :: DB.Connection -> User -> Profile -> ExceptT StoreError IO () createContact db user profile = do currentTs <- liftIO getCurrentTime - void $ createContact_ db user profile emptyChatPrefs Nothing "" Nothing currentTs + void $ createContact_ db user profile emptyChatPrefs Nothing "" currentTs -createContact_ :: DB.Connection -> User -> Profile -> Preferences -> Maybe (ACreatedConnLink, Maybe SharedMsgId) -> LocalAlias -> Maybe Int64 -> UTCTime -> ExceptT StoreError IO ContactId -createContact_ db User {userId} Profile {displayName, fullName, shortDescr, image, contactLink, peerType, preferences} ctUserPreferences prepared localAlias viaGroup currentTs = +createContact_ :: DB.Connection -> User -> Profile -> Preferences -> Maybe (ACreatedConnLink, Maybe SharedMsgId) -> LocalAlias -> UTCTime -> ExceptT StoreError IO ContactId +createContact_ db User {userId} Profile {displayName, fullName, shortDescr, image, contactLink, peerType, preferences} ctUserPreferences prepared localAlias currentTs = ExceptT . withLocalDisplayName db userId displayName $ \ldn -> do DB.execute db @@ -396,8 +412,8 @@ createContact_ db User {userId} Profile {displayName, fullName, shortDescr, imag profileId <- insertedRowId db DB.execute db - "INSERT INTO contacts (contact_profile_id, user_preferences, local_display_name, user_id, via_group, created_at, updated_at, chat_ts, contact_used, conn_full_link_to_connect, conn_short_link_to_connect, welcome_shared_msg_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)" - ((profileId, ctUserPreferences, ldn, userId, viaGroup, currentTs, currentTs, currentTs, BI True) :. toPreparedContactRow prepared) + "INSERT INTO contacts (contact_profile_id, user_preferences, local_display_name, user_id, created_at, updated_at, chat_ts, contact_used, conn_full_link_to_connect, conn_short_link_to_connect, welcome_shared_msg_id) VALUES (?,?,?,?,?,?,?,?,?,?,?)" + ((profileId, ctUserPreferences, ldn, userId, currentTs, currentTs, currentTs, BI True) :. toPreparedContactRow prepared) contactId <- insertedRowId db pure $ Right contactId @@ -459,12 +475,12 @@ type PreparedContactRow = (Maybe AConnectionRequestUri, Maybe AConnShortLink, Ma type GroupDirectInvitationRow = (Maybe ConnReqInvitation, Maybe GroupId, Maybe GroupMemberId, Maybe Int64, BoolInt) -type ContactRow' = (ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, BoolInt, ContactStatus) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime) :. PreparedContactRow :. (Maybe Int64, Maybe GroupMemberId, BoolInt) :. GroupDirectInvitationRow :. (Maybe UIThemeEntityOverrides, BoolInt, Maybe CustomData, Maybe Int64) +type ContactRow' = (ProfileId, ContactName, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, BoolInt, ContactStatus) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime) :. PreparedContactRow :. (Maybe Int64, Maybe GroupMemberId, BoolInt) :. GroupDirectInvitationRow :. (Maybe UIThemeEntityOverrides, BoolInt, Maybe CustomData, Maybe Int64) type ContactRow = Only ContactId :. ContactRow' toContact :: VersionRangeChat -> User -> [ChatTagId] -> ContactRow :. MaybeConnectionRow -> Contact -toContact vr user chatTags ((Only contactId :. (profileId, localDisplayName, viaGroup, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL)) :. connRow) = +toContact vr user chatTags ((Only contactId :. (profileId, localDisplayName, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL)) :. connRow) = let profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, peerType, preferences, localAlias} activeConn = toMaybeConnection vr connRow chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} @@ -472,7 +488,7 @@ toContact vr user chatTags ((Only contactId :. (profileId, localDisplayName, via mergedPreferences = contactUserPreferences user userPreferences preferences incognito preparedContact = toPreparedContact preparedContactRow groupDirectInv = toGroupDirectInvitation groupDirectInvRow - in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, preparedContact, contactRequestId, contactGroupMemberId, contactGrpInvSent, groupDirectInv, chatTags, chatItemTTL, uiThemes, chatDeleted, customData} + in Contact {contactId, localDisplayName, profile, activeConn, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, preparedContact, contactRequestId, contactGroupMemberId, contactGrpInvSent, groupDirectInv, chatTags, chatItemTTL, uiThemes, chatDeleted, customData} toPreparedContact :: PreparedContactRow -> Maybe PreparedContact toPreparedContact (connFullLink, connShortLink, welcomeSharedMsgId, requestSharedMsgId) = @@ -638,21 +654,22 @@ type PreparedGroupRow = (Maybe ConnReqContact, Maybe ShortLinkContact, BoolInt, type BusinessChatInfoRow = (Maybe BusinessChatType, Maybe MemberId, Maybe MemberId) -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, Maybe CustomData, Maybe Int64, Int, Maybe ConnReqContact) :. GroupMemberRow +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 ProfileRow = (ProfileId, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, Maybe Preferences) toGroupInfo :: VersionRangeChat -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo -toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, shortDescr, localAlias, description, image) :. (enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. preparedGroupRow :. businessRow :. (uiThemes, customData, chatItemTTL, membersRequireAttention, viaGroupLinkUri) :. userMemberRow) = +toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, shortDescr, localAlias, description, image) :. (enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. preparedGroupRow :. businessRow :. (uiThemes, currentMembers, customData, chatItemTTL, membersRequireAttention, viaGroupLinkUri) :. userMemberRow) = let membership = (toGroupMember userContactId userMemberRow) {memberChatVRange = vr} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} fullGroupPreferences = mergeGroupPreferences groupPreferences groupProfile = GroupProfile {displayName, fullName, shortDescr, description, image, groupPreferences, memberAdmission} businessChat = toBusinessChatInfo businessRow preparedGroup = toPreparedGroup preparedGroupRow - in GroupInfo {groupId, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, preparedGroup, chatTags, chatItemTTL, uiThemes, customData, membersRequireAttention, viaGroupLinkUri} + groupSummary = GroupSummary {currentMembers} + in GroupInfo {groupId, useRelays = BoolDef False, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, preparedGroup, chatTags, chatItemTTL, uiThemes, groupSummary, customData, membersRequireAttention, viaGroupLinkUri} toPreparedGroup :: PreparedGroupRow -> Maybe PreparedGroup toPreparedGroup = \case @@ -670,16 +687,38 @@ toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberChatVRange = fromMaybe (versionToRange maxVer) $ safeVersionRange minVer maxVer supportChat = case supportChatTs_ of Just chatTs -> - Just GroupSupportChat { - chatTs, - unread = supportChatUnread, - memberAttention = supportChatMemberAttention, - mentions = supportChatMentions, - lastMsgFromMemberTs = supportChatLastMsgFromMemberTs - } + Just + GroupSupportChat + { chatTs, + unread = supportChatUnread, + memberAttention = supportChatMemberAttention, + mentions = supportChatMentions, + lastMsgFromMemberTs = supportChatLastMsgFromMemberTs + } _ -> Nothing in GroupMember {..} +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.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 + |] + +toContactMember :: VersionRangeChat -> User -> (GroupMemberRow :. MaybeConnectionRow) -> GroupMember +toContactMember vr User {userContactId} (memberRow :. connRow) = + (toGroupMember userContactId memberRow) {activeConn = toMaybeConnection vr connRow} + rowToLocalProfile :: ProfileRow -> LocalProfile rowToLocalProfile (profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, preferences) = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, preferences} @@ -689,7 +728,10 @@ toBusinessChatInfo (Just chatType, Just businessId, Just customerId) = Just Busi toBusinessChatInfo _ = Nothing groupInfoQuery :: Query -groupInfoQuery = +groupInfoQuery = groupInfoQueryFields <> " " <> groupInfoQueryFrom + +groupInfoQueryFields :: Query +groupInfoQueryFields = [sql| SELECT -- GroupInfo @@ -698,13 +740,18 @@ groupInfoQuery = g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, - g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + 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.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, 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 + |] + +groupInfoQueryFrom :: Query +groupInfoQueryFrom = + [sql| FROM groups g JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id JOIN group_members mu ON mu.group_id = g.group_id @@ -790,7 +837,8 @@ setViaGroupLinkUri db groupId connId = do DB.query db "SELECT via_contact_uri, via_contact_uri_hash FROM connections WHERE connection_id = ?" - (Only connId) :: IO [(Maybe ConnReqContact, Maybe ConnReqUriHash)] + (Only connId) :: + IO [(Maybe ConnReqContact, Maybe ConnReqUriHash)] forM_ (listToMaybe r) $ \(viaContactUri, viaContactUriHash) -> DB.execute db diff --git a/src/Simplex/Chat/Terminal/Output.hs b/src/Simplex/Chat/Terminal/Output.hs index 8a37a4c477..ca591903ff 100644 --- a/src/Simplex/Chat/Terminal/Output.hs +++ b/src/Simplex/Chat/Terminal/Output.hs @@ -33,11 +33,9 @@ import Simplex.Chat.Styled import Simplex.Chat.Terminal.Notification (Notification (..), initializeNotifications) import Simplex.Chat.Types import Simplex.Chat.View -import Simplex.Messaging.Agent.Protocol -import Simplex.Messaging.Encoding.String import Simplex.Messaging.TMap (TMap) import qualified Simplex.Messaging.TMap as TM -import Simplex.Messaging.Util (safeDecodeUtf8, tshow) +import Simplex.Messaging.Util (tshow) import System.Console.ANSI.Types import System.IO (IOMode (..), hPutStrLn, withFile) import System.Mem.Weak (Weak) @@ -196,8 +194,6 @@ chatEventNotification t@ChatTerminal {sendNotification} cc = \case CEvtContactAnotherClient u ct -> do whenCurrUser cc u $ unsetActiveContact t ct when (contactNtf u ct False) $ sendNtf (viewContactName ct <> "> ", "connected to another client") - CEvtContactsDisconnected srv _ -> serverNtf srv "disconnected" - CEvtContactsSubscribed srv _ -> serverNtf srv "connected" CEvtReceivedGroupInvitation u g ct _ _ -> when (contactNtf u ct False) $ sendNtf ("#" <> viewGroupName g <> " " <> viewContactName ct <> "> ", "invited you to join the group") @@ -215,7 +211,6 @@ chatEventNotification t@ChatTerminal {sendNotification} cc = \case _ -> pure () where sendNtf = maybe (\_ -> pure ()) (. uncurry Notification) sendNotification - serverNtf (SMPServer host _ _) str = sendNtf ("server " <> str, safeDecodeUtf8 $ strEncode host) msgText :: MsgContent -> Maybe MarkdownList -> Text msgText (MCFile _) _ = "wants to send a file" diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 5d25ae64e3..bb86cb2522 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -49,7 +49,6 @@ import Data.Word (Word16) import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme -import Simplex.Chat.Types.Util import Simplex.FileTransfer.Description (FileDigest) import Simplex.FileTransfer.Types (RcvFileId, SndFileId) import Simplex.Messaging.Agent.Protocol (ACorrId, ACreatedConnLink, AEventTag (..), AEvtTag (..), ConnId, ConnShortLink, ConnectionLink, ConnectionMode (..), ConnectionRequestUri, CreatedConnLink, InvitationId, SAEntity (..), UserId) @@ -181,7 +180,6 @@ data Contact = Contact localDisplayName :: ContactName, profile :: LocalProfile, activeConn :: Maybe Connection, - viaGroup :: Maybe Int64, contactUsed :: Bool, contactStatus :: ContactStatus, chatSettings :: ChatSettings, @@ -355,9 +353,6 @@ data UserContact = UserContact } deriving (Eq, Show) -userContactGroupId :: UserContact -> Maybe GroupId -userContactGroupId UserContact {groupId} = groupId - data UserContactRequest = UserContactRequest { contactRequestId :: Int64, agentInvitationId :: AgentInvId, @@ -443,26 +438,6 @@ optionalFullName displayName fullName shortDescr | T.null fullName || displayName == fullName = maybe "" (\sd -> " (" <> sd <> ")") shortDescr | otherwise = " (" <> fullName <> ")" -data ShortGroup = ShortGroup - { shortInfo :: ShortGroupInfo, - members :: [ShortGroupMember] - } - -data ShortGroupInfo = ShortGroupInfo - { groupId :: GroupId, - groupName :: GroupName, - membershipStatus :: GroupMemberStatus - } - deriving (Eq, Show) - -data ShortGroupMember = ShortGroupMember - { groupMemberId :: GroupMemberId, - groupId :: GroupId, - memberName :: ContactName, - connId :: AgentConnId - } - deriving (Show) - data Group = Group {groupInfo :: GroupInfo, members :: [GroupMember]} deriving (Eq, Show) @@ -470,6 +445,7 @@ type GroupId = Int64 data GroupInfo = GroupInfo { groupId :: GroupId, + useRelays :: BoolDef, localDisplayName :: GroupName, groupProfile :: GroupProfile, localAlias :: Text, @@ -486,6 +462,7 @@ data GroupInfo = GroupInfo chatItemTTL :: Maybe Int64, uiThemes :: Maybe UIThemeEntityOverrides, customData :: Maybe CustomData, + groupSummary :: GroupSummary, membersRequireAttention :: Int, viaGroupLinkUri :: Maybe ConnReqContact } @@ -522,11 +499,18 @@ groupName' :: GroupInfo -> GroupName groupName' GroupInfo {localDisplayName = g} = g data GroupSummary = GroupSummary - { currentMembers :: Int + { currentMembers :: Int64 } - deriving (Show) + deriving (Eq, Show) -data GroupInfoSummary = GIS {groupInfo :: GroupInfo, groupSummary :: GroupSummary} +data GroupLink = GroupLink + { userContactLinkId :: Int64, + connLinkContact :: CreatedLinkContact, + shortLinkDataSet :: Bool, + shortLinkLargeDataSet :: BoolDef, + groupLinkId :: GroupLinkId, + acceptMemberRole :: GroupMemberRole + } deriving (Show) data ContactOrGroup = CGContact Contact | CGGroup GroupInfo [GroupMember] @@ -596,6 +580,10 @@ groupFeatureMemberAllowed :: GroupFeatureRoleI f => SGroupFeature f -> GroupMemb groupFeatureMemberAllowed feature GroupMember {memberRole} = groupFeatureMemberAllowed' feature memberRole . fullGroupPreferences +groupFeatureUserAllowed :: GroupFeatureRoleI f => SGroupFeature f -> GroupInfo -> Bool +groupFeatureUserAllowed feature GroupInfo {membership = GroupMember {memberRole}, fullGroupPreferences} = + groupFeatureMemberAllowed' feature memberRole fullGroupPreferences + mergeUserChatPrefs :: User -> Contact -> FullPreferences mergeUserChatPrefs user ct = mergeUserChatPrefs' user (contactConnIncognito ct) (userPreferences ct) @@ -688,10 +676,6 @@ profilesMatch LocalProfile {displayName = n2, fullName = fn2, image = i2} = n1 == n2 && fn1 == fn2 && i1 == i2 -redactedMemberProfile :: Profile -> Profile -redactedMemberProfile Profile {displayName, fullName, shortDescr, image, peerType} = - Profile {displayName, fullName, shortDescr, image, contactLink = Nothing, preferences = Nothing, peerType} - data IncognitoProfile = NewIncognito Profile | ExistingIncognito LocalProfile fromIncognitoProfile :: IncognitoProfile -> Profile @@ -699,11 +683,6 @@ fromIncognitoProfile = \case NewIncognito p -> p ExistingIncognito lp -> fromLocalProfile lp -userProfileInGroup :: User -> Maybe Profile -> Profile -userProfileInGroup User {profile = p} incognitoProfile = - let p' = fromMaybe (fromLocalProfile p) incognitoProfile - in redactedMemberProfile p' - userProfileDirect :: User -> Maybe Profile -> Maybe Contact -> Bool -> Profile userProfileDirect user@User {profile = p} incognitoProfile ct canFallbackToUserTTL = let p' = fromMaybe (fromLocalProfile p) incognitoProfile @@ -885,15 +864,6 @@ data BusinessChatInfo = BusinessChatInfo } deriving (Eq, Show) -memberInfo :: GroupMember -> MemberInfo -memberInfo GroupMember {memberId, memberRole, memberProfile, activeConn} = - MemberInfo - { memberId, - memberRole, - v = ChatVersionRange . peerChatVRange <$> activeConn, - profile = redactedMemberProfile $ fromLocalProfile memberProfile - } - data MemberRestrictionStatus = MRSBlocked | MRSUnrestricted @@ -1002,6 +972,11 @@ groupMemberRef :: GroupMember -> GroupMemberRef groupMemberRef GroupMember {groupMemberId, memberProfile = p} = GroupMemberRef {groupMemberId, profile = fromLocalProfile p} +-- TODO [channels fwd] knowledge whether member is a relay should come from protocol, not implicitly via role +-- TODO - in channels members should directly connect only to relays +isMemberRelay :: GroupMember -> Bool +isMemberRelay GroupMember {memberRole} = memberRole == GRAdmin + memberConn :: GroupMember -> Maybe Connection memberConn GroupMember {activeConn} = activeConn @@ -1229,7 +1204,9 @@ memberPending m = case memberStatus m of memberCurrentOrPending :: GroupMember -> Bool memberCurrentOrPending m = memberCurrent m || memberPending m --- update getGroupSummary if this is changed +-- *** Please note: +-- *** update getGroupSummary and SQL function used in update triggers if this is changed +-- *** memberCurrent' :: GroupMemberStatus -> Bool memberCurrent' = \case GSMemRejected -> False @@ -1317,9 +1294,6 @@ data SndFileTransfer = SndFileTransfer } deriving (Eq, Show) -sndFileTransferConnId :: SndFileTransfer -> ConnId -sndFileTransferConnId SndFileTransfer {agentConnId = AgentConnId acId} = acId - type FileTransferId = Int64 data FileInvitation = FileInvitation @@ -1407,10 +1381,10 @@ data RcvFileDescr = RcvFileDescr data RcvFileStatus = RFSNew - | RFSAccepted {fileInfo :: RcvFileInfo} - | RFSConnected {fileInfo :: RcvFileInfo} - | RFSComplete {fileInfo :: RcvFileInfo} - | RFSCancelled {fileInfo_ :: Maybe RcvFileInfo} + | RFSAccepted {filePath :: FilePath} + | RFSConnected {filePath :: FilePath} + | RFSComplete {filePath :: FilePath} + | RFSCancelled {filePath_ :: Maybe FilePath} deriving (Eq, Show) rcvFileComplete :: RcvFileStatus -> Bool @@ -1421,29 +1395,11 @@ rcvFileComplete = \case rcvFileCompleteOrCancelled :: RcvFileTransfer -> Bool rcvFileCompleteOrCancelled RcvFileTransfer {fileStatus, cancelled} = rcvFileComplete fileStatus || cancelled -data RcvFileInfo = RcvFileInfo - { filePath :: FilePath, - connId :: Maybe Int64, - agentConnId :: Maybe AgentConnId - } - deriving (Eq, Show) - -liveRcvFileTransferInfo :: RcvFileTransfer -> Maybe RcvFileInfo -liveRcvFileTransferInfo RcvFileTransfer {fileStatus} = case fileStatus of - RFSAccepted fi -> Just fi - RFSConnected fi -> Just fi - _ -> Nothing - -liveRcvFileTransferConnId :: RcvFileTransfer -> Maybe ConnId -liveRcvFileTransferConnId ft = acId =<< liveRcvFileTransferInfo ft - where - acId RcvFileInfo {agentConnId = Just (AgentConnId cId)} = Just cId - acId _ = Nothing - liveRcvFileTransferPath :: RcvFileTransfer -> Maybe FilePath -liveRcvFileTransferPath ft = fp <$> liveRcvFileTransferInfo ft - where - fp RcvFileInfo {filePath} = filePath +liveRcvFileTransferPath RcvFileTransfer {fileStatus} = case fileStatus of + RFSAccepted filePath -> Just filePath + RFSConnected filePath -> Just filePath + _ -> Nothing newtype AgentConnId = AgentConnId ConnId deriving (Eq, Ord, Show) @@ -1764,7 +1720,7 @@ instance TextEncoding ConnStatus where ConnReady -> "ready" ConnDeleted -> "deleted" -data ConnType = ConnContact | ConnMember | ConnSndFile | ConnRcvFile | ConnUserContact +data ConnType = ConnContact | ConnMember | ConnUserContact deriving (Eq, Show) instance FromField ConnType where fromField = fromTextField_ textDecode @@ -1782,23 +1738,18 @@ instance TextEncoding ConnType where textDecode = \case "contact" -> Just ConnContact "member" -> Just ConnMember - "snd_file" -> Just ConnSndFile - "rcv_file" -> Just ConnRcvFile "user_contact" -> Just ConnUserContact _ -> Nothing textEncode = \case ConnContact -> "contact" ConnMember -> "member" - ConnSndFile -> "snd_file" - ConnRcvFile -> "rcv_file" ConnUserContact -> "user_contact" data GroupMemberIntro = GroupMemberIntro { introId :: Int64, reMember :: GroupMember, toMember :: GroupMember, - introStatus :: GroupMemberIntroStatus, - introInvitation :: Maybe IntroInvitation + introStatus :: GroupMemberIntroStatus } deriving (Show) @@ -1837,26 +1788,6 @@ serializeIntroStatus = \case GMIntroToConnected -> "to-con" GMIntroConnected -> "con" -data NetworkStatus - = NSUnknown - | NSConnected - | NSDisconnected - | NSError {connectionError :: String} - deriving (Eq, Ord, Show) - -netStatusStr :: NetworkStatus -> String -netStatusStr = \case - NSUnknown -> "unknown" - NSConnected -> "connected" - NSDisconnected -> "disconnected" - NSError e -> "error: " <> e - -data ConnNetworkStatus = ConnNetworkStatus - { agentConnId :: AgentConnId, - networkStatus :: NetworkStatus - } - deriving (Show) - type CommandId = Int64 aCorrId :: CommandId -> ACorrId @@ -1889,8 +1820,8 @@ instance TextEncoding CommandStatus where data CommandFunction = CFCreateConnGrpMemInv | CFCreateConnGrpInv - | CFCreateConnFileInvDirect - | CFCreateConnFileInvGroup + | CFCreateConnFileInvDirect -- deprecated + | CFCreateConnFileInvGroup -- deprecated | CFJoinConn | CFAllowConn | CFAcceptContact @@ -2046,10 +1977,6 @@ $(JQ.deriveJSON defaultJSON ''GroupMemberSettings) $(JQ.deriveJSON defaultJSON ''SecurityCode) -$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "NS") ''NetworkStatus) - -$(JQ.deriveJSON defaultJSON ''ConnNetworkStatus) - $(JQ.deriveJSON defaultJSON ''Connection) $(JQ.deriveJSON defaultJSON ''PendingContactConnection) @@ -2068,13 +1995,17 @@ $(JQ.deriveJSON defaultJSON ''BusinessChatInfo) $(JQ.deriveJSON defaultJSON ''PreparedGroup) +$(JQ.deriveToJSON defaultJSON ''GroupSummary) + +instance FromJSON GroupSummary where + parseJSON = $(JQ.mkParseJSON defaultJSON ''GroupSummary) + omittedField = Just GroupSummary {currentMembers = 0} + $(JQ.deriveJSON defaultJSON ''GroupInfo) $(JQ.deriveJSON defaultJSON ''Group) -$(JQ.deriveJSON defaultJSON ''GroupSummary) - -$(JQ.deriveJSON defaultJSON ''GroupInfoSummary) +$(JQ.deriveJSON defaultJSON ''GroupLink) instance FromField MsgFilter where fromField = fromIntField_ msgFilterIntP @@ -2108,8 +2039,6 @@ $(JQ.deriveJSON defaultJSON ''RcvFileDescr) $(JQ.deriveJSON defaultJSON ''XFTPRcvFile) -$(JQ.deriveJSON defaultJSON ''RcvFileInfo) - $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RFS") ''RcvFileStatus) $(JQ.deriveJSON defaultJSON ''RcvFileTransfer) @@ -2141,7 +2070,3 @@ $(JQ.deriveJSON defaultJSON ''ContactRef) $(JQ.deriveJSON defaultJSON ''NoteFolder) $(JQ.deriveJSON defaultJSON ''ChatTag) - -$(JQ.deriveJSON defaultJSON ''ShortGroupInfo) - -$(JQ.deriveJSON defaultJSON ''ShortGroupMember) diff --git a/src/Simplex/Chat/Types/UITheme.hs b/src/Simplex/Chat/Types/UITheme.hs index d0f23a7307..096cda06f5 100644 --- a/src/Simplex/Chat/Types/UITheme.hs +++ b/src/Simplex/Chat/Types/UITheme.hs @@ -14,7 +14,6 @@ import Data.Char (toLower) import Data.Maybe (fromMaybe) import Data.Text (Text) import Simplex.Chat.Options.DB (FromField (..), ToField (..)) -import Simplex.Chat.Types.Util import Simplex.Messaging.Agent.Store.DB (fromTextField_) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON) diff --git a/src/Simplex/Chat/Types/Util.hs b/src/Simplex/Chat/Types/Util.hs deleted file mode 100644 index afea178e41..0000000000 --- a/src/Simplex/Chat/Types/Util.hs +++ /dev/null @@ -1,8 +0,0 @@ -module Simplex.Chat.Types.Util where - -import qualified Data.Aeson as J -import qualified Data.Aeson.Types as JT -import Simplex.Messaging.Encoding.String - -textParseJSON :: TextEncoding a => String -> J.Value -> JT.Parser a -textParseJSON name = J.withText name $ maybe (fail $ "bad " <> name) pure . textDecode diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 6fef6cc204..c16f76148f 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -22,7 +22,7 @@ import qualified Data.ByteString.Lazy.Char8 as LB import Data.Char (isSpace, toUpper) import Data.Function (on) import Data.Int (Int64) -import Data.List (groupBy, intercalate, intersperse, partition, sortOn) +import Data.List (groupBy, intercalate, intersperse, sortOn) import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) @@ -50,13 +50,14 @@ import Simplex.Chat.Operators import Simplex.Chat.Protocol import Simplex.Chat.Remote.AppVersion (AppVersion (..), pattern AppVersionRange) import Simplex.Chat.Remote.Types -import Simplex.Chat.Store (AddressSettings (..), AutoAccept (..), GroupLink (..), StoreError (..), UserContactLink (..)) +import Simplex.Chat.Store (AddressSettings (..), AutoAccept (..), StoreError (..), UserContactLink (..)) import Simplex.Chat.Styled import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme import qualified Simplex.FileTransfer.Transport as XFTP +import Simplex.Messaging.Agent (DatabaseDiff (..)) import Simplex.Messaging.Agent.Client (ProtocolTestFailure (..), ProtocolTestStep (..), SubscriptionsInfo (..)) import Simplex.Messaging.Agent.Env.SQLite (NetworkConfig (..), ServerRoles (..)) import Simplex.Messaging.Agent.Protocol @@ -68,7 +69,7 @@ import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, taggedObjectJSON) -import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType, BlockingInfo (..), BlockingReason (..), ProtocolServer (..), ProtocolTypeI, SProtocolType (..), UserProtocol) +import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType, BlockingInfo (..), BlockingReason (..), NetworkError (..), ProtocolServer (..), ProtocolTypeI, SProtocolType (..), UserProtocol) import qualified Simplex.Messaging.Protocol as SMP import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Util (safeDecodeUtf8, tshow) @@ -116,6 +117,9 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte CRChatStarted -> ["chat started"] CRChatRunning -> ["chat is running"] CRChatStopped -> ["chat stopped"] + CRConnectionsDiff showIds userDiff connDiff + | showIds -> viewConnDiffIds userDiff connDiff + | otherwise -> viewConnDiffSummary userDiff connDiff CRApiChats u chats -> ttyUser u $ if testView then testViewChats chats else [viewJSON chats] CRChats chats -> viewChats ts tz chats CRApiChat u chat _ -> ttyUser u $ if testView then testViewChat chat else [viewJSON chat] @@ -128,7 +132,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte CRChatItemTTL u ttl -> ttyUser u $ viewChatItemTTL ttl CRNetworkConfig netCfg -> viewNetworkConfig netCfg CRContactInfo u ct cStats customUserProfile -> ttyUser u $ viewContactInfo ct cStats customUserProfile - CRGroupInfo u g s -> ttyUser u $ viewGroupInfo g s + CRGroupInfo u g -> ttyUser u $ viewGroupInfo g CRGroupMemberInfo u g m cStats -> ttyUser u $ viewGroupMemberInfo g m cStats CRQueueInfo _ msgInfo qInfo -> [ "last received msg: " <> maybe "none" viewJSON msgInfo, @@ -228,9 +232,9 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte CRRcvStandaloneFileCreated u ft -> ttyUser u $ receivingFileStandalone "started" ft CRSndStandaloneFileCreated u ft -> ttyUser u $ uploadingFileStandalone "started" ft CRStandaloneFileInfo info_ -> maybe ["no file information in URI"] (\j -> [viewJSON j]) info_ - CRNetworkStatuses u statuses -> if testView then ttyUser' u $ viewNetworkStatuses statuses else [] CRJoinedGroupMember u g m -> ttyUser u $ viewJoinedGroupMember g m CRMemberAccepted u g m -> ttyUser u $ viewMemberAccepted g m + CRMemberSupportChatRead u g m -> ttyUser u $ viewSupportChatRead g m CRMemberSupportChatDeleted u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " support chat deleted"] CRMembersRoleUser u g members r' -> ttyUser u $ viewMemberRoleUserChanged g members r' CRMembersBlockedForAllUser u g members blocked -> ttyUser u $ viewMembersBlockedForAllUser g members blocked @@ -376,8 +380,8 @@ ttyUserPrefix (currentRH, user_) outputRH User {userId, localDisplayName = u} ss userPrefix = ["user: " <> highlight u | Just userId /= currentUserId] currentUserId = (\User {userId = uId} -> uId) <$> user_ -viewErrorsSummary :: [a] -> StyledString -> [StyledString] -viewErrorsSummary summary s = [ttyError (T.pack . show $ length summary) <> s <> " (run with -c option to show each error)" | not (null summary)] +viewSubErrorsSummary :: [ConnSubResult] -> [StyledString] +viewSubErrorsSummary subErrs = [ttyError (T.pack . show $ length subErrs) <> " subscription errors (run with -c option to show each error)" | not (null subErrs)] contactList :: [ContactRef] -> String contactList cs = T.unpack . T.intercalate ", " $ map (\ContactRef {localDisplayName = n} -> "@" <> n) cs @@ -423,7 +427,6 @@ chatEventToView hu ChatConfig {logLevel, showReactions, showReceipts, testView} CEvtRcvFileProgressXFTP {} -> [] CEvtContactUpdated {user = u, fromContact = c, toContact = c'} -> ttyUser u $ viewContactUpdated c c' <> viewContactPrefsUpdated u c c' CEvtGroupMemberUpdated {} -> [] - CEvtContactsMerged u intoCt mergedCt ct' -> ttyUser u $ viewContactsMerged intoCt mergedCt ct' CEvtReceivedContactRequest u UserContactRequest {localDisplayName = c, profile} _chat -> ttyUser u $ viewReceivedContactRequest c profile CEvtRcvFileStart u ci -> ttyUser u $ receivingFile_' hu testView "started" ci CEvtRcvFileComplete u ci -> ttyUser u $ receivingFile_' hu testView "completed" ci @@ -449,26 +452,11 @@ chatEventToView hu ChatConfig {logLevel, showReactions, showReceipts, testView} CEvtContactConnected u ct userCustomProfile -> ttyUser u $ viewContactConnected ct userCustomProfile testView CEvtContactSndReady u ct -> ttyUser u [ttyFullContact ct <> ": you can send messages to contact"] CEvtContactAnotherClient u c -> ttyUser u [ttyContact' c <> ": contact is connected to another client"] + CEvtConnectionsDiff userDiff connDiff -> viewConnDiffSync userDiff connDiff CEvtSubscriptionEnd u acEntity -> let Connection {connId} = entityConnection acEntity in ttyUser u [sShow connId <> ": END"] - CEvtContactsDisconnected srv cs -> [plain $ "server disconnected " <> showSMPServer srv <> " (" <> contactList cs <> ")"] - CEvtContactsSubscribed srv cs -> [plain $ "server connected " <> showSMPServer srv <> " (" <> contactList cs <> ")"] - CEvtContactSubError u c e -> ttyUser u [ttyContact' c <> ": contact error " <> sShow e] - CEvtContactSubSummary u summary -> - ttyUser u $ [sShow (length subscribed) <> " contacts connected (use " <> highlight' "/cs" <> " for the list)" | not (null subscribed)] <> viewErrorsSummary errors " contact errors" - where - (errors, subscribed) = partition (isJust . contactError) summary - CEvtUserContactSubSummary u summary -> - ttyUser u $ - map addressSS addresses - <> ([sShow (length groupLinksSubscribed) <> " group links active" | not (null groupLinksSubscribed)] <> viewErrorsSummary groupLinkErrors " group link errors") - where - (addresses, groupLinks) = partition (\UserContactSubStatus {userContact} -> isNothing . userContactGroupId $ userContact) summary - addressSS UserContactSubStatus {userContactError} = maybe ("Your address is active! To show: " <> highlight' "/sa") (\e -> "User address error: " <> sShow e <> ", to delete your address: " <> highlight' "/da") userContactError - (groupLinkErrors, groupLinksSubscribed) = partition (isJust . userContactError) groupLinks - CEvtNetworkStatus status conns -> if testView then [plain $ show (length conns) <> " connections " <> netStatusStr status] else [] - CEvtNetworkStatuses u statuses -> if testView then ttyUser' u $ viewNetworkStatuses statuses else [] + CEvtSubscriptionStatus srv status conns -> [plain $ subStatusStr status <> " " <> show (length conns) <> " connections on server " <> showSMPServer srv] CEvtReceivedGroupInvitation {user = u, groupInfo = g, contact = c, memberRole = r} -> ttyUser u $ viewReceivedGroupInvitation g c r CEvtUserJoinedGroup u g _ -> ttyUser u $ viewUserJoinedGroup g CEvtJoinedGroupMember u g m -> ttyUser u $ viewJoinedGroupMember g m @@ -533,16 +521,6 @@ chatEventToView hu ChatConfig {logLevel, showReactions, showReceipts, testView} TENewMemberContact u _ g m -> ttyUser u ["contact for member " <> ttyGroup' g <> " " <> ttyMember m <> " is created"] TEContactVerificationReset u ct -> ttyUser u $ viewContactVerificationReset ct TEGroupMemberVerificationReset u g m -> ttyUser u $ viewGroupMemberVerificationReset g m - TEGroupSubscribed u ShortGroupInfo {groupName = g} -> ttyUser u $ viewGroupSubscribed g - TEGroupInvitation u g -> ttyUser u [groupInvitationSub g] - TEGroupEmpty u ShortGroupInfo {groupName = g} -> ttyUser u [ttyGroup g <> ": group is empty"] - TEMemberSubError u ShortGroupInfo {groupName = g} ShortGroupMember {memberName = n} e -> ttyUser u [ttyGroup g <> " member " <> ttyContact n <> " error: " <> sShow e] - TEMemberSubSummary u summary -> ttyUser u $ viewErrorsSummary (filter (isJust . memberError) summary) " group member errors" - TEPendingSubSummary u _ -> ttyUser u [] - TESndFileSubError u SndFileTransfer {fileId, fileName} e -> - ttyUser u ["sent file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] - TERcvFileSubError u RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}} e -> - ttyUser u ["received file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e] CEvtCustomChatEvent u r -> ttyUser' u $ map plain $ T.lines r where ttyUser :: User -> [StyledString] -> [StyledString] @@ -621,9 +599,6 @@ viewUsersList us = Just CPTBot -> " (bot)" _ -> "" -viewGroupSubscribed :: GroupName -> [StyledString] -viewGroupSubscribed g = [ttyGroup g <> ": connected to server(s)"] - showSMPServer :: SMPServer -> String showSMPServer ProtocolServer {host} = B.unpack $ strEncode host @@ -1198,12 +1173,6 @@ viewDirectMessagesProhibited :: MsgDirection -> Contact -> [StyledString] viewDirectMessagesProhibited MDSnd c = ["direct messages to indirect contact " <> ttyContact' c <> " are prohibited"] viewDirectMessagesProhibited MDRcv c = ["received prohibited direct message from indirect contact " <> ttyContact' c <> " (discarded)"] -viewNetworkStatuses :: [ConnNetworkStatus] -> [StyledString] -viewNetworkStatuses = map viewStatuses . L.groupBy ((==) `on` netStatus) . sortOn netStatus - where - netStatus ConnNetworkStatus {networkStatus} = networkStatus - viewStatuses ss@(s :| _) = plain $ show (L.length ss) <> " connections " <> netStatusStr (netStatus s) - viewUserJoinedGroup :: GroupInfo -> [StyledString] viewUserJoinedGroup g@GroupInfo {membership} = case incognitoMembershipProfile g of @@ -1229,6 +1198,11 @@ viewMemberAccepted g m@GroupMember {memberStatus} = case memberStatus of GSMemPendingReview -> [ttyGroup' g <> ": " <> ttyMember m <> " accepted and pending review (will introduce moderators)"] _ -> [ttyGroup' g <> ": " <> ttyMember m <> " accepted"] +viewSupportChatRead :: GroupInfo -> GroupMember -> [StyledString] +viewSupportChatRead g@GroupInfo {membership = GroupMember {groupMemberId = membershipId}} m + | groupMemberId' m == membershipId = [ttyGroup' g <> ": support chat read"] + | otherwise = [ttyGroup' g <> ": " <> ttyMember m <> " support chat read"] + viewMemberAcceptedByOther :: GroupInfo -> GroupMember -> GroupMember -> [StyledString] viewMemberAcceptedByOther g acceptingMember m@GroupMember {memberCategory, memberStatus} = case memberCategory of GCUserMember -> case memberStatus of @@ -1324,8 +1298,12 @@ viewGroupMembers (Group GroupInfo {membership} members) = map groupMember . filt | otherwise = [] viewMemberSupportChats :: GroupInfo -> [GroupMember] -> [StyledString] -viewMemberSupportChats GroupInfo {membership} ms = support <> map groupMember ms +viewMemberSupportChats GroupInfo {membership = membership@GroupMember {memberRole = membershipRole}, membersRequireAttention} ms = + memsAttention <> support <> map groupMember ms where + memsAttention + | membershipRole >= GRModerator = ["members require attention: " <> sShow membersRequireAttention] + | otherwise = [] support = case supportChat membership of Just sc -> ["support: " <> chatStats sc] Nothing -> [] @@ -1350,13 +1328,13 @@ viewContactConnected ct userIncognitoProfile testView = Nothing -> [ttyFullContact ct <> ": contact is connected"] -viewGroupsList :: [GroupInfoSummary] -> [StyledString] +viewGroupsList :: [GroupInfo] -> [StyledString] viewGroupsList [] = ["you have no groups!", "to create: " <> highlight' "/g "] viewGroupsList gs = map groupSS $ sortOn ldn_ gs where - ldn_ :: GroupInfoSummary -> Text - ldn_ (GIS GroupInfo {localDisplayName} _) = T.toLower localDisplayName - groupSS (GIS g@GroupInfo {membership, chatSettings = ChatSettings {enableNtfs}} GroupSummary {currentMembers}) = + ldn_ :: GroupInfo -> Text + ldn_ GroupInfo {localDisplayName} = T.toLower localDisplayName + groupSS g@GroupInfo {membership, chatSettings = ChatSettings {enableNtfs}, groupSummary = GroupSummary {currentMembers}} = case memberStatus membership of GSMemInvited -> groupInvitation' g s -> membershipIncognito g <> ttyFullGroup g <> viewMemberStatus s <> alias g @@ -1400,21 +1378,6 @@ groupInvitation' g@GroupInfo {localDisplayName = ldn, groupProfile = GroupProfil Just mp -> " to join as " <> incognitoProfile' (fromLocalProfile mp) <> ", " Nothing -> " to join, " -groupInvitationSub :: ShortGroupInfo -> StyledString -groupInvitationSub ShortGroupInfo {groupName = ldn} = - highlight ("#" <> viewName ldn) - <> " - you are invited (" - <> highlight ("/j " <> viewName ldn) - <> " to join, " - <> highlight ("/d #" <> viewName ldn) - <> " to delete invitation)" - -viewContactsMerged :: Contact -> Contact -> Contact -> [StyledString] -viewContactsMerged c1 c2 ct' = - [ "contact " <> ttyContact' c2 <> " is merged into " <> ttyContact' c1, - "use " <> ttyToContact' ct' <> highlight' "" <> " to send messages" - ] - viewNewMemberContactReceivedInv :: User -> Contact -> GroupInfo -> GroupMember -> [StyledString] viewNewMemberContactReceivedInv user ct@Contact {localDisplayName = c} g m | isTrue (autoAcceptMemberContacts user) = @@ -1458,6 +1421,58 @@ viewUserPrivacy User {userId} User {userId = userId', localDisplayName = n', sho "profile is " <> if isJust viewPwdHash then "hidden" else "visible" ] +viewConnDiffSync :: DatabaseDiff AgentUserId -> DatabaseDiff AgentConnId -> [StyledString] +viewConnDiffSync userDiff connDiff + | noDiff userDiff && noDiff connDiff = [] + | otherwise = + viewConnDiffSummary' userDiff connDiff + <> ["removed extra users in agent" | not (null $ extraIds userDiff)] + <> ["removed extra connections in agent" | not (null $ extraIds connDiff)] + where + noDiff DatabaseDiff {missingIds, extraIds} = null missingIds && null extraIds + +viewConnDiffSummary :: DatabaseDiff AgentUserId -> DatabaseDiff AgentConnId -> [StyledString] +viewConnDiffSummary userDiff connDiff + | noDiff userDiff && noDiff connDiff = + ["no difference between agent and chat connections"] + | otherwise = + viewConnDiffSummary' userDiff connDiff + where + noDiff DatabaseDiff {missingIds, extraIds} = null missingIds && null extraIds + +viewConnDiffSummary' :: DatabaseDiff AgentUserId -> DatabaseDiff AgentConnId -> [StyledString] +viewConnDiffSummary' userDiff connDiff = + ["connections difference summary:"] + <> showDatabaseDiff "users" userDiff + <> showDatabaseDiff "connections" connDiff + where + showDatabaseDiff name DatabaseDiff {missingIds, extraIds} = + ["number of missing " <> name <> " in agent: " <> sShow (length missingIds) | not (null missingIds)] + <> ["number of extra " <> name <> " in agent: " <> sShow (length extraIds) | not (null extraIds)] + +viewConnDiffIds :: DatabaseDiff AgentUserId -> DatabaseDiff AgentConnId -> [StyledString] +viewConnDiffIds userDiff connDiff + | noDiff userDiff && noDiff connDiff = + ["no difference between agent and chat connections"] + | otherwise = + ["connections difference:"] + <> showDatabaseDiff "users" (\(AgentUserId uId) -> uId) userDiff + <> showDatabaseDiff "connections" (\(AgentConnId cId) -> cId) connDiff + where + noDiff DatabaseDiff {missingIds, extraIds} = null missingIds && null extraIds + showDatabaseDiff name unwrapId DatabaseDiff {missingIds, extraIds} = + ["missing " <> name <> " in agent (agent IDs): " <> showIds missingIds | not (null missingIds)] + <> ["extra " <> name <> " in agent (agent IDs): " <> showIds extraIds | not (null extraIds)] + where + showIds = plain . T.intercalate ", " . map (tshow . unwrapId) + +subStatusStr :: SubscriptionStatus -> String +subStatusStr = \case + SSActive -> "subscribed" + SSPending -> "disconnected" + SSRemoved e -> "removed: " <> e + SSNoSub -> "no subscription" + viewUserServers :: UserOperatorServers -> [StyledString] viewUserServers (UserOperatorServers _ [] []) = [] viewUserServers UserOperatorServers {operator, smpServers, xftpServers} = @@ -1505,11 +1520,11 @@ viewServerTestResult (AProtoServerWithAuth p _) = \case result <> [pName <> " server requires authorization to create queues, check password" | testStep == TSCreateQueue && (case testError of SMP _ SMP.AUTH -> True; _ -> False)] <> [pName <> " server requires authorization to upload files, check password" | testStep == TSCreateFile && (case testError of XFTP _ XFTP.AUTH -> True; _ -> False)] - <> ["Possibly, certificate fingerprint in " <> pName <> " server address is incorrect" | testStep == TSConnect && brokerErr] + <> ["Certificate fingerprint in " <> pName <> " server address does not match server certificate" | testStep == TSConnect && unknownCA] where result = [pName <> " server test failed at " <> plain (drop 2 $ show testStep) <> ", error: " <> sShow testError] - brokerErr = case testError of - BROKER _ NETWORK -> True + unknownCA = case testError of + BROKER _ (NETWORK NEUnknownCAError) -> True _ -> False _ -> [pName <> " server test passed"] where @@ -1618,8 +1633,8 @@ viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, conta <> viewUITheme uiThemes <> viewCustomData customData -viewGroupInfo :: GroupInfo -> GroupSummary -> [StyledString] -viewGroupInfo GroupInfo {groupId, uiThemes, customData} s = +viewGroupInfo :: GroupInfo -> [StyledString] +viewGroupInfo GroupInfo {groupId, uiThemes, customData, groupSummary = s} = [ "group ID: " <> sShow groupId, "current members: " <> sShow (currentMembers s) ] @@ -2241,8 +2256,8 @@ viewFileTransferStatus (FTRcv ft@RcvFileTransfer {fileId, fileInvitation = FileI RFSNew -> "not accepted yet, use " <> highlight ("/fr " <> show fileId) <> " to receive file" RFSAccepted _ -> "just started" RFSConnected _ -> "progress " <> fileProgress chunksNum chunkSize fileSize - RFSComplete RcvFileInfo {filePath} -> "complete, path: " <> plain filePath - RFSCancelled (Just RcvFileInfo {filePath}) -> "cancelled, received part path: " <> plain filePath + RFSComplete filePath -> "complete, path: " <> plain filePath + RFSCancelled (Just filePath) -> "cancelled, received part path: " <> plain filePath RFSCancelled Nothing -> "cancelled" viewFileTransferStatusXFTP :: AChatItem -> [StyledString] @@ -2390,7 +2405,7 @@ viewRemoteCtrl CtrlAppInfo {deviceName, appVersionRange = AppVersionRange _ (App viewRemoteCtrlStopped :: RemoteCtrlStopReason -> [StyledString] viewRemoteCtrlStopped = \case - RCSRConnectionFailed (ChatErrorAgent (RCP RCEIdentity) _) -> + RCSRConnectionFailed (ChatErrorAgent (RCP RCEIdentity) _ _) -> ["remote controller stopped: this link was used with another controller, please create a new link on the host"] _ -> ["remote controller stopped"] @@ -2462,7 +2477,6 @@ viewChatError isCmd logLevel testView = \case CEFileCancelled f -> ["file cancelled: " <> plain f] CEFileCancel fileId e -> ["error cancelling file " <> sShow fileId <> ": " <> sShow e] CEFileAlreadyExists f -> ["file already exists: " <> plain f] - CEFileRead f e -> ["cannot read file " <> plain f <> ": " <> plain e] CEFileWrite f e -> ["cannot write file " <> plain f <> ": " <> plain e] CEFileSend fileId e -> ["error sending file " <> sShow fileId <> ": " <> sShow e] CEFileRcvChunk e -> ["error receiving file: " <> plain e] @@ -2517,6 +2531,8 @@ viewChatError isCmd logLevel testView = \case | testView -> ["duplicate group message, group id: " <> sShow groupId <> ", message id: " <> sShow sharedMsgId] | otherwise -> [] SEUserNoteFolderNotFound -> ["no notes folder"] + SEInternalError {message} + | testView && message == "referenced group member not found" -> [] e -> ["chat db error: " <> sShow e] ChatErrorDatabase err -> case err of DBErrorEncrypted -> ["error: chat database is already encrypted"] @@ -2524,7 +2540,7 @@ viewChatError isCmd logLevel testView = \case DBErrorExport e -> ["error encrypting database: " <> sqliteError' e] DBErrorOpen e -> ["error opening database after encryption: " <> sqliteError' e] e -> ["chat database error: " <> sShow e] - ChatErrorAgent err entity_ -> case err of + ChatErrorAgent err (AgentConnId acId) entity_ -> case err of CMD PROHIBITED cxt -> [withConnEntity <> plain ("error: command is prohibited, " <> cxt)] SMP _ SMP.AUTH -> [ withConnEntity @@ -2536,7 +2552,7 @@ viewChatError isCmd logLevel testView = \case reasonStr = case reason of BRSpam -> "spam" BRContent -> "content violates conditions of use" - BROKER _ NETWORK | not isCmd -> [] + BROKER _ (NETWORK _) | not isCmd -> [] BROKER _ TIMEOUT | not isCmd -> [] AGENT A_DUPLICATE -> [withConnEntity <> "error: AGENT A_DUPLICATE" | logLevel == CLLDebug || isCmd] AGENT (A_PROHIBITED e) -> [withConnEntity <> "error: AGENT A_PROHIBITED, " <> plain e | logLevel <= CLLWarning || isCmd] @@ -2553,13 +2569,11 @@ viewChatError isCmd logLevel testView = \case "[" <> connEntityLabel entity <> ", connId: " <> cId conn <> "] " Just entity@(RcvGroupMsgConnection conn GroupInfo {groupId} GroupMember {groupMemberId}) -> "[" <> connEntityLabel entity <> ", groupId: " <> sShow groupId <> ", memberId: " <> sShow groupMemberId <> ", connId: " <> cId conn <> "] " - Just entity@(RcvFileConnection conn RcvFileTransfer {fileId}) -> - "[" <> connEntityLabel entity <> ", fileId: " <> sShow fileId <> ", connId: " <> cId conn <> "] " - Just entity@(SndFileConnection conn SndFileTransfer {fileId}) -> - "[" <> connEntityLabel entity <> ", fileId: " <> sShow fileId <> ", connId: " <> cId conn <> "] " Just entity@(UserContactConnection conn UserContact {userContactLinkId}) -> "[" <> connEntityLabel entity <> ", userContactLinkId: " <> sShow userContactLinkId <> ", connId: " <> cId conn <> "] " - Nothing -> "" + Nothing + | acId == "" -> "" + | otherwise -> plain $ "agent conn ID: " <> acId cId :: Connection -> StyledString cId Connection {connId} = sShow connId ChatErrorRemoteCtrl e -> [plain $ "remote controller error: " <> show e] @@ -2592,8 +2606,6 @@ connEntityLabel = \case RcvDirectMsgConnection _ (Just Contact {localDisplayName = c}) -> plain c RcvDirectMsgConnection _ Nothing -> "rcv direct msg" RcvGroupMsgConnection _ GroupInfo {localDisplayName = g} GroupMember {localDisplayName = m} -> plain $ "#" <> g <> " " <> m - RcvFileConnection _ RcvFileTransfer {fileInvitation = FileInvitation {fileName}} -> plain $ "rcv file " <> T.pack fileName - SndFileConnection _ SndFileTransfer {fileName} -> plain $ "snd file " <> T.pack fileName UserContactConnection _ UserContact {} -> "contact address" ttyContact :: ContactName -> StyledString diff --git a/tests/APIDocs.hs b/tests/APIDocs.hs index 51261f6fe5..aa9cc8459f 100644 --- a/tests/APIDocs.hs +++ b/tests/APIDocs.hs @@ -8,6 +8,7 @@ module APIDocs where import API.Docs.Commands import API.Docs.Events import API.Docs.Generate +import qualified API.Docs.Generate.TypeScript as TS import API.Docs.Responses import API.Docs.Types import API.TypeInfo @@ -15,6 +16,7 @@ import Control.Monad import Data.Containers.ListUtils (nubOrd) import Data.List (foldl', intercalate, sort, (\\)) import qualified Data.Set as S +import qualified Data.Text as T import qualified Data.Text.IO as T import Simplex.Messaging.Util (ifM) import System.Directory (doesFileExist) @@ -26,15 +28,20 @@ apiDocsTest = do it "should be documented" testCommandsHaveDocs it "should have field names" testCommandsHaveNamedFields it "should have defined responses" testCommandsHaveResponses - it "generate markdown" testGenerateCommandsMD + it "generate markdown" $ testGenerate commandsDocFile commandsDocText describe "API responses" $ do it "should be documented" testResponsesHaveDocs describe "API events" $ do it "should be documented" testEventsHaveDocs - it "generate markdown" testGenerateEventsMD + it "generate markdown" $ testGenerate eventsDocFile eventsDocText describe "API types" $ do it "should be documented" testTypesHaveDocs - it "generate markdown" testGenerateTypesMD + it "generate markdown" $ testGenerate typesDocFile typesDocText + describe "TypeScript" $ do + it "generate typescript commands code" $ testGenerate TS.commandsCodeFile TS.commandsCodeText + it "generate typescript responses code" $ testGenerate TS.responsesCodeFile TS.responsesCodeText + it "generate typescript events code" $ testGenerate TS.eventsCodeFile TS.eventsCodeText + it "generate typescript types code" $ testGenerate TS.typesCodeFile TS.typesCodeText documentedCmds :: [String] documentedCmds = concatMap (map consName' . commands) chatCommandsDocs @@ -137,20 +144,8 @@ testCommandsHaveResponses = do unless (null undocResps) $ expectationFailure $ "Undocumented command responses: " <> intercalate ", " undocResps unless (null extraResps) $ expectationFailure $ "Unused documented command responses: " <> intercalate ", " extraResps -testGenerateCommandsMD :: IO () -testGenerateCommandsMD = do - cmdsDoc <- ifM (doesFileExist commandsDocFile) (T.readFile commandsDocFile) (pure "") - T.writeFile commandsDocFile commandsDocText - commandsDocText `shouldBe` cmdsDoc - -testGenerateEventsMD :: IO () -testGenerateEventsMD = do - evtsDoc <- ifM (doesFileExist eventsDocFile) (T.readFile eventsDocFile) (pure "") - T.writeFile eventsDocFile eventsDocText - eventsDocText `shouldBe` evtsDoc - -testGenerateTypesMD :: IO () -testGenerateTypesMD = do - typesDoc <- ifM (doesFileExist typesDocFile) (T.readFile typesDocFile) (pure "") - T.writeFile typesDocFile typesDocText - typesDocText `shouldBe` typesDoc +testGenerate :: FilePath -> T.Text -> IO () +testGenerate file text = do + current <- ifM (doesFileExist file) (T.readFile file) (pure "") + T.writeFile file text + text `shouldBe` current diff --git a/tests/Bots/BroadcastTests.hs b/tests/Bots/BroadcastTests.hs index bec8d99f7e..f56a4d803d 100644 --- a/tests/Bots/BroadcastTests.hs +++ b/tests/Bots/BroadcastTests.hs @@ -71,7 +71,7 @@ testBroadcastMessages ps = do withTestChat ps "alice" $ \alice -> withNewTestChat ps "bob" bobProfile $ \bob -> withNewTestChat ps "cath" cathProfile $ \cath -> do - alice <## "1 contacts connected (use /cs for the list)" + alice <## "subscribed 1 connections on server localhost" bob `connectVia` botLink bob #> "@broadcast_bot hello" bob <# "broadcast_bot> > hello" diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index a69f57cfca..42d59cfa9f 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -12,15 +12,16 @@ import ChatTests.Utils import Control.Concurrent (forkIO, killThread, threadDelay) import Control.Exception (finally) import Control.Monad (forM_, when) +import qualified Data.Aeson as J import qualified Data.Text as T import Directory.Captcha +import Directory.Listing import Directory.Options import Directory.Service import Directory.Store import GHC.IO.Handle (hClose) import Simplex.Chat.Bot.KnownContacts -import Simplex.Chat.Controller (ChatConfig (..), ChatHooks (..), defaultChatHooks) -import Simplex.Chat.Core +import Simplex.Chat.Controller (ChatConfig (..)) import qualified Simplex.Chat.Markdown as MD import Simplex.Chat.Options (CoreChatOpts (..)) import Simplex.Chat.Options.DB @@ -65,8 +66,8 @@ directoryServiceTests = do it "should prohibit confirmation if a duplicate group is listed" testDuplicateProhibitConfirmation it "should prohibit when profile is updated and not send for approval" testDuplicateProhibitWhenUpdated it "should prohibit approval if a duplicate group is listed" testDuplicateProhibitApproval - describe "list groups" $ do - it "should list user's groups" testListUserGroups + describe "list and promote groups" $ do + it "should list and promote user's groups" $ testListUserGroups True describe "member admission" $ do it "should ask member to pass captcha screen" testCapthaScreening describe "store log" $ do @@ -75,10 +76,10 @@ directoryServiceTests = do it "should accept some incorrect spellings" testCaptcha directoryProfile :: Profile -directoryProfile = Profile {displayName = "SimpleX-Directory", fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences = Nothing} +directoryProfile = Profile {displayName = "SimpleX Directory", fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences = Nothing} -mkDirectoryOpts :: TestParams -> [KnownContact] -> Maybe KnownGroup -> DirectoryOpts -mkDirectoryOpts TestParams {tmpPath = ps} superUsers ownersGroup = +mkDirectoryOpts :: TestParams -> [KnownContact] -> Maybe KnownGroup -> Maybe FilePath -> DirectoryOpts +mkDirectoryOpts TestParams {tmpPath = ps} superUsers ownersGroup webFolder = DirectoryOpts { coreOptions = testCoreOpts @@ -101,9 +102,11 @@ mkDirectoryOpts TestParams {tmpPath = ps} superUsers ownersGroup = profileNameLimit = maxBound, captchaGenerator = Nothing, directoryLog = Just $ ps "directory_service.log", - serviceName = "SimpleX-Directory", + migrateDirectoryLog = Nothing, + serviceName = "SimpleX Directory", runCLI = False, searchResults = 3, + webFolder, testing = True } @@ -119,101 +122,102 @@ testDirectoryService ps = withNewTestChat ps "bob" bobProfile $ \bob -> withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink - bob #> "@SimpleX-Directory privacy" - bob <# "SimpleX-Directory> > privacy" + bob #> "@'SimpleX Directory' privacy" + bob <# "'SimpleX Directory'> > privacy" bob <## " No groups found" -- putStrLn "*** create a group" bob ##> "/g PSA Privacy, Security & Anonymity" bob <## "group #PSA (Privacy, Security & Anonymity) is created" bob <## "to add members use /a PSA or /create link #PSA" - bob ##> "/a PSA SimpleX-Directory member" - bob <## "invitation to join the group #PSA sent to SimpleX-Directory" - bob <# "SimpleX-Directory> You must grant directory service admin role to register the group" - bob ##> "/mr PSA SimpleX-Directory admin" + bob ##> "/a PSA 'SimpleX Directory' member" + bob <## "invitation to join the group #PSA sent to 'SimpleX Directory'" + bob <# "'SimpleX Directory'> You must grant directory service admin role to register the group" + bob ##> "/mr PSA 'SimpleX Directory' admin" -- putStrLn "*** discover service joins group and creates the link for profile" - bob <## "#PSA: you changed the role of SimpleX-Directory to admin" - bob <# "SimpleX-Directory> Joining the group PSA…" - bob <## "#PSA: SimpleX-Directory joined the group" - bob <# "SimpleX-Directory> Joined the group PSA, creating the link…" - bob <# "SimpleX-Directory> Created the public link to join the group via this directory service that is always online." + bob <## "#PSA: you changed the role of 'SimpleX Directory' to admin" + bob <# "'SimpleX Directory'> Joining the group PSA…" + bob <## "#PSA: 'SimpleX Directory' joined the group" + bob <# "'SimpleX Directory'> Joined the group PSA, creating the link…" + bob <# "'SimpleX Directory'> Created the public link to join the group via this directory service that is always online." bob <## "" bob <## "Please add it to the group welcome message." bob <## "For example, add:" - welcomeWithLink <- dropStrPrefix "SimpleX-Directory> " . dropTime <$> getTermLine bob + welcomeWithLink <- dropStrPrefix "'SimpleX Directory'> " . dropTime <$> getTermLine bob -- putStrLn "*** update profile without link" updateGroupProfile bob "Welcome!" - bob <# "SimpleX-Directory> The profile updated for ID 1 (PSA), but the group link is not added to the welcome message." + bob <# "'SimpleX Directory'> The profile updated for ID 1 (PSA), but the group link is not added to the welcome message." (superUser Thank you! The group link for ID 1 (PSA) is added to the welcome message." + bob <# "'SimpleX Directory'> Thank you! The group link for ID 1 (PSA) is added to the welcome message." bob <## "You will be notified once the group is added to the directory - it may take up to 48 hours." approvalRequested superUser welcomeWithLink (1 :: Int) -- putStrLn "*** update profile so that it still has link" let welcomeWithLink' = "Welcome! " <> welcomeWithLink updateGroupProfile bob welcomeWithLink' - bob <# "SimpleX-Directory> The group ID 1 (PSA) is updated!" + bob <# "'SimpleX Directory'> The group ID 1 (PSA) is updated!" bob <## "It is hidden from the directory until approved." - superUser <# "SimpleX-Directory> The group ID 1 (PSA) is updated." + superUser <# "'SimpleX Directory'> The group ID 1 (PSA) is updated." approvalRequested superUser welcomeWithLink' (2 :: Int) -- putStrLn "*** try approving with the old registration code" - bob #> "@SimpleX-Directory /approve 1:PSA 1" - bob <# "SimpleX-Directory> > /approve 1:PSA 1" + bob #> "@'SimpleX Directory' /approve 1:PSA 1" + bob <# "'SimpleX Directory'> > /approve 1:PSA 1" bob <## " You are not allowed to use this command" - superUser #> "@SimpleX-Directory /approve 1:PSA 1" - superUser <# "SimpleX-Directory> > /approve 1:PSA 1" + superUser #> "@'SimpleX Directory' /approve 1:PSA 1" + superUser <# "'SimpleX Directory'> > /approve 1:PSA 1" superUser <## " Incorrect approval code" -- putStrLn "*** update profile so that it has no link" updateGroupProfile bob "Welcome!" - bob <# "SimpleX-Directory> The group link for ID 1 (PSA) is removed from the welcome message." + bob <# "'SimpleX Directory'> The group link for ID 1 (PSA) is removed from the welcome message." bob <## "" bob <## "The group is hidden from the directory until the group link is added and the group is re-approved." - superUser <# "SimpleX-Directory> The group link is removed from ID 1 (PSA), de-listed." - superUser #> "@SimpleX-Directory /approve 1:PSA 2" - superUser <# "SimpleX-Directory> > /approve 1:PSA 2" + superUser <# "'SimpleX Directory'> The group link is removed from ID 1 (PSA), de-listed." + superUser #> "@'SimpleX Directory' /approve 1:PSA 2" + superUser <# "'SimpleX Directory'> > /approve 1:PSA 2" superUser <## " Error: the group ID 1 (PSA) is not pending approval." -- putStrLn "*** update profile so that it has link again" updateGroupProfile bob welcomeWithLink' - bob <# "SimpleX-Directory> Thank you! The group link for ID 1 (PSA) is added to the welcome message." + bob <# "'SimpleX Directory'> Thank you! The group link for ID 1 (PSA) is added to the welcome message." bob <## "You will be notified once the group is added to the directory - it may take up to 48 hours." approvalRequested superUser welcomeWithLink' (1 :: Int) - superUser #> "@SimpleX-Directory /pending" - superUser <# "SimpleX-Directory> > /pending" + superUser #> "@'SimpleX Directory' /pending" + superUser <# "'SimpleX Directory'> > /pending" superUser <## " 1 registered group(s)" - superUser <# "SimpleX-Directory> 1. PSA (Privacy, Security & Anonymity)" + superUser <# "'SimpleX Directory'> 1. PSA (Privacy, Security & Anonymity)" superUser <## "Welcome message:" superUser <##. "Welcome! Link to join the group PSA: " superUser <## "Owner: bob" superUser <## "2 members" superUser <## "Status: pending admin approval" - superUser #> "@SimpleX-Directory /approve 1:PSA 1" - superUser <# "SimpleX-Directory> > /approve 1:PSA 1" + superUser <## "/'role 1', /'filter 1'" + superUser #> "@'SimpleX Directory' /approve 1:PSA 1" + superUser <# "'SimpleX Directory'> > /approve 1:PSA 1" superUser <## " Group approved!" - bob <# "SimpleX-Directory> The group ID 1 (PSA) is approved and listed in directory - please moderate it!" + bob <# "'SimpleX Directory'> The group ID 1 (PSA) is approved and listed in directory - please moderate it!" bob <## "Please note: if you change the group profile it will be hidden from directory until it is re-approved." bob <## "" bob <## "Supported commands:" - bob <## "- /filter 1 - to configure anti-spam filter." - bob <## "- /role 1 - to set default member role." - bob <## "- /help commands - other commands." + bob <## "/'filter 1' - to configure anti-spam filter." + bob <## "/'role 1' - to set default member role." + bob <## "/'link 1' - to view/upgrade group link." search bob "privacy" welcomeWithLink' search bob "security" welcomeWithLink' cath `connectVia` dsLink search cath "privacy" welcomeWithLink' - bob #> "@SimpleX-Directory /exec /contacts" - bob <# "SimpleX-Directory> > /exec /contacts" + bob #> "@'SimpleX Directory' /exec /contacts" + bob <# "'SimpleX Directory'> > /exec /contacts" bob <## " You are not allowed to use this command" - superUser #> "@SimpleX-Directory /exec /contacts" - superUser <# "SimpleX-Directory> > /exec /contacts" + superUser #> "@'SimpleX Directory' /exec /contacts" + superUser <# "'SimpleX Directory'> > /exec /contacts" superUser <## " alice (Alice)" superUser <## "bob (Bob)" superUser <## "cath (Catherine)" where search u s welcome = do - u #> ("@SimpleX-Directory " <> s) - u <# ("SimpleX-Directory> > " <> s) + u #> ("@'SimpleX Directory' " <> s) + u <# ("'SimpleX Directory'> > " <> s) u <## " Found 1 group(s)." - u <# "SimpleX-Directory> PSA (Privacy, Security & Anonymity)" + u <# "'SimpleX Directory'> PSA (Privacy, Security & Anonymity)" u <## "Welcome message:" u <## welcome u <## "2 members" @@ -222,14 +226,14 @@ testDirectoryService ps = u <## "welcome message changed to:" u <## welcome approvalRequested su welcome grId = do - su <# "SimpleX-Directory> bob submitted the group ID 1:" + su <# "'SimpleX Directory'> bob submitted the group ID 1:" su <## "PSA (Privacy, Security & Anonymity)" su <## "Welcome message:" su <## welcome su <## "2 members" su <## "" su <## "To approve send:" - su <# ("SimpleX-Directory> /approve 1:PSA " <> show grId) + su <# ("'SimpleX Directory'> /approve 1:PSA " <> show grId) testSuspendResume :: HasCallStack => TestParams -> IO () testSuspendResume ps = @@ -238,26 +242,26 @@ testSuspendResume ps = bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" groupFound bob "privacy" - superUser #> "@SimpleX-Directory /suspend 1:privacy" - superUser <# "SimpleX-Directory> > /suspend 1:privacy" + superUser #> "@'SimpleX Directory' /suspend 1:privacy" + superUser <# "'SimpleX Directory'> > /suspend 1:privacy" superUser <## " Group suspended!" - bob <# "SimpleX-Directory> The group ID 1 (privacy) is suspended and hidden from directory. Please contact the administrators." + bob <# "'SimpleX Directory'> The group ID 1 (privacy) is suspended and hidden from directory. Please contact the administrators." groupNotFound bob "privacy" - superUser #> "@SimpleX-Directory /resume 1:privacy" - superUser <# "SimpleX-Directory> > /resume 1:privacy" + superUser #> "@'SimpleX Directory' /resume 1:privacy" + superUser <# "'SimpleX Directory'> > /resume 1:privacy" superUser <## " Group listing resumed!" - bob <# "SimpleX-Directory> The group ID 1 (privacy) is listed in the directory again!" + bob <# "'SimpleX Directory'> The group ID 1 (privacy) is listed in the directory again!" groupFound bob "privacy" - superUser #> "@SimpleX-Directory privacy" + superUser #> "@'SimpleX Directory' privacy" groupFoundN_ "" (Just 1) 2 superUser "privacy" - superUser #> "@SimpleX-Directory /link 1:privacy" - superUser <# "SimpleX-Directory> > /link 1:privacy" + superUser #> "@'SimpleX Directory' /link 1:privacy" + superUser <# "'SimpleX Directory'> > /link 1:privacy" superUser <## " The link to join the group ID 1 (privacy):" superUser <##. "https://localhost/g#" superUser <## "New member role: member" -- get and change the link to the equivalent - should not ask to re-approve - bob #> "@SimpleX-Directory /link 1" - bob <# "SimpleX-Directory> > /link 1" + bob #> "@'SimpleX Directory' /link 1" + bob <# "'SimpleX Directory'> > /link 1" bob <## " The link to join the group ID 1 (privacy):" gLink <- getTermLine bob gLink `shouldStartWith` "https://localhost/g#" @@ -268,38 +272,40 @@ testSuspendResume ps = bob ##> ("/set welcome #privacy Link to join the group privacy: " <> gLink <> "?same_link=true") bob <## "welcome message changed to:" bob <## ("Link to join the group privacy: " <> gLink <> "?same_link=true") - bob <# "SimpleX-Directory> The group ID 1 (privacy) is updated!" + bob <# "'SimpleX Directory'> The group ID 1 (privacy) is updated!" bob <## "The group is listed in directory." - superUser <# "SimpleX-Directory> The group ID 1 (privacy) is updated - only link or whitespace changes." + superUser <# "'SimpleX Directory'> The group ID 1 (privacy) is updated - only link or whitespace changes." superUser <## "The group remained listed in directory." +#if !defined(dbPostgres) -- upgrade link -- make it upgradeable first - superUser #> "@SimpleX-Directory /x /sql chat UPDATE user_contact_links SET short_link_contact = NULL" - superUser <# "SimpleX-Directory> > /x /sql chat UPDATE user_contact_links SET short_link_contact = NULL" + superUser #> "@'SimpleX Directory' /x /sql chat UPDATE user_contact_links SET short_link_contact = NULL" + superUser <# "'SimpleX Directory'> > /x /sql chat UPDATE user_contact_links SET short_link_contact = NULL" superUser <## "" - bob #> "@SimpleX-Directory /link 1" - bob <# "SimpleX-Directory> > /link 1" + bob #> "@'SimpleX Directory' /link 1" + bob <# "'SimpleX Directory'> > /link 1" bob <## " The link to join the group ID 1 (privacy):" bob <##. "https://simplex.chat/contact#/" bob <## "New member role: member" bob <## "The link is being upgraded..." - bob <# "SimpleX-Directory> Please replace the old link in welcome message of your group ID 1 (privacy)" + bob <# "'SimpleX Directory'> Please replace the old link in welcome message of your group ID 1 (privacy)" bob <## "If this is the only change, the group will remain listed in directory without re-approval." bob <## "" bob <## "The new link:" - gLink' <- dropStrPrefix "SimpleX-Directory> " . dropTime <$> getTermLine bob + gLink' <- dropStrPrefix "'SimpleX Directory'> " . dropTime <$> getTermLine bob bob ##> ("/set welcome #privacy Link to join the group privacy: " <> gLink') bob <## "welcome message changed to:" bob <## ("Link to join the group privacy: " <> gLink') - bob <# "SimpleX-Directory> The group ID 1 (privacy) is updated!" + bob <# "'SimpleX Directory'> The group ID 1 (privacy) is updated!" bob <## "The group is listed in directory." - superUser <# "SimpleX-Directory> The group ID 1 (privacy) is updated - only link or whitespace changes." + superUser <# "'SimpleX Directory'> The group ID 1 (privacy) is updated - only link or whitespace changes." superUser <## "The group remained listed in directory." -- send message to group owner - superUser #> "@SimpleX-Directory /owner 1:privacy hello there" - superUser <# "SimpleX-Directory> > /owner 1:privacy hello there" + superUser #> "@'SimpleX Directory' /owner 1:privacy hello there" + superUser <# "'SimpleX Directory'> > /owner 1:privacy hello there" superUser <## " Forwarded to @bob, the owner of the group ID 1 (privacy)" - bob <# "SimpleX-Directory> hello there" + bob <# "'SimpleX Directory'> hello there" +#endif testDeleteGroup :: HasCallStack => TestParams -> IO () testDeleteGroup ps = @@ -308,8 +314,8 @@ testDeleteGroup ps = bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" groupFound bob "privacy" - bob #> "@SimpleX-Directory /delete 1:privacy" - bob <# "SimpleX-Directory> > /delete 1:privacy" + bob #> "@'SimpleX Directory' /delete 1:privacy" + bob <# "'SimpleX Directory'> > /delete 1:privacy" bob <## " Your group privacy is deleted from the directory" groupNotFound bob "privacy" @@ -326,19 +332,19 @@ testDeleteGroupAdmin ps = groupFound bob "security" listUserGroup bob "privacy" "Privacy" listUserGroup cath "security" "Security" - superUser #> "@SimpleX-Directory /last" - superUser <# "SimpleX-Directory> > /last" + superUser #> "@'SimpleX Directory' /last" + superUser <# "'SimpleX Directory'> > /last" superUser <## " 2 registered group(s)" memberGroupListing superUser bob 1 "privacy" "Privacy" 2 "active" memberGroupListing superUser cath 2 "security" "Security" 2 "active" -- trying to register group with the same name submitGroup bob "security" "Security" - bob <# "SimpleX-Directory> The group security (Security) is already listed in the directory, please choose another name." + bob <# "'SimpleX Directory'> The group security (Security) is already listed in the directory, please choose another name." bob ##> "/d #security" bob <## "#security: you deleted the group" -- admin can delete the group - superUser #> "@SimpleX-Directory /delete 2:security" - superUser <# "SimpleX-Directory> > /delete 2:security" + superUser #> "@'SimpleX Directory' /delete 2:security" + superUser <# "'SimpleX Directory'> > /delete 2:security" superUser <## " The group security is deleted from the directory" groupFound cath "privacy" listUserGroup bob "privacy" "Privacy" @@ -355,8 +361,8 @@ testSetRole ps = bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" groupFound bob "privacy" - bob #> "@SimpleX-Directory /role 1:privacy observer" - bob <# "SimpleX-Directory> > /role 1:privacy observer" + bob #> "@'SimpleX Directory' /role 1:privacy observer" + bob <# "'SimpleX Directory'> > /role 1:privacy observer" bob <## " The initial member role for the group privacy is set to observer" bob <## "" note <- getTermLine bob @@ -365,13 +371,13 @@ testSetRole ps = cath <## "connection request sent!" cath <## "#privacy: joining the group..." cath <## "#privacy: you joined the group" - cath <#. "#privacy SimpleX-Directory> Link to join the group privacy: https://localhost/g#" + cath <#. "#privacy 'SimpleX Directory'> Link to join the group privacy: https://localhost/g#" cath <## "#privacy: member bob (Bob) is connected" - bob <## "#privacy: SimpleX-Directory added cath (Catherine) to the group (connecting...)" + bob <## "#privacy: 'SimpleX Directory' added cath (Catherine) to the group (connecting...)" bob <## "#privacy: new member cath is connected" bob ##> "/ms #privacy" bob <## "bob (Bob): owner, you, created group" - bob <## "SimpleX-Directory: admin, invited, connected" + bob <## "'SimpleX Directory': admin, invited, connected" bob <## "cath (Catherine): observer, connected" cath ##> "#privacy hello" cath <## "#privacy: you don't have permission to send messages" @@ -385,10 +391,10 @@ testJoinGroup ps = bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" cath `connectVia` dsLink - cath #> "@SimpleX-Directory privacy" - cath <# "SimpleX-Directory> > privacy" + cath #> "@'SimpleX Directory' privacy" + cath <# "'SimpleX Directory'> > privacy" cath <## " Found 1 group(s)." - cath <# "SimpleX-Directory> privacy (Privacy)" + cath <# "'SimpleX Directory'> privacy (Privacy)" cath <## "Welcome message:" welcomeMsg <- getTermLine cath let groupLink = dropStrPrefix "Link to join the group privacy: " welcomeMsg @@ -397,11 +403,11 @@ testJoinGroup ps = cath <## "connection request sent!" cath <## "#privacy: joining the group..." cath <## "#privacy: you joined the group" - cath <## "contact and member are merged: SimpleX-Directory, #privacy SimpleX-Directory_1" - cath <## "use @SimpleX-Directory to send messages" - cath <# ("#privacy SimpleX-Directory> " <> welcomeMsg) + cath <## "contact and member are merged: 'SimpleX Directory', #privacy 'SimpleX Directory_1'" + cath <## "use @'SimpleX Directory' to send messages" + cath <# ("#privacy 'SimpleX Directory'> " <> welcomeMsg) cath <## "#privacy: member bob (Bob) is connected" - bob <## "#privacy: SimpleX-Directory added cath (Catherine) to the group (connecting...)" + bob <## "#privacy: 'SimpleX Directory' added cath (Catherine) to the group (connecting...)" bob <## "#privacy: new member cath is connected" bob ##> "/create link #privacy" bobLink <- getGroupLink bob "privacy" GRMember True @@ -416,7 +422,7 @@ testJoinGroup ps = dan <## "#privacy: you joined the group" dan <# ("#privacy bob> " <> welcomeMsg) dan - <### [ "#privacy: member SimpleX-Directory is connected", + <### [ "#privacy: member 'SimpleX Directory' is connected", "#privacy: member cath (Catherine) is connected" ], do @@ -431,15 +437,15 @@ testGroupNameWithSpaces ps = bob `connectVia` dsLink registerGroup superUser bob "Privacy & Security" "" groupFound bob "Privacy & Security" - superUser #> "@SimpleX-Directory /suspend 1:'Privacy & Security'" - superUser <# "SimpleX-Directory> > /suspend 1:'Privacy & Security'" + superUser #> "@'SimpleX Directory' /suspend 1:'Privacy & Security'" + superUser <# "'SimpleX Directory'> > /suspend 1:'Privacy & Security'" superUser <## " Group suspended!" - bob <# "SimpleX-Directory> The group ID 1 (Privacy & Security) is suspended and hidden from directory. Please contact the administrators." + bob <# "'SimpleX Directory'> The group ID 1 (Privacy & Security) is suspended and hidden from directory. Please contact the administrators." groupNotFound bob "privacy" - superUser #> "@SimpleX-Directory /resume 1:'Privacy & Security'" - superUser <# "SimpleX-Directory> > /resume 1:'Privacy & Security'" + superUser #> "@'SimpleX Directory' /resume 1:'Privacy & Security'" + superUser <# "'SimpleX Directory'> > /resume 1:'Privacy & Security'" superUser <## " Group listing resumed!" - bob <# "SimpleX-Directory> The group ID 1 (Privacy & Security) is listed in the directory again!" + bob <# "'SimpleX Directory'> The group ID 1 (Privacy & Security) is listed in the directory again!" groupFound bob "Privacy & Security" testSearchGroups :: HasCallStack => TestParams -> IO () @@ -453,58 +459,58 @@ testSearchGroups ps = connectUsers bob cath fullAddMember "MyGroup" "" bob cath GRMember joinGroup "MyGroup" cath bob - cath <## "#MyGroup: member SimpleX-Directory_1 is connected" - cath <## "contact and member are merged: SimpleX-Directory, #MyGroup SimpleX-Directory_1" - cath <## "use @SimpleX-Directory to send messages" - cath #> "@SimpleX-Directory MyGroup" - cath <# "SimpleX-Directory> > MyGroup" + cath <## "#MyGroup: member 'SimpleX Directory_1' is connected" + cath <## "contact and member are merged: 'SimpleX Directory', #MyGroup 'SimpleX Directory_1'" + cath <## "use @'SimpleX Directory' to send messages" + cath #> "@'SimpleX Directory' MyGroup" + cath <# "'SimpleX Directory'> > MyGroup" cath <## " Found 7 group(s), sending top 3." receivedGroup cath 0 3 receivedGroup cath 1 2 receivedGroup cath 2 2 - cath <# "SimpleX-Directory> Send /next or just . for 4 more result(s)." - cath #> "@SimpleX-Directory /next" - cath <# "SimpleX-Directory> > /next" + cath <# "'SimpleX Directory'> Send /next for 4 more result(s)." + cath #> "@'SimpleX Directory' /next" + cath <# "'SimpleX Directory'> > /next" cath <## " Sending 3 more group(s)." receivedGroup cath 3 2 receivedGroup cath 4 2 receivedGroup cath 5 2 - cath <# "SimpleX-Directory> Send /next or just . for 1 more result(s)." + cath <# "'SimpleX Directory'> Send /next for 1 more result(s)." -- search of another user does not affect the search of the first user groupFound bob "Another" - cath #> "@SimpleX-Directory ." - cath <# "SimpleX-Directory> > ." + cath #> "@'SimpleX Directory' ." + cath <# "'SimpleX Directory'> > ." cath <## " Sending 1 more group(s)." receivedGroup cath 6 2 - cath #> "@SimpleX-Directory /all" - cath <# "SimpleX-Directory> > /all" + cath #> "@'SimpleX Directory' /all" + cath <# "'SimpleX Directory'> > /all" cath <## " 8 group(s) listed, sending top 3." receivedGroup cath 0 3 receivedGroup cath 1 2 receivedGroup cath 2 2 - cath <# "SimpleX-Directory> Send /next or just . for 5 more result(s)." - cath #> "@SimpleX-Directory /new" - cath <# "SimpleX-Directory> > /new" + cath <# "'SimpleX Directory'> Send /next for 5 more result(s)." + cath #> "@'SimpleX Directory' /new" + cath <# "'SimpleX Directory'> > /new" cath <## " 8 group(s) listed, sending the most recent 3." receivedGroup cath 7 2 receivedGroup cath 6 2 receivedGroup cath 5 2 - cath <# "SimpleX-Directory> Send /next or just . for 5 more result(s)." - cath #> "@SimpleX-Directory term3" - cath <# "SimpleX-Directory> > term3" + cath <# "'SimpleX Directory'> Send /next for 5 more result(s)." + cath #> "@'SimpleX Directory' term3" + cath <# "'SimpleX Directory'> > term3" cath <## " Found 3 group(s)." receivedGroup cath 4 2 receivedGroup cath 5 2 receivedGroup cath 6 2 - cath #> "@SimpleX-Directory term1" - cath <# "SimpleX-Directory> > term1" + cath #> "@'SimpleX Directory' term1" + cath <# "'SimpleX Directory'> > term1" cath <## " Found 6 group(s), sending top 3." receivedGroup cath 1 2 receivedGroup cath 2 2 receivedGroup cath 3 2 - cath <# "SimpleX-Directory> Send /next or just . for 3 more result(s)." - cath #> "@SimpleX-Directory ." - cath <# "SimpleX-Directory> > ." + cath <# "'SimpleX Directory'> Send /next for 3 more result(s)." + cath #> "@'SimpleX Directory' ." + cath <# "'SimpleX Directory'> > ." cath <## " Sending 3 more group(s)." receivedGroup cath 4 2 receivedGroup cath 5 2 @@ -523,24 +529,24 @@ testSearchGroups ps = ] receivedGroup :: TestCC -> Int -> Int -> IO () receivedGroup u ix count = do - u <#. ("SimpleX-Directory> " <> groups !! ix) + u <#. ("'SimpleX Directory'> " <> groups !! ix) u <## "Welcome message:" u <##. "Link to join the group " u <## (show count <> " members") testInviteToOwnersGroup :: HasCallStack => TestParams -> IO () testInviteToOwnersGroup ps = - withDirectoryServiceCfgOwnersGroup ps testCfg True $ \superUser dsLink -> + withDirectoryServiceCfgOwnersGroup ps testCfg True Nothing $ \superUser dsLink -> withNewTestChatCfg ps testCfg "bob" bobProfile $ \bob -> do bob `connectVia` dsLink registerGroupId superUser bob "privacy" "Privacy" 2 1 - bob <## "#owners: SimpleX-Directory invites you to join the group as member" + bob <## "#owners: 'SimpleX Directory' invites you to join the group as member" bob <## "use /j owners to accept" superUser <## "Invited @bob, the owner of the group ID 2 (privacy) to owners' group owners" bob ##> "/j owners" bob <## "#owners: you joined the group" bob <## "#owners: member alice (Alice) is connected" - superUser <## "#owners: SimpleX-Directory added bob (Bob) to the group (connecting...)" + superUser <## "#owners: 'SimpleX Directory' added bob (Bob) to the group (connecting...)" superUser <## "#owners: new member bob is connected" -- second group registerGroupId superUser bob "security" "Security" 3 2 @@ -556,13 +562,13 @@ testDelistedOwnerLeaves ps = addCathAsOwner bob cath leaveGroup "privacy" bob cath <## "#privacy: bob left the group" - bob <# "SimpleX-Directory> You left the group ID 1 (privacy)." + bob <# "'SimpleX Directory'> You left the group ID 1 (privacy)." bob <## "" bob <## "The group is no longer listed in the directory." - superUser <# "SimpleX-Directory> The group ID 1 (privacy) is de-listed (group owner left)." + superUser <# "'SimpleX Directory'> The group ID 1 (privacy) is de-listed (group owner left)." cath `connectVia` dsLink - cath <## "contact and member are merged: SimpleX-Directory_1, #privacy SimpleX-Directory" - cath <## "use @SimpleX-Directory to send messages" + cath <## "contact and member are merged: 'SimpleX Directory_1', #privacy 'SimpleX Directory'" + cath <## "use @'SimpleX Directory' to send messages" groupNotFound cath "privacy" testDelistedOwnerRemoved :: HasCallStack => TestParams -> IO () @@ -574,13 +580,13 @@ testDelistedOwnerRemoved ps = registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath removeMember "privacy" cath bob - bob <# "SimpleX-Directory> You are removed from the group ID 1 (privacy)." + bob <# "'SimpleX Directory'> You are removed from the group ID 1 (privacy)." bob <## "" bob <## "The group is no longer listed in the directory." - superUser <# "SimpleX-Directory> The group ID 1 (privacy) is de-listed (group owner is removed)." + superUser <# "'SimpleX Directory'> The group ID 1 (privacy) is de-listed (group owner is removed)." cath `connectVia` dsLink - cath <## "contact and member are merged: SimpleX-Directory_1, #privacy SimpleX-Directory" - cath <## "use @SimpleX-Directory to send messages" + cath <## "contact and member are merged: 'SimpleX Directory_1', #privacy 'SimpleX Directory'" + cath <## "use @'SimpleX Directory' to send messages" groupNotFound cath "privacy" testNotDelistedMemberLeaves :: HasCallStack => TestParams -> IO () @@ -595,7 +601,7 @@ testNotDelistedMemberLeaves ps = bob <## "#privacy: cath left the group" (superUser "@SimpleX-Directory_1 privacy" + cath #> "@'SimpleX Directory_1' privacy" groupFoundN_ "_1" Nothing 2 cath "privacy" testNotDelistedMemberRemoved :: HasCallStack => TestParams -> IO () @@ -609,7 +615,7 @@ testNotDelistedMemberRemoved ps = removeMember "privacy" bob cath (superUser "@SimpleX-Directory_1 privacy" + cath #> "@'SimpleX Directory_1' privacy" groupFoundN_ "_1" Nothing 2 cath "privacy" testDelistedServiceRemoved :: HasCallStack => TestParams -> IO () @@ -620,13 +626,13 @@ testDelistedServiceRemoved ps = bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath - bob ##> "/rm #privacy SimpleX-Directory" - bob <## "#privacy: you removed SimpleX-Directory from the group" - cath <## "#privacy: bob removed SimpleX-Directory from the group" - bob <# "SimpleX-Directory> SimpleX-Directory is removed from the group ID 1 (privacy)." + bob ##> "/rm #privacy 'SimpleX Directory'" + bob <## "#privacy: you removed 'SimpleX Directory' from the group" + cath <## "#privacy: bob removed 'SimpleX Directory' from the group" + bob <# "'SimpleX Directory'> SimpleX Directory is removed from the group ID 1 (privacy)." bob <## "" bob <## "The group is no longer listed in the directory." - superUser <# "SimpleX-Directory> The group ID 1 (privacy) is de-listed (directory service is removed)." + superUser <# "'SimpleX Directory'> The group ID 1 (privacy) is de-listed (directory service is removed)." cath `connectVia` dsLink groupNotFound_ "_1" cath "privacy" @@ -641,17 +647,17 @@ testDelistedGroupDeleted ps = connectUsers bob cath fullAddMember "privacy" "Privacy" bob cath GROwner joinGroup "privacy" cath bob - cath <## "#privacy: member SimpleX-Directory_1 is connected" - cath <## "contact and member are merged: SimpleX-Directory, #privacy SimpleX-Directory_1" - cath <## "use @SimpleX-Directory to send messages" + cath <## "#privacy: member 'SimpleX Directory_1' is connected" + cath <## "contact and member are merged: 'SimpleX Directory', #privacy 'SimpleX Directory_1'" + cath <## "use @'SimpleX Directory' to send messages" bob ##> "/d #privacy" bob <## "#privacy: you deleted the group" - bob <# "SimpleX-Directory> The group ID 1 (privacy) is deleted." + bob <# "'SimpleX Directory'> The group ID 1 (privacy) is deleted." bob <## "" bob <## "The group is no longer listed in the directory." cath <## "#privacy: bob deleted the group" cath <## "use /d #privacy to delete the local copy of the group" - superUser <# "SimpleX-Directory> The group ID 1 (privacy) is de-listed (group is deleted)." + superUser <# "'SimpleX Directory'> The group ID 1 (privacy) is de-listed (group is deleted)." groupNotFound cath "privacy" testDelistedRoleChanges :: HasCallStack => TestParams -> IO () @@ -663,44 +669,44 @@ testDelistedRoleChanges ps = registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath cath `connectVia` dsLink - cath <## "contact and member are merged: SimpleX-Directory_1, #privacy SimpleX-Directory" - cath <## "use @SimpleX-Directory to send messages" + cath <## "contact and member are merged: 'SimpleX Directory_1', #privacy 'SimpleX Directory'" + cath <## "use @'SimpleX Directory' to send messages" groupFoundN 3 cath "privacy" -- de-listed if service role changed - bob ##> "/mr privacy SimpleX-Directory member" - bob <## "#privacy: you changed the role of SimpleX-Directory to member" - cath <## "#privacy: bob changed the role of SimpleX-Directory from admin to member" - bob <# "SimpleX-Directory> SimpleX-Directory role in the group ID 1 (privacy) is changed to member." + bob ##> "/mr privacy 'SimpleX Directory' member" + bob <## "#privacy: you changed the role of 'SimpleX Directory' to member" + cath <## "#privacy: bob changed the role of 'SimpleX Directory' from admin to member" + bob <# "'SimpleX Directory'> SimpleX Directory role in the group ID 1 (privacy) is changed to member." bob <## "" bob <## "The group is no longer listed in the directory." - superUser <# "SimpleX-Directory> The group ID 1 (privacy) is de-listed (SimpleX-Directory role is changed to member)." + superUser <# "'SimpleX Directory'> The group ID 1 (privacy) is de-listed (SimpleX Directory role is changed to member)." groupNotFound cath "privacy" -- re-listed if service role changed back without profile changes - cath ##> "/mr privacy SimpleX-Directory admin" - cath <## "#privacy: you changed the role of SimpleX-Directory to admin" - bob <## "#privacy: cath changed the role of SimpleX-Directory from member to admin" - bob <# "SimpleX-Directory> SimpleX-Directory role in the group ID 1 (privacy) is changed to admin." + cath ##> "/mr privacy 'SimpleX Directory' admin" + cath <## "#privacy: you changed the role of 'SimpleX Directory' to admin" + bob <## "#privacy: cath changed the role of 'SimpleX Directory' from member to admin" + bob <# "'SimpleX Directory'> SimpleX Directory role in the group ID 1 (privacy) is changed to admin." bob <## "" bob <## "The group is listed in the directory again." - superUser <# "SimpleX-Directory> The group ID 1 (privacy) is listed (SimpleX-Directory role is changed to admin)." + superUser <# "'SimpleX Directory'> The group ID 1 (privacy) is listed (SimpleX Directory role is changed to admin)." groupFoundN 3 cath "privacy" -- de-listed if owner role changed cath ##> "/mr privacy bob admin" cath <## "#privacy: you changed the role of bob to admin" bob <## "#privacy: cath changed your role from owner to admin" - bob <# "SimpleX-Directory> Your role in the group ID 1 (privacy) is changed to admin." + bob <# "'SimpleX Directory'> Your role in the group ID 1 (privacy) is changed to admin." bob <## "" bob <## "The group is no longer listed in the directory." - superUser <# "SimpleX-Directory> The group ID 1 (privacy) is de-listed (user role is set to admin)." + superUser <# "'SimpleX Directory'> The group ID 1 (privacy) is de-listed (user role is set to admin)." groupNotFound cath "privacy" -- re-listed if owner role changed back without profile changes cath ##> "/mr privacy bob owner" cath <## "#privacy: you changed the role of bob to owner" bob <## "#privacy: cath changed your role from admin to owner" - bob <# "SimpleX-Directory> Your role in the group ID 1 (privacy) is changed to owner." + bob <# "'SimpleX Directory'> Your role in the group ID 1 (privacy) is changed to owner." bob <## "" bob <## "The group is listed in the directory again." - superUser <# "SimpleX-Directory> The group ID 1 (privacy) is listed (user role is set to owner)." + superUser <# "'SimpleX Directory'> The group ID 1 (privacy) is listed (user role is set to owner)." groupFoundN 3 cath "privacy" testNotDelistedMemberRoleChanged :: HasCallStack => TestParams -> IO () @@ -712,8 +718,8 @@ testNotDelistedMemberRoleChanged ps = registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath cath `connectVia` dsLink - cath <## "contact and member are merged: SimpleX-Directory_1, #privacy SimpleX-Directory" - cath <## "use @SimpleX-Directory to send messages" + cath <## "contact and member are merged: 'SimpleX Directory_1', #privacy 'SimpleX Directory'" + cath <## "use @'SimpleX Directory' to send messages" groupFoundN 3 cath "privacy" bob ##> "/mr privacy cath member" bob <## "#privacy: you changed the role of cath to member" @@ -729,13 +735,13 @@ testNotSentApprovalBadRoles ps = cath `connectVia` dsLink submitGroup bob "privacy" "Privacy" welcomeWithLink <- groupAccepted bob "privacy" - bob ##> "/mr privacy SimpleX-Directory member" - bob <## "#privacy: you changed the role of SimpleX-Directory to member" + bob ##> "/mr privacy 'SimpleX Directory' member" + bob <## "#privacy: you changed the role of 'SimpleX Directory' to member" updateProfileWithLink bob "privacy" welcomeWithLink 1 - bob <# "SimpleX-Directory> You must grant directory service admin role to register the group" - bob ##> "/mr privacy SimpleX-Directory admin" - bob <## "#privacy: you changed the role of SimpleX-Directory to admin" - bob <# "SimpleX-Directory> SimpleX-Directory role in the group ID 1 (privacy) is changed to admin." + bob <# "'SimpleX Directory'> You must grant directory service admin role to register the group" + bob ##> "/mr privacy 'SimpleX Directory' admin" + bob <## "#privacy: you changed the role of 'SimpleX Directory' to admin" + bob <# "'SimpleX Directory'> SimpleX Directory role in the group ID 1 (privacy) is changed to admin." bob <## "" bob <## "The group is submitted for approval." notifySuperUser superUser bob "privacy" "Privacy" welcomeWithLink 1 @@ -754,16 +760,16 @@ testNotApprovedBadRoles ps = welcomeWithLink <- groupAccepted bob "privacy" updateProfileWithLink bob "privacy" welcomeWithLink 1 notifySuperUser superUser bob "privacy" "Privacy" welcomeWithLink 1 - bob ##> "/mr privacy SimpleX-Directory member" - bob <## "#privacy: you changed the role of SimpleX-Directory to member" + bob ##> "/mr privacy 'SimpleX Directory' member" + bob <## "#privacy: you changed the role of 'SimpleX Directory' to member" let approve = "/approve 1:privacy 1" - superUser #> ("@SimpleX-Directory " <> approve) - superUser <# ("SimpleX-Directory> > " <> approve) - superUser <## " Group is not approved: SimpleX-Directory is not an admin." + superUser #> ("@'SimpleX Directory' " <> approve) + superUser <# ("'SimpleX Directory'> > " <> approve) + superUser <## " Group is not approved: SimpleX Directory is not an admin." groupNotFound cath "privacy" - bob ##> "/mr privacy SimpleX-Directory admin" - bob <## "#privacy: you changed the role of SimpleX-Directory to admin" - bob <# "SimpleX-Directory> SimpleX-Directory role in the group ID 1 (privacy) is changed to admin." + bob ##> "/mr privacy 'SimpleX Directory' admin" + bob <## "#privacy: you changed the role of 'SimpleX Directory' to admin" + bob <# "'SimpleX Directory'> SimpleX Directory role in the group ID 1 (privacy) is changed to admin." bob <## "" bob <## "The group is submitted for approval." notifySuperUser superUser bob "privacy" "Privacy" welcomeWithLink 1 @@ -780,15 +786,15 @@ testRegOwnerChangedProfile ps = addCathAsOwner bob cath bob ##> "/gp privacy privacy Privacy and Security" bob <## "description changed to: Privacy and Security" - bob <# "SimpleX-Directory> The group ID 1 (privacy) is updated!" + bob <# "'SimpleX Directory'> The group ID 1 (privacy) is updated!" bob <## "It is hidden from the directory until approved." cath <## "bob updated group #privacy:" cath <## "description changed to: Privacy and Security" cath `connectVia` dsLink - cath <## "contact and member are merged: SimpleX-Directory_1, #privacy SimpleX-Directory" - cath <## "use @SimpleX-Directory to send messages" + cath <## "contact and member are merged: 'SimpleX Directory_1', #privacy 'SimpleX Directory'" + cath <## "use @'SimpleX Directory' to send messages" groupNotFound cath "privacy" - superUser <# "SimpleX-Directory> The group ID 1 (privacy) is updated." + superUser <# "'SimpleX Directory'> The group ID 1 (privacy) is updated." reapproveGroup 3 superUser bob groupFoundN 3 cath "privacy" @@ -801,16 +807,16 @@ testAnotherOwnerChangedProfile ps = registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath cath `connectVia` dsLink - cath <## "contact and member are merged: SimpleX-Directory_1, #privacy SimpleX-Directory" - cath <## "use @SimpleX-Directory to send messages" + cath <## "contact and member are merged: 'SimpleX Directory_1', #privacy 'SimpleX Directory'" + cath <## "use @'SimpleX Directory' to send messages" cath ##> "/gp privacy privacy Privacy and Security" cath <## "description changed to: Privacy and Security" bob <## "cath updated group #privacy:" bob <## "description changed to: Privacy and Security" - bob <# "SimpleX-Directory> The group ID 1 (privacy) is updated by cath!" + bob <# "'SimpleX Directory'> The group ID 1 (privacy) is updated by cath!" bob <## "It is hidden from the directory until approved." groupNotFound cath "privacy" - superUser <# "SimpleX-Directory> The group ID 1 (privacy) is updated by cath." + superUser <# "'SimpleX Directory'> The group ID 1 (privacy) is updated by cath." reapproveGroup 3 superUser bob groupFoundN 3 cath "privacy" @@ -828,10 +834,10 @@ testNotConnectedOwnerChangedProfile ps = cath <## "description changed to: Privacy and Security" bob <## "cath updated group #privacy:" bob <## "description changed to: Privacy and Security" - bob <# "SimpleX-Directory> The group ID 1 (privacy) is updated by cath!" + bob <# "'SimpleX Directory'> The group ID 1 (privacy) is updated by cath!" bob <## "It is hidden from the directory until approved." groupNotFound dan "privacy" - superUser <# "SimpleX-Directory> The group ID 1 (privacy) is updated by cath." + superUser <# "'SimpleX Directory'> The group ID 1 (privacy) is updated by cath." reapproveGroup 3 superUser bob groupFoundN 3 dan "privacy" @@ -849,22 +855,22 @@ testRegOwnerRemovedLink ps = bob ##> "/set welcome #privacy Welcome!" bob <## "welcome message changed to:" bob <## "Welcome!" - bob <# "SimpleX-Directory> The group link for ID 1 (privacy) is removed from the welcome message." + bob <# "'SimpleX Directory'> The group link for ID 1 (privacy) is removed from the welcome message." bob <## "" bob <## "The group is hidden from the directory until the group link is added and the group is re-approved." cath <## "bob updated group #privacy:" cath <## "welcome message changed to:" cath <## "Welcome!" - superUser <# "SimpleX-Directory> The group link is removed from ID 1 (privacy), de-listed." + superUser <# "'SimpleX Directory'> The group link is removed from ID 1 (privacy), de-listed." cath `connectVia` dsLink - cath <## "contact and member are merged: SimpleX-Directory_1, #privacy SimpleX-Directory" - cath <## "use @SimpleX-Directory to send messages" + cath <## "contact and member are merged: 'SimpleX Directory_1', #privacy 'SimpleX Directory'" + cath <## "use @'SimpleX Directory' to send messages" groupNotFound cath "privacy" let withChangedLink = T.unpack $ T.replace "contact#/?v=2-7&" "contact#/?v=3-7&" $ T.pack welcomeWithLink bob ##> ("/set welcome #privacy " <> withChangedLink) bob <## "welcome message changed to:" bob <## withChangedLink - bob <# "SimpleX-Directory> Thank you! The group link for ID 1 (privacy) is added to the welcome message." + bob <# "'SimpleX Directory'> Thank you! The group link for ID 1 (privacy) is added to the welcome message." bob <## "You will be notified once the group is added to the directory - it may take up to 48 hours." cath <## "bob updated group #privacy:" cath <## "welcome message changed to:" @@ -881,8 +887,8 @@ testAnotherOwnerRemovedLink ps = registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath cath `connectVia` dsLink - cath <## "contact and member are merged: SimpleX-Directory_1, #privacy SimpleX-Directory" - cath <## "use @SimpleX-Directory to send messages" + cath <## "contact and member are merged: 'SimpleX Directory_1', #privacy 'SimpleX Directory'" + cath <## "use @'SimpleX Directory' to send messages" bob ##> "/show welcome #privacy" bob <## "Welcome message:" welcomeWithLink <- getTermLine bob @@ -892,10 +898,10 @@ testAnotherOwnerRemovedLink ps = bob <## "cath updated group #privacy:" bob <## "welcome message changed to:" bob <## "Welcome!" - bob <# "SimpleX-Directory> The group link for ID 1 (privacy) is removed from the welcome message by cath." + bob <# "'SimpleX Directory'> The group link for ID 1 (privacy) is removed from the welcome message by cath." bob <## "" bob <## "The group is hidden from the directory until the group link is added and the group is re-approved." - superUser <# "SimpleX-Directory> The group link is removed from ID 1 (privacy), de-listed." + superUser <# "'SimpleX Directory'> The group link is removed from ID 1 (privacy), de-listed." groupNotFound cath "privacy" cath ##> ("/set welcome #privacy " <> welcomeWithLink) cath <## "welcome message changed to:" @@ -903,7 +909,7 @@ testAnotherOwnerRemovedLink ps = bob <## "cath updated group #privacy:" bob <## "welcome message changed to:" bob <## welcomeWithLink - bob <# "SimpleX-Directory> Thank you! The group link for ID 1 (privacy) is added to the welcome message by cath." + bob <# "'SimpleX Directory'> Thank you! The group link for ID 1 (privacy) is added to the welcome message by cath." bob <## "You will be notified once the group is added to the directory - it may take up to 48 hours." reapproveGroup 3 superUser bob groupFoundN 3 cath "privacy" @@ -927,10 +933,10 @@ testNotConnectedOwnerRemovedLink ps = bob <## "cath updated group #privacy:" bob <## "welcome message changed to:" bob <## "Welcome!" - bob <# "SimpleX-Directory> The group link for ID 1 (privacy) is removed from the welcome message by cath." + bob <# "'SimpleX Directory'> The group link for ID 1 (privacy) is removed from the welcome message by cath." bob <## "" bob <## "The group is hidden from the directory until the group link is added and the group is re-approved." - superUser <# "SimpleX-Directory> The group link is removed from ID 1 (privacy), de-listed." + superUser <# "'SimpleX Directory'> The group link is removed from ID 1 (privacy), de-listed." groupNotFound dan "privacy" cath ##> ("/set welcome #privacy " <> welcomeWithLink) cath <## "welcome message changed to:" @@ -938,13 +944,13 @@ testNotConnectedOwnerRemovedLink ps = bob <## "cath updated group #privacy:" bob <## "welcome message changed to:" bob <## welcomeWithLink - -- bob <# "SimpleX-Directory> The group link is added by another group member, your registration will not be processed." + -- bob <# "'SimpleX Directory'> The group link is added by another group member, your registration will not be processed." -- bob <## "" -- bob <## "Please update the group profile yourself." -- bob ##> ("/set welcome #privacy " <> welcomeWithLink <> " - welcome!") -- bob <## "welcome message changed to:" -- bob <## (welcomeWithLink <> " - welcome!") - bob <# "SimpleX-Directory> Thank you! The group link for ID 1 (privacy) is added to the welcome message by cath." + bob <# "'SimpleX Directory'> Thank you! The group link for ID 1 (privacy) is added to the welcome message by cath." bob <## "You will be notified once the group is added to the directory - it may take up to 48 hours." -- cath <## "bob updated group #privacy:" -- cath <## "welcome message changed to:" @@ -962,13 +968,13 @@ testDuplicateAskConfirmation ps = _ <- groupAccepted bob "privacy" cath `connectVia` dsLink submitGroup cath "privacy" "Privacy" - cath <# "SimpleX-Directory> The group privacy (Privacy) is already submitted to the directory." + cath <# "'SimpleX Directory'> The group privacy (Privacy) is already submitted to the directory." cath <## "To confirm the registration, please send:" - cath <# "SimpleX-Directory> /confirm 1:privacy" - cath #> "@SimpleX-Directory /confirm 1:privacy" + cath <# "'SimpleX Directory'> /confirm 1:privacy" + cath #> "@'SimpleX Directory' /confirm 1:privacy" welcomeWithLink <- groupAccepted cath "privacy" groupNotFound bob "privacy" - completeRegistration superUser cath "privacy" "Privacy" welcomeWithLink 2 + completeRegistrationId superUser cath "privacy" "Privacy" welcomeWithLink 2 1 groupFound bob "privacy" testDuplicateProhibitRegistration :: HasCallStack => TestParams -> IO () @@ -981,7 +987,7 @@ testDuplicateProhibitRegistration ps = cath `connectVia` dsLink groupFound cath "privacy" _ <- submitGroup cath "privacy" "Privacy" - cath <# "SimpleX-Directory> The group privacy (Privacy) is already listed in the directory, please choose another name." + cath <# "'SimpleX Directory'> The group privacy (Privacy) is already listed in the directory, please choose another name." testDuplicateProhibitConfirmation :: HasCallStack => TestParams -> IO () testDuplicateProhibitConfirmation ps = @@ -993,14 +999,14 @@ testDuplicateProhibitConfirmation ps = welcomeWithLink <- groupAccepted bob "privacy" cath `connectVia` dsLink submitGroup cath "privacy" "Privacy" - cath <# "SimpleX-Directory> The group privacy (Privacy) is already submitted to the directory." + cath <# "'SimpleX Directory'> The group privacy (Privacy) is already submitted to the directory." cath <## "To confirm the registration, please send:" - cath <# "SimpleX-Directory> /confirm 1:privacy" + cath <# "'SimpleX Directory'> /confirm 1:privacy" groupNotFound cath "privacy" completeRegistration superUser bob "privacy" "Privacy" welcomeWithLink 1 groupFound cath "privacy" - cath #> "@SimpleX-Directory /confirm 1:privacy" - cath <# "SimpleX-Directory> The group privacy (Privacy) is already listed in the directory, please choose another name." + cath #> "@'SimpleX Directory' /confirm 1:privacy" + cath <# "'SimpleX Directory'> The group privacy (Privacy) is already listed in the directory, please choose another name." testDuplicateProhibitWhenUpdated :: HasCallStack => TestParams -> IO () testDuplicateProhibitWhenUpdated ps = @@ -1012,10 +1018,10 @@ testDuplicateProhibitWhenUpdated ps = welcomeWithLink <- groupAccepted bob "privacy" cath `connectVia` dsLink submitGroup cath "privacy" "Privacy" - cath <# "SimpleX-Directory> The group privacy (Privacy) is already submitted to the directory." + cath <# "'SimpleX Directory'> The group privacy (Privacy) is already submitted to the directory." cath <## "To confirm the registration, please send:" - cath <# "SimpleX-Directory> /confirm 1:privacy" - cath #> "@SimpleX-Directory /confirm 1:privacy" + cath <# "'SimpleX Directory'> /confirm 1:privacy" + cath #> "@'SimpleX Directory' /confirm 1:privacy" welcomeWithLink' <- groupAccepted cath "privacy" groupNotFound cath "privacy" completeRegistration superUser bob "privacy" "Privacy" welcomeWithLink 1 @@ -1023,13 +1029,13 @@ testDuplicateProhibitWhenUpdated ps = cath ##> ("/set welcome privacy " <> welcomeWithLink') cath <## "welcome message changed to:" cath <## welcomeWithLink' - cath <# "SimpleX-Directory> The group privacy (Privacy) is already listed in the directory, please choose another name." + cath <# "'SimpleX Directory'> The group privacy (Privacy) is already listed in the directory, please choose another name." cath ##> "/gp privacy security Security" cath <## "changed to #security (Security)" - cath <# "SimpleX-Directory> Thank you! The group link for ID 2 (security) is added to the welcome message." + cath <# "'SimpleX Directory'> Thank you! The group link for ID 1 (security) is added to the welcome message." cath <## "You will be notified once the group is added to the directory - it may take up to 48 hours." notifySuperUser superUser cath "security" "Security" welcomeWithLink' 2 - approveRegistration superUser cath "security" 2 + approveRegistrationId superUser cath "security" 2 1 groupFound bob "security" groupFound cath "security" @@ -1043,49 +1049,96 @@ testDuplicateProhibitApproval ps = welcomeWithLink <- groupAccepted bob "privacy" cath `connectVia` dsLink submitGroup cath "privacy" "Privacy" - cath <# "SimpleX-Directory> The group privacy (Privacy) is already submitted to the directory." + cath <# "'SimpleX Directory'> The group privacy (Privacy) is already submitted to the directory." cath <## "To confirm the registration, please send:" - cath <# "SimpleX-Directory> /confirm 1:privacy" - cath #> "@SimpleX-Directory /confirm 1:privacy" + cath <# "'SimpleX Directory'> /confirm 1:privacy" + cath #> "@'SimpleX Directory' /confirm 1:privacy" welcomeWithLink' <- groupAccepted cath "privacy" - updateProfileWithLink cath "privacy" welcomeWithLink' 2 + updateProfileWithLink cath "privacy" welcomeWithLink' 1 notifySuperUser superUser cath "privacy" "Privacy" welcomeWithLink' 2 groupNotFound cath "privacy" completeRegistration superUser bob "privacy" "Privacy" welcomeWithLink 1 groupFound cath "privacy" -- fails at approval, as already listed let approve = "/approve 2:privacy 1" - superUser #> ("@SimpleX-Directory " <> approve) - superUser <# ("SimpleX-Directory> > " <> approve) + superUser #> ("@'SimpleX Directory' " <> approve) + superUser <# ("'SimpleX Directory'> > " <> approve) superUser <## " The group ID 2 (privacy) is already listed in the directory." -testListUserGroups :: HasCallStack => TestParams -> IO () -testListUserGroups ps = - withDirectoryService ps $ \superUser dsLink -> +testListUserGroups :: HasCallStack => Bool -> TestParams -> IO () +testListUserGroups promote ps = + withDirectoryServiceCfgOwnersGroup ps testCfg False (Just "./tests/tmp/web") $ \superUser dsLink -> withNewTestChat ps "bob" bobProfile $ \bob -> withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink cath `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" + checkListings ["privacy"] [] connectUsers bob cath fullAddMember "privacy" "Privacy" bob cath GRMember joinGroup "privacy" cath bob - cath <## "#privacy: member SimpleX-Directory_1 is connected" - cath <## "contact and member are merged: SimpleX-Directory, #privacy SimpleX-Directory_1" - cath <## "use @SimpleX-Directory to send messages" + cath <## "#privacy: member 'SimpleX Directory_1' is connected" + cath <## "contact and member are merged: 'SimpleX Directory', #privacy 'SimpleX Directory_1'" + cath <## "use @'SimpleX Directory' to send messages" registerGroupId superUser bob "security" "Security" 2 2 + checkListings ["privacy", "security"] [] registerGroupId superUser cath "anonymity" "Anonymity" 3 1 + checkListings ["privacy", "security", "anonymity"] [] listUserGroup cath "anonymity" "Anonymity" -- with de-listed group groupFound cath "anonymity" - cath ##> "/mr anonymity SimpleX-Directory member" - cath <## "#anonymity: you changed the role of SimpleX-Directory to member" - cath <# "SimpleX-Directory> SimpleX-Directory role in the group ID 1 (anonymity) is changed to member." + cath ##> "/mr anonymity 'SimpleX Directory' member" + cath <## "#anonymity: you changed the role of 'SimpleX Directory' to member" + cath <# "'SimpleX Directory'> SimpleX Directory role in the group ID 1 (anonymity) is changed to member." cath <## "" cath <## "The group is no longer listed in the directory." - superUser <# "SimpleX-Directory> The group ID 3 (anonymity) is de-listed (SimpleX-Directory role is changed to member)." + superUser <# "'SimpleX Directory'> The group ID 3 (anonymity) is de-listed (SimpleX Directory role is changed to member)." + checkListings ["privacy", "security"] [] groupNotFound cath "anonymity" listGroups superUser bob cath + when promote $ do + superUser #> "@'SimpleX Directory' /promote 1:privacy on" + superUser <# "'SimpleX Directory'> > /promote 1:privacy on" + superUser <## " Group promotion enabled." + checkListings ["privacy", "security"] ["privacy"] + bob ##> "/gp privacy privacy" + bob <## "description removed" + bob <# "'SimpleX Directory'> The group ID 1 (privacy) is updated!" + bob <## "It is hidden from the directory until approved." + cath <## "bob updated group #privacy:" + cath <## "description removed" + superUser <# "'SimpleX Directory'> The group ID 1 (privacy) is updated." + superUser <# "'SimpleX Directory'> bob submitted the group ID 1:" + superUser <## "privacy" + superUser <## "Welcome message:" + superUser <##. "Link to join the group privacy: https://localhost/g#" + superUser <## "3 members" + superUser <## "" + superUser <## "To approve send:" + superUser <# "'SimpleX Directory'> /approve 1:privacy 1 promote=on" + checkListings ["security"] [] + superUser #> "@'SimpleX Directory' /approve 1:privacy 1" + superUser <# "'SimpleX Directory'> > /approve 1:privacy 1" + superUser <## " Group approved (promoted)!" + bob <# "'SimpleX Directory'> The group ID 1 (privacy) is approved and listed in directory - please moderate it!" + bob <## "Please note: if you change the group profile it will be hidden from directory until it is re-approved." + bob <## "" + bob <## "Supported commands:" + bob <## "/'filter 1' - to configure anti-spam filter." + bob <## "/'role 1' - to set default member role." + bob <## "/'link 1' - to view/upgrade group link." + checkListings ["privacy", "security"] ["privacy"] + +checkListings :: HasCallStack => [T.Text] -> [T.Text] -> IO () +checkListings listed promoted = do + threadDelay 100000 + checkListing listingFileName listed + checkListing promotedFileName promoted + where + checkListing f expected = do + Just (DirectoryListing gs) <- J.decodeFileStrict $ "./tests/tmp/web/data" f + map groupName gs `shouldBe` expected + groupName DirectoryEntry {displayName} = displayName testCapthaScreening :: HasCallStack => TestParams -> IO () testCapthaScreening ps = @@ -1095,32 +1148,34 @@ testCapthaScreening ps = bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" -- check default role - bob #> "@SimpleX-Directory /role 1" - bob <# "SimpleX-Directory> > /role 1" + bob #> "@'SimpleX Directory' /role 1" + bob <# "'SimpleX Directory'> > /role 1" bob <## " The initial member role for the group privacy is set to member" - bob <## "Send /role 1 observer to change it." + bob <## "Send /'role 1 observer' to change it." bob <## "" note <- getTermLine bob let groupLink = dropStrPrefix "Please note: it applies only to members joining via this link: " note -- enable captcha - bob #> "@SimpleX-Directory /filter 1 captcha" - bob <# "SimpleX-Directory> > /filter 1 captcha" + bob #> "@'SimpleX Directory' /filter 1 captcha" + bob <# "'SimpleX Directory'> > /filter 1 captcha" bob <## " Spam filter settings for group privacy set to:" bob <## "- reject long/inappropriate names: disabled" bob <## "- pass captcha to join: enabled" bob <## "" - bob <## "Use /filter 1 [name] [captcha] to enable and /filter 1 off to disable filter." + bob <## "/'filter 1 name' - enable name filter" + bob <## "/'filter 1 name captcha' - enable both" + bob <## "/'filter 1 off' - disable filter" -- connect with captcha screen _ <- join cath groupLink cath ##> "/_send #1(_support) text 123" -- sending incorrect captcha cath <# "#privacy (support) 123" - cath <# "#privacy (support) SimpleX-Directory!> > cath 123" + cath <# "#privacy (support) 'SimpleX Directory'!> > cath 123" cath <## " Incorrect text, please try again." - captcha <- dropStrPrefix "#privacy (support) SimpleX-Directory> " . dropTime <$> getTermLine cath + captcha <- dropStrPrefix "#privacy (support) 'SimpleX Directory'> " . dropTime <$> getTermLine cath sendCaptcha cath captcha - cath <#. "#privacy SimpleX-Directory> Link to join the group privacy: https://" + cath <#. "#privacy 'SimpleX Directory'> Link to join the group privacy: https://" cath <## "#privacy: member bob (Bob) is connected" - bob <## "#privacy: SimpleX-Directory added cath (Catherine) to the group (connecting...)" + bob <## "#privacy: 'SimpleX Directory' added cath (Catherine) to the group (connecting...)" bob <## "#privacy: new member cath is connected" cath #> "#privacy hello" bob <# "#privacy cath> hello" @@ -1131,8 +1186,8 @@ testCapthaScreening ps = cath ##> "/d #privacy" cath <## "#privacy: you deleted the group" -- change default role to observer - bob #> "@SimpleX-Directory /role 1 observer" - bob <# "SimpleX-Directory> > /role 1 observer" + bob #> "@'SimpleX Directory' /role 1 observer" + bob <# "'SimpleX Directory'> > /role 1 observer" bob <## " The initial member role for the group privacy is set to observer" bob <## "" bob <##. "Please note: it applies only to members joining via this link: https://" @@ -1140,18 +1195,18 @@ testCapthaScreening ps = captcha' <- join cath groupLink sendCaptcha cath captcha' -- message from cath that left - pastMember <- dropStrPrefix "#privacy: SimpleX-Directory forwarded a message from an unknown member, creating unknown member record " <$> getTermLine cath + pastMember <- dropStrPrefix "#privacy: 'SimpleX Directory' forwarded a message from an unknown member, creating unknown member record " <$> getTermLine cath cath <# ("#privacy " <> pastMember <> "> hello [>>]") - cath <#. "#privacy SimpleX-Directory> Link to join the group privacy: https://" + cath <#. "#privacy 'SimpleX Directory'> Link to join the group privacy: https://" cath <## "#privacy: member bob (Bob) is connected" - bob <## "#privacy: SimpleX-Directory added cath_1 (Catherine) to the group (connecting...)" + bob <## "#privacy: 'SimpleX Directory' added cath_1 (Catherine) to the group (connecting...)" bob <## "#privacy: new member cath_1 is connected" cath ##> "#privacy hello" cath <## "#privacy: you don't have permission to send messages" (bob "/ms privacy" cath <## "cath (Catherine): observer, you, connected" - cath <## "SimpleX-Directory: admin, host, connected" + cath <## "'SimpleX Directory': admin, host, connected" cath <## "bob (Bob): owner, connected" cath <## (pastMember <> ": author, status unknown") where @@ -1160,38 +1215,30 @@ testCapthaScreening ps = cath <## "connection request sent!" cath <## "#privacy: joining the group..." cath <## "#privacy: you joined the group, pending approval" - cath <# "#privacy (support) SimpleX-Directory> Captcha is generated by SimpleX Directory service." + cath <# "#privacy (support) 'SimpleX Directory'> Captcha is generated by SimpleX Directory service." cath <## "" cath <## "Send captcha text to join the group privacy." - dropStrPrefix "#privacy (support) SimpleX-Directory> " . dropTime <$> getTermLine cath + dropStrPrefix "#privacy (support) 'SimpleX Directory'> " . dropTime <$> getTermLine cath sendCaptcha cath captcha = do cath ##> ("/_send #1(_support) text " <> captcha) cath <# ("#privacy (support) " <> captcha) - cath <# ("#privacy (support) SimpleX-Directory!> > cath " <> captcha) + cath <# ("#privacy (support) 'SimpleX Directory'!> > cath " <> captcha) cath <## " Correct, you joined the group privacy" cath <## "#privacy: you joined the group" testRestoreDirectory :: HasCallStack => TestParams -> IO () testRestoreDirectory ps = do - testListUserGroups ps - restoreDirectoryService ps 3 3 $ \superUser _dsLink -> + testListUserGroups False ps + restoreDirectoryService ps 11 $ \superUser _dsLink -> withTestChat ps "bob" $ \bob -> withTestChat ps "cath" $ \cath -> do - bob <## "2 contacts connected (use /cs for the list)" - bob - <### [ "#privacy: connected to server(s)", - "#security: connected to server(s)" - ] - cath <## "2 contacts connected (use /cs for the list)" - cath - <### [ "#privacy: connected to server(s)", - "#anonymity: connected to server(s)" - ] + bob <## "subscribed 5 connections on server localhost" + cath <## "subscribed 5 connections on server localhost" listGroups superUser bob cath groupFoundN 3 bob "privacy" groupFound bob "security" groupFoundN 3 cath "privacy" - cath #> "@SimpleX-Directory security" + cath #> "@'SimpleX Directory' security" groupFoundN' 2 cath "security" testCaptcha :: HasCallStack => TestParams -> IO () @@ -1215,18 +1262,18 @@ listGroups superUser bob cath = do sendListCommand cath 1 groupListing cath 1 "anonymity" "Anonymity" 2 "suspended because roles changed" -- superuser lists all groups - bob #> "@SimpleX-Directory /last" - bob <# "SimpleX-Directory> > /last" + bob #> "@'SimpleX Directory' /last" + bob <# "'SimpleX Directory'> > /last" bob <## " You are not allowed to use this command" - superUser #> "@SimpleX-Directory /last" - superUser <# "SimpleX-Directory> > /last" + superUser #> "@'SimpleX Directory' /last" + superUser <# "'SimpleX Directory'> > /last" superUser <## " 3 registered group(s)" memberGroupListing superUser bob 1 "privacy" "Privacy" 3 "active" memberGroupListing superUser bob 2 "security" "Security" 2 "active" memberGroupListing superUser cath 3 "anonymity" "Anonymity" 2 "suspended because roles changed" -- showing last 1 group - superUser #> "@SimpleX-Directory /last 1" - superUser <# "SimpleX-Directory> > /last 1" + superUser #> "@'SimpleX Directory' /last 1" + superUser <# "'SimpleX Directory'> > /last 1" superUser <## " 3 registered group(s), showing the last 1" memberGroupListing superUser cath 3 "anonymity" "Anonymity" 2 "suspended because roles changed" @@ -1237,8 +1284,8 @@ listUserGroup u n fn = do sendListCommand :: HasCallStack => TestCC -> Int -> IO () sendListCommand u count = do - u #> "@SimpleX-Directory /list" - u <# "SimpleX-Directory> > /list" + u #> "@'SimpleX Directory' /list" + u <# "'SimpleX Directory'> > /list" u <## (" " <> show count <> " registered group(s)") groupListing :: HasCallStack => TestCC -> Int -> String -> String -> Int -> String -> IO () @@ -1249,7 +1296,7 @@ memberGroupListing su owner = groupListing_ su (Just owner) groupListing_ :: HasCallStack => TestCC -> Maybe TestCC -> Int -> String -> String -> Int -> String -> IO () groupListing_ su owner_ gId n fn count status = do - su <# ("SimpleX-Directory> " <> show gId <> ". " <> n <> " (" <> fn <> ")") + su <# ("'SimpleX Directory'> " <> show gId <> ". " <> n <> " (" <> fn <> ")") su <## "Welcome message:" su <##. ("Link to join the group " <> n <> ": ") forM_ owner_ $ \owner -> do @@ -1257,43 +1304,44 @@ groupListing_ su owner_ gId n fn count status = do su <## ("Owner: " <> ownerName) su <## (show count <> " members") su <## ("Status: " <> status) + su <## ("/'role " <> show gId <> "', /'filter " <> show gId <> "'") reapproveGroup :: HasCallStack => Int -> TestCC -> TestCC -> IO () reapproveGroup count superUser bob = do - superUser <# "SimpleX-Directory> bob submitted the group ID 1:" + superUser <# "'SimpleX Directory'> bob submitted the group ID 1:" superUser <##. "privacy (" superUser <## "Welcome message:" superUser <##. "Link to join the group privacy: " superUser <## (show count <> " members") superUser <## "" superUser <## "To approve send:" - superUser <# "SimpleX-Directory> /approve 1:privacy 1" - superUser #> "@SimpleX-Directory /approve 1:privacy 1" - superUser <# "SimpleX-Directory> > /approve 1:privacy 1" + superUser <# "'SimpleX Directory'> /approve 1:privacy 1" + superUser #> "@'SimpleX Directory' /approve 1:privacy 1" + superUser <# "'SimpleX Directory'> > /approve 1:privacy 1" superUser <## " Group approved!" - bob <# "SimpleX-Directory> The group ID 1 (privacy) is approved and listed in directory - please moderate it!" + bob <# "'SimpleX Directory'> The group ID 1 (privacy) is approved and listed in directory - please moderate it!" bob <## "Please note: if you change the group profile it will be hidden from directory until it is re-approved." bob <## "" bob <## "Supported commands:" - bob <## "- /filter 1 - to configure anti-spam filter." - bob <## "- /role 1 - to set default member role." - bob <## "- /help commands - other commands." + bob <## "/'filter 1' - to configure anti-spam filter." + bob <## "/'role 1' - to set default member role." + bob <## "/'link 1' - to view/upgrade group link." addCathAsOwner :: HasCallStack => TestCC -> TestCC -> IO () addCathAsOwner bob cath = do connectUsers bob cath fullAddMember "privacy" "Privacy" bob cath GROwner joinGroup "privacy" cath bob - cath <## "#privacy: member SimpleX-Directory is connected" + cath <## "#privacy: member 'SimpleX Directory' is connected" withDirectoryService :: HasCallStack => TestParams -> (TestCC -> String -> IO ()) -> IO () withDirectoryService ps = withDirectoryServiceCfg ps testCfg withDirectoryServiceCfg :: HasCallStack => TestParams -> ChatConfig -> (TestCC -> String -> IO ()) -> IO () -withDirectoryServiceCfg ps cfg = withDirectoryServiceCfgOwnersGroup ps cfg False +withDirectoryServiceCfg ps cfg = withDirectoryServiceCfgOwnersGroup ps cfg False Nothing -withDirectoryServiceCfgOwnersGroup :: HasCallStack => TestParams -> ChatConfig -> Bool -> (TestCC -> String -> IO ()) -> IO () -withDirectoryServiceCfgOwnersGroup ps cfg createOwnersGroup test = do +withDirectoryServiceCfgOwnersGroup :: HasCallStack => TestParams -> ChatConfig -> Bool -> Maybe FilePath -> (TestCC -> String -> IO ()) -> IO () +withDirectoryServiceCfgOwnersGroup ps cfg createOwnersGroup webFolder test = do dsLink <- withNewTestChatCfg ps cfg serviceDbPrefix directoryProfile $ \ds -> withNewTestChatCfg ps cfg "super_user" aliceProfile $ \superUser -> do @@ -1302,25 +1350,22 @@ withDirectoryServiceCfgOwnersGroup ps cfg createOwnersGroup test = do superUser ##> "/g owners" superUser <## "group #owners is created" superUser <## "to add members use /a owners or /create link #owners" - superUser ##> "/a owners SimpleX-Directory admin" - superUser <## "invitation to join the group #owners sent to SimpleX-Directory" + superUser ##> "/a owners 'SimpleX Directory' admin" + superUser <## "invitation to join the group #owners sent to 'SimpleX Directory'" ds <## "#owners: alice invites you to join the group as admin" ds <## "use /j owners to accept" ds ##> "/j owners" ds <## "#owners: you joined the group" - superUser <## "#owners: SimpleX-Directory joined the group" + superUser <## "#owners: 'SimpleX Directory' joined the group" ds ##> "/ad" getContactLink ds True - withDirectoryOwnersGroup ps cfg dsLink createOwnersGroup test + withDirectoryOwnersGroup ps cfg dsLink createOwnersGroup webFolder test -restoreDirectoryService :: HasCallStack => TestParams -> Int -> Int -> (TestCC -> String -> IO ()) -> IO () -restoreDirectoryService ps ctCount grCount test = do +restoreDirectoryService :: HasCallStack => TestParams -> Int -> (TestCC -> String -> IO ()) -> IO () +restoreDirectoryService ps connCount test = do dsLink <- withTestChat ps serviceDbPrefix $ \ds -> do - ds <## (show ctCount <> " contacts connected (use /cs for the list)") - ds <## "Your address is active! To show: /sa" - ds <## (show grCount <> " group links active") - forM_ [1 .. grCount] $ \_ -> ds <##. "#" + ds .<## ("subscribed " <> show connCount <> " connections on server localhost") ds ##> "/sa" dsLink <- getContactLink ds False ds <## "auto_accept on" @@ -1328,29 +1373,24 @@ restoreDirectoryService ps ctCount grCount test = do withDirectory ps testCfg dsLink test withDirectory :: HasCallStack => TestParams -> ChatConfig -> String -> (TestCC -> String -> IO ()) -> IO () -withDirectory ps cfg dsLink = withDirectoryOwnersGroup ps cfg dsLink False +withDirectory ps cfg dsLink = withDirectoryOwnersGroup ps cfg dsLink False Nothing -withDirectoryOwnersGroup :: HasCallStack => TestParams -> ChatConfig -> String -> Bool -> (TestCC -> String -> IO ()) -> IO () -withDirectoryOwnersGroup ps cfg dsLink createOwnersGroup test = do - let opts = mkDirectoryOpts ps [KnownContact 2 "alice"] $ if createOwnersGroup then Just $ KnownGroup 1 "owners" else Nothing +withDirectoryOwnersGroup :: HasCallStack => TestParams -> ChatConfig -> String -> Bool -> Maybe FilePath -> (TestCC -> String -> IO ()) -> IO () +withDirectoryOwnersGroup ps cfg dsLink createOwnersGroup webFolder test = do + let opts = mkDirectoryOpts ps [KnownContact 2 "alice"] (if createOwnersGroup then Just $ KnownGroup 1 "owners" else Nothing) webFolder runDirectory cfg opts $ withTestChatCfg ps cfg "super_user" $ \superUser -> do - superUser <## "1 contacts connected (use /cs for the list)" - when createOwnersGroup $ - superUser <## "#owners: connected to server(s)" + if createOwnersGroup + then superUser <## "subscribed 2 connections on server localhost" + else superUser <## "subscribed 1 connections on server localhost" test superUser dsLink runDirectory :: ChatConfig -> DirectoryOpts -> IO () -> IO () runDirectory cfg opts@DirectoryOpts {directoryLog} action = do - st <- restoreDirectoryStore directoryLog - t <- forkIO $ bot st + st <- openDirectoryLog directoryLog + t <- forkIO $ directoryService st opts cfg threadDelay 500000 action `finally` (mapM_ hClose (directoryLogFile st) >> killThread t) - where - bot st = do - env <- newServiceState opts - let cfg' = cfg {chatHooks = defaultChatHooks {acceptMember = Just $ acceptMemberHook opts env}} - simplexChatCore cfg' (mkChatOpts opts) $ directoryService st opts env registerGroup :: TestCC -> TestCC -> String -> String -> IO () registerGroup su u n fn = registerGroupId su u n fn 1 1 @@ -1366,19 +1406,19 @@ submitGroup u n fn = do u ##> ("/g " <> viewName n <> if null fn then "" else " " <> fn) u <## ("group #" <> viewName n <> (if null fn then "" else " (" <> fn <> ")") <> " is created") u <## ("to add members use /a " <> viewName n <> " or /create link #" <> viewName n) - u ##> ("/a " <> viewName n <> " SimpleX-Directory admin") - u <## ("invitation to join the group #" <> viewName n <> " sent to SimpleX-Directory") + u ##> ("/a " <> viewName n <> " 'SimpleX Directory' admin") + u <## ("invitation to join the group #" <> viewName n <> " sent to 'SimpleX Directory'") groupAccepted :: TestCC -> String -> IO String groupAccepted u n = do - u <# ("SimpleX-Directory> Joining the group " <> n <> "…") - u <## ("#" <> viewName n <> ": SimpleX-Directory joined the group") - u <# ("SimpleX-Directory> Joined the group " <> n <> ", creating the link…") - u <# "SimpleX-Directory> Created the public link to join the group via this directory service that is always online." + u <# ("'SimpleX Directory'> Joining the group " <> n <> "…") + u <## ("#" <> viewName n <> ": 'SimpleX Directory' joined the group") + u <# ("'SimpleX Directory'> Joined the group " <> n <> ", creating the link…") + u <# "'SimpleX Directory'> Created the public link to join the group via this directory service that is always online." u <## "" u <## "Please add it to the group welcome message." u <## "For example, add:" - dropStrPrefix "SimpleX-Directory> " . dropTime <$> getTermLine u -- welcome message with link + dropStrPrefix "'SimpleX Directory'> " . dropTime <$> getTermLine u -- welcome message with link completeRegistration :: TestCC -> TestCC -> String -> String -> String -> Int -> IO () completeRegistration su u n fn welcomeWithLink gId = @@ -1395,13 +1435,13 @@ updateProfileWithLink u n welcomeWithLink ugId = do u ##> ("/set welcome " <> viewName n <> " " <> welcomeWithLink) u <## "welcome message changed to:" u <## welcomeWithLink - u <# ("SimpleX-Directory> Thank you! The group link for ID " <> show ugId <> " (" <> n <> ") is added to the welcome message.") + u <# ("'SimpleX Directory'> Thank you! The group link for ID " <> show ugId <> " (" <> n <> ") is added to the welcome message.") u <## "You will be notified once the group is added to the directory - it may take up to 48 hours." notifySuperUser :: TestCC -> TestCC -> String -> String -> String -> Int -> IO () notifySuperUser su u n fn welcomeWithLink gId = do uName <- userName u - su <# ("SimpleX-Directory> " <> uName <> " submitted the group ID " <> show gId <> ":") + su <# ("'SimpleX Directory'> " <> uName <> " submitted the group ID " <> show gId <> ":") su <## (n <> if null fn then "" else " (" <> fn <> ")") su <## "Welcome message:" su <## welcomeWithLink @@ -1409,7 +1449,7 @@ notifySuperUser su u n fn welcomeWithLink gId = do su <## "" su <## "To approve send:" let approve = "/approve " <> show gId <> ":" <> viewName n <> " 1" - su <# ("SimpleX-Directory> " <> approve) + su <# ("'SimpleX Directory'> " <> approve) approveRegistration :: TestCC -> TestCC -> String -> Int -> IO () approveRegistration su u n gId = @@ -1418,29 +1458,29 @@ approveRegistration su u n gId = approveRegistrationId :: TestCC -> TestCC -> String -> Int -> Int -> IO () approveRegistrationId su u n gId ugId = do let approve = "/approve " <> show gId <> ":" <> viewName n <> " 1" - su #> ("@SimpleX-Directory " <> approve) - su <# ("SimpleX-Directory> > " <> approve) + su #> ("@'SimpleX Directory' " <> approve) + su <# ("'SimpleX Directory'> > " <> approve) su <## " Group approved!" - u <# ("SimpleX-Directory> The group ID " <> show ugId <> " (" <> n <> ") is approved and listed in directory - please moderate it!") + u <# ("'SimpleX Directory'> The group ID " <> show ugId <> " (" <> n <> ") is approved and listed in directory - please moderate it!") u <## "Please note: if you change the group profile it will be hidden from directory until it is re-approved." u <## "" u <## "Supported commands:" - u <## ("- /filter " <> show ugId <> " - to configure anti-spam filter.") - u <## ("- /role " <> show ugId <> " - to set default member role.") - u <## "- /help commands - other commands." + u <## ("/'filter " <> show ugId <> "' - to configure anti-spam filter.") + u <## ("/'role " <> show ugId <> "' - to set default member role.") + u <## ("/'link " <> show ugId <> "' - to view/upgrade group link.") connectVia :: TestCC -> String -> IO () u `connectVia` dsLink = do u ##> ("/c " <> dsLink) u <## "connection request sent!" u .<## ": contact is connected" - u .<# "> Welcome to SimpleX-Directory service!" - u <## "Send a search string to find groups or /help to learn how to add groups to directory." + u .<# "> Welcome to SimpleX Directory!" u <## "" - u <## "For example, send privacy to find groups about privacy." - u <## "Or send /all or /new to list groups." + u <## "🔍 Send search string to find groups - try security." + u <## "/help - how to submit your group." + u <## "/new - recent groups." u <## "" - u <## "Content and privacy policy: https://simplex.chat/docs/directory.html" + u <## "[Directory rules](https://simplex.chat/docs/directory.html)." joinGroup :: String -> TestCC -> TestCC -> IO () joinGroup gName member host = do @@ -1474,7 +1514,7 @@ groupFound = groupFoundN 2 groupFoundN :: Int -> TestCC -> String -> IO () groupFoundN count u name = do - u #> ("@SimpleX-Directory " <> name) + u #> ("@'SimpleX Directory' " <> name) groupFoundN' count u name groupFoundN' :: Int -> TestCC -> String -> IO () @@ -1482,9 +1522,9 @@ groupFoundN' = groupFoundN_ "" Nothing groupFoundN_ :: String -> Maybe Int -> Int -> TestCC -> String -> IO () groupFoundN_ suffix shownId_ count u name = do - u <# ("SimpleX-Directory" <> suffix <> "> > " <> name) + u <# ("'SimpleX Directory" <> suffix <> "'> > " <> name) u <## " Found 1 group(s)." - u <#. ("SimpleX-Directory" <> suffix <> "> " <> maybe "" (\gId -> show gId <> ". ") shownId_ <> name) + u <#. ("'SimpleX Directory" <> suffix <> "'> " <> maybe "" (\gId -> show gId <> ". ") shownId_ <> name) u <## "Welcome message:" u <##. "Link to join the group " u <## (show count <> " members") @@ -1494,6 +1534,6 @@ groupNotFound = groupNotFound_ "" groupNotFound_ :: String -> TestCC -> String -> IO () groupNotFound_ suffix u s = do - u #> ("@SimpleX-Directory" <> suffix <> " " <> s) - u <# ("SimpleX-Directory" <> suffix <> "> > " <> s) + u #> ("@'SimpleX Directory" <> suffix <> "' " <> s) + u <# ("'SimpleX Directory" <> suffix <> "'> > " <> s) u <## " No groups found" diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index b868671f3b..8fd8a5976d 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -47,7 +47,7 @@ import Simplex.Messaging.Agent.Env.SQLite import Simplex.Messaging.Agent.Protocol (currentSMPAgentVersion, duplexHandshakeSMPAgentVersion, pqdrSMPAgentVersion, supportedSMPAgentVRange) import Simplex.Messaging.Agent.RetryInterval import Simplex.Messaging.Agent.Store.Interface (closeDBStore) -import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation (..), MigrationError) +import Simplex.Messaging.Agent.Store.Shared (MigrationConfig (..), MigrationConfirmation (..), MigrationError) import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Client (ProtocolClientConfig (..)) import Simplex.Messaging.Client.Agent (defaultSMPClientAgentConfig) @@ -151,7 +151,8 @@ testCoreOpts = tbqSize = 16, deviceName = Nothing, highlyAvailable = False, - yesToUpMigrations = False + yesToUpMigrations = False, + migrationBackupPath = Nothing } #if !defined(dbPostgres) @@ -208,7 +209,8 @@ testCfg = showReceipts = False, shortLinkPresetServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7001"], testView = True, - tbqSize = 16 + tbqSize = 16, + confirmMigrations = MCYesUp } testCfgSlow :: ChatConfig @@ -296,13 +298,13 @@ startTestChat ps cfg opts@ChatOpts {coreOptions} dbPrefix = do createDatabase :: TestParams -> CoreChatOpts -> String -> IO (Either MigrationError ChatDatabase) #if defined(dbPostgres) createDatabase _params CoreChatOpts {dbOptions} dbPrefix = do - createChatDatabase dbOptions {dbSchemaPrefix = "client_" <> dbPrefix} MCError + createChatDatabase dbOptions {dbSchemaPrefix = "client_" <> dbPrefix} (MigrationConfig MCError Nothing) insertUser :: DBStore -> IO () insertUser st = withTransaction st (`DB.execute_` "INSERT INTO users DEFAULT VALUES") #else createDatabase TestParams {tmpPath} CoreChatOpts {dbOptions} dbPrefix = do - createChatDatabase dbOptions {dbFilePrefix = tmpPath dbPrefix} MCError + createChatDatabase dbOptions {dbFilePrefix = tmpPath dbPrefix} (MigrationConfig MCError Nothing) insertUser :: DBStore -> IO () insertUser st = withTransaction st (`DB.execute_` "INSERT INTO users (user_id) VALUES (1)") @@ -533,6 +535,7 @@ smpServerCfg = dailyBlockQueueQuota = 20, messageExpiration = Just defaultMessageExpiration, expireMessagesOnStart = False, + expireMessagesOnSend = False, idleQueueInterval = defaultIdleQueueInterval, notificationExpiration = defaultNtfExpiration, inactiveClientExpiration = Just defaultInactiveClientExpiration, diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index cac90936a8..1b93013258 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -16,7 +16,7 @@ import ChatTests.DBUtils import ChatTests.Utils import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) -import Control.Monad (forM_) +import Control.Monad (forM_, void) import Data.Aeson (ToJSON) import qualified Data.Aeson as J import qualified Data.ByteString.Char8 as B @@ -55,7 +55,7 @@ chatDirectTests = do describe "direct messages" $ do describe "add contact and send/receive messages" testAddContact it "retry connecting via the same link" testRetryConnecting - xit'' "retry connecting via the same link with client timeout" testRetryConnectingClientTimeout + it "retry connecting via the same link with client timeout" testRetryConnectingClientTimeout it "mark multiple messages as read" testMarkReadDirect it "clear chat with contact" testContactClear it "deleting contact deletes profile" testDeleteContactDeletesProfile @@ -118,6 +118,11 @@ chatDirectTests = do it "export/import chat with files" testMaintenanceModeWithFiles it "encrypt/decrypt database" testDatabaseEncryption #endif + describe "connections synchronization" $ do + it "should report users missing in agent" testConnSyncMissingAgentUsers + it "should remove and report extra users in agent" testConnSyncExtraAgentUsers + it "should report connections missing in agent" testConnSyncMissingAgentConns + it "should remove and report extra connections in agent" testConnSyncExtraAgentConns describe "coordination between app and NSE" $ do it "should not subscribe in NSE and subscribe in the app" testSubscribeAppNSE describe "mute/unmute messages" $ do @@ -129,6 +134,7 @@ chatDirectTests = do it "both users have contact link" testMultipleUserAddresses it "create user with same servers" testCreateUserSameServers it "delete user" testDeleteUser + it "delete user with chat tags" testDeleteUserChatTags it "users have different chat item TTL configuration, chat items expire" testUsersDifferentCIExpirationTTL it "chat items expire after restart for all users according to per user configuration" testUsersRestartCIExpiration it "chat items only expire for users who configured expiration" testEnableCIExpirationOnlyForOneUser @@ -175,8 +181,6 @@ chatDirectTests = do testReqVRange vr11 supportedChatVRange testReqVRange vr11 vr11 it "update peer version range on received messages" testUpdatePeerChatVRange - describe "network statuses" $ do - it "should get network statuses" testGetNetworkStatuses where testInvVRange vr1 vr2 = it (vRangeStr vr1 <> " - " <> vRangeStr vr2) $ testConnInvChatVRange vr1 vr2 testReqVRange vr1 vr2 = it (vRangeStr vr1 <> " - " <> vRangeStr vr2) $ testConnReqChatVRange vr1 vr2 @@ -243,14 +247,14 @@ testRetryConnecting ps = testChatCfgOpts2 cfg' opts' aliceProfile bobProfile tes inv <- withSmpServer' serverCfg' $ do alice ##> "/_connect 1" getInvitation alice - alice <## "server disconnected localhost ()" + alice <## "disconnected 1 connections on server localhost" bob ##> ("/_connect plan 1 " <> inv) bob <## "invitation link: ok to connect" _sLinkData <- getTermLine bob bob ##> ("/_connect 1 " <> inv) bob <##. "smp agent error: BROKER" withSmpServer' serverCfg' $ do - alice <## "server connected localhost ()" + alice <## "subscribed 1 connections on server localhost" threadDelay 250000 bob ##> ("/_connect plan 1 " <> inv) bob <## "invitation link: ok to connect" @@ -264,8 +268,8 @@ testRetryConnecting ps = testChatCfgOpts2 cfg' opts' aliceProfile bobProfile tes bob <# "alice> message 1" bob #> "@alice message 2" alice <# "bob> message 2" - bob <## "server disconnected localhost (@alice)" - alice <## "server disconnected localhost (@bob)" + bob <## "disconnected 1 connections on server localhost" + alice <## "disconnected 1 connections on server localhost" serverCfg' = smpServerCfg { transports = [("7003", transport @TLS, False)], @@ -312,6 +316,8 @@ testRetryConnectingClientTimeout ps = do withTestChatCfgOpts ps cfg' opts' "alice" $ \alice -> do withTestChatCfgOpts ps cfg' opts' "bob" $ \bob -> do threadDelay 250000 + alice <## "subscribed 1 connections on server localhost" + -- bob <## "1 subscription errors (run with -c option to show each error)" bob ##> ("/_connect plan 1 " <> inv) bob <## "invitation link: ok to connect" _sLinkData <- getTermLine bob @@ -342,6 +348,7 @@ testRetryConnectingClientTimeout ps = do messageRetryInterval = RetryInterval2 {riFast = fastRetryInterval, riSlow = fastRetryInterval} } } + ChatConfig {presetServers = presetSrvs@PresetServers {netCfg}} = testCfg cfgZeroTimeout = (testCfg :: ChatConfig) { agentConfig = @@ -349,9 +356,7 @@ testRetryConnectingClientTimeout ps = do { quotaExceededTimeout = 1, messageRetryInterval = RetryInterval2 {riFast = fastRetryInterval, riSlow = fastRetryInterval} }, - presetServers = - let def@PresetServers {netCfg} = presetServers testCfg - in def {netCfg = (netCfg :: NetworkConfig) {tcpTimeout = NetworkTimeout 10 10}} + presetServers = presetSrvs {netCfg = (netCfg :: NetworkConfig) {tcpTimeout = NetworkTimeout 10 10}} } opts' = testOpts @@ -1161,8 +1166,8 @@ testTestSMPServerConnection = alice ##> "/smp test smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7001" alice <## "SMP server test passed" alice ##> "/smp test smp://LcJU@localhost:7001" - alice <## "SMP server test failed at Connect, error: BROKER {brokerAddress = \"smp://LcJU@localhost:7001\", brokerErr = NETWORK}" - alice <## "Possibly, certificate fingerprint in SMP server address is incorrect" + alice <## "SMP server test failed at Connect, error: BROKER {brokerAddress = \"smp://LcJU@localhost:7001\", brokerErr = NETWORK {networkError = NEUnknownCAError}}" + alice <## "Certificate fingerprint in SMP server address does not match server certificate" testGetSetXFTPServers :: HasCallStack => TestParams -> IO () testGetSetXFTPServers = @@ -1203,8 +1208,8 @@ testTestXFTPServer = alice ##> "/xftp test xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=:server_password@localhost:7002" alice <## "XFTP server test passed" alice ##> "/xftp test xftp://LcJU@localhost:7002" - alice <## "XFTP server test failed at Connect, error: BROKER {brokerAddress = \"xftp://LcJU@localhost:7002\", brokerErr = NETWORK}" - alice <## "Possibly, certificate fingerprint in XFTP server address is incorrect" + alice <## "XFTP server test failed at Connect, error: BROKER {brokerAddress = \"xftp://LcJU@localhost:7002\", brokerErr = NETWORK {networkError = NEUnknownCAError}}" + alice <## "Certificate fingerprint in XFTP server address does not match server certificate" testOperators :: HasCallStack => TestParams -> IO () testOperators = @@ -1247,6 +1252,7 @@ testAsyncInitiatingOffline withShortLink aliceCfg bobCfg ps = do bob ##> ("/c " <> inv) bob <## "confirmation sent!" withTestChatCfg ps aliceCfg "alice" $ \alice -> do + alice <## "subscribed 1 connections on server localhost" concurrently_ (bob <## "alice (Alice): contact is connected") (alice <## "bob (Bob): contact is connected") @@ -1262,6 +1268,8 @@ testAsyncAcceptingOffline withShortLink aliceCfg bobCfg ps = do bob <## "confirmation sent!" withTestChatCfg ps aliceCfg "alice" $ \alice -> do withTestChatCfg ps bobCfg "bob" $ \bob -> do + alice <## "subscribed 1 connections on server localhost" + bob <## "subscribed 1 connections on server localhost" concurrently_ (bob <## "alice (Alice): contact is connected") (alice <## "bob (Bob): contact is connected") @@ -1277,9 +1285,11 @@ testFullAsyncFast ps = do bob ##> ("/c " <> inv) bob <## "confirmation sent!" threadDelay 250000 - withTestChat ps "alice" $ \alice -> + withTestChat ps "alice" $ \alice -> do + alice <## "subscribed 1 connections on server localhost" alice <## "bob (Bob): contact is connected" - withTestChat ps "bob" $ \bob -> + withTestChat ps "bob" $ \bob -> do + bob <## "subscribed 1 connections on server localhost" bob <## "alice (Alice): contact is connected" testFullAsyncSlow :: HasCallStack => Bool -> ChatConfig -> ChatConfig -> TestParams -> IO () @@ -1292,13 +1302,15 @@ testFullAsyncSlow withShortLink aliceCfg bobCfg ps = do threadDelay 250000 bob ##> ("/c " <> inv) bob <## "confirmation sent!" - withAlice $ \_ -> pure () -- connecting... notification in UI - withBob $ \_ -> pure () -- connecting... notification in UI + withAlice $ \alice -> + alice <## "subscribed 1 connections on server localhost" + withBob $ \bob -> + bob <## "subscribed 1 connections on server localhost" withAlice $ \alice -> do - alice <## "1 contacts connected (use /cs for the list)" + alice <## "subscribed 1 connections on server localhost" alice <## "bob (Bob): contact is connected" withBob $ \bob -> do - bob <## "1 contacts connected (use /cs for the list)" + bob <## "subscribed 1 connections on server localhost" bob <## "alice (Alice): contact is connected" where withAlice = withTestChatCfg ps aliceCfg "alice" @@ -1392,7 +1404,7 @@ testMaintenanceMode ps = do alice ##> "/_start" alice <## "chat started" -- chat works after start - alice <## "1 contacts connected (use /cs for the list)" + alice <## "subscribed 1 connections on server localhost" alice #> "@bob hi again" bob <# "alice> hi again" bob #> "@alice hello" @@ -1413,7 +1425,7 @@ testMaintenanceMode ps = do testChatWorking :: HasCallStack => TestCC -> TestCC -> IO () testChatWorking alice bob = do - alice <## "1 contacts connected (use /cs for the list)" + alice <## "subscribed 1 connections on server localhost" alice #> "@bob hello again" bob <# "alice> hello again" bob #> "@alice hello too" @@ -1514,6 +1526,168 @@ testDatabaseEncryption ps = do testChatWorking alice bob #endif +testConnSyncMissingAgentUsers :: HasCallStack => TestParams -> IO () +testConnSyncMissingAgentUsers ps = do + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChat ps "alice" aliceProfile $ \alice -> do + connectUsers alice bob + + alice ##> "/create user alisa" + showActiveUser alice "alisa" + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + + alice ##> "/_connections diff" + alice <## "no difference between agent and chat connections" + + void $ withCCAgentTransaction alice $ \db -> + DB.execute_ db "DELETE FROM users WHERE user_id = 2" + + alice ##> "/_connections diff" + alice <## "connections difference summary:" + alice <## "number of missing users in agent: 1" + + alice ##> "/_connections diff show_ids=on" + alice <## "connections difference:" + alice <## "missing users in agent (agent IDs): 2" + + void $ withCCTransaction alice $ \db -> + DB.execute_ db "UPDATE connections_sync SET should_sync = 1 WHERE connections_sync_id = 1" + + withTestChat ps "alice" $ \alice -> do + alice <## "connections difference summary:" + alice <## "number of missing users in agent: 1" + + alice <## "subscribed 1 connections on server localhost" + + alice <##> bob + +testConnSyncExtraAgentUsers :: HasCallStack => TestParams -> IO () +testConnSyncExtraAgentUsers ps = do + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChat ps "alice" aliceProfile $ \alice -> do + connectUsers alice bob + + alice ##> "/_connections diff" + alice <## "no difference between agent and chat connections" + + void $ withCCAgentTransaction alice $ \db -> + DB.execute_ db "INSERT INTO users DEFAULT VALUES" + agentUserCount <- withCCAgentTransaction alice $ \db -> + DB.query_ db "SELECT count(1) FROM users" :: IO [[Int]] + agentUserCount `shouldBe` [[2]] + + alice ##> "/_connections diff" + alice <## "connections difference summary:" + alice <## "number of extra users in agent: 1" + + alice ##> "/_connections diff show_ids=on" + alice <## "connections difference:" + alice <## "extra users in agent (agent IDs): 2" + + void $ withCCTransaction alice $ \db -> + DB.execute_ db "UPDATE connections_sync SET should_sync = 1 WHERE connections_sync_id = 1" + + withTestChat ps "alice" $ \alice -> do + alice <## "connections difference summary:" + alice <## "number of extra users in agent: 1" + alice <## "removed extra users in agent" + + alice <## "subscribed 1 connections on server localhost" + + threadDelay 100000 + agentUserCount <- withCCAgentTransaction alice $ \db -> + DB.query_ db "SELECT count(1) FROM users" :: IO [[Int]] + agentUserCount `shouldBe` [[1]] + + alice <##> bob + +testConnSyncMissingAgentConns :: HasCallStack => TestParams -> IO () +testConnSyncMissingAgentConns ps = do + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChat ps "cath" cathProfile $ \cath -> do + withNewTestChat ps "alice" aliceProfile $ \alice -> do + connectUsers alice bob + + alice ##> "/create user alisa" + showActiveUser alice "alisa" + + -- connection with cath is in user 2, below we delete connection by user_id + -- because it's one of the simplest ways to differentiate them in agent db + connectUsers alice cath + + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + + alice ##> "/_connections diff" + alice <## "no difference between agent and chat connections" + + void $ withCCAgentTransaction alice $ \db -> + DB.execute_ db "DELETE FROM connections WHERE user_id = 2" + + alice ##> "/_connections diff" + alice <## "connections difference summary:" + alice <## "number of missing connections in agent: 1" + + alice ##> "/_connections diff show_ids=on" + alice <## "connections difference:" + alice <##. "missing connections in agent (agent IDs):" + + void $ withCCTransaction alice $ \db -> + DB.execute_ db "UPDATE connections_sync SET should_sync = 1 WHERE connections_sync_id = 1" + + withTestChat ps "alice" $ \alice -> do + alice <## "connections difference summary:" + alice <## "number of missing connections in agent: 1" + + alice <## "subscribed 1 connections on server localhost" + -- alice <## "[user: alisa] 1 subscription errors (run with -c option to show each error)" + + alice <##> bob + +testConnSyncExtraAgentConns :: HasCallStack => TestParams -> IO () +testConnSyncExtraAgentConns ps = do + withNewTestChat ps "bob" bobProfile $ \bob -> do + withNewTestChat ps "cath" cathProfile $ \cath -> do + withNewTestChat ps "alice" aliceProfile $ \alice -> do + connectUsers alice bob + connectUsers alice cath + + alice ##> "/_connections diff" + alice <## "no difference between agent and chat connections" + + -- deleting connection record in chat db + void $ withCCTransaction alice $ \db -> + DB.execute_ db "DELETE FROM connections WHERE contact_id = (SELECT contact_id FROM contacts WHERE local_display_name = 'cath')" + agentConnCount <- withCCAgentTransaction alice $ \db -> + DB.query_ db "SELECT count(1) FROM connections" :: IO [[Int]] + agentConnCount `shouldBe` [[2]] + + alice ##> "/_connections diff" + alice <## "connections difference summary:" + alice <## "number of extra connections in agent: 1" + + alice ##> "/_connections diff show_ids=on" + alice <## "connections difference:" + alice <##. "extra connections in agent (agent IDs):" + + void $ withCCTransaction alice $ \db -> + DB.execute_ db "UPDATE connections_sync SET should_sync = 1 WHERE connections_sync_id = 1" + + withTestChat ps "alice" $ \alice -> do + alice <## "connections difference summary:" + alice <## "number of extra connections in agent: 1" + alice <## "removed extra connections in agent" + + alice <## "subscribed 1 connections on server localhost" + + threadDelay 100000 + agentConnCount <- withCCAgentTransaction alice $ \db -> + DB.query_ db "SELECT count(1) FROM connections" :: IO [[Int]] + agentConnCount `shouldBe` [[1]] + + alice <##> bob + testSubscribeAppNSE :: HasCallStack => TestParams -> IO () testSubscribeAppNSE ps = withNewTestChat ps "bob" bobProfile $ \bob -> do @@ -1532,7 +1706,7 @@ testSubscribeAppNSE ps = (nseAlice "/_app activate" alice <## "ok" - alice <## "Your address is active! To show: /sa" + alice <## "subscribed 1 connections on server localhost" alice <## "bob (Bob) wants to connect to you!" alice <## "to accept: /ac bob" alice <## "to reject: /rc bob (the sender will NOT be notified)" @@ -1743,8 +1917,8 @@ testUsersSubscribeAfterRestart ps = do withTestChat ps "alice" $ \alice -> do -- second user is active - alice <## "1 contacts connected (use /cs for the list)" - alice <## "[user: alice] 1 contacts connected (use /cs for the list)" + alice <## "subscribed 1 connections on server localhost" + alice <## "subscribed 1 connections on server localhost" -- second user receives message alice <##> bob @@ -1937,6 +2111,26 @@ testDeleteUser = alice ##> "/users" alice <## "no users" +testDeleteUserChatTags :: HasCallStack => TestParams -> IO () +testDeleteUserChatTags = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + + alice ##> "/_create tag {\"text\":\"my tag\"}" + alice <## "[{\"chatTagId\":1,\"chatTagText\":\"my tag\"}]" + alice ##> "/_tags @2 1" + alice <## "chat tags updated" + + alice ##> "/create user alisa" + showActiveUser alice "alisa" + + alice ##> "/_delete user 1 del_smp=off" + alice <## "ok" + + alice ##> "/users" + alice <## "alisa (active)" + testUsersDifferentCIExpirationTTL :: HasCallStack => TestParams -> IO () testUsersDifferentCIExpirationTTL ps = do withNewTestChat ps "bob" bobProfile $ \bob -> do @@ -2049,8 +2243,8 @@ testUsersRestartCIExpiration ps = do showActiveUser alice "alice (Alice)" withTestChatCfg ps cfg "alice" $ \alice -> do - alice <## "1 contacts connected (use /cs for the list)" - alice <## "[user: alisa] 1 contacts connected (use /cs for the list)" + alice <## "subscribed 1 connections on server localhost" + alice <## "subscribed 1 connections on server localhost" -- first user messages alice ##> "/user alice" @@ -2148,8 +2342,8 @@ testEnableCIExpirationOnlyForOneUser ps = do alice #$> ("/_get chat @6 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4")]) withTestChatCfg ps cfg "alice" $ \alice -> do - alice <## "1 contacts connected (use /cs for the list)" - alice <## "[user: alice] 1 contacts connected (use /cs for the list)" + alice <## "subscribed 1 connections on server localhost" + alice <## "subscribed 1 connections on server localhost" -- messages are not deleted for second user after restart alice #$> ("/_get chat @6 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4")]) @@ -2204,8 +2398,8 @@ testDisableCIExpirationOnlyForOneUser ps = do alice #$> ("/_get chat @6 count=100", chat, [(1,"chat banner")]) withTestChatCfg ps cfg "alice" $ \alice -> do - alice <## "1 contacts connected (use /cs for the list)" - alice <## "[user: alice] 1 contacts connected (use /cs for the list)" + alice <## "subscribed 1 connections on server localhost" + alice <## "subscribed 1 connections on server localhost" -- second user still has ttl configured after restart alice #$> ("/ttl", id, "old messages are set to be deleted after: 1 second(s)") @@ -2311,8 +2505,8 @@ testUsersTimedMessages ps = do alice <# "bob> alisa 4" withTestChat ps "alice" $ \alice -> do - alice <## "1 contacts connected (use /cs for the list)" - alice <## "[user: alice] 1 contacts connected (use /cs for the list)" + alice <## "subscribed 1 connections on server localhost" + alice <## "subscribed 1 connections on server localhost" alice ##> "/user alice" showActiveUser alice "alice (Alice)" @@ -2660,7 +2854,8 @@ testAbortSwitchContact ps = do -- repeat switch stop is prohibited alice ##> "/abort switch bob" alice <## "error: command is prohibited, abortConnectionSwitch: not allowed" - withTestChatContactConnected ps "bob" $ \bob -> do + withTestChat ps "bob" $ \bob -> do + bob <## "subscribed 1 connections on server localhost" bob <## "alice started changing address for you" -- alice changes address again alice #$> ("/switch bob", id, "switch started") @@ -2706,8 +2901,8 @@ testAbortSwitchGroupMember ps = do -- repeat switch stop is prohibited alice ##> "/abort switch #team bob" alice <## "error: command is prohibited, abortConnectionSwitch: not allowed" - withTestChatContactConnected ps "bob" $ \bob -> do - bob <## "#team: connected to server(s)" + withTestChat ps "bob" $ \bob -> do + bob <## "subscribed 2 connections on server localhost" bob <## "#team: alice started changing address for you" -- alice changes address again alice #$> ("/switch #team bob", id, "switch started") @@ -2806,7 +3001,7 @@ testMsgDecryptError ps = alice <# "bob> hey" setupDesynchronizedRatchet ps alice withTestChat ps "bob" $ \bob -> do - bob <## "1 contacts connected (use /cs for the list)" + bob <## "subscribed 1 connections on server localhost" alice #> "@bob hello again" bob <# "alice> skipped message ID 9..11" bob <# "alice> hello again" @@ -2817,7 +3012,7 @@ setupDesynchronizedRatchet :: HasCallStack => TestParams -> TestCC -> IO () setupDesynchronizedRatchet ps alice = do copyDb "bob" "bob_old" withTestChat ps "bob" $ \bob -> do - bob <## "1 contacts connected (use /cs for the list)" + bob <## "subscribed 1 connections on server localhost" alice #> "@bob 1" bob <# "alice> 1" bob #> "@alice 2" @@ -2828,7 +3023,7 @@ setupDesynchronizedRatchet ps alice = do alice <# "bob> 4" threadDelay 500000 withTestChat ps "bob_old" $ \bob -> do - bob <## "1 contacts connected (use /cs for the list)" + bob <## "subscribed 1 connections on server localhost" bob ##> "/sync alice" bob <## "error: command is prohibited, synchronizeRatchet: not allowed" alice #> "@bob 1" @@ -2859,7 +3054,7 @@ testSyncRatchet ps = alice <# "bob> hey" setupDesynchronizedRatchet ps alice withTestChat ps "bob_old" $ \bob -> do - bob <## "1 contacts connected (use /cs for the list)" + bob <## "subscribed 1 connections on server localhost" bob ##> "/sync alice" bob <## "connection synchronization started" alice <## "bob: connection synchronization agreed" @@ -2898,7 +3093,7 @@ testSyncRatchetCodeReset ps = aliceInfo bob True setupDesynchronizedRatchet ps alice withTestChat ps "bob_old" $ \bob -> do - bob <## "1 contacts connected (use /cs for the list)" + bob <## "subscribed 1 connections on server localhost" bob ##> "/sync alice" bob <## "connection synchronization started" alice <## "bob: connection synchronization agreed" @@ -3122,7 +3317,7 @@ testUpdatePeerChatVRange ps = contactInfoChatVRange bob supportedChatVRange withTestChat ps "bob" $ \bob -> do - bob <## "1 contacts connected (use /cs for the list)" + bob <## "subscribed 1 connections on server localhost" bob #> "@alice hello 1" alice <# "bob> hello 1" @@ -3134,7 +3329,7 @@ testUpdatePeerChatVRange ps = contactInfoChatVRange bob supportedChatVRange withTestChatCfg ps cfg11 "bob" $ \bob -> do - bob <## "1 contacts connected (use /cs for the list)" + bob <## "subscribed 1 connections on server localhost" bob #> "@alice hello 2" alice <# "bob> hello 2" @@ -3147,20 +3342,6 @@ testUpdatePeerChatVRange ps = where cfg11 = testCfg {chatVRange = vr11} :: ChatConfig -testGetNetworkStatuses :: HasCallStack => TestParams -> IO () -testGetNetworkStatuses ps = do - withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do - withNewTestChatCfg ps cfg "bob" bobProfile $ \bob -> do - connectUsers alice bob - alice ##> "/_network_statuses" - alice <## "1 connections connected" - withTestChatCfg ps cfg "alice" $ \alice -> - withTestChatCfg ps cfg "bob" $ \bob -> do - alice <## "1 connections connected" - bob <## "1 connections connected" - where - cfg = testCfg {coreApi = True} - vr11 :: VersionRangeChat vr11 = mkVersionRange (VersionChat 1) (VersionChat 1) diff --git a/tests/ChatTests/Files.hs b/tests/ChatTests/Files.hs index c573a1957a..2de9d7ffe5 100644 --- a/tests/ChatTests/Files.hs +++ b/tests/ChatTests/Files.hs @@ -818,7 +818,7 @@ testXFTPContinueRcv ps = do -- server is down - file is not received withTestChat ps "bob" $ \bob -> do - bob <## "1 contacts connected (use /cs for the list)" + bob <## "subscribed 1 connections on server localhost" bob ##> "/fr 1 ./tests/tmp" bob <### [ "saving file 1 from alice to ./tests/tmp/test.pdf", @@ -833,7 +833,7 @@ testXFTPContinueRcv ps = do withXFTPServer $ do -- server is up - file reception is continued withTestChat ps "bob" $ \bob -> do - bob <## "1 contacts connected (use /cs for the list)" + bob <## "subscribed 1 connections on server localhost" bob <## "completed receiving file 1 (test.pdf) from alice" src <- B.readFile "./tests/fixtures/test.pdf" dest <- B.readFile "./tests/tmp/test.pdf" @@ -866,7 +866,7 @@ testXFTPMarkToReceive = do bob <## "chat started" bob - <### [ "1 contacts connected (use /cs for the list)", + <### [ "subscribed 1 connections on server localhost", "started receiving file 1 (test.pdf) from alice", "saving file 1 from alice to test.pdf" ] @@ -892,7 +892,7 @@ testXFTPRcvError ps = do -- server is up w/t store log - file reception should fail withXFTPServer' xftpServerConfig {storeLogFile = Nothing} $ do withTestChat ps "bob" $ \bob -> do - bob <## "1 contacts connected (use /cs for the list)" + bob <## "subscribed 1 connections on server localhost" bob ##> "/fr 1 ./tests/tmp" bob <### [ "saving file 1 from alice to ./tests/tmp/test.pdf", diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 365bd16f71..52a015c31f 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -1,5 +1,6 @@ {-# LANGUAGE CPP #-} {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE NumericUnderscores #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PostfixOperators #-} @@ -18,6 +19,7 @@ import Control.Concurrent.Async (concurrently_) import Control.Monad (forM_, void, when) import Data.Bifunctor (second) import qualified Data.ByteString.Char8 as B +import Data.Int (Int64) import Data.List (intercalate, isInfixOf) import qualified Data.Map.Strict as M import qualified Data.Text as T @@ -32,6 +34,7 @@ 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.Encoding.String import Simplex.Messaging.Server.Env.STM hiding (subscriptions) import Simplex.Messaging.Transport import Simplex.Messaging.Version @@ -84,6 +87,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 @@ -91,8 +95,8 @@ chatGroupTests = do -- TODO [postgres] this test hangs with PostgreSQL it "send multiple messages (many chat batches)" testSendMultiManyBatches #endif - xit'' "shared message body is reused" testSharedMessageBody - xit'' "shared batch body is reused" testSharedBatchBody + it "shared message body is reused" testSharedMessageBody + it "shared batch body is reused" testSharedBatchBody describe "async group connections" $ do xit "create and join group when clients go offline" testGroupAsync describe "group links" $ do @@ -151,7 +155,8 @@ chatGroupTests = do it "manually accept contact with group member" testMemberContactAccept it "manually accept contact with group member incognito" testMemberContactAcceptIncognito describe "group message forwarding" $ do - it "forward messages between invitee and introduced (x.msg.new)" testGroupMsgForward + it "forward messages between invitee and introduced (x.msg.new)" testGroupMsgForwardMessage + it "forward batched messages" testGroupMsgForwardBatched it "forward reports to moderators, don't forward to members (x.msg.new, MCReport)" testGroupMsgForwardReport it "deduplicate forwarded messages" testGroupMsgForwardDeduplicate it "forward message edit (x.msg.update)" testGroupMsgForwardEdit @@ -194,6 +199,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,6 +225,20 @@ chatGroupTests = do 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 + -- 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) + -- TODO - tests with delivery loop over members restored after restart + -- TODO - delivery in support scopes inside channels + xdescribe "channels" $ do + describe "relay delivery" $ do + it "should deliver messages to members" testChannelsRelayDeliver + describe "should deliver messages in a loop over members" $ do + it "number of recipients is multiple of bucket size (3/1)" (testChannelsRelayDeliverLoop 1) + it "number of recipients is NOT multiple of bucket size (3/2)" (testChannelsRelayDeliverLoop 2) + it "number of recipients is equal to bucket size (3/3)" (testChannelsRelayDeliverLoop 3) + it "sender should deduplicate their own messages" testChannelsSenderDeduplicateOwn testGroupCheckMessages :: HasCallStack => TestParams -> IO () testGroupCheckMessages = @@ -308,46 +328,26 @@ testGroupShared alice bob cath checkMessages = do bob <## "#team: you don't have permission to send messages" bob ##> "/rm team cath" bob <## "#team: you have insufficient permissions for this action, the required role is admin" - cath #> "#team hello" - concurrentlyN_ - [ alice <# "#team cath> hello", - bob <# "#team cath> hello" - ] alice ##> "/mr team bob admin" concurrentlyN_ [ alice <## "#team: you changed the role of bob to admin", bob <## "#team: alice changed your role from observer to admin", cath <## "#team: alice changed the role of bob from observer to admin" ] - -- remove member - bob ##> "/rm team cath" - concurrentlyN_ - [ bob <## "#team: you removed cath from the group", - alice <## "#team: bob removed cath from the group", - do - cath <## "#team: bob removed you from the group" - cath <## "use /d #team to delete the group" - ] - bob #> "#team hi" - concurrently_ - (alice <# "#team bob> hi") - (cath "#team hello" - concurrently_ - (bob <# "#team alice> hello") - (cath "#team hello" - cath <## "bad chat command: not current member" -- delete contact alice ##> "/d bob" alice <## "bob: contact is deleted" bob <## "alice (Alice) deleted contact with you" when checkMessages $ threadDelay 1000000 alice #> "#team checking connection" - bob <# "#team alice> checking connection" + concurrently_ + (bob <# "#team alice> checking connection") + (cath <# "#team alice> checking connection") when checkMessages $ threadDelay 1000000 bob #> "#team received" - alice <# "#team bob> received" + concurrently_ + (alice <# "#team bob> received") + (cath <# "#team bob> received") when checkMessages $ do alice @@@ [("@cath", "sent invitation to join group team as admin"), ("#team", "received")] bob @@@ [("@alice", "contact deleted"), ("#team", "received")] @@ -708,7 +708,7 @@ testGroupDelete :: HasCallStack => TestParams -> IO () testGroupDelete = testChatCfg3 cfg aliceProfile bobProfile cathProfile $ \alice bob cath -> do - createGroup3 "team" alice bob cath + createGroup3' "team" alice (bob, GRMember) (cath, GRMember) alice ##> "/d #team" concurrentlyN_ [ alice <## "#team: you deleted the group", @@ -976,7 +976,7 @@ testGroupRemoveAdd :: HasCallStack => TestParams -> IO () testGroupRemoveAdd = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do - createGroup3 "team" alice bob cath + createGroup3' "team" alice (bob, GRMember) (cath, GRMember) threadDelay 100000 @@ -1776,12 +1776,10 @@ testGroupDelayedModeration ps = do alice <## "message marked deleted by you" cath <# "#team cath> [marked deleted by alice] hi" withTestChatCfg ps cfg "bob" $ \bob -> do - bob <## "1 contacts connected (use /cs for the list)" - bob <## "#team: connected to server(s)" + bob <## "subscribed 2 connections on server localhost" bob <## "#team: alice added cath (Catherine) to the group (connecting...)" withTestChatCfg ps cfg "cath" $ \cath -> do - cath <## "1 contacts connected (use /cs for the list)" - cath <## "#team: connected to server(s)" + cath <## "subscribed 3 connections on server localhost" cath <## "#team: member bob (Bob) is connected" bob <### [ "#team: new member cath is connected", @@ -1832,15 +1830,13 @@ testGroupDelayedModerationFullDelete ps = do cath <## "updated group preferences:" cath <## "Full deletion: on" withTestChatCfg ps cfg "bob" $ \bob -> do - bob <## "1 contacts connected (use /cs for the list)" - bob <## "#team: connected to server(s)" + bob <## "subscribed 2 connections on server localhost" bob <## "#team: alice added cath (Catherine) to the group (connecting...)" bob <## "alice updated group #team:" bob <## "updated group preferences:" bob <## "Full deletion: on" withTestChatCfg ps cfg "cath" $ \cath -> do - cath <## "1 contacts connected (use /cs for the list)" - cath <## "#team: connected to server(s)" + cath <## "subscribed 3 connections on server localhost" cath <## "#team: member bob (Bob) is connected" bob <### [ "#team: new member cath is connected", @@ -1859,7 +1855,7 @@ testDeleteMemberWithMessages :: HasCallStack => TestParams -> IO () testDeleteMemberWithMessages = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do - createGroup3 "team" alice bob cath + createGroup3' "team" alice (bob, GRMember) (cath, GRMember) threadDelay 750000 alice ##> "/set delete #team on" alice <## "updated group preferences:" @@ -1897,7 +1893,7 @@ testDeleteMemberMarkMessagesDeleted :: HasCallStack => TestParams -> IO () testDeleteMemberMarkMessagesDeleted = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do - createGroup3 "team" alice bob cath + createGroup3' "team" alice (bob, GRMember) (cath, GRMember) threadDelay 1000000 bob #> "#team hello" concurrently_ @@ -1916,6 +1912,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 $ @@ -2010,7 +2059,7 @@ testSharedMessageBody ps = withNewTestChatOpts ps opts' "cath" cathProfile $ \cath -> do createGroup3 "team" alice bob cath - alice <##. "server disconnected localhost" + alice <## "disconnected 4 connections on server localhost" alice #> "#team hello" bodiesCount1 <- withCCAgentTransaction alice $ \db -> DB.query_ db "SELECT count(1) FROM snd_message_bodies" :: IO [[Int]] @@ -2020,13 +2069,9 @@ testSharedMessageBody ps = withTestChatOpts ps opts' "bob" $ \bob -> withTestChatOpts ps opts' "cath" $ \cath -> do concurrentlyN_ - [ alice <##. "server connected localhost", - do - bob <## "1 contacts connected (use /cs for the list)" - bob <## "#team: connected to server(s)", - do - cath <## "1 contacts connected (use /cs for the list)" - cath <## "#team: connected to server(s)" + [ alice <## "subscribed 4 connections on server localhost", + bob <## "subscribed 3 connections on server localhost", + cath <## "subscribed 3 connections on server localhost" ] bob <# "#team alice> hello" cath <# "#team alice> hello" @@ -2034,7 +2079,7 @@ testSharedMessageBody ps = DB.query_ db "SELECT count(1) FROM snd_message_bodies" :: IO [[Int]] bodiesCount2 `shouldBe` [[0]] - alice <##. "server disconnected localhost" + alice <## "disconnected 4 connections on server localhost" where tmp = tmpPath ps serverCfg' = @@ -2058,7 +2103,7 @@ testSharedBatchBody ps = withNewTestChatOpts ps opts' "cath" cathProfile $ \cath -> do createGroup3 "team" alice bob cath - alice <##. "server disconnected localhost" + alice <## "disconnected 4 connections on server localhost" let cm i = "{\"msgContent\": {\"type\": \"text\", \"text\": \"message " <> show i <> "\"}}" cms = intercalate ", " (map cm [1 .. 300 :: Int]) @@ -2074,13 +2119,9 @@ testSharedBatchBody ps = withTestChatOpts ps opts' "bob" $ \bob -> withTestChatOpts ps opts' "cath" $ \cath -> do concurrentlyN_ - [ alice <##. "server connected localhost", - do - bob <## "1 contacts connected (use /cs for the list)" - bob <## "#team: connected to server(s)", - do - cath <## "1 contacts connected (use /cs for the list)" - cath <## "#team: connected to server(s)" + [ alice <## "subscribed 4 connections on server localhost", + bob <## "subscribed 3 connections on server localhost", + cath <## "subscribed 3 connections on server localhost" ] forM_ [(1 :: Int) .. 300] $ \i -> do concurrently_ @@ -2090,7 +2131,7 @@ testSharedBatchBody ps = DB.query_ db "SELECT count(1) FROM snd_message_bodies" :: IO [[Int]] bodiesCount2 `shouldBe` [[0]] - alice <##. "server disconnected localhost" + alice <## "disconnected 4 connections on server localhost" where tmp = tmpPath ps serverCfg' = @@ -2129,8 +2170,7 @@ testGroupAsync ps = do bob <# "#team alice> hello bob" withTestChat ps "alice" $ \alice -> do withNewTestChat ps "cath" cathProfile $ \cath -> do - alice <## "1 contacts connected (use /cs for the list)" - alice <## "#team: connected to server(s)" + alice <## "subscribed 2 connections on server localhost" connectUsers alice cath alice ##> "/a team cath" concurrentlyN_ @@ -2150,21 +2190,18 @@ testGroupAsync ps = do withTestChat ps "cath" $ \cath -> do concurrentlyN_ [ do - bob <## "1 contacts connected (use /cs for the list)" - bob <## "#team: connected to server(s)" + bob <## "subscribed 2 connections on server localhost" bob <## "#team: alice added cath (Catherine) to the group (connecting...)" bob <# "#team alice> hello cath" bob <## "#team: new member cath is connected", do - cath <## "2 contacts connected (use /cs for the list)" - cath <## "#team: connected to server(s)" + cath <## "subscribed 3 connections on server localhost" cath <## "#team: member bob (Bob) is connected" ] threadDelay 500000 withTestChat ps "bob" $ \bob -> do withNewTestChat ps "dan" danProfile $ \dan -> do - bob <## "2 contacts connected (use /cs for the list)" - bob <## "#team: connected to server(s)" + bob <## "subscribed 4 connections on server localhost" connectUsers bob dan bob ##> "/a team dan" concurrentlyN_ @@ -2185,18 +2222,15 @@ testGroupAsync ps = do withTestChat ps "dan" $ \dan -> do concurrentlyN_ [ do - alice <## "2 contacts connected (use /cs for the list)" - alice <## "#team: connected to server(s)" + alice <## "subscribed 4 connections on server localhost" alice <## "#team: bob added dan (Daniel) to the group (connecting...)" alice <## "#team: new member dan is connected", do - cath <## "2 contacts connected (use /cs for the list)" - cath <## "#team: connected to server(s)" + cath <## "subscribed 4 connections on server localhost" cath <## "#team: bob added dan (Daniel) to the group (connecting...)" cath <## "#team: new member dan is connected", do - dan <## "3 contacts connected (use /cs for the list)" - dan <## "#team: connected to server(s)" + dan <## "subscribed 5 connections on server localhost" dan <## "#team: member alice (Alice) is connected" dan <## "#team: member cath (Catherine) is connected" ] @@ -2206,18 +2240,10 @@ testGroupAsync ps = do withTestChat ps "cath" $ \cath -> do withTestChat ps "dan" $ \dan -> do concurrentlyN_ - [ do - alice <## "3 contacts connected (use /cs for the list)" - alice <## "#team: connected to server(s)", - do - bob <## "3 contacts connected (use /cs for the list)" - bob <## "#team: connected to server(s)", - do - cath <## "3 contacts connected (use /cs for the list)" - cath <## "#team: connected to server(s)", - do - dan <## "3 contacts connected (use /cs for the list)" - dan <## "#team: connected to server(s)" + [ alice <## "subscribed 6 connections server localhost", + bob <## "subscribed 6 connections server localhost", + cath <## "subscribed 6 connections server localhost", + dan <## "subscribed 6 connections server localhost" ] alice #> "#team hello" concurrentlyN_ @@ -3558,12 +3584,12 @@ testPlanGroupLinkConnecting ps = do threadDelay 100000 withTestChat ps "alice" $ \alice -> do alice - <### [ "1 group links active", - "#team: group is empty", + <### [ "subscribed 1 connections on server localhost", "bob (Bob): accepting request to join group #team..." ] withTestChat ps "bob" $ \bob -> do threadDelay 500000 + bob <## "subscribed 1 connections on server localhost" bob <## "#team: joining the group..." bob <## "#team: you joined the group" @@ -3605,12 +3631,12 @@ testPlanGroupLinkConnectingSlow ps = do threadDelay 100000 withTestChatCfg ps testCfgSlow "alice" $ \alice -> do alice - <### [ "1 group links active", - "#team: group is empty", + <### [ "subscribed 1 connections on server localhost", "bob (Bob): accepting request to join group #team..." ] withTestChatCfg ps testCfgSlow "bob" $ \bob -> do threadDelay 500000 + bob <## "subscribed 1 connections on server localhost" bob <## "#team: joining the group..." bob ##> ("/_connect plan 1 " <> gLink) @@ -3635,8 +3661,7 @@ testGroupMsgDecryptError ps = alice <# "#team bob> hey" setupDesynchronizedRatchet ps alice withTestChat ps "bob" $ \bob -> do - bob <## "1 contacts connected (use /cs for the list)" - bob <## "#team: connected to server(s)" + bob <## "subscribed 2 connections on server localhost" alice #> "#team hello again" bob <# "#team alice> skipped message ID 9..11" bob <# "#team alice> hello again" @@ -3647,8 +3672,7 @@ setupDesynchronizedRatchet :: HasCallStack => TestParams -> TestCC -> IO () setupDesynchronizedRatchet ps alice = do copyDb "bob" "bob_old" withTestChat ps "bob" $ \bob -> do - bob <## "1 contacts connected (use /cs for the list)" - bob <## "#team: connected to server(s)" + bob <## "subscribed 2 connections on server localhost" alice #> "#team 1" bob <# "#team alice> 1" bob #> "#team 2" @@ -3658,8 +3682,7 @@ setupDesynchronizedRatchet ps alice = do bob #> "#team 4" alice <# "#team bob> 4" withTestChat ps "bob_old" $ \bob -> do - bob <## "1 contacts connected (use /cs for the list)" - bob <## "#team: connected to server(s)" + bob <## "subscribed 2 connections on server localhost" bob ##> "/sync #team alice" bob <## "error: command is prohibited, synchronizeRatchet: not allowed" alice #> "#team 1" @@ -3687,8 +3710,7 @@ testGroupSyncRatchet ps = alice <# "#team bob> hey" setupDesynchronizedRatchet ps alice withTestChat ps "bob_old" $ \bob -> do - bob <## "1 contacts connected (use /cs for the list)" - bob <## "#team: connected to server(s)" + bob <## "subscribed 2 connections on server localhost" bob `send` "#team 1" -- "send prohibited" error is not printed in group as SndMessage is created, -- but it should be displayed in per member snd statuses @@ -3733,8 +3755,7 @@ testGroupSyncRatchetCodeReset ps = aliceInfo bob True setupDesynchronizedRatchet ps alice withTestChat ps "bob_old" $ \bob -> do - bob <## "1 contacts connected (use /cs for the list)" - bob <## "#team: connected to server(s)" + bob <## "subscribed 2 connections on server localhost" bob ##> "/sync #team alice" bob <## "connection synchronization started" alice <## "#team bob: connection synchronization agreed" @@ -4432,20 +4453,20 @@ testMemberContactInvitedConnectionReplaced ps = do items `shouldContain` [(0, "security code changed")] withTestChat ps "bob" $ \bob -> do - subscriptions bob 1 + subscriptions bob 3 checkConnectionsWork alice bob withTestChat ps "alice" $ \alice -> do - subscriptions alice 2 + subscriptions alice 4 withTestChat ps "bob" $ \bob -> do - subscriptions bob 1 + subscriptions bob 3 checkConnectionsWork alice bob withTestChat ps "cath" $ \cath -> do - subscriptions cath 1 + subscriptions cath 3 -- group messages work alice #> "#team hello" @@ -4462,9 +4483,8 @@ testMemberContactInvitedConnectionReplaced ps = do (bob <# "#team cath> hey team") where subscriptions :: TestCC -> Int -> IO () - subscriptions cc n = do - cc <## (show n <> " contacts connected (use /cs for the list)") - cc <## "#team: connected to server(s)" + subscriptions cc n = + cc <## ("subscribed " <> show n <> " connections on server localhost") checkConnectionsWork alice bob = do alice <##> bob alice @@@ [("@bob", "hey"), ("@cath", "sent invitation to join group team as admin"), ("#team", "connected")] @@ -4853,8 +4873,8 @@ testMemberContactAcceptIncognito = cath ?#> ("@" <> bobIncognito <> " hey") bob ?<# (cathIncognito <> "> hey") -testGroupMsgForward :: HasCallStack => TestParams -> IO () -testGroupMsgForward = +testGroupMsgForwardMessage :: HasCallStack => TestParams -> IO () +testGroupMsgForwardMessage = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do createGroup3 "team" alice bob cath @@ -4882,6 +4902,61 @@ testGroupMsgForward = cath <# "#team bob> hi there [>>]" cath <# "#team hey team" +testGroupMsgForwardBatched :: HasCallStack => TestParams -> IO () +testGroupMsgForwardBatched = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup3 "team" alice bob cath + setupGroupForwarding alice bob cath + + bob ##> "/_send #1 json [{\"msgContent\": {\"type\": \"text\", \"text\": \"test 1\"}}, {\"msgContent\": {\"type\": \"text\", \"text\": \"test 2\"}}, {\"msgContent\": {\"type\": \"text\", \"text\": \"test 3\"}}]" + bob <# "#team test 1" + bob <# "#team test 2" + bob <# "#team test 3" + alice <# "#team bob> test 1" + alice <# "#team bob> test 2" + alice <# "#team bob> test 3" + cath <# "#team bob> test 1 [>>]" + cath <# "#team bob> test 2 [>>]" + cath <# "#team bob> test 3 [>>]" + + threadDelay 1000000 + + cath ##> "/_send #1 json [{\"msgContent\": {\"type\": \"text\", \"text\": \"test 4\"}}, {\"msgContent\": {\"type\": \"text\", \"text\": \"test 5\"}}, {\"msgContent\": {\"type\": \"text\", \"text\": \"test 6\"}}]" + cath <# "#team test 4" + cath <# "#team test 5" + cath <# "#team test 6" + alice <# "#team cath> test 4" + alice <# "#team cath> test 5" + alice <# "#team cath> test 6" + bob <# "#team cath> test 4 [>>]" + bob <# "#team cath> test 5 [>>]" + bob <# "#team cath> test 6 [>>]" + + alice ##> "/tail #team 6" + alice <# "#team bob> test 1" + alice <# "#team bob> test 2" + alice <# "#team bob> test 3" + alice <# "#team cath> test 4" + alice <# "#team cath> test 5" + alice <# "#team cath> test 6" + + bob ##> "/tail #team 6" + bob <# "#team test 1" + bob <# "#team test 2" + bob <# "#team test 3" + bob <# "#team cath> test 4 [>>]" + bob <# "#team cath> test 5 [>>]" + bob <# "#team cath> test 6 [>>]" + + cath ##> "/tail #team 6" + cath <# "#team bob> test 1 [>>]" + cath <# "#team bob> test 2 [>>]" + cath <# "#team bob> test 3 [>>]" + cath <# "#team test 4" + cath <# "#team test 5" + cath <# "#team test 6" + testGroupMsgForwardReport :: HasCallStack => TestParams -> IO () testGroupMsgForwardReport = testChat3 aliceProfile bobProfile cathProfile $ @@ -6953,6 +7028,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 @@ -6981,8 +7089,7 @@ testGroupMemberInactive ps = do threadDelay 1500000 withTestChatCfgOpts ps cfg' opts' "bob" $ \bob -> do - bob <## "1 contacts connected (use /cs for the list)" - bob <## "#team: connected to server(s)" + bob <## "subscribed 2 connections on server localhost" bob <# "#team alice> 1" bob <# "#team alice> 2" bob <#. "#team alice> skipped message ID" @@ -7432,8 +7539,10 @@ testScopedSupportManyModerators = cath #$> ("/_get chat #1(_support:3) count=100", chat, []) alice ##> "/member support chats #team" + alice <## "members require attention: 0" alice <## "bob (Bob) (id 2): unread: 0, require attention: 0, mentions: 0" dan ##> "/member support chats #team" + dan <## "members require attention: 0" dan <## "bob (Bob) (id 3): unread: 0, require attention: 0, mentions: 0" bob ##> "/member support chats #team" bob <## "support: unread: 0, require attention: 0, mentions: 0" @@ -7888,8 +7997,10 @@ testScopedSupportUnreadStatsOnRead = dan <# "#team (support: bob) alice> 3" alice ##> "/member support chats #team" + alice <## "members require attention: 0" alice <## "bob (Bob) (id 2): unread: 0, require attention: 0, mentions: 0" dan ##> "/member support chats #team" + dan <## "members require attention: 0" dan <## "bob (Bob) (id 3): unread: 1, require attention: 0, mentions: 0" bob ##> "/member support chats #team" bob <## "support: unread: 1, require attention: 0, mentions: 0" @@ -7899,8 +8010,10 @@ testScopedSupportUnreadStatsOnRead = [alice, dan] *<# "#team (support: bob) bob> 4" alice ##> "/member support chats #team" + alice <## "members require attention: 1" alice <## "bob (Bob) (id 2): unread: 1, require attention: 1, mentions: 0" dan ##> "/member support chats #team" + dan <## "members require attention: 1" dan <## "bob (Bob) (id 3): unread: 2, require attention: 1, mentions: 0" bob ##> "/member support chats #team" bob <## "support: unread: 1, require attention: 0, mentions: 0" @@ -7913,9 +8026,11 @@ testScopedSupportUnreadStatsOnRead = bob <# "#team (support) dan> 5" alice ##> "/member support chats #team" + alice <## "members require attention: 0" alice <## "bob (Bob) (id 2): unread: 2, require attention: 0, mentions: 0" -- In test "answering" doesn't reset unanswered, but in UI items would be marked read on opening chat dan ##> "/member support chats #team" + dan <## "members require attention: 1" dan <## "bob (Bob) (id 3): unread: 2, require attention: 1, mentions: 0" bob ##> "/member support chats #team" bob <## "support: unread: 2, require attention: 0, mentions: 0" @@ -7928,8 +8043,10 @@ testScopedSupportUnreadStatsOnRead = bob <# "#team (support) dan> @alice 6" alice ##> "/member support chats #team" + alice <## "members require attention: 1" alice <## "bob (Bob) (id 2): unread: 3, require attention: 0, mentions: 1" dan ##> "/member support chats #team" + dan <## "members require attention: 1" dan <## "bob (Bob) (id 3): unread: 2, require attention: 1, mentions: 0" bob ##> "/member support chats #team" bob <## "support: unread: 3, require attention: 0, mentions: 0" @@ -7944,8 +8061,10 @@ testScopedSupportUnreadStatsOnRead = dan <# "#team (support: bob) bob> @alice 7" alice ##> "/member support chats #team" + alice <## "members require attention: 1" alice <## "bob (Bob) (id 2): unread: 4, require attention: 1, mentions: 2" dan ##> "/member support chats #team" + dan <## "members require attention: 1" dan <## "bob (Bob) (id 3): unread: 3, require attention: 2, mentions: 0" bob ##> "/member support chats #team" bob <## "support: unread: 3, require attention: 0, mentions: 0" @@ -7958,8 +8077,10 @@ testScopedSupportUnreadStatsOnRead = dan <# "#team (support: bob) bob!> @dan 8" alice ##> "/member support chats #team" + alice <## "members require attention: 1" alice <## "bob (Bob) (id 2): unread: 5, require attention: 2, mentions: 2" dan ##> "/member support chats #team" + dan <## "members require attention: 1" dan <## "bob (Bob) (id 3): unread: 4, require attention: 3, mentions: 1" bob ##> "/member support chats #team" bob <## "support: unread: 3, require attention: 0, mentions: 0" @@ -7967,11 +8088,13 @@ testScopedSupportUnreadStatsOnRead = alice #$> ("/_read chat items #1(_support:2) " <> aliceMentionedByDanItemId, id, "items read for chat") alice ##> "/member support chats #team" + alice <## "members require attention: 1" alice <## "bob (Bob) (id 2): unread: 4, require attention: 2, mentions: 1" alice #$> ("/_read chat items #1(_support:2) " <> aliceMentionedByBobItemId, id, "items read for chat") alice ##> "/member support chats #team" + alice <## "members require attention: 1" alice <## "bob (Bob) (id 2): unread: 3, require attention: 1, mentions: 0" threadDelay 1000000 @@ -7982,23 +8105,30 @@ testScopedSupportUnreadStatsOnRead = bob <# "#team (support) dan!> @bob 9" alice ##> "/member support chats #team" + alice <## "members require attention: 0" alice <## "bob (Bob) (id 2): unread: 4, require attention: 0, mentions: 0" dan ##> "/member support chats #team" + dan <## "members require attention: 1" dan <## "bob (Bob) (id 3): unread: 4, require attention: 3, mentions: 1" bob ##> "/member support chats #team" bob <## "support: unread: 4, require attention: 0, mentions: 1" - alice #$> ("/_read chat #1(_support:2)", id, "ok") + alice ##> "/_read chat #1(_support:2)" + alice <## "#team: bob support chat read" alice ##> "/member support chats #team" + alice <## "members require attention: 0" alice <## "bob (Bob) (id 2): unread: 0, require attention: 0, mentions: 0" - dan #$> ("/_read chat #1(_support:3)", id, "ok") + dan ##> "/_read chat #1(_support:3)" + dan <## "#team: bob support chat read" dan ##> "/member support chats #team" + dan <## "members require attention: 0" dan <## "bob (Bob) (id 3): unread: 0, require attention: 0, mentions: 0" - bob #$> ("/_read chat #1(_support)", id, "ok") + bob ##> "/_read chat #1(_support)" + bob <## "#team: support chat read" bob ##> "/member support chats #team" bob <## "support: unread: 0, require attention: 0, mentions: 0" @@ -8029,15 +8159,253 @@ testScopedSupportUnreadStatsOnDelete = msgIdBob <- lastItemId bob alice ##> "/member support chats #team" + alice <## "members require attention: 1" alice <## "bob (Bob) (id 2): unread: 1, require attention: 1, mentions: 0" bob #$> ("/_delete item #1(_support) " <> msgIdBob <> " broadcast", id, "message deleted") alice <# "#team (support: bob) bob> [deleted] 1" alice ##> "/member support chats #team" + alice <## "members require attention: 0" alice <## "bob (Bob) (id 2): unread: 0, require attention: 0, mentions: 0" where opts = testOpts { markRead = False } + +testScopedSupportUnreadStatsCorrectOnOpen :: HasCallStack => TestParams -> IO () +testScopedSupportUnreadStatsCorrectOnOpen = + testChatOpts2 opts aliceProfile bobProfile $ \alice bob -> do + createGroup2 "team" alice bob + + 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 ##> "/_read chat #1(_support:2)" + alice <## "#team: bob support chat read" + + alice ##> "/member support chats #team" + alice <## "members require attention: 0" + alice <## "bob (Bob) (id 2): unread: 0, require attention: 0, mentions: 0" + + bob #> "#team (support) 3" + alice <# "#team (support: bob) bob> 3" + + bob #> "#team (support) 4" + alice <# "#team (support: bob) bob> 4" + + bob #> "#team (support) 5" + alice <# "#team (support: bob) bob> 5" + + alice ##> "/member support chats #team" + alice <## "members require attention: 1" + alice <## "bob (Bob) (id 2): unread: 3, require attention: 3, mentions: 0" + + -- opening chat should correct group_members.support_chat_items_member_attention value if it got out of sync + void $ withCCTransaction alice $ \db -> + DB.execute db "UPDATE group_members SET support_chat_items_member_attention=100 WHERE group_member_id=?" (Only (2 :: Int64)) + + alice ##> "/member support chats #team" + alice <## "members require attention: 1" + alice <## "bob (Bob) (id 2): unread: 3, require attention: 100, mentions: 0" + + alice #$> ("/_get chat #1(_support:2) count=100", chat, [(0, "1"), (0, "2"), (0, "3"), (0, "4"), (0, "5")]) + + alice ##> "/member support chats #team" + alice <## "members require attention: 1" + alice <## "bob (Bob) (id 2): unread: 3, require attention: 3, mentions: 0" + + alice ##> "/_read chat #1(_support:2)" + alice <## "#team: bob support chat read" + + alice ##> "/member support chats #team" + alice <## "members require attention: 0" + alice <## "bob (Bob) (id 2): unread: 0, require attention: 0, mentions: 0" + + -- opening chat should also correct groups.members_require_attention value if corrected member no longer requires attention + void $ withCCTransaction alice $ \db -> do + DB.execute db "UPDATE group_members SET support_chat_items_member_attention=100 WHERE group_member_id=?" (Only (2 :: Int64)) + DB.execute db "UPDATE groups SET members_require_attention=1 WHERE group_id=?" (Only (1 :: Int64)) + + alice ##> "/member support chats #team" + alice <## "members require attention: 1" + alice <## "bob (Bob) (id 2): unread: 0, require attention: 100, mentions: 0" + + alice #$> ("/_get chat #1(_support:2) count=100", chat, [(0, "1"), (0, "2"), (0, "3"), (0, "4"), (0, "5")]) + + alice ##> "/member support chats #team" + alice <## "members require attention: 0" + alice <## "bob (Bob) (id 2): unread: 0, require attention: 0, mentions: 0" + where + opts = + testOpts + { markRead = False + } + +testChannelsRelayDeliver :: HasCallStack => TestParams -> IO () +testChannelsRelayDeliver = + testChat5 aliceProfile bobProfile cathProfile danProfile eveProfile $ \alice bob cath dan eve -> do + createChannel5 alice bob cath dan eve GRObserver + + alice #> "#team hi" + bob <# "#team alice> hi" + [cath, dan, eve] *<# "#team alice> hi [>>]" + + cath ##> "+1 #team hi" + cath <## "added 👍" + bob <# "#team cath> > alice hi" + bob <## " + 👍" + alice <# "#team cath> > alice hi" + alice <## " + 👍" + dan <# "#team cath> > alice hi" + dan <## " + 👍" + eve <# "#team cath> > alice hi" + eve <## " + 👍" + +-- TODO [channels fwd] correctly setup channel with relay forwarding +-- TODO - alice to create group as channel +-- TODO - add bob as relay +-- TODO - alice to manage group link, but members to connect to relay (bob) +createChannel5 :: TestCC -> TestCC -> TestCC -> TestCC -> TestCC -> GroupMemberRole -> IO () +createChannel5 alice bob cath dan eve mRole = do + createGroup2 "team" alice bob + bob ##> ("/create link #team " <> B.unpack (strEncode mRole)) + gLink <- getGroupLink bob "team" mRole True + cath ##> ("/c " <> gLink) + cath <## "connection request sent!" + bob <## "cath (Catherine): accepting request to join group #team..." + concurrentlyN_ + [ bob <## "#team: cath joined the group", + do + cath <## "#team: joining the group..." + cath <## "#team: you joined the group" + cath <## "#team: member alice (Alice) is connected", + do + alice <## "#team: bob added cath (Catherine) to the group (connecting...)" + alice <## "#team: new member cath is connected" + ] + dan ##> ("/c " <> gLink) + dan <## "connection request sent!" + bob <## "dan (Daniel): accepting request to join group #team..." + concurrentlyN_ + [ bob <## "#team: dan joined the group", + do + dan <## "#team: joining the group..." + dan <## "#team: you joined the group" + dan <## "#team: member alice (Alice) is connected" + dan <## "#team: member cath (Catherine) is connected", + do + alice <## "#team: bob added dan (Daniel) to the group (connecting...)" + alice <## "#team: new member dan is connected", + do + cath <## "#team: bob added dan (Daniel) to the group (connecting...)" + cath <## "#team: new member dan is connected" + ] + eve ##> ("/c " <> gLink) + eve <## "connection request sent!" + bob <## "eve (Eve): accepting request to join group #team..." + concurrentlyN_ + [ bob <## "#team: eve joined the group", + eve + <### [ "#team: joining the group...", + "#team: you joined the group", + "#team: member alice (Alice) is connected", + "#team: member cath (Catherine) is connected", + "#team: member dan (Daniel) is connected" + ], + do + alice <## "#team: bob added eve (Eve) to the group (connecting...)" + alice <## "#team: new member eve is connected", + do + cath <## "#team: bob added eve (Eve) to the group (connecting...)" + cath <## "#team: new member eve is connected", + do + dan <## "#team: bob added eve (Eve) to the group (connecting...)" + dan <## "#team: new member eve is connected" + ] + +testChannelsRelayDeliverLoop :: HasCallStack => Int -> TestParams -> IO () +testChannelsRelayDeliverLoop deliveryBucketSize = + testChatCfg5 cfg aliceProfile bobProfile cathProfile danProfile eveProfile $ \alice bob cath dan eve -> do + createChannel5 alice bob cath dan eve GRObserver + + alice #> "#team hi" + bob <# "#team alice> hi" + [cath, dan, eve] *<# "#team alice> hi [>>]" + + cath ##> "+1 #team hi" + cath <## "added 👍" + bob <# "#team cath> > alice hi" + bob <## " + 👍" + alice <# "#team cath> > alice hi" + alice <## " + 👍" + dan <# "#team cath> > alice hi" + dan <## " + 👍" + eve <# "#team cath> > alice hi" + eve <## " + 👍" + where + cfg = testCfg {deliveryBucketSize} + +testChannelsSenderDeduplicateOwn :: HasCallStack => TestParams -> IO () +testChannelsSenderDeduplicateOwn ps = do + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + withNewTestChatCfg ps cfg "bob" bobProfile $ \bob -> + createChannel5 alice bob cath dan eve GRMember + + -- chat relay bob is offline + alice #> "#team 1" + alice #> "#team 2" + alice #> "#team 3" + cath #> "#team 4" + cath #> "#team 5" + dan #> "#team 6" + + withTestChatCfg ps cfg "bob" $ \bob -> do + bob <## "subscribed 6 connections server localhost" + bob + <### [ WithTime "#team alice> 1", + WithTime "#team alice> 2", + WithTime "#team alice> 3", + WithTime "#team cath> 4", + WithTime "#team cath> 5", + WithTime "#team dan> 6" + ] + alice + <### [ WithTime "#team cath> 4 [>>]", + WithTime "#team cath> 5 [>>]", + WithTime "#team dan> 6 [>>]" + ] + cath + <### [ WithTime "#team alice> 1 [>>]", + WithTime "#team alice> 2 [>>]", + WithTime "#team alice> 3 [>>]", + WithTime "#team dan> 6 [>>]" + ] + dan + <### [ WithTime "#team alice> 1 [>>]", + WithTime "#team alice> 2 [>>]", + WithTime "#team alice> 3 [>>]", + WithTime "#team cath> 4 [>>]", + WithTime "#team cath> 5 [>>]" + ] + eve + <### [ WithTime "#team alice> 1 [>>]", + WithTime "#team alice> 2 [>>]", + WithTime "#team alice> 3 [>>]", + WithTime "#team cath> 4 [>>]", + WithTime "#team cath> 5 [>>]", + WithTime "#team dan> 6 [>>]" + ] + where + cfg = testCfg {deliveryWorkerDelay = 250000} diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 07bf06b61b..a1ab8548ed 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -142,6 +142,7 @@ shortLinkTests = do it "changing profile should update address short link data" testShortLinkAddressChangeProfile it "changing auto-reply message should update address short link data" testShortLinkAddressChangeAutoReply it "changing group profile should update short link data" testShortLinkGroupChangeProfile + it "receiving group profile update should update short link data" testShortLinkGroupChangeProfileReceived testUpdateProfile :: HasCallStack => TestParams -> IO () testUpdateProfile = @@ -320,27 +321,27 @@ testRetryConnectingViaContactLink ps = testChatCfgOpts2 cfg' opts' aliceProfile cLink <- withSmpServer' serverCfg' $ do alice ##> "/ad" getContactLink alice True - alice <## "server disconnected localhost ()" + alice <## "disconnected 1 connections on server localhost" bob ##> ("/_connect plan 1 " <> cLink) bob <## "contact address: ok to connect" _sLinkData <- getTermLine bob bob ##> ("/_connect 1 " <> cLink) bob <##. "smp agent error: BROKER" withSmpServer' serverCfg' $ do - alice <## "server connected localhost ()" + alice <## "subscribed 1 connections on server localhost" threadDelay 250000 bob ##> ("/_connect plan 1 " <> cLink) bob <## "contact address: ok to connect" _sLinkData <- getTermLine bob bob ##> ("/_connect 1 " <> cLink) alice <#? bob - alice <## "server disconnected localhost ()" - bob <## "server disconnected localhost ()" + alice <## "disconnected 1 connections on server localhost" + bob <## "disconnected 1 connections on server localhost" alice ##> "/ac bob" alice <##. "smp agent error: BROKER" withSmpServer' serverCfg' $ do - alice <## "server connected localhost ()" - bob <## "server connected localhost ()" + alice <## "subscribed 1 connections on server localhost" + bob <## "subscribed 1 connections on server localhost" alice ##> "/ac bob" alice <## "bob (Bob): accepting contact request, you can send messages to contact" concurrently_ @@ -350,8 +351,8 @@ testRetryConnectingViaContactLink ps = testChatCfgOpts2 cfg' opts' aliceProfile bob <# "alice> message 1" bob #> "@alice message 2" alice <# "bob> message 2" - alice <## "server disconnected localhost (@bob)" - bob <## "server disconnected localhost (@alice)" + alice <## "disconnected 2 connections on server localhost" + bob <## "disconnected 1 connections on server localhost" serverCfg' = smpServerCfg { transports = [("7003", transport @TLS, False)], @@ -1085,7 +1086,7 @@ testPlanAddressConnecting ps = do threadDelay 100000 withTestChat ps "alice" $ \alice -> do - alice <## "Your address is active! To show: /sa" + alice <## "subscribed 1 connections on server localhost" alice <## "bob (Bob) wants to connect to you!" alice <## "to accept: /ac bob" alice <## "to reject: /rc bob (the sender will NOT be notified)" @@ -1093,6 +1094,7 @@ testPlanAddressConnecting ps = do alice <## "bob (Bob): accepting contact request, you can send messages to contact" withTestChat ps "bob" $ \bob -> do threadDelay 500000 + bob <## "subscribed 1 connections on server localhost" bob <## "alice (Alice): contact is connected" bob @@@ [("@alice", "Audio/video calls: enabled")] bob ##> ("/_connect plan 1 " <> cLink) @@ -1128,7 +1130,7 @@ testPlanAddressConnectingSlow ps = do threadDelay 100000 withTestChatCfg ps testCfgSlow "alice" $ \alice -> do - alice <## "Your address is active! To show: /sa" + alice <## "subscribed 1 connections on server localhost" alice <## "bob (Bob) wants to connect to you!" alice <## "to accept: /ac bob" alice <## "to reject: /rc bob (the sender will NOT be notified)" @@ -1136,6 +1138,7 @@ testPlanAddressConnectingSlow ps = do alice <## "bob (Bob): accepting contact request..." withTestChatCfg ps testCfgSlow "bob" $ \bob -> do threadDelay 500000 + bob <## "subscribed 1 connections on server localhost" bob @@@ [("@alice", "")] bob ##> ("/_connect plan 1 " <> cLink) bob <## "contact address: connecting to contact alice" @@ -1545,10 +1548,12 @@ testSetConnectionIncognitoProhibitedDuringNegotiation ps = do bob <## "confirmation sent!" withTestChat ps "alice" $ \alice -> do threadDelay 250000 + alice <## "subscribed 1 connections on server localhost" alice <## "bob (Bob): contact is connected" alice ##> "/_set incognito :1 on" alice <## "chat db error: SEPendingConnectionNotFound {connId = 1}" withTestChat ps "bob" $ \bob -> do + bob <## "subscribed 1 connections on server localhost" bob <## "alice (Alice): contact is connected" alice <##> bob alice `hasContactProfiles` ["alice", "bob"] @@ -1566,9 +1571,11 @@ testSetConnectionIncognitoProhibitedDuringNegotiationSlow ps = do bob <## "confirmation sent!" withTestChatCfg ps testCfgSlow "alice" $ \alice -> do threadDelay 250000 + alice <## "subscribed 1 connections on server localhost" alice ##> "/_set incognito :1 on" alice <## "chat db error: SEPendingConnectionNotFound {connId = 1}" withTestChatCfg ps testCfgSlow "bob" $ \bob -> do + bob <## "subscribed 1 connections on server localhost" concurrently_ (bob <## "alice (Alice): contact is connected") (alice <## "bob (Bob): contact is connected") @@ -1657,11 +1664,11 @@ testJoinGroupIncognito = alice <## "group #secret_club is created" alice <## "to add members use /a secret_club or /create link #secret_club" -- alice invites bob - alice ##> "/a secret_club bob admin" + alice ##> "/a secret_club bob member" concurrentlyN_ [ alice <## "invitation to join the group #secret_club sent to bob", do - bob <## "#secret_club: alice invites you to join the group as admin" + bob <## "#secret_club: alice invites you to join the group as member" bob <## "use /j secret_club to accept" ] bob ##> "/j secret_club" @@ -1669,11 +1676,11 @@ testJoinGroupIncognito = (alice <## "#secret_club: bob joined the group") (bob <## "#secret_club: you joined the group") -- alice invites cath - alice ##> ("/a secret_club " <> cathIncognito <> " admin") + alice ##> ("/a secret_club " <> cathIncognito <> " member") concurrentlyN_ [ alice <## ("invitation to join the group #secret_club sent to " <> cathIncognito), do - cath <## "#secret_club: alice invites you to join the group as admin" + cath <## "#secret_club: alice invites you to join the group as member" cath <## ("use /j secret_club to join incognito as " <> cathIncognito) ] -- cath uses the same incognito profile when joining group, cath and bob don't merge contacts @@ -1687,15 +1694,12 @@ testJoinGroupIncognito = bob <## ("#secret_club: alice added " <> cathIncognito <> " to the group (connecting...)") bob <## ("#secret_club: new member " <> cathIncognito <> " is connected") ] - -- cath cannot invite to the group because her membership is incognito - cath ##> "/a secret_club dan" - cath <## "you are using an incognito profile for this group - prohibited to invite contacts" -- alice invites dan - alice ##> "/a secret_club dan admin" + alice ##> "/a secret_club dan member" concurrentlyN_ [ alice <## "invitation to join the group #secret_club sent to dan", do - dan <## "#secret_club: alice invites you to join the group as admin" + dan <## "#secret_club: alice invites you to join the group as member" dan <## "use /j secret_club to accept" ] dan ##> "/j secret_club" @@ -1754,39 +1758,39 @@ testJoinGroupIncognito = alice ##> "/ms secret_club" alice <### [ "alice (Alice): owner, you, created group", - "bob (Bob): admin, invited, connected", - ConsoleString $ cathIncognito <> ": admin, invited, connected", - "dan (Daniel): admin, invited, connected" + "bob (Bob): member, invited, connected", + ConsoleString $ cathIncognito <> ": member, invited, connected", + "dan (Daniel): member, invited, connected" ] bob ##> "/ms secret_club" bob <### [ "alice (Alice): owner, host, connected", - "bob (Bob): admin, you, connected", - ConsoleString $ cathIncognito <> ": admin, connected", - "dan (Daniel): admin, connected" + "bob (Bob): member, you, connected", + ConsoleString $ cathIncognito <> ": member, connected", + "dan (Daniel): member, connected" ] cath ##> "/ms secret_club" cath <### [ "alice (Alice): owner, host, connected", - "bob_1 (Bob): admin, connected", - ConsoleString $ "i " <> cathIncognito <> ": admin, you, connected", - "dan_1 (Daniel): admin, connected" + "bob_1 (Bob): member, connected", + ConsoleString $ "i " <> cathIncognito <> ": member, you, connected", + "dan_1 (Daniel): member, connected" ] dan ##> "/ms secret_club" dan <### [ "alice (Alice): owner, host, connected", - "bob (Bob): admin, connected", - ConsoleString $ cathIncognito <> ": admin, connected", - "dan (Daniel): admin, you, connected" + "bob (Bob): member, connected", + ConsoleString $ cathIncognito <> ": member, connected", + "dan (Daniel): member, you, connected" ] -- remove member - bob ##> ("/rm secret_club " <> cathIncognito) + alice ##> ("/rm secret_club " <> cathIncognito) concurrentlyN_ - [ bob <## ("#secret_club: you removed " <> cathIncognito <> " from the group"), - alice <## ("#secret_club: bob removed " <> cathIncognito <> " from the group"), - dan <## ("#secret_club: bob removed " <> cathIncognito <> " from the group"), + [ alice <## ("#secret_club: you removed " <> cathIncognito <> " from the group"), + bob <## ("#secret_club: alice removed " <> cathIncognito <> " from the group"), + dan <## ("#secret_club: alice removed " <> cathIncognito <> " from the group"), do - cath <## "#secret_club: bob_1 removed you from the group" + cath <## "#secret_club: alice removed you from the group" cath <## "use /d #secret_club to delete the group" ] bob #> "#secret_club hi" @@ -3031,11 +3035,11 @@ testShortLinkInvitationConnectRetry ps = testChatOpts2 opts' aliceProfile bobPro bob ##> ("/_prepare contact 1 " <> fullLink <> " " <> shortLink <> " " <> contactSLinkData) bob <## "alice: contact is prepared" pure shortLink - alice <## "server disconnected localhost ()" + alice <## "disconnected 1 connections on server localhost" bob ##> "/_connect contact @2 text hello" bob <##. "smp agent error: BROKER" withSmpServer' serverCfg' $ do - alice <## "server connected localhost ()" + alice <## "subscribed 1 connections on server localhost" threadDelay 250000 bob ##> ("/_connect plan 1 " <> shortLink) bob <## "invitation link: known prepared contact alice" @@ -3049,8 +3053,8 @@ testShortLinkInvitationConnectRetry ps = testChatOpts2 opts' aliceProfile bobPro (bob <## "alice (Alice): contact is connected") (alice <## "bob (Bob): contact is connected") alice <##> bob - alice <## "server disconnected localhost (@bob)" - bob <## "server disconnected localhost (@alice)" + alice <## "disconnected 1 connections on server localhost" + bob <## "disconnected 1 connections on server localhost" tmp = tmpPath ps serverCfg' = smpServerCfg @@ -3196,11 +3200,11 @@ testShortLinkAddressConnectRetry ps = bob ##> ("/_prepare contact 1 " <> fullLink <> " " <> shortLink <> " " <> contactSLinkData) bob <## "alice: contact is prepared" pure shortLink - alice <## "server disconnected localhost ()" + alice <## "disconnected 1 connections on server localhost" bob ##> "/_connect contact @2 text hello" bob <##. "smp agent error: BROKER" withSmpServer' serverCfg' $ do - alice <## "server connected localhost ()" + alice <## "subscribed 1 connections on server localhost" threadDelay 250000 bob ##> ("/_connect plan 1 " <> shortLink) bob <## "contact address: known prepared contact alice" @@ -3221,8 +3225,8 @@ testShortLinkAddressConnectRetry ps = (bob <## "alice (Alice): contact is connected") (alice <## "bob (Bob): contact is connected") alice <##> bob - alice <## "server disconnected localhost (@bob)" - bob <## "server disconnected localhost (@alice)" + alice <## "disconnected 2 connections on server localhost" + bob <## "disconnected 1 connections on server localhost" where tmp = tmpPath ps serverCfg' = @@ -3251,11 +3255,11 @@ testShortLinkAddressConnectRetryIncognito ps = bob ##> ("/_prepare contact 1 " <> fullLink <> " " <> shortLink <> " " <> contactSLinkData) bob <## "alice: contact is prepared" pure shortLink - alice <## "server disconnected localhost ()" + alice <## "disconnected 1 connections on server localhost" bob ##> "/_connect contact @2 incognito=on text hello" bob <##. "smp agent error: BROKER" - bobIncognito <- withSmpServer' serverCfg' $ do - alice <## "server connected localhost ()" + withSmpServer' serverCfg' $ do + alice <## "subscribed 1 connections on server localhost" threadDelay 250000 bob ##> ("/_connect plan 1 " <> shortLink) bob <## "contact address: known prepared contact alice" @@ -3284,9 +3288,8 @@ testShortLinkAddressConnectRetryIncognito ps = bob ?<# "alice> hi" bob ?#> "@alice hey" alice <# (bobIncognito <> "> hey") - pure bobIncognito - alice <## ("server disconnected localhost (@" <> bobIncognito <> ")") - bob <## "server disconnected localhost (@alice)" + alice <## "disconnected 2 connections on server localhost" + bob <## "disconnected 1 connections on server localhost" where tmp = tmpPath ps serverCfg' = @@ -3541,15 +3544,15 @@ testShortLinkGroupRetry ps = testChatOpts2 opts' aliceProfile bobProfile test ps bob ##> ("/_prepare group 1 " <> fullLink <> " " <> shortLink <> " " <> groupSLinkData) bob <## "#team: group is prepared" pure shortLink - alice <## "server disconnected localhost (@bob)" - bob <## "server disconnected localhost (@alice)" + alice <## "disconnected 2 connections on server localhost" + bob <## "disconnected 1 connections on server localhost" bob ##> "/_connect group #1" bob <##. "smp agent error: BROKER" withSmpServer' serverCfg' $ do bob ##> ("/_connect plan 1 " <> shortLink) bob <## "group link: known prepared group #team" - alice <## "server connected localhost (@bob)" - bob <## "server connected localhost (@alice)" + alice <## "subscribed 2 connections on server localhost" + bob <## "subscribed 1 connections on server localhost" threadDelay 250000 bob ##> "/_connect group #1" bob <## "#team: connection started" @@ -3568,8 +3571,8 @@ testShortLinkGroupRetry ps = testChatOpts2 opts' aliceProfile bobProfile test ps bob <# "#team alice> 1" bob #> "#team 2" alice <# "#team bob> 2" - alice <## "server disconnected localhost (@bob)" - bob <## "server disconnected localhost (@alice)" + alice <## "disconnected 3 connections on server localhost" + bob <## "disconnected 2 connections on server localhost" tmp = tmpPath ps serverCfg' = smpServerCfg @@ -4146,3 +4149,41 @@ testShortLinkGroupChangeProfile = testChat3 aliceProfile bobProfile cathProfile [alice, cath] *<# "#club bob> 2" cath #> "#club 3" [alice, bob] *<# "#club cath> 3" + +testShortLinkGroupChangeProfileReceived :: HasCallStack => TestParams -> IO () +testShortLinkGroupChangeProfileReceived = testChat3 aliceProfile bobProfile cathProfile test + where + test alice bob cath = do + createGroup2' "team" alice (cath, GROwner) True + alice ##> "/create link #team" + (shortLink, fullLink) <- getGroupLinks alice "team" GRMember True + + cath ##> "/gp team club" + cath <## "changed to #club" + alice <## "cath updated group #team:" + alice <## "changed to #club" + + bob ##> ("/_connect plan 1 " <> shortLink) + bob <## "group link: ok to connect" + groupSLinkData <- getTermLine bob + bob ##> ("/_prepare group 1 " <> fullLink <> " " <> shortLink <> " " <> groupSLinkData) + bob <## "#club: group is prepared" + bob ##> "/_connect group #1" + bob <## "#club: connection started" + alice <## "bob (Bob): accepting request to join group #club..." + concurrentlyN_ + [ alice <## "#club: bob joined the group", + do + bob <## "#club: joining the group..." + bob <## "#club: you joined the group" + bob <## "#club: member cath (Catherine) is connected", + do + cath <## "#club: alice added bob (Bob) to the group (connecting...)" + cath <## "#club: new member bob is connected" + ] + alice #> "#club 1" + [bob, cath] *<# "#club alice> 1" + bob #> "#club 2" + [alice, cath] *<# "#club bob> 2" + cath #> "#club 3" + [alice, bob] *<# "#club cath> 3" diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index 8650b80b06..aa6ba70e7b 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -24,6 +24,7 @@ import Data.Maybe (fromMaybe) import Data.String import qualified Data.Text as T import Simplex.Chat.Controller (ChatConfig (..), ChatController (..)) +import Simplex.Chat.Markdown (viewName) import Simplex.Chat.Messages.CIContent (e2eInfoNoPQText, e2eInfoPQText) import Simplex.Chat.Protocol import Simplex.Chat.Store.Direct (getContact) @@ -139,34 +140,6 @@ runTestCfg3 aliceCfg bobCfg cathCfg runTest ps = withNewTestChatCfg ps cathCfg "cath" cathProfile $ \cath -> runTest alice bob cath -withTestChatGroup3Connected :: HasCallStack => TestParams -> String -> (HasCallStack => TestCC -> IO a) -> IO a -withTestChatGroup3Connected ps dbPrefix action = do - withTestChat ps dbPrefix $ \cc -> do - cc <## "2 contacts connected (use /cs for the list)" - cc <## "#team: connected to server(s)" - action cc - -withTestChatGroup3Connected' :: HasCallStack => TestParams -> String -> IO () -withTestChatGroup3Connected' ps dbPrefix = withTestChatGroup3Connected ps dbPrefix $ \_ -> pure () - -withTestChatContactConnected :: HasCallStack => TestParams -> String -> (HasCallStack => TestCC -> IO a) -> IO a -withTestChatContactConnected ps dbPrefix action = - withTestChat ps dbPrefix $ \cc -> do - cc <## "1 contacts connected (use /cs for the list)" - action cc - -withTestChatContactConnected' :: HasCallStack => TestParams -> String -> IO () -withTestChatContactConnected' ps dbPrefix = withTestChatContactConnected ps dbPrefix $ \_ -> pure () - -withTestChatContactConnectedV1 :: HasCallStack => TestParams -> String -> (HasCallStack => TestCC -> IO a) -> IO a -withTestChatContactConnectedV1 ps dbPrefix action = - withTestChatV1 ps dbPrefix $ \cc -> do - cc <## "1 contacts connected (use /cs for the list)" - action cc - -withTestChatContactConnectedV1' :: HasCallStack => TestParams -> String -> IO () -withTestChatContactConnectedV1' ps dbPrefix = withTestChatContactConnectedV1 ps dbPrefix $ \_ -> pure () - -- | test sending direct messages (<##>) :: HasCallStack => TestCC -> TestCC -> IO () cc1 <##> cc2 = do @@ -723,7 +696,7 @@ connectUsers_ cc1 cc2 noShortLink = do showName :: TestCC -> IO String showName (TestCC ChatController {currentUser} _ _ _ _ _) = do Just User {localDisplayName, profile = LocalProfile {fullName, shortDescr}} <- readTVarIO currentUser - pure . T.unpack $ localDisplayName <> optionalFullName localDisplayName fullName shortDescr + pure . T.unpack $ viewName localDisplayName <> optionalFullName localDisplayName fullName shortDescr createGroup2 :: HasCallStack => String -> TestCC -> TestCC -> IO () createGroup2 gName cc1 cc2 = createGroup2' gName cc1 (cc2, GRAdmin) True diff --git a/tests/JSONFixtures.hs b/tests/JSONFixtures.hs index a2577240a1..f3364dac81 100644 --- a/tests/JSONFixtures.hs +++ b/tests/JSONFixtures.hs @@ -28,33 +28,15 @@ chatStartedSwift = "{\"result\":{\"_owsf\":true,\"chatStarted\":{}}}" chatStartedTagged :: LB.ByteString chatStartedTagged = "{\"result\":{\"type\":\"chatStarted\"}}" -networkStatusesSwift :: LB.ByteString -networkStatusesSwift = "{\"result\":{\"_owsf\":true,\"networkStatuses\":{\"user_\":" <> userJSON <> ",\"networkStatuses\":[]}}}" +connectionsDiffSwift :: LB.ByteString +connectionsDiffSwift = "{\"result\":{\"_owsf\":true,\"connectionsDiff\":{\"userIds\":{\"missingIds\":[],\"extraIds\":[]},\"connIds\":{\"missingIds\":[],\"extraIds\":[]}}}}" -networkStatusesTagged :: LB.ByteString -networkStatusesTagged = "{\"result\":{\"type\":\"networkStatuses\",\"user_\":" <> userJSON <> ",\"networkStatuses\":[]}}" +connectionsDiffTagged :: LB.ByteString +connectionsDiffTagged = "{\"result\":{\"type\":\"connectionsDiff\",\"userIds\":{\"missingIds\":[],\"extraIds\":[]},\"connIds\":{\"missingIds\":[],\"extraIds\":[]}}}" userJSON :: LB.ByteString userJSON = "{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"\",\"shortDescr\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"files\":{\"allow\":\"always\"},\"calls\":{\"allow\":\"yes\"},\"sessions\":{\"allow\":\"no\"},\"commands\":[]},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true,\"autoAcceptMemberContacts\":false}" -memberSubSummarySwift :: LB.ByteString -memberSubSummarySwift = "{\"result\":{\"_owsf\":true,\"memberSubSummary\":{\"user\":" <> userJSON <> ",\"memberSubscriptions\":[]}}}" - -memberSubSummaryTagged :: LB.ByteString -memberSubSummaryTagged = "{\"result\":{\"type\":\"memberSubSummary\",\"user\":" <> userJSON <> ",\"memberSubscriptions\":[]}}" - -userContactSubSummarySwift :: LB.ByteString -userContactSubSummarySwift = "{\"result\":{\"_owsf\":true,\"userContactSubSummary\":{\"user\":" <> userJSON <> ",\"userContactSubscriptions\":[]}}}" - -userContactSubSummaryTagged :: LB.ByteString -userContactSubSummaryTagged = "{\"result\":{\"type\":\"userContactSubSummary\",\"user\":" <> userJSON <> ",\"userContactSubscriptions\":[]}}" - -pendingSubSummarySwift :: LB.ByteString -pendingSubSummarySwift = "{\"result\":{\"_owsf\":true,\"pendingSubSummary\":{\"user\":" <> userJSON <> ",\"pendingSubscriptions\":[]}}}" - -pendingSubSummaryTagged :: LB.ByteString -pendingSubSummaryTagged = "{\"result\":{\"type\":\"pendingSubSummary\",\"user\":" <> userJSON <> ",\"pendingSubscriptions\":[]}}" - parsedMarkdownSwift :: LB.ByteString parsedMarkdownSwift = "{\"formattedText\":[{\"format\":{\"_owsf\":true,\"bold\":{}},\"text\":\"hello\"}]}" diff --git a/tests/JSONTests.hs b/tests/JSONTests.hs index 400db87b36..52d722d26a 100644 --- a/tests/JSONTests.hs +++ b/tests/JSONTests.hs @@ -25,10 +25,7 @@ owsf2TaggedJSONTest = do activeUserExistsSwift `to` activeUserExistsTagged activeUserSwift `to` activeUserTagged chatStartedSwift `to` chatStartedTagged - networkStatusesSwift `to` networkStatusesTagged - memberSubSummarySwift `to` memberSubSummaryTagged - userContactSubSummarySwift `to` userContactSubSummaryTagged - pendingSubSummarySwift `to` pendingSubSummaryTagged + connectionsDiffSwift `to` connectionsDiffTagged parsedMarkdownSwift `to` parsedMarkdownTagged where to :: LB.ByteString -> LB.ByteString -> IO () diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index 0ba784a1fc..7213a0e91c 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -1,12 +1,15 @@ {-# LANGUAGE CPP #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TemplateHaskell #-} {-# OPTIONS_GHC -fno-warn-orphans #-} -module MobileTests where +module MobileTests (mobileTests) where +import ChatClient import ChatTests.DBUtils import ChatTests.Utils import Control.Concurrent.STM @@ -28,7 +31,8 @@ import Foreign.StablePtr import Foreign.Storable (peek) import GHC.IO.Encoding (setLocaleEncoding, setFileSystemEncoding, setForeignEncoding) import JSONFixtures -import Simplex.Chat.Controller (ChatController (..)) +import Simplex.Chat +import Simplex.Chat.Controller (ChatController (..), ChatDatabase (..)) import Simplex.Chat.Mobile hiding (error) import Simplex.Chat.Mobile.File import Simplex.Chat.Mobile.Shared @@ -37,8 +41,7 @@ import Simplex.Chat.Options.DB import Simplex.Chat.Store import Simplex.Chat.Store.Profiles import Simplex.Chat.Types (AgentUserId (..), Profile (..)) -import Simplex.Messaging.Agent.Store.Interface -import Simplex.Messaging.Agent.Store.Shared (MigrationConfirmation (..)) +import Simplex.Messaging.Agent.Store.Shared (MigrationConfig (..), MigrationConfirmation (..)) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile(..), CryptoFileArgs (..)) @@ -111,36 +114,12 @@ chatStarted = chatStartedTagged #endif -networkStatuses :: LB.ByteString -networkStatuses = +connectionsDiff :: LB.ByteString +connectionsDiff = #if defined(darwin_HOST_OS) && defined(swiftJSON) - networkStatusesSwift + connectionsDiffSwift #else - networkStatusesTagged -#endif - -memberSubSummary :: LB.ByteString -memberSubSummary = -#if defined(darwin_HOST_OS) && defined(swiftJSON) - memberSubSummarySwift -#else - memberSubSummaryTagged -#endif - -userContactSubSummary :: LB.ByteString -userContactSubSummary = -#if defined(darwin_HOST_OS) && defined(swiftJSON) - userContactSubSummarySwift -#else - userContactSubSummaryTagged -#endif - -pendingSubSummary :: LB.ByteString -pendingSubSummary = -#if defined(darwin_HOST_OS) && defined(swiftJSON) - pendingSubSummarySwift -#else - pendingSubSummaryTagged + connectionsDiffTagged #endif parsedMarkdown :: LB.ByteString @@ -166,16 +145,16 @@ testChatApi :: TestParams -> IO () testChatApi ps = do let tmp = tmpPath ps dbPrefix = tmp "1" - f = dbPrefix <> chatSuffix - Right st <- createChatStore (DBOpts f "myKey" False True DB.TQOff) MCYesUp - Right _ <- withTransaction st $ \db -> runExceptT $ createUserRecord db (AgentUserId 1) aliceProfile {preferences = Nothing} True + Right ChatDatabase {chatStore, agentStore} <- createChatDatabase (ChatDbOpts dbPrefix "myKey" DB.TQOff True) (MigrationConfig MCYesUp Nothing) + insertUser agentStore + Right _ <- withTransaction chatStore $ \db -> runExceptT $ createUserRecord db (AgentUserId 1) aliceProfile {preferences = Nothing} True Right cc <- chatMigrateInit dbPrefix "myKey" "yesUp" Left (DBMErrorNotADatabase _) <- chatMigrateInit dbPrefix "" "yesUp" Left (DBMErrorNotADatabase _) <- chatMigrateInit dbPrefix "anotherKey" "yesUp" chatSendCmd cc "/u" `shouldReturn` activeUser chatSendCmd cc "/create user alice Alice" `shouldReturn` activeUserExists chatSendCmd cc "/_start" `shouldReturn` chatStarted - chatRecvMsg cc `shouldReturn` networkStatuses + chatRecvMsg cc `shouldReturn` connectionsDiff chatRecvMsgWait cc 10000 `shouldReturn` "" chatParseMarkdown "hello" `shouldBe` "{}" chatParseMarkdown "*hello*" `shouldBe` parsedMarkdown diff --git a/tests/PostgresSchemaDump.hs b/tests/PostgresSchemaDump.hs index f1b509ac6f..61ea5f8fdd 100644 --- a/tests/PostgresSchemaDump.hs +++ b/tests/PostgresSchemaDump.hs @@ -14,7 +14,7 @@ import Data.Maybe (fromJust, isJust) import Simplex.Messaging.Agent.Store.Postgres (closeDBStore, createDBStore) import Simplex.Messaging.Agent.Store.Postgres.Common (DBOpts (..)) import qualified Simplex.Messaging.Agent.Store.Postgres.Migrations as Migrations -import Simplex.Messaging.Agent.Store.Shared (Migration (..), MigrationConfirmation (..), MigrationsToRun (..), toDownMigration) +import Simplex.Messaging.Agent.Store.Shared (Migration (..), MigrationConfig (..), MigrationConfirmation (..), MigrationsToRun (..), toDownMigration) import Simplex.Messaging.Util (ifM, whenM) import System.Directory (doesFileExist, removeFile) import System.Process (readCreateProcess, shell) @@ -24,20 +24,20 @@ testSchemaPath :: FilePath testSchemaPath = "tests/tmp/test_schema.sql" -- copied from simplexmq -postgresSchemaDumpTest :: [Migration] -> [String] -> DBOpts -> FilePath -> Spec -postgresSchemaDumpTest migrations skipComparisonForDownMigrations testDBOpts@DBOpts {connstr, schema = testDBSchema} srcSchemaPath = do +postgresSchemaDumpTest :: [Migration] -> DBOpts -> FilePath -> Spec +postgresSchemaDumpTest migrations testDBOpts@DBOpts {connstr, schema = testDBSchema} srcSchemaPath = do it "verify and overwrite schema dump" testVerifySchemaDump it "verify schema down migrations" testSchemaMigrations where testVerifySchemaDump = do savedSchema <- ifM (doesFileExist srcSchemaPath) (readFile srcSchemaPath) (pure "") savedSchema `deepseq` pure () - void $ createDBStore testDBOpts migrations MCConsole + void $ createDBStore testDBOpts migrations (MigrationConfig MCConsole Nothing) getSchema srcSchemaPath `shouldReturn` savedSchema testSchemaMigrations = do let noDownMigrations = dropWhileEnd (\Migration {down} -> isJust down) migrations - st <- createDBStore testDBOpts noDownMigrations MCYesUpDown >>= \case + st <- createDBStore testDBOpts noDownMigrations (MigrationConfig MCYesUpDown Nothing) >>= \case Right st -> pure st Left e -> error $ show e mapM_ (testDownMigration st) $ drop (length noDownMigrations) migrations @@ -48,14 +48,14 @@ postgresSchemaDumpTest migrations skipComparisonForDownMigrations testDBOpts@DBO putStrLn $ "down migration " <> name m let downMigr = fromJust $ toDownMigration m schema <- getSchema testSchemaPath - Migrations.run st $ MTRUp [m] + Migrations.run st Nothing $ MTRUp [m] schema' <- getSchema testSchemaPath schema' `shouldNotBe` schema - Migrations.run st $ MTRDown [downMigr] + Migrations.run st Nothing $ MTRDown [downMigr] unless (name m `elem` skipComparisonForDownMigrations) $ do schema'' <- getSchema testSchemaPath schema'' `shouldBe` schema - Migrations.run st $ MTRUp [m] + Migrations.run st Nothing $ MTRUp [m] schema''' <- getSchema testSchemaPath schema''' `shouldBe` schema' @@ -72,3 +72,9 @@ postgresSchemaDumpTest migrations skipComparisonForDownMigrations testDBOpts@DBO void $ readCreateProcess (shell $ sed <> " '/^--/d' " <> schemaPath) "" sch <- readFile schemaPath sch `deepseq` pure sch + +skipComparisonForDownMigrations :: [String] +skipComparisonForDownMigrations = + [ -- via_group field moves + "20250922_remove_unused_connections" + ] diff --git a/tests/SchemaDump.hs b/tests/SchemaDump.hs index df1739449e..b1b7dbc264 100644 --- a/tests/SchemaDump.hs +++ b/tests/SchemaDump.hs @@ -26,7 +26,7 @@ import Simplex.Messaging.Agent.Store.DB (TrackQueries (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Agent.Store.Interface import qualified Simplex.Messaging.Agent.Store.SQLite.Migrations as Migrations -import Simplex.Messaging.Agent.Store.Shared (Migration (..), MigrationConfirmation (..), MigrationsToRun (..), toDownMigration) +import Simplex.Messaging.Agent.Store.Shared (Migration (..), MigrationConfig (..), MigrationConfirmation (..), MigrationsToRun (..), toDownMigration) import Simplex.Messaging.Util (ifM, tshow, whenM) import System.Directory (doesFileExist, removeFile) import System.Process (readCreateProcess, shell) @@ -63,7 +63,7 @@ testVerifySchemaDump :: IO () testVerifySchemaDump = withTmpFiles $ do savedSchema <- ifM (doesFileExist appSchema) (readFile appSchema) (pure "") savedSchema `deepseq` pure () - void $ createChatStore (DBOpts testDB "" False True TQOff) MCError + void $ createChatStore (DBOpts testDB "" False True TQOff) (MigrationConfig MCError Nothing) getSchema testDB appSchema `shouldReturn` savedSchema removeFile testDB @@ -71,14 +71,14 @@ testVerifyLintFKeyIndexes :: IO () testVerifyLintFKeyIndexes = withTmpFiles $ do savedLint <- ifM (doesFileExist appLint) (readFile appLint) (pure "") savedLint `deepseq` pure () - void $ createChatStore (DBOpts testDB "" False True TQOff) MCError + void $ createChatStore (DBOpts testDB "" 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 MCError + Right st <- createDBStore (DBOpts testDB "" False True TQOff) noDownMigrations (MigrationConfig MCError Nothing) mapM_ (testDownMigration st) $ drop (length noDownMigrations) Store.migrations closeDBStore st removeFile testDB @@ -88,15 +88,15 @@ testSchemaMigrations = withTmpFiles $ do putStrLn $ "down migration " <> name m let downMigr = fromJust $ toDownMigration m schema <- getSchema testDB testSchema - Migrations.run st True $ MTRUp [m] + Migrations.run st Nothing True $ MTRUp [m] schema' <- getSchema testDB testSchema unless (name m `elem` skipComparisonForUpMigrations) $ schema' `shouldNotBe` schema - Migrations.run st True $ MTRDown [downMigr] + Migrations.run st Nothing True $ MTRDown [downMigr] unless (name m `elem` skipComparisonForDownMigrations) $ do schema'' <- getSchema testDB testSchema schema'' `shouldBe` schema - Migrations.run st True $ MTRUp [m] + Migrations.run st Nothing True $ MTRUp [m] schema''' <- getSchema testDB testSchema schema''' `shouldBe` schema' @@ -129,7 +129,9 @@ skipComparisonForDownMigrations = -- index moves down to the end of the file "20250227_member_acceptance", -- index moves down to the end of the file - "20250721_indexes" + "20250721_indexes", + -- indexes move down to the end of the file + "20250922_remove_unused_connections" ] getSchema :: FilePath -> FilePath -> IO String @@ -150,7 +152,7 @@ saveQueryPlans = it "verify and overwrite query plans" $ \TestParams {chatQueryS updatePlans appChatQueryPlans chatQueryStats - (createChatStore (DBOpts testDB "" False True TQOff) MCError) + (createChatStore (DBOpts testDB "" 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)" @@ -159,7 +161,7 @@ saveQueryPlans = it "verify and overwrite query plans" $ \TestParams {chatQueryS updatePlans appAgentQueryPlans agentQueryStats - (createAgentStore (DBOpts testAgentDB "" False True TQOff) MCError) + (createAgentStore (DBOpts testAgentDB "" False True TQOff) (MigrationConfig MCError Nothing)) (const $ pure ()) chatSavedPlans' == chatSavedPlans `shouldBe` True agentSavedPlans' == agentSavedPlans `shouldBe` True diff --git a/tests/Test.hs b/tests/Test.hs index 46106f0654..e4e76fd43e 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -3,7 +3,6 @@ {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE TupleSections #-} -import APIDocs import Bots.BroadcastTests import Bots.DirectoryTests import ChatClient @@ -27,9 +26,10 @@ import ViewTests import Control.Exception (bracket_) import PostgresSchemaDump import Simplex.Chat.Store.Postgres.Migrations (migrations) -import Simplex.Messaging.Agent.Store.Postgres.Util (createDBAndUserIfNotExists, dropAllSchemasExceptSystem, dropDatabaseAndUser) -import System.Directory (createDirectory, removePathForcibly) +import Simplex.Messaging.Agent.Store.Postgres.Util (createDBAndUserIfNotExists, dropDatabaseAndUser) +import System.Directory (createDirectoryIfMissing, removePathForcibly) #else +import APIDocs import qualified Simplex.Messaging.TMap as TM import MobileTests import SchemaDump @@ -44,17 +44,12 @@ main = do agentQueryStats <- TM.emptyIO #endif withGlobalLogging logCfg . hspec -#if defined(dbPostgres) - . beforeAll_ (dropDatabaseAndUser testDBConnectInfo >> createDBAndUserIfNotExists testDBConnectInfo) - . afterAll_ (dropDatabaseAndUser testDBConnectInfo) -#endif $ do #if defined(dbPostgres) - around_ (bracket_ (createDirectory "tests/tmp") (removePathForcibly "tests/tmp")) $ + createdDropDb . around_ (bracket_ (createDirectoryIfMissing False "tests/tmp") (removePathForcibly "tests/tmp")) $ describe "Postgres schema dump" $ postgresSchemaDumpTest migrations - [] -- skipComparisonForDownMigrations schemaDumpDBOpts "src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql" #else @@ -71,8 +66,7 @@ main = do describe "Operators" operatorTests describe "Random servers" randomServersTests #if defined(dbPostgres) - around testBracket - . after_ (dropAllSchemasExceptSystem testDBConnectInfo) + createdDropDb . around testBracket #else around (testBracket chatQueryStats agentQueryStats) #endif @@ -89,6 +83,9 @@ main = do #endif where #if defined(dbPostgres) + createdDropDb = + before_ (dropDatabaseAndUser testDBConnectInfo >> createDBAndUserIfNotExists testDBConnectInfo) + . after_ (dropDatabaseAndUser testDBConnectInfo) testBracket test = withSmpServer $ tmpBracket $ \tmpPath -> test TestParams {tmpPath, printOutput = False} #else testBracket chatQueryStats agentQueryStats test = diff --git a/website/.eleventy.js b/website/.eleventy.js index a0a35f3366..7c979e8da7 100644 --- a/website/.eleventy.js +++ b/website/.eleventy.js @@ -53,7 +53,7 @@ const globalConfig = { } const translationsDirectoryPath = './langs' -const supportedRoutes = ["blog", "contact", "invitation", "docs", "fdroid", ""] +const supportedRoutes = ["blog", "contact", "invitation", "messaging", "docs", "fdroid", ""] let supportedLangs = [] fs.readdir(translationsDirectoryPath, (err, files) => { if (err) { @@ -69,6 +69,13 @@ fs.readdir(translationsDirectoryPath, (err, files) => { const translations = require("./translations.json") module.exports = function (ty) { + // Add this after your markdownLib definition + ty.addShortcode("mdInclude", function (filepath) { + const fullPath = path.join(__dirname, 'src/_includes', filepath); + const content = fs.readFileSync(fullPath, 'utf8'); + return markdownLib.render(content); + }); + ty.addShortcode("cfg", (name) => globalConfig[name]) ty.addFilter("getlang", (path) => { @@ -106,9 +113,10 @@ module.exports = function (ty) { allContentNodes.forEach((node) => { const regex = new RegExp(`(?${term.term}` const beforeContent = node.innerHTML - node.innerHTML = node.innerHTML.replace(regex, replacement) + node.innerHTML = node.innerHTML.replace(regex, (match) => { + return `${match}` + }) if (beforeContent !== node.innerHTML && !changeNoted) { changeNoted = true } @@ -256,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") diff --git a/website/README.md b/website/README.md new file mode 100644 index 0000000000..5a5155a917 --- /dev/null +++ b/website/README.md @@ -0,0 +1,9 @@ +# SimpleX Chat website + +## License + +SimpleX Chat website code is licensed under the GNU Affero General Public License version 3 (AGPLv3). See the [LICENSE](../LICENSE) file for details. + +The SimpleX and SimpleX Chat name, logo, and associated branding materials are not covered by this license and are subject to the terms outlined in the [TRADEMARK](./docs/TRADEMARK.md) file. + +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. diff --git a/website/langs/ar.json b/website/langs/ar.json index 0c1fb4515a..7467f657a9 100644 --- a/website/langs/ar.json +++ b/website/langs/ar.json @@ -80,13 +80,13 @@ "hero-overlay-card-1-p-4": "هذا التصميم يمنع تسريب أي البيانات الوصفية للمستخدمين على مستوى التطبيق. لزيادة تحسين الخصوصية وحماية عنوان IP الخاص بك، يمكنك الاتصال بخوادم المراسلة عبر Tor.", "hero-overlay-card-1-p-5": "الأجهزة العميلة فقط هي التي تخزن ملفات تعريف المستخدمين، جهات الاتصال والمجموعات؛ يتم إرسال الرسائل بتعمية ثنائية الطبقة بين الطرفين.", "hero-overlay-card-2-p-2": "يمكنهم بعد ذلك ربط هذه المعلومات بالشبكات الاجتماعية العامة الحالية، وتحديد بعض الهويات الحقيقية.", - "simplex-network-overlay-card-1-li-3": "P2P لا يحل مشكلة هجوم الوسيط (MITM)، ومعظم التطبيقات الحالية لا تستخدم خارج النطاق رسائل للتبادل الأولي للمفاتيح. يستخدم SimpleX رسائل خارج النطاق أو، في بعض الحالات، اتصالات آمنة وموثوق بها موجودة مسبقًا لتبادل المفاتيح الأولي.", - "privacy-matters-overlay-card-1-p-3": "تستخدم بعض الشركات المالية والتأمين رسوماً بيانية اجتماعية لتحديد أسعار الفائدة وأقساط التأمين. عادةً ما تجعل الأشخاص ذوي الدخل المنخفض يدفعون أكثر — أو كما يسمى \"علاوة الفقر\".", + "simplex-network-overlay-card-1-li-3": "P2P لا يحل مشكلة هجوم الوسيط (MITM)، ومعظم التطبيقات الحالية لا تستخدم خارج النطاق رسائل للتبادل الأولي للمفاتيح. يستخدم SimpleX رسائل خارج النطاق أو، في بعض الحالات، اتصالات آمنة وموثوق بها موجودة مسبقًا لتبادل المفاتيح الأولي.", + "privacy-matters-overlay-card-1-p-3": "تستخدم بعض الشركات المالية والتأمين رسوماً بيانية اجتماعية لتحديد أسعار الفائدة وأقساط التأمين. عادةً ما تجعل الأشخاص ذوي الدخل المنخفض يدفعون أكثر — أو كما يسمى \"علاوة الفقر\".", "privacy-matters-overlay-card-1-p-2": "يعرف تجار التجزئة عبر الإنترنت أن الأشخاص ذوي الدخل المنخفض هم أكثر عرضة لإجراء عمليات شراء عاجلة، لذلك قد يفرضون أسعارًا أعلى أو يزيلون الخصومات.", "simplex-private-6-title": "تبديل
خارج النطاق", "simplex-private-9-title": "قوائم انتظار
أحادية الاتجاه", "privacy-matters-overlay-card-1-p-4": "تحمي شبكة SimpleX خصوصية اتصالاتك بشكل أفضل من أي بديل آخر، مما يمنع تمامًا الرسم البياني الاجتماعي الخاص بك من أن يصبح متاحًا لأي شركات أو مؤسسات. حتى عندما يستخدم الأشخاص الخوادم التي توفرها SimpleX Chat، فإننا لا نعرف عدد المستخدمين أو اتصالاتهم.", - "simplex-private-card-1-point-1": "بروتوكول السقاطة المزدوجة —
رسائل OTR مع السرية المستمرة واستعادة الاختراق.", + "simplex-private-card-1-point-1": "بروتوكول Double-ratchet —
رسائل OTR مع السرية المستمرة واستعادة الاختراق.", "simplex-private-card-1-point-2": "NaCL cryptobox في كل قائمة انتظار لمنع ارتباط حركة مرور البيانات بين قوائم انتظار الرسائل في حالة اختراق TLS.", "simplex-private-card-2-point-1": "طبقة إضافية من تعمية الخادم للتسليم إلى المُستلم، لمنع الارتباط بين حركة مرور بيانات الخادم المُستلمة والمُرسلة في حالة اختراق TLS.", "simplex-private-card-4-point-2": "لاستخدام SimpleX عبر تور، يُرجى تثبيت تطبيق Orbot وتمكّين وكيل SOCKS5 (أو VPN على iOS ).", @@ -100,16 +100,16 @@ "hero-overlay-card-1-p-3": "أنت تحدد الخادم (الخوادم) المراد استخدامه لاستلام الرسائل وجهات اتصالك — الخوادم التي تستخدمها لإرسال الرسائل إليهم. من المرجح أن تستخدم كل مُحادثة خادمين مختلفين.", "hero-overlay-card-1-p-1": "سأل العديد من المستخدمين: إذا لم يكن لدى SimpleX معرّفات مستخدم، فكيف يمكنها معرفة مكان تسليم الرسائل؟ ", "hero-overlay-card-1-p-2": "لتسليم الرسائل، بدلاً من معرّفات المُستخدم المُستخدمة من قِبل جميع الشبكات الأخرى، يستخدم SimpleX معرّفات زوجية مجهولة مؤقتة لقوائم الرسائل، منفصلة لكل اتصال من اتصالاتك — ولا توجد معرّفات طويلة الأجل.", - "simplex-network-overlay-card-1-p-1": "بروتوكولات المُراسلة والتطبيقات P2P بها مشاكل مختلفة تجعلها أقل موثوقية من SimpleX وأكثر تعقيدًا في التحليل و عرضة لعدة أنواع من الهجمات.", + "simplex-network-overlay-card-1-p-1": "بروتوكولات المُراسلة والتطبيقات P2P بها مشاكل مختلفة تجعلها أقل موثوقية من SimpleX وأكثر تعقيدًا في التحليل و عرضة لعدة أنواع من الهجمات.", "hero-overlay-card-2-p-1": "عندما يكون لدى المستخدمين هويات ثابتة، حتى لو كان هذا مجرد رقم عشوائي، مثل معرّف الجلسة، فهناك خطر يتمثل في أن الموفر أو المهاجم يمكنه مراقبة كيفية اتصال المستخدمين وعدد الرسائل التي يرسلونها.", - "hero-overlay-card-1-p-6": "اقرأ المزيد في SimpleX whitepaper .", + "hero-overlay-card-1-p-6": "اقرأ المزيد في SimpleX whitepaper.", "hero-overlay-card-2-p-3": "حتى مع معظم التطبيقات الخاصة التي تستخدم خدمات Tor v3، إذا تحدثت إلى جهتي اتصال مختلفتين عبر نفس الملف الشخصي، فيمكنهما إثبات أنهما متصلان بنفس الشخص.", "hero-overlay-card-2-p-4": "يحمي SimpleX من هذه الهجمات من خلال عدم وجود أي معرّفات مستخدم في تصميمه. وإذا كنت تستخدم وضع التخفي، فسيكون لديك اسم عرض مختلف لكل جهة اتصال، مع تجنب أي بيانات مشتركة بينهما.", - "simplex-network-overlay-card-1-li-6": "قد تكون شبكات P2P عرضة هجوم DRDoS، عندما يتمكّن العملاء من إعادة بث حركة مرور البيانات وتضخيمها، مما يؤدي إلى رفض الخدمة على مستوى الشبكة. يقوم عملاء SimpleX فقط بترحيل حركة مرور البيانات من اتصال معروف ولا يمكن للمهاجم استخدامها لتضخيم حركة مرور البيانات في الشبكة بالكامل.", - "simplex-network-overlay-card-1-li-1": "تعتمد شبكات P2P على بعض المتغيرات من DHT لتوجيه الرسائل. يجب أن توازن تصميمات DHT بين ضمان التسليم وزمن الوصول. تتمتع SimpleX بضمان تسليم أفضل وزمن انتقال أقل من P2P، لأنه يمكّن تمرير الرسالة بشكل متكرر عبر عدة خوادم بالتوازي، باستخدام الخوادم التي اختارها المُستلم. في شبكات P2P، يتم تمرير الرسالة عبر عُقد O (log N) بالتسلسل، باستخدام العُقد التي تختارها الخوارزمية.", + "simplex-network-overlay-card-1-li-6": "قد تكون شبكات P2P عرضة DRDoS attack، عندما يتمكّن العملاء من إعادة بث حركة مرور البيانات وتضخيمها، مما يؤدي إلى رفض الخدمة على مستوى الشبكة. يقوم عملاء SimpleX فقط بترحيل حركة مرور البيانات من اتصال معروف ولا يمكن للمهاجم استخدامها لتضخيم حركة مرور البيانات في الشبكة بالكامل.", + "simplex-network-overlay-card-1-li-1": "تعتمد شبكات P2P على بعض المتغيرات من DHT لتوجيه الرسائل. يجب أن توازن تصميمات DHT بين ضمان التسليم وزمن الوصول. تتمتع SimpleX بضمان تسليم أفضل وزمن انتقال أقل من P2P، لأنه يمكّن تمرير الرسالة بشكل متكرر عبر عدة خوادم بالتوازي، باستخدام الخوادم التي اختارها المُستلم. في شبكات P2P، يتم تمرير الرسالة عبر عُقد O (log N) بالتسلسل، باستخدام العُقد التي تختارها الخوارزمية.", "simplex-network-overlay-card-1-li-2": "لا يحتوي تصميم SimpleX، عكس معظم شبكات P2P، على معرّفات مستخدم عالمية من أي نوع، حتى مؤقتة، ويستخدم فقط المعرّفات المزدوجة المؤقتة، مما يوفر إخفاء هوية أفضل وحماية للبيانات الوصفية.", - "simplex-network-overlay-card-1-li-4": "يمكن لبعض مزودي خدمة الإنترنت حظر تطبيقات P2P (مثل BitTorrent ). SimpleX حيادي النقل - يمكنه العمل عبر بروتوكولات الويب القياسية، على سبيل المثال WebSockets.", - "simplex-network-overlay-card-1-li-5": "قد تكون جميع شبكات P2P المعروفة عرضة لهجوم Sybil، لأن كل عقدة قابلة للاكتشاف، وتعمل الشبكة ككل. تتطلب الإجراءات المعروفة لتخفيفها إما مكونًا مركزيًا أو إثبات عمل مكلف . لا تحتوي شبكة SimpleX على إمكانية اكتشاف الخادم، فهي مجزأة وتعمل كشبكات فرعية متعددة ومعزولة، مما يجعل الهجمات على مستوى الشبكة مستحيلة.", + "simplex-network-overlay-card-1-li-4": "يمكن لبعض مزودي خدمة الإنترنت حظر تطبيقات P2P (مثل BitTorrent). SimpleX حيادي النقل - يمكنه العمل عبر بروتوكولات الويب القياسية، على سبيل المثال WebSockets.", + "simplex-network-overlay-card-1-li-5": "قد تكون جميع شبكات P2P المعروفة عرضة Sybil attack، لأن كل عقدة قابلة للاكتشاف، وتعمل الشبكة ككل. تتطلب الإجراءات المعروفة لتخفيفها إما مكونًا مركزيًا أو إثبات عمل مكلف . لا تحتوي شبكة SimpleX على إمكانية اكتشاف الخادم، فهي مجزأة وتعمل كشبكات فرعية متعددة ومعزولة، مما يجعل الهجمات على مستوى الشبكة مستحيلة.", "privacy-matters-overlay-card-3-p-1": "يجب على الجميع الاهتمام بخصوصية وأمان اتصالاتهم — يمكن للمُحادثات غير الضارة أن تعرضك للخطر، حتى لو لم يكن لديك ما تخفيه.", "privacy-matters-overlay-card-3-p-4": "لا يكفي استخدام برنامج مُراسلة مُعمَّاة بين الطرفين، يجب علينا جميعًا استخدام برامج مُراسلة التي تحمي خصوصية شبكاتنا الشخصية — مع من نحن مرتبطون.", "simplex-unique-overlay-card-1-p-3": "يحمي هذا التصميم خصوصية الأشخاص الذين تتواصل معهم، ويخفيها عن خوادم شبكة SimpleX ومن أي مراقبين. لإخفاء عنوان IP الخاص بك من الخوادم، يمكنك الاتصال بخوادم SimpleX عبر تور.", @@ -118,22 +118,22 @@ "simplex-unique-overlay-card-3-p-2": "يتم الاحتفاظ بالرسائل المُعمَّاة بين الطرفين مؤقتًا على خوادم ترحيل SimpleX حتى يتم استلامها، ثُمَّ تُحذف نهائيًا.", "simplex-unique-overlay-card-3-p-4": "لا توجد معرفّات أو نص مُعَمَّى مشترك بين حركة مرور بيانات الخادم المُرسلة والمُستلمة — إذا كان أي شخص يراقب ذلك، فلن يتمكّن بسهولة من تحديد من يتواصل مع من، حتى لو اختُرق TLS.", "simplex-unique-card-1-p-1": "يحمي SimpleX خصوصية ملف تعريفك، جهات اتصالك والبيانات الوصفية، ويخفيه عن خوادم شبكة SimpleX وأي مراقبين.", - "privacy-matters-overlay-card-2-p-1": "منذ وقت ليس ببعيد، لاحظنا أن الانتخابات الرئيسية يتم التلاعب بها بواسطة شركة استشارية ذات سمعة طيبة التي استخدمت الرسوم البيانية الاجتماعية لتشويه نظرتنا للعالم الحقيقي والتلاعب بأصواتنا.", + "privacy-matters-overlay-card-2-p-1": "منذ وقت ليس ببعيد، لاحظنا أن الانتخابات الرئيسية يتم التلاعب بها بواسطة شركة استشارية ذات سمعة طيبة التي استخدمت الرسوم البيانية الاجتماعية لتشويه نظرتنا للعالم الحقيقي والتلاعب بأصواتنا.", "privacy-matters-overlay-card-2-p-2": "لكي تكون موضوعيًا وتتخذ قرارات مستقلة، عليك التحكم في مساحة معلوماتك. لا يمكن تحقيق ذلك إلا باستخدام شبكة اتصال خاصة لا يمكنها الوصول إلى حسابك على مواقع التواصل الاجتماعي.", "privacy-matters-overlay-card-2-p-3": "SimpleX هي أول شبكة لا تحتوي على أي معرّفات مستخدم من حيث التصميم، وبهذه الطريقة تحمي مخطط اتصالاتك بشكل أفضل من أي بديل معروف.", - "privacy-matters-overlay-card-3-p-2": "واحدة من أكثر القصص إثارة للصدمة هي تجربة محمدو ولد صلاحي الموصوفة في مذكراته والموضحة في فيلم موريتاني. تم وضعه في معتقل غوانتانامو بدون محاكمة، وتعرض للتعذيب هناك لمدة 15 عامًا بعد مكالمة هاتفية مع قريبه في أفغانستان، للاشتباه في تورطه في هجمات 11 سبتمبر، على الرغم من أنه عاش في ألمانيا طوال السنوات العشر الماضية.", - "privacy-matters-overlay-card-3-p-3": "يتم القبض على الأشخاص العاديين بسبب ما يشاركونه عبر الإنترنت، حتى عبر حساباتهم \"المجهولة\"، وحتى في البلدان الديمقراطية.", + "privacy-matters-overlay-card-3-p-2": "واحدة من أكثر القصص إثارة للصدمة هي تجربة محمدو ولد صلاحي الموصوفة في مذكراته والموضحة في فيلم موريتاني. تم وضعه في معتقل غوانتانامو بدون محاكمة، وتعرض للتعذيب هناك لمدة 15 عامًا بعد مكالمة هاتفية مع قريبه في أفغانستان، للاشتباه في تورطه في هجمات 11 سبتمبر، على الرغم من أنه عاش في ألمانيا طوال السنوات العشر الماضية.", + "privacy-matters-overlay-card-3-p-3": "يتم القبض على الأشخاص العاديين بسبب ما يشاركونه عبر الإنترنت، حتى عبر حساباتهم \"المجهولة\"، وحتى في البلدان الديمقراطية.", "simplex-unique-overlay-card-1-p-1": "على عكس شبكات المُراسلة الأخرى، لا يحتوي SimpleX على معرّفات مخصصة للمستخدمين. لا يعتمد على أرقام الهواتف أو العناوين المستندة إلى النطاقات (مثل البريد الإلكتروني أو XMPP)، أسماء المستخدمين، المفاتيح العامة أو حتى الأرقام العشوائية لتحديد مستخدميها — مُشغلي خادم SimpleX لا يعرفون عدد الأشخاص الذين يستخدمون خوادمهم.", - "simplex-unique-overlay-card-1-p-2": "لتسليم الرسائل، يستخدم SimpleX العناوين المزدوجة المجهولة لقوائم انتظار الرسائل أحادية الاتجاه، منفصلة عن الرسائل المُستلمة والمُرسلة، عادةً عبر خوادم مختلفة.", + "simplex-unique-overlay-card-1-p-2": "لتسليم الرسائل، يستخدم العناوين المزدوجة المجهولة لقوائم انتظار الرسائل أحادية الاتجاه، منفصلة عن الرسائل المُستلمة والمُرسلة، عادةً عبر خوادم مختلفة.", "simplex-unique-overlay-card-3-p-1": "يخزن SimpleX Chat جميع بيانات المستخدم على أجهزة العميل فقط باستخدام تنسيق قاعدة بيانات محمولة مُعمَّاة يمكّن تصديرها ونقلها إلى أي جهاز مدعوم.", "simplex-unique-overlay-card-3-p-3": "على عكس خوادم الشبكات الاتحادية (البريد الإلكتروني أو XMPP أو Matrix)، لا تقوم خوادم SimpleX بتخزين حسابات المستخدمين، فهي تقوم فقط بترحيل الرسائل، مما يحمي خصوصية كلا الطرفين.", "simplex-unique-overlay-card-4-p-1": "يمكنك استخدام SimpleX مع خوادمك والاستمرار في التواصل مع الأشخاص الذين يستخدمون الخوادم المُهيأة مسبقًا في التطبيقات.", - "simplex-unique-overlay-card-4-p-3": "إذا كنت تفكر في التطوير شبكة SimpleX، على سبيل المثال، بوت الدردشة لمستخدمي تطبيق SimpleX، أو دمج مكتبة SimpleX Chat في تطبيقات الأجهزة المحمولة، من فضلك تواصل معي لأي نصيحة والدعم.", + "simplex-unique-overlay-card-4-p-3": "إذا كنت تفكر في التطوير شبكة SimpleX، على سبيل المثال، بوت الدردشة لمستخدمي تطبيق SimpleX، أو دمج مكتبة SimpleX Chat في تطبيقات الأجهزة المحمولة، من فضلك تواصل معي لأي نصيحة والدعم.", "donate-here-to-help-us": "تبرّع هنا لمساعدتنا", "sign-up-to-receive-our-updates": "اشترك للحصول على آخر مستجداتنا", "enter-your-email-address": "أدخل عنوان بريدك الإلكتروني", "get-simplex": "احصل على تطبيق سطح المكتب SimpleX", - "why-simplex-is-unique": "لماذا SimpleX فريد من نوعه", + "why-simplex-is-unique": "لماذا SimpleX فريد من نوعه", "learn-more": "اقرأ أكثر", "more-info": "معلومات أكثر", "hide-info": "معلومات أقل", @@ -181,10 +181,10 @@ "the-instructions--source-code": "للتعليمات حول كيفية تنزيله أو تجميعه من التعليمات البرمجية المصدر.", "if-you-already-installed-simplex-chat-for-the-terminal": "إذا قمت بالفعل بتثبيت SimpleX Chat للوحدة الطرفية", "simplex-chat-for-the-terminal": "SimpleX Chat للوحدة الطرفية", - "privacy-matters-section-header": "لماذا الخصوصية مهمة", + "privacy-matters-section-header": "لماذا الخصوصية مهمة", "privacy-matters-section-label": "تأكّد من أن برنامج المُراسلة الخاص بك لا يمكنه الوصول إلى بياناتك!", "tap-to-close": "انقر للإغلاق", - "simplex-network-section-header": "شبكة SimpleX ", + "simplex-network-section-header": "شبكة SimpleX", "simplex-network-1-header": "على عكس شبكات P2P", "simplex-network-3-header": "شبكة SimpleX", "protocol-1-text": "Signal، منصات كبيرة", @@ -192,23 +192,23 @@ "simplex-unique-card-1-p-2": "على عكس أي شبكة مُراسلة آخر، لا يحتوي SimpleX على معرّفات مخصّصة للمستخدمين — ولا حتى أرقام عشوائية.", "simplex-unique-card-2-p-1": "نظرًا لعدم وجود معرّف أو عنوان ثابت على شبكة SimpleX، لا يمكن لأي شخص الاتصال بك ما لم تشارك عنوان مستخدم لمرة واحدة أو مؤقتًا، كرمز QR أو رابط.", "simplex-unique-card-3-p-2": "يتم الاحتفاظ بالرسائل المُعمَّاة بين الطرفين مؤقتًا على خوادم ترحيل SimpleX حتى يتم استلامها، ثُمَّ تُحذف نهائيًا.", - "tap-the-connect-button-in-the-app": "اضغط على زر \"اتصال\" في التطبيق", + "tap-the-connect-button-in-the-app": "اضغط على زر \"اتصل\" في التطبيق", "scan-the-qr-code-with-the-simplex-chat-app": "امسح رمز QR باستخدام تطبيق SimpleX Chat", "scan-the-qr-code-with-the-simplex-chat-app-description": "لا يتم إرسال المفاتيح العامة وعنوان قائمة انتظار الرسائل في هذا الارتباط عبر الشبكة عند عرض هذه الصفحة —
فهي موجودة في جزء التجزئة لعنوان URL للرابط.", "if-you-already-installed": "إذا قمت بالفعل بتثبيت", "copy-the-command-below-text": "انسخ الأمر الذي في الاسفل واستخدمه في الدردشة:", - "simplex-private-section-header": "ما الذي يجعل SimpleX خصوصيًّا", - "privacy-matters-section-subheader": "الحفاظ على خصوصية بياناتك الوصفية — مع من تتحدث — يحميك من:", + "simplex-private-section-header": "ما الذي يجعل SimpleX خصوصيًّا", + "privacy-matters-section-subheader": "الحفاظ على خصوصية بياناتك الوصفية — مع مَن تتحدث — يحميك من:", "simplex-network-1-overlay-linktext": "مشاكل شبكات P2P", "simplex-network-section-desc": "يوفر Simplex Chat أفضل خصوصية من خلال الجمع بين مزايا P2P والشبكات الاتحادية.", "simplex-network-1-desc": "يتم إرسال جميع الرسائل عبر الخوادم، مما يوفر خصوصية أفضل للبيانات الوصفية وتسليمًا موثوقًا للرسائل غير المتزامنة، مع تجنب الكثير", "simplex-network-2-header": "على عكس الشبكات الاتحادية", - "simplex-network-3-desc": "توفر الخوادم قوائم انتظار أحادية الاتجاه لتوصيل المستخدمين، لكن ليس لديهم رؤية للرسم البياني لاتصال الشبكة — إلا للمستخدمين فقط.", + "simplex-network-3-desc": "توفر الخوادم قوائم انتظار أحادية الاتجاه لتوصيل المستخدمين، لكن ليس لديهم رؤية للرسم البياني لاتصال الشبكة — إلا للمستخدمين فقط.", "comparison-section-header": "مقارنة مع البروتوكولات الأخرى", "simplex-network-2-desc": "لا تقوم خوادم الترحيل SimpleX بتخزين ملفات تعريف المستخدمين وجهات الاتصال والرسائل التي تم تسليمها، ولا تتصل ببعضها البعض، ولا يوجد دليل خوادم.", "comparison-point-1-text": "يتطلب هوية عالمية", "protocol-3-text": "بروتوكولات P2P", - "simplex-unique-overlay-card-4-p-2": "تستخدم شبكة SimpleX بروتوكول مفتوح وتوفر SDK لإنشاء روبوتات دردشة، مما يسمح بتنفيذ الخدمات التي يمكن للمستخدمين التفاعل معها عبر تطبيقات SimpleX Chat — التي تتطلع حقًا لمعرفة خدمات SimpleX التي ستبنيها.", + "simplex-unique-overlay-card-4-p-2": "تستخدم شبكة بروتوكول مفتوح وتوفرSDK لإنشاء روبوتات دردشة، مما يسمح بتنفيذ الخدمات التي يمكن للمستخدمين التفاعل معها عبر تطبيقات SimpleX Chat — التي تتطلع حقًا لمعرفة خدمات SimpleX التي ستبنيها.", "guide-dropdown-1": "بداية سريعة", "guide-dropdown-2": "إرسال الرسائل", "guide-dropdown-3": "مجموعات سرية", @@ -232,7 +232,7 @@ "docs-dropdown-2": "الوصول إلى ملفات اندرويد", "docs-dropdown-3": "الوصول إلى قاعدة بيانات الدردشة", "glossary": "المعجم", - "docs-dropdown-8": "SimpleX خدمة الدليل", + "docs-dropdown-8": "دليل SimpleX", "simplex-chat-via-f-droid": "SimpleX Chat عبر F-Droid", "simplex-chat-repo": "مستودع SimpleX Chat", "stable-and-beta-versions-built-by-developers": "الإصدارات الثابتة والتجريبية التي أنشأها المطورون", @@ -240,8 +240,8 @@ "f-droid-org-repo": "مستودع F-Droid.org", "stable-versions-built-by-f-droid-org": "الإصدارات الثابتة التي تم إنشاؤها بواسطة F-Droid.org", "releases-to-this-repo-are-done-1-2-days-later": "تتم الإصدارات إلى هذا المستودع بعد عِدة أيام", - "f-droid-page-simplex-chat-repo-section-text": "لإضافته إلى عميل F-Droid، امسح رمز QR أو استخدم عنوان URL هذا:", - "f-droid-page-f-droid-org-repo-section-text": "مستودعات SimpleX Chat و F-Droid.org مبنية على مفاتيح مختلفة. للتبديل، يُرجى تصدير قاعدة بيانات الدردشة وإعادة تثبيت التطبيق.", + "f-droid-page-simplex-chat-repo-section-text": "لإضافته إلى عميل F-Droid ،امسح رمز QR أو استخدم عنوان URL هذا:", + "f-droid-page-f-droid-org-repo-section-text": "مستودعات SimpleX Chat و F-Droid.org مبنية على مفاتيح مختلفة. للتبديل، يُرجى صدّر قاعدة بيانات الدردشة وأعد تثبيت التطبيق.", "comparison-section-list-point-4a": "مُرحلات SimpleX لا يمكنها أن تتنازل عن تعمية بين الطرفين. تحقق من رمز الأمان للتخفيف من الهجوم على القناة خارج النطاق", "hero-overlay-3-title": "التقييمات الأمنية", "hero-overlay-card-3-p-2": "قامت Trail of Bits بمراجعة مكونات التشفير والشبكات الخاصة بشبكة SimpleX في نوفمبر 2022. اقرأ المزيد في الإعلان.", @@ -255,5 +255,60 @@ "docs-dropdown-11": "الأسئلة الأكثر شيوعًا", "docs-dropdown-12": "الأمان", "hero-overlay-card-3-p-3": "قامت Trail of Bits بمراجعة التصميم التعموي لبروتوكولات شبكة SimpleX في يوليو 2024. اقرأ المزيد.", - "docs-dropdown-14": "SimpleX للأعمال التجارية" + "docs-dropdown-14": "SimpleX للأعمال التجارية", + "directory": "دليل", + "about-and-contact-us": "عن واتصل بنا", + "index-hero-h1": "كن
حراً", + "index-hero-h2": "حرية وأمن
اتصالاتك", + "index-hero-p1": "الشبكة الأولى التي تمتلك فيها هويتك وجهات اتصالك ومجموعاتك.", + "index-hero-download-desktop-btn-title": "نزّل تطبيق سطح مكتب SimpleX", + "index-testflight-title": "إصدار SimpleX التجريبي ل iOS على TestFlight", + "index-f-droid-title": "تطبيق SimpleX من خلال F-Droid", + "index-security-assessment-title": "التحقيقات الآمنية", + "index-security-review-2022-title": "تحقيق آمني 2022", + "index-security-review-2024-title": "تحقيق آمني 2024", + "index-security-audits-label": "التحقيقات
الآمنية", + "index-publications-privacy-guides-title": "توصيات Privacy Guides للمُراسلة", + "index-publications-whonix-title": "توصيات Whonix للمُراسلة", + "index-publications-heise-title": "منشورات Heise Online", + "index-publications-kuketz-title": "مراجعة بواسطة Mike Kuketz", + "index-publications-optout-title": "مقابلة بودكاست OptOut", + "worlds-most-secure-messaging": "المُراسلة الأكثر أمانًا في العالم", + "index-messaging-p1": "تتميز رسائل SimpleX بتعمية متطورة بين الطرفين.", + "index-messaging-p2": "لأمانك وخصوصيتك، لا يمكن للخوادم رؤية رسائلك ومَن تتحدث إليه.", + "index-messaging-cta": "تعرّف على المزيد حول مراسلة SimpleX", + "index-nextweb-h2": "أنت تملك
الشبكة التالية", + "index-nextweb-p1": "تأسست SimpleX على الاعتقاد بأن عليك امتلاك هويتك وجهات اتصالك ومجتمعاتك.", + "index-nextweb-p2": "شبكة مفتوحة ولامركزية تتيح لك التواصل مع الأشخاص ومشاركة الأفكار: كن حرًا وآمنًا.", + "index-token-h2": "مجتمعات تدوم", + "index-token-p1": "ستدعم مجموعاتك المفضلة بقسائم المجتمع المستقبلية.", + "index-token-p2": "ستدفع القسائم ثمن الخوادم، لتمكين مجتمعاتك من البقاء حرة ومستقلة.", + "index-roadmap-h2": "خارطة طريق SimpleX للإنترنت المجاني", + "index-roadmap-2025": "2025", + "index-roadmap-2025-title": "التوسع إلى مجتمعات كبيرة", + "index-roadmap-2025-desc": "الهروب من المنصات المركزية", + "index-roadmap-2026-title": "مجتمعات وخوادم مستدامة", + "index-roadmap-2026-desc": "إطلاق قسائم المجتمع", + "index-roadmap-2027-title": "اجعل مجتمعاتك تنمو", + "index-roadmap-2027-desc": "أدوات لتعزيز مجتمعاتك", + "index-directory-h2": "انضم إلى مجتمعات SimpleX", + "index-directory-p1": "مئات الآلاف من الأشخاص يثقون بالفعل في مُراسلة SimpleX.", + "index-directory-p2": "ابحث عن مجتمعاتك في دليل SimpleX وأنشئ مجتمعك الخاص!", + "index-directory-cta": "اعرض دليل SimpleX", + "index-directory-users-group-title": "مجموعة مستخدمي SimpleX", + "how-secure-comparison-title": "مقارنة أمان التعمية بين الطرفين في برامج المُراسلة المختلفة", + "how-secure-message-padding": "حشو الرسالة", + "how-secure-repudiation-deniability": "الإنكار (عدم القدرة على إثبات التورط)", + "how-secure-forward-secrecy": "السرية المستمرة", + "how-secure-break-in-recovery": "الأمن بعد الاختراق", + "how-secure-two-factor-key-exchange": "تبادل مفتاح ثنائي العوامل", + "how-secure-post-quantum-hybrid-crypto": "التعمية الهجينة ما بعد الكمومي", + "messengers-comparison-section-list-point-1": "يقوم Briar بحشو الرسائل إلى حجم مقرب لأعلى إلى 1024 بايت، بينما Signal يحشوها إلى 160 بايت", + "messengers-comparison-section-list-point-2": "لا يشمل الإنكار اتصال العميل بالخادم.", + "messengers-comparison-section-list-point-3": "يبدو أن استخدام التوقيعات المُعمّاة يضر بالإنكار (عدم القدرة على التنصل)، ولكن هذا يحتاج إلى توضيح.", + "messengers-comparison-section-list-point-4": "تطبيقات الأجهزة المتعددة تعرض أمن Double Ratchet بعد الاختراق للخطر", + "messengers-comparison-section-list-point-5": "تبادل المفاتيح الثنائي اختياري عبر التحقق من رمز الأمان.", + "messengers-comparison-section-list-point-6": "اتفاقية مفتاح ما بعد الكم \"متفرقة\" — فهي تحمي بعض خطوات ratchet فقط.", + "index-roadmap-2026": "2026", + "index-roadmap-2027": "2027" } diff --git a/website/langs/bg.json b/website/langs/bg.json index fe70e244d9..7661d916a3 100644 --- a/website/langs/bg.json +++ b/website/langs/bg.json @@ -21,7 +21,7 @@ "smp-protocol": "СМП Протокол", "chat-protocol": "Чат протокол", "donate": "Дарете", - "copyright-label": "© 2020-2025 SimpleX | Проект с отворен код", + "copyright-label": "© 2020-2025 SimpleX Chat | Проект с отворен код", "simplex-chat-protocol": "SimpleX Чат протокол", "terminal-cli": "Системна конзола", "terms-and-privacy-policy": "Политика за поверителност", diff --git a/website/langs/cs.json b/website/langs/cs.json index 146626551f..fc609128bf 100644 --- a/website/langs/cs.json +++ b/website/langs/cs.json @@ -25,7 +25,7 @@ "smp-protocol": "SMP protokol", "chat-protocol": "Chat protokol", "donate": "Darovat", - "copyright-label": "© 2020-2025 SimpleX | Projekt s otevřeným zdrojovým kódem", + "copyright-label": "© 2020-2025 SimpleX Chat | Projekt s otevřeným zdrojovým kódem", "simplex-chat-protocol": "SimpleX Chat protokol", "terminal-cli": "Terminálové rozhraní příkazového řádku", "terms-and-privacy-policy": "Ochrana soukromí", @@ -238,7 +238,7 @@ "f-droid-page-simplex-chat-repo-section-text": "Chcete-li jej přidat do vašeho F-Droid clienta, naskenujte QR kód nebo použijte tuto adresu URL:", "f-droid-page-f-droid-org-repo-section-text": "SimpleX Chat a F-Droid.org repozitáře jsou podepsané různými klíči. Chcete-li přepnout, prosím exportujte chat databázi a přeinstalujte aplikaci.", "comparison-section-list-point-4a": "SimpleX relé nemůže ohrozit šifrování e2e. Ověřte bezpečnostní kód, který zmírňuje mimo pásmový útok na kanál", - "docs-dropdown-8": "Služba SimpleX Directory", + "docs-dropdown-8": "SimpleX Directory", "please-enable-javascript": "Prosím, povolte JavaScript k zobrazení QR kódu.", "please-use-link-in-mobile-app": "Prosím použijte odkaz v mobilní aplikaci", "simplex-chat-via-f-droid": "SimpleX Chat přes F-Droid", @@ -255,5 +255,7 @@ "docs-dropdown-11": "FAQ (často kladené dotazy)", "docs-dropdown-12": "Bezpečnost", "hero-overlay-card-3-p-3": "Trail of Bits přezkoumala kryptografický design síťových protokolů SimpleX v červenci 2024. Přečíst více.", - "docs-dropdown-14": "SimpleX pro podnikání" + "docs-dropdown-14": "SimpleX pro podnikání", + "directory": "Složka", + "about-and-contact-us": "O nás & Kontakt" } diff --git a/website/langs/de.json b/website/langs/de.json index ccfb813e6f..18f56b5066 100644 --- a/website/langs/de.json +++ b/website/langs/de.json @@ -21,7 +21,7 @@ "smp-protocol": "SMP-Protokoll", "chat-bot-example": "Beispiel für einen Chatbot", "donate": "Spenden", - "copyright-label": "© 2020-2025 SimpleX | Open-Source-Projekt", + "copyright-label": "© 2020-2025 SimpleX Chat | Open-Source-Projekt", "chat-protocol": "Chat-Protokoll", "simplex-chat-protocol": "SimpleX-Chat-Protokoll", "terminal-cli": "Terminal-Kommandozeilen-Schnittstelle", @@ -92,20 +92,20 @@ "simplex-private-card-8-point-1": "Die SimpleX-Server arbeiten als Mix-Knoten mit geringer Verzögerung — eingehende und ausgehende Nachrichten haben eine unterschiedliche Reihenfolge.", "hero-overlay-card-1-p-3": "Sie definieren, welche(n) Server Sie für den Empfang von Nachrichten nutzen. Ihre Kontakte — nutzen diese Server, um ihnen Nachrichten darüber zu senden. Jede Konversation nutzt üblicherweise also zwei unterschiedliche Server.", "hero-overlay-card-1-p-5": "Die Benutzer-Profile, Kontakte und Gruppen werden nur auf den Endgerät des Nutzers gespeichert. Die Nachrichten werden mit einer 2-Schichten-Ende-zu-Ende-Verschlüsselung versendet.", - "hero-overlay-card-1-p-6": "Lesen Sie mehr darüber im SimpleX-Whitepaper.", + "hero-overlay-card-1-p-6": "Lesen Sie mehr darüber im SimpleX-Whitepaper.", "hero-overlay-card-2-p-2": "Sie können diese Informationen mit bestehenden öffentlichen sozialen Netzwerken korrelieren und damit wahre Identitäten herausfinden.", "hero-overlay-card-2-p-3": "Wenn Sie sich mit zwei unterschiedlichen Kontakten über dasselbe Profil unterhalten, können sie, selbst bei sehr auf Privatsphäre bedachten Apps, die Tor-v3-Dienste nutzen, feststellen, dass diese Kontakte mit derselben Person verbunden sind.", "hero-overlay-card-1-p-2": "Um Nachrichten auszuliefern, nutzt SimpleX statt Benutzerkennungen wie alle anderen Netzwerke nur temporäre, anonyme und paarweise Kennungen für Nachrichten-Warteschlangen, die für jede Ihrer Verbindungen unterschiedlich sind — es gibt keinerlei Langzeit-Kennungen.", "hero-overlay-card-1-p-4": "Dieses Design verhindert schon auf der Applikations-Ebene Datenlecks für jegliche Benutzer'Metadaten. Sie können sich über Tor mit Nachrichten-Servern verbinden, um Ihre Privatsphäre weiter zu verbessern und die von Ihnen genutzte IP-Adresse zu schützen.", "hero-overlay-card-2-p-1": "Wenn Nutzer dauerhafte Identitäten besitzen, selbst wenn diese eine Zufallsnummer, wie eine Sitzungs-ID, ist, besteht ein Risiko, das Provider oder Angreifer feststellen können, wie Nutzer miteinander verbunden sind und wie viele Nachrichten sie versenden.", "hero-overlay-card-2-p-4": "SimpleX schützt gegen solche Angriffe, weil es vom Design her keinerlei Benutzerkennungen besitzt. Und Sie haben sogar unterschiedliche Anzeigenamen für jeden Kontakt und vermeiden jegliche geteilte Daten zwischen diesen, wenn Sie den Inkognito-Modus nutzen.", - "simplex-network-overlay-card-1-p-1": "Peer-to-Peer-Nachrichten-Protokolle und -Applikationen haben verschiedene Probleme, die diese weniger vertrauenswürdig, die Analyse wesentlich komplexer und anfälliger gegen verschiedene Arten von Angriffen, als bei SimpleX machen.", + "simplex-network-overlay-card-1-p-1": "Peer-to-Peer-Nachrichten-Protokolle und -Applikationen haben verschiedene Probleme, die diese weniger vertrauenswürdig, die Analyse wesentlich komplexer und anfälliger gegen verschiedene Arten von Angriffen, als bei SimpleX machen.", "simplex-network-overlay-card-1-li-2": "Das SimpleX Design hat, im Gegensatz zu den meisten P2P-Netzwerken, keinerlei globalen Benutzerkennungen, auch keine temporären. Es nutzt ausschließlich temporäre paarweise Kennungen, die bessere Anonymität und Metadaten-Schutz bieten.", - "simplex-network-overlay-card-1-li-4": "——P2P-Implementierungen können durch Internetanbieter blockiert werden, wie beispielweise BitTorrent). SimpleX ist transportunabhängig — es kann über Standard-Web-Protokolle, wie beispielsweise WebSockets, arbeiten.", - "simplex-network-overlay-card-1-li-6": "P2P-Netzwerke können anfällig für DRDoS-Angriffe sein, wenn die Clients den Datenverkehr erneut senden und verstärken können, was zu einem netzwerkweiten Denial-of-Service führt. SimpleX-Clients leiten nur Datenverkehr von bekannten Verbindungen weiter und können von einem Angreifer nicht dazu verwendet werden, den Datenverkehr im gesamten Netzwerk zu verstärken.", + "simplex-network-overlay-card-1-li-4": "——P2P-Implementierungen können durch Internetanbieter blockiert werden, wie beispielweise BitTorrent). SimpleX ist transportunabhängig — es kann über Standard-Web-Protokolle, wie beispielsweise WebSockets, arbeiten.", + "simplex-network-overlay-card-1-li-6": "P2P-Netzwerke können anfällig für DRDoS-Angriffe sein, wenn die Clients den Datenverkehr erneut senden und verstärken können, was zu einem netzwerkweiten Denial-of-Service führt. SimpleX-Clients leiten nur Datenverkehr von bekannten Verbindungen weiter und können von einem Angreifer nicht dazu verwendet werden, den Datenverkehr im gesamten Netzwerk zu verstärken.", "privacy-matters-overlay-card-1-p-1": "Viele große Unternehmen nutzen Informationen, mit wem Sie in Verbindung stehen, um Ihr Einkommen zu schätzen, Ihnen Produkte zu verkaufen, die Sie nicht wirklich benötigen und um die Preise zu bestimmen.", "privacy-matters-overlay-card-1-p-2": "Online-Händler wissen, dass Menschen mit geringerem Einkommen eher dringende Einkäufe tätigen, sodass sie möglicherweise höhere Preise verlangen können oder Rabatte streichen.", - "privacy-matters-overlay-card-1-p-3": "Einige Finanz- und Versicherungsunternehmen verwenden soziale Graphen, um Zinssätze und Prämien zu ermitteln. Menschen mit niedrigerem Einkommen zahlen so häufig mehr — dies ist als \"Armutsprämie\" bekannt.", + "privacy-matters-overlay-card-1-p-3": "Einige Finanz- und Versicherungsunternehmen verwenden soziale Graphen, um Zinssätze und Prämien zu ermitteln. Menschen mit niedrigerem Einkommen zahlen so häufig mehr — dies ist als \"Armutsprämie\" bekannt.", "privacy-matters-overlay-card-2-p-2": "Um objektiv zu sein und unabhängige Entscheidungen treffen zu können, müssen Sie die Kontrolle über Ihren Informationsraum haben. Dies ist nur möglich, wenn Sie ein privates Kommunikations-Netzwerk verwenden, welches keinen Zugriff auf Ihren sozialen Graphen hat.", "privacy-matters-overlay-card-2-p-3": "SimpleX ist das erste Netzwerk, welches per Design keinerlei Benutzerkennungen hat und auf diese Weise Ihren Verbindungsgraphen besser schützt als jede andere bekannte Alternative.", "privacy-matters-overlay-card-3-p-1": "Jede Person sollte sich um ihre Privatsphäre und die Sicherheit ihrer Kommunikation kümmern — Harmlose Gespräche könnten Sie in Gefahr bringen, selbst wenn Sie nichts zu verbergen haben.", @@ -145,10 +145,10 @@ "the-instructions--source-code": "Für die Anleitungen, wie Sie es herunterladen oder aus dem Quellcode kompilieren.", "if-you-already-installed": "Wenn Sie es schon installiert haben", "simplex-chat-for-the-terminal": "SimpleX Chat für das Terminal", - "privacy-matters-section-header": "Warum es auf Privatsphäre ankommt", + "privacy-matters-section-header": "Warum es auf Privatsphäre ankommt", "privacy-matters-section-label": "Stellen Sie sicher, dass Ihr Messenger keinen Zugriff auf Ihre Daten hat!", "tap-to-close": "Zum Schließen drücken", - "simplex-network-section-header": "SimpleX-Netzwerk", + "simplex-network-section-header": "SimpleX-Netzwerk", "simplex-network-1-header": "Im Gegensatz zu P2P-Netzwerken", "simplex-network-1-desc": "Alle Nachrichten werden über Server versandt, was sowohl einen besseren Schutz der Metadaten als auch eine zuverlässigere asynchrone Nachrichtenübermittlung ermöglicht und dabei Vieles vermeidet", "simplex-network-1-overlay-linktext": "Probleme von P2P-Netzwerken", @@ -175,40 +175,40 @@ "comparison-section-list-point-7": "P2P-Netzwerke haben entweder eine zentrale Verwaltung oder das gesamte Netzwerk kann kompromittiert werden", "see-here": "Siehe hier", "simplex-unique-card-4-p-1": "Das SimpleX-Netzwerk ist vollständig dezentralisiert und unabhängig von Kryptowährungen oder anderen Netzwerken außer dem Internet.", - "privacy-matters-overlay-card-2-p-1": "Vor nicht allzu langer Zeit beobachteten wir, wie große Wahlen von einem angesehenen Beratungsunternehmen manipuliert wurden, welches unsere sozialen Graphen nutzte, um unsere Sicht auf die reale Welt zu verzerren und unsere Stimmen zu manipulieren.", - "privacy-matters-overlay-card-3-p-2": "Eine der schockierendsten Geschichten ist die Erfahrung von Mohamedou Ould Salahi, welche in seinen Memoiren beschrieben und im Film „The Mauritanian“ gezeigt wird. Er kam nach einem Anruf bei seinen Verwandten in Afghanistan und ohne Gerichtsverfahren in das Guantanamo-Lager und wurde dort einige Jahre lang gefoltert, weil er verdächtigt wurde, an den 9/11-Angriffen beteiligt gewesen zu sein, obwohl er die vorhergehenden 10 Jahre in Deutschland gelebt hatte.", + "privacy-matters-overlay-card-2-p-1": "Vor nicht allzu langer Zeit beobachteten wir, wie große Wahlen von einem angesehenen Beratungsunternehmen manipuliert wurden, welches unsere sozialen Graphen nutzte, um unsere Sicht auf die reale Welt zu verzerren und unsere Stimmen zu manipulieren.", + "privacy-matters-overlay-card-3-p-2": "Eine der schockierendsten Geschichten ist die Erfahrung von Mohamedou Ould Salahi, welche in seinen Memoiren beschrieben und im Film „The Mauritanian“ gezeigt wird. Er kam nach einem Anruf bei seinen Verwandten in Afghanistan und ohne Gerichtsverfahren in das Guantanamo-Lager und wurde dort einige Jahre lang gefoltert, weil er verdächtigt wurde, an den 9/11-Angriffen beteiligt gewesen zu sein, obwohl er die vorhergehenden 10 Jahre in Deutschland gelebt hatte.", "simplex-unique-overlay-card-1-p-1": "Im Gegensatz zu anderen Nachrichten-Netzwerken weist SimpleX den Benutzern keine Kennungen zu. Es verlässt sich nicht auf Telefonnummern, domänenbasierte Adressen (wie E-Mail oder XMPP), Benutzernamen, öffentliche Schlüssel oder Zufallszahlen, um seine Benutzer zu identifizieren — selbst SimpleX-Server-Betreiber wissen nicht, wie viele Personen deren Server verwenden.", - "simplex-unique-overlay-card-1-p-2": "Um Nachrichten auszuliefern nutzt SimpleX paarweise anonyme Adressen aus unidirektionalen Nachrichten-Warteschlangen, die für empfangene und gesendete Nachrichten separiert sind und gewöhnlich über verschiedene Server gesendet werden.", - "simplex-network-overlay-card-1-li-5": "Alle bekannten P2P-Netzwerke können anfällig für Sybil-Angriffe sein, da jeder Knoten ermittelbar ist und das Netzwerk als Ganzes funktioniert. Bekannte Maßnahmen zur Verhinderung erfordern entweder eine zentralisierte Komponente oder einen teuren Ausführungsnachweis. Das SimpleX-Netzwerk bietet keine Ermittlung der Server, ist fragmentiert und arbeitet mit mehreren isolierten Subnetzwerken, wodurch netzwerkweite Angriffe unmöglich werden.", - "simplex-network-overlay-card-1-li-3": "P2P löst nicht das Problem des MITM-Angriffs und die meisten bestehenden Implementierungen nutzen für den initialen Schlüsselaustausch keine Out-of-Band-Nachrichten. Im Gegensatz hierzu nutzt SimpleX für den initialen Schlüsselaustausch Out-of-Band-Nachrichten oder zum Teil schon bestehende sichere und vertrauenswürdige Verbindungen.", - "tap-the-connect-button-in-the-app": "Drücken Sie die „Verbinden“-Taste in der Applikation", + "simplex-unique-overlay-card-1-p-2": "Um Nachrichten auszuliefern nutzt SimpleX paarweise anonyme Adressen aus unidirektionalen Nachrichten-Warteschlangen, die für empfangene und gesendete Nachrichten separiert sind und gewöhnlich über verschiedene Server gesendet werden.", + "simplex-network-overlay-card-1-li-5": "Alle bekannten P2P-Netzwerke können anfällig für Sybil-Angriffe sein, da jeder Knoten ermittelbar ist und das Netzwerk als Ganzes funktioniert. Bekannte Maßnahmen zur Verhinderung erfordern entweder eine zentralisierte Komponente oder einen teuren Ausführungsnachweis. Das SimpleX-Netzwerk bietet keine Ermittlung der Server, ist fragmentiert und arbeitet mit mehreren isolierten Subnetzwerken, wodurch netzwerkweite Angriffe unmöglich werden.", + "simplex-network-overlay-card-1-li-3": "P2P löst nicht das Problem des MITM-Angriffs und die meisten bestehenden Implementierungen nutzen für den initialen Schlüsselaustausch keine Out-of-Band-Nachrichten. Im Gegensatz hierzu nutzt SimpleX für den initialen Schlüsselaustausch Out-of-Band-Nachrichten oder zum Teil schon bestehende sichere und vertrauenswürdige Verbindungen.", + "tap-the-connect-button-in-the-app": "Drücken Sie die „Verbinden“-Taste in der Applikation", "to-make-a-connection": "Um eine Verbindung zu starten:", "contact-hero-p-3": "Nutzen Sie die unten genannten Links, um die Applikation herunterzuladen.", "scan-the-qr-code-with-the-simplex-chat-app": "Scannen Sie den QR-Code mit der SimpleX-Chat-Applikation", "copy-the-command-below-text": "Kopieren Sie sich das unten genannte Kommando und nutzen Sie es im Chat:", - "privacy-matters-section-subheader": "Die Wahrung der Privatsphäre Ihrer Metadaten — mit wem Sie wann Kontakt haben — schützt Sie vor:", - "simplex-private-section-header": "Was macht SimpleX vertraulich", + "privacy-matters-section-subheader": "Die Wahrung der Privatsphäre Ihrer Metadaten — mit wem Sie wann Kontakt haben — schützt Sie vor:", + "simplex-private-section-header": "Was macht SimpleX vertraulich", "simplex-network-section-desc": "Durch die Kombination der Vorteile von P2P und föderierten Netzwerken stellt SimpleX die bestmögliche Privatsphäre zur Verfügung.", "simplex-network-2-header": "Im Gegensatz zu föderierten Netzwerken", "comparison-section-list-point-1": "Normalerweise auf der Grundlage einer Telefonnummer, in einigen Fällen auf der Grundlage von Benutzernamen", "comparison-point-5-text": "Zentrale Komponente oder andere Netzwerk-weite Angriffe", "no-decentralized": "Nein – dezentralisiert", "comparison-section-list-point-5": "Die Privatsphäre-Metadaten des Nutzers werden nicht geschützt", - "simplex-network-overlay-card-1-li-1": "P2P-Netzwerke vertrauen auf Varianten von DHT, um Nachrichten zu routen. DHT-Designs müssen zwischen Zustellungsgarantie und Latenz ausgleichen. Verglichen mit P2P bietet SimpleX sowohl eine bessere Zustellungsgarantie als auch eine niedrigere Latenz, weil eine Nachricht redundant und parallel über mehrere Server gesendet werden kann, wobei die durch den Empfänger ausgewählten Server genutzt werden. In P2P-Netzwerken werden Nachrichten sequentiell über O(log N)-Knoten gesendet, wobei die Knoten durch einen Algorithmus ausgewählt werden.", + "simplex-network-overlay-card-1-li-1": "P2P-Netzwerke vertrauen auf Varianten von DHT, um Nachrichten zu routen. DHT-Designs müssen zwischen Zustellungsgarantie und Latenz ausgleichen. Verglichen mit P2P bietet SimpleX sowohl eine bessere Zustellungsgarantie als auch eine niedrigere Latenz, weil eine Nachricht redundant und parallel über mehrere Server gesendet werden kann, wobei die durch den Empfänger ausgewählten Server genutzt werden. In P2P-Netzwerken werden Nachrichten sequentiell über O(log N)-Knoten gesendet, wobei die Knoten durch einen Algorithmus ausgewählt werden.", "simplex-unique-overlay-card-3-p-4": "Zwischen dem gesendeten und empfangenen Serververkehr gibt es keine gemeinsamen Kennungen oder Chiffriertexte — sodass ein Beobachter nicht ohne weiteres feststellen kann, wer mit wem kommuniziert, selbst wenn TLS kompromittiert wurde.", - "simplex-unique-overlay-card-4-p-3": "Falls Sie Interesse daran haben, aktiv bei der Entwicklung des SimpleX-Netzwerks mitzuhelfen, z.B. einen Chatbot für SimpleX-App-Nutzer zu entwickeln oder die Integration der SimpleX-Chat-Bibliothek in mobile Apps voranzutreiben, kontaktieren Sie uns bitte für eine weitere Beratung und Unterstützung.", + "simplex-unique-overlay-card-4-p-3": "Falls Sie Interesse daran haben, aktiv bei der Entwicklung des SimpleX-Netzwerks mitzuhelfen, z.B. einen Chatbot für SimpleX-App-Nutzer zu entwickeln oder die Integration der SimpleX-Chat-Bibliothek in mobile Apps voranzutreiben, kontaktieren Sie uns bitte für eine weitere Beratung und Unterstützung.", "privacy-matters-overlay-card-1-p-4": "Das SimpleX-Netzwerk schützt die Privatsphäre Ihrer Verbindungen besser als jede Alternative und verhindert vollständig, dass Ihr sozialer Graph für Unternehmen oder Organisationen verfügbar wird. Selbst wenn Anwender die in der SimpleX-Chat-App vorkonfigurierten Server verwenden, kennen die Server-Betreiber die Anzahl der Benutzer oder deren Verbindungen nicht.", "contact-hero-header": "Sie haben eine Adresse zur Verbindung mit SimpleX Chat erhalten", "invitation-hero-header": "Sie haben einen Einmal-Link zur Verbindung mit SimpleX Chat erhalten", - "privacy-matters-overlay-card-3-p-3": "Normale Menschen werden für das, was sie online teilen, sogar unter Nutzung ihrer „anonymen“ Konten, selbst in demokratischen Ländern verhaftet.", + "privacy-matters-overlay-card-3-p-3": "Normale Menschen werden für das, was sie online teilen, sogar unter Nutzung ihrer „anonymen“ Konten, selbst in demokratischen Ländern verhaftet.", "simplex-unique-overlay-card-3-p-1": "SimpleX Chat speichert alle Benutzerdaten ausschließlich auf den Endgeräten in einem portablen und verschlüsselten Datenbankformat, welches exportiert und auf jedes unterstützte Gerät übertragen werden kann.", "simplex-unique-overlay-card-2-p-2": "Auch wenn die optionale Benutzeradresse zum Versenden von Spam-Kontaktanfragen verwendet werden kann, können Sie sie ändern oder ganz löschen, ohne dass Ihre Verbindungen verloren gehen.", - "simplex-unique-overlay-card-4-p-2": "Das SimpleX-Netzwerk verwendet ein offenes Protokoll und bietet ein SDK zur Erstellung von Chatbots an. Dies ermöglicht die Erstellung von Diensten, mit denen Nutzer über SimpleX-Chat-Apps interagieren können — wir sind gespannt, welche SimpleX-Dienste Sie entwickeln werden.", + "simplex-unique-overlay-card-4-p-2": "Das SimpleX-Netzwerk verwendet ein offenes Protokoll und bietet ein SDK zur Erstellung von Chatbots an. Dies ermöglicht die Erstellung von Diensten, mit denen Nutzer über SimpleX-Chat-Apps interagieren können — wir sind gespannt, welche SimpleX-Dienste Sie entwickeln werden.", "simplex-unique-card-4-p-2": "Sie können SimpleX mit Ihren eigenen Servern oder mit den von uns zur Verfügung gestellten Servern verwenden — und sich trotzdem mit jedem Benutzer verbinden.", - "why-simplex-is-unique": "Warum ist SimpleX einmalig", + "why-simplex-is-unique": "Warum ist SimpleX einmalig", "contact-hero-p-1": "Die öffentlichen Schlüssel und die Adresse der Nachrichtenwarteschlange in diesem Link werden NICHT über das Netzwerk gesendet, wenn Sie diese Seite aufrufen — sie sind in dem Hash-Fragment der Link-URL enthalten.", "if-you-already-installed-simplex-chat-for-the-terminal": "Wenn Sie SimpleX schon für das Terminal installiert haben", - "simplex-network-3-desc": "Die Server stellen unidirektionale Warteschlangen zur Verfügung, um die Benutzer miteinander zu verbinden. Sie haben aber keinen Einblick in den Verbindungs-Graphen des Netzwerks — Diesen haben nur die Benutzer selbst.", + "simplex-network-3-desc": "Die Server stellen unidirektionale Warteschlangen zur Verfügung, um die Benutzer miteinander zu verbinden. Sie haben aber keinen Einblick in den Verbindungs-Graphen des Netzwerks — Diesen haben nur die Benutzer selbst.", "guide-dropdown-1": "Schnellstart", "guide-dropdown-2": "Nachrichten senden", "guide-dropdown-4": "Chat-Profile", @@ -235,18 +235,18 @@ "signing-key-fingerprint": "Fingerabdruck des Signaturschlüssels (SHA-256)", "f-droid-org-repo": "F-Droid.org-Repository", "stable-versions-built-by-f-droid-org": "Von F-Droid.org erstellte stabile Versionen", - "f-droid-page-f-droid-org-repo-section-text": "SimpleX-Chat- und F-Droid.org-Repositorys signieren ihre Builds mit verschiedenen Schlüsseln. Zum Umschalten bitte die Chat-Datenbank exportieren und die App neu installieren.", + "f-droid-page-f-droid-org-repo-section-text": "SimpleX-Chat- und F-Droid.org-Repositorys signieren ihre Builds mit verschiedenen Schlüsseln. Zum Umschalten bitte die Chat-Datenbank exportieren und die App neu installieren.", "releases-to-this-repo-are-done-1-2-days-later": "Die Versionen für dieses Repository werden einige Tage später erstellt", - "docs-dropdown-8": "SimpleX-Verzeichnisdienst", + "docs-dropdown-8": "SimpleX-Verzeichnis", "simplex-chat-via-f-droid": "SimpleX Chat per F-Droid", "simplex-chat-repo": "SimpleX-Chat-Repository", "stable-and-beta-versions-built-by-developers": "Von den Entwicklern erstellte stabile und Beta-Versionen", - "f-droid-page-simplex-chat-repo-section-text": "Um es Ihrem F-Droid-Client hinzuzufügen, scannen Sie den QR-Code oder nutzen Sie diese URL:", + "f-droid-page-simplex-chat-repo-section-text": "Um es Ihrem F-Droid-Client hinzuzufügen, scannen Sie den QR-Code oder nutzen Sie diese URL:", "comparison-section-list-point-4a": "SimpleX-Relais können die E2E-Verschlüsselung nicht kompromittieren. Überprüfen Sie den Sicherheitscode, um einen möglichen Angriff auf den Out-of-Band-Kanal zu entschärfen", - "hero-overlay-3-title": "Sicherheits-Gutachten", + "hero-overlay-3-title": "Security-Gutachten", "hero-overlay-card-3-p-2": "Trail of Bits untersuchte im November 2022 die kryptografischen und Netzwerk-Komponenten der SimpleX-Plattform. Lesen Sie mehr dazu in der Ankündigung.", "jobs": "Treten Sie dem Team bei", - "hero-overlay-3-textlink": "Sicherheits-Gutachten", + "hero-overlay-3-textlink": "Security-Gutachten", "hero-overlay-card-3-p-1": "Trail of Bits ist eine führende Security- und Technologie-Unternehmensberatung, deren Kunden aus den Bereichen Big-Tech, Regierungsbehörden und großen Blockchain-Projekten stammen.", "docs-dropdown-9": "Downloads", "please-enable-javascript": "Bitte aktivieren Sie JavaScript, um den QR-Code zu sehen.", @@ -255,5 +255,61 @@ "docs-dropdown-11": "FAQ", "docs-dropdown-12": "Sicherheit", "hero-overlay-card-3-p-3": "Trail of Bits hat das kryptografische Design des Netzwerk-Protokolls von SimpleX im Juli 2024 überprüft. Hier finden Sie weitere Informationen dazu.", - "docs-dropdown-14": "SimpleX für geschäftliche Anwendungen" + "docs-dropdown-14": "SimpleX für geschäftliche Anwendungen", + "about-and-contact-us": "Über uns & Kontakt", + "directory": "Verzeichnis", + "index-hero-h1": "Sei
frei", + "index-hero-h2": "Freiheit & Sicherheit
Ihrer Kommunikation", + "index-hero-p1": "Das erste Netzwerk, in dem Sie Ihre Identität, Kontakte und Gruppen besitzen.", + "index-hero-download-desktop-btn-title": "Download der SimpleX Desktop-App", + "index-testflight-title": "Öffentlicher iOS-Preview auf TestFlight", + "index-f-droid-title": "SimpleX-App über das F-Droid-Repository", + "index-security-assessment-title": "Security-Gutachten", + "index-security-review-2022-title": "Security-Überprüfung 2022", + "index-security-review-2024-title": "Security-Überprüfung 2024", + "index-security-audits-label": "Security-
Audits", + "index-publications-privacy-guides-title": "Messenger-Empfehlungen von Privacy Guides", + "index-publications-whonix-title": "Whonix - Empfehlungen für Messenger", + "index-publications-heise-title": "Veröffentlichungen von Heise Online", + "index-publications-kuketz-title": "Überprüfung von Mike Kuketz", + "index-publications-optout-title": "Podcast-Interview von OptOut", + "worlds-most-secure-messaging": "Das sicherste Messaging-System der Welt", + "index-messaging-p1": "SimpleX-Messaging verfügt über modernste Ende-zu-Ende-Verschlüsselung.", + "index-messaging-p2": "Zu Ihrer Sicherheit und zum Schutz Ihrer Privatsphäre können Server Ihre Nachrichten weder sehen, noch mit wem Sie kommunizieren.", + "index-messaging-cta": "Lernen Sie mehr über SimpleX-Messaging", + "index-nextweb-h2": "Sie besitzen
The Next Web", + "index-nextweb-p1": "SimpleX basiert auf der Überzeugung, dass Sie Eigentümer Ihrer Identität, Ihrer Kontakte und Ihrer Communities sein müssen.", + "index-nextweb-p2": "Ein offenes und dezentrales Netzwerk ermöglicht es Ihnen, mit Menschen in Kontakt zu treten und Ideen auszutauschen: Seien Sie frei und sicher.", + "index-token-h2": "Communities, die Bestand haben", + "index-token-p1": "Sie werden Ihre Lieblingsgruppen mit zukünftigen Community-Gutscheinen unterstützen.", + "index-token-p2": "Server werden mit Gutscheinen bezahlt, damit Ihre Communities kostenlos und unabhängig bleiben können.", + "index-token-cta": "Erfahren Sie mehr und holen Sie sich Ihren kostenlosen NFT zum frühzeitigen ausprobieren.", + "index-roadmap-h2": "SimpleX - Roadmap zum freien Internet", + "index-roadmap-2025": "2025", + "index-roadmap-2025-title": "Skalierung auf große Communities", + "index-roadmap-2025-desc": "Ausstieg aus zentralisierten Plattformen", + "index-roadmap-2026": "2026", + "index-roadmap-2026-title": "Nachhaltige Communities & Server", + "index-roadmap-2026-desc": "Einführung von Community-Gutscheinen", + "index-roadmap-2027": "2027", + "index-roadmap-2027-title": "Lassen Sie Ihre Communities wachsen", + "index-roadmap-2027-desc": "Tools zur Förderung Ihrer Communities", + "index-directory-h2": "Treten Sie SimpleX-Communities bei", + "index-directory-p1": "Hunderttausende Menschen vertrauen bereits SimpleX-Messaging.", + "index-directory-p2": "Finden Sie Ihre Communities im SimpleX-Verzeichnis und erstellen Sie Ihre Eigenen!", + "index-directory-cta": "SimpleX-Verzeichnis anzeigen", + "index-directory-users-group-title": "SimpleX-Nutzergruppe", + "how-secure-comparison-title": "Sicherheitsvergleich der Ende-zu-Ende-Verschlüsselung in verschiedenen Messengern", + "how-secure-message-padding": "Nachrichten-Padding", + "how-secure-repudiation-deniability": "Glaubhafte Abstreitbarkeit", + "how-secure-forward-secrecy": "Forward Secrecy", + "how-secure-break-in-recovery": "Sicherheit nach Kompromittierung", + "how-secure-two-factor-key-exchange": "2-Faktor-Schlüsselaustausch", + "how-secure-post-quantum-hybrid-crypto": "Post-Quanten-Hybridkryptografie", + "messengers-comparison-section-list-point-1": "Briar verwendet Padding, um Nachrichten auf eine Länge von mindestens 1024 Byte aufzurunden; Signal - auf 160 Byte", + "messengers-comparison-section-list-point-2": "Abstreitbarkeit gilt nicht für die Client-Server-Verbindung.", + "messengers-comparison-section-list-point-3": "Es scheint, dass die Verwendung kryptografischer Signaturen die Abstreitbarkeit beeinträchtigt, aber dies muss noch geklärt werden.", + "messengers-comparison-section-list-point-4": "Die Multi-Geräte-Implementierung beeinträchtigt die Sicherheit nach Kompromittierung des Double Ratchet-Algorithmus", + "messengers-comparison-section-list-point-5": "Ein 2-Faktor-Schlüsselaustausch ist optional und kann über die Überprüfung eines Sicherheitscodes erfolgen.", + "messengers-comparison-section-list-point-6": "Die Schlüsselvereinbarung per Post-Quanten-Security ist nicht durchgängig — Sie schützt lediglich ausgewählte Schritte innerhalb des Ratchet-Prozesses." } diff --git a/website/langs/en.json b/website/langs/en.json index f9691e2594..dc97d9ab66 100644 --- a/website/langs/en.json +++ b/website/langs/en.json @@ -1,9 +1,11 @@ { "home": "Home", + "directory": "Directory", "developers": "Developers", "reference": "Reference", "blog": "Blog", "features": "Features", + "navbar-token": "Token", "why-simplex": "Why SimpleX", "simplex-privacy": "SimpleX privacy", "simplex-network": "SimpleX network", @@ -14,16 +16,17 @@ "simplex-explained-tab-1-p-1": "You can create contacts and groups, and have two-way conversations, as in any other messenger.", "simplex-explained-tab-1-p-2": "How can it work with unidirectional queues and without user profile identifiers?", "simplex-explained-tab-2-p-1": "For each connection you use two separate messaging queues to send and receive messages via different servers.", - "simplex-explained-tab-2-p-2": "Servers only pass messages one way, without having the full picture of user's conversations or connections.", + "simplex-explained-tab-2-p-2": "Servers only pass messages one way, without having the full picture of user's conversations or connections.", "simplex-explained-tab-3-p-1": "The servers have separate anonymous credentials for each queue, and do not know which users they belong to.", "simplex-explained-tab-3-p-2": "Users can further improve metadata privacy by using Tor to access servers, preventing corellation by IP address.", "chat-bot-example": "Chat bot example", "smp-protocol": "SMP protocol", "chat-protocol": "Chat protocol", "donate": "Donate", - "copyright-label": "© 2020-2025 SimpleX | Open-Source Project", + "copyright-label": "© 2020-2025 SimpleX Chat | Open-Source Project", "simplex-chat-protocol": "SimpleX Chat protocol", "terminal-cli": "Terminal CLI", + "about-and-contact-us": "About & Contact us", "terms-and-privacy-policy": "Privacy Policy", "hero-header": "Privacy redefined", "hero-subheader": "The first messenger
without user IDs", @@ -96,7 +99,7 @@ "hero-overlay-card-1-p-3": "You define which server(s) to use to receive the messages, your contacts — the servers you use to send the messages to them. Every conversation is likely to use two different servers.", "hero-overlay-card-1-p-4": "This design prevents leaking any users' metadata on the application level. To further improve privacy and protect your IP address you can connect to messaging servers via Tor.", "hero-overlay-card-1-p-5": "Only client devices store user profiles, contacts and groups; the messages are sent with 2-layer end-to-end encryption.", - "hero-overlay-card-1-p-6": "Read more in SimpleX whitepaper.", + "hero-overlay-card-1-p-6": "Read more in SimpleX whitepaper.", "hero-overlay-card-2-p-1": "When users have persistent identities, even if this is just a random number, like a Session ID, there is a risk that the provider or an attacker can observe how the users are connected and how many messages they send.", "hero-overlay-card-2-p-2": "They could then correlate this information with the existing public social networks, and determine some real identities.", "hero-overlay-card-2-p-3": "Even with the most private apps that use Tor v3 services, if you talk to two different contacts via the same profile they can prove that they are connected to the same person.", @@ -104,26 +107,26 @@ "hero-overlay-card-3-p-1": "Trail of Bits is a leading security and technology consultancy whose clients include big tech, governmental agencies and major blockchain projects.", "hero-overlay-card-3-p-2": "Trail of Bits reviewed SimpleX network cryptography and networking components in November 2022. Read more.", "hero-overlay-card-3-p-3": "Trail of Bits reviewed cryptographic design of SimpleX network protocols in July 2024. Read more.", - "simplex-network-overlay-card-1-p-1": "P2P messaging protocols and apps have various problems that make them less reliable than SimpleX, more complex to analyse, and vulnerable to several types of attack.", - "simplex-network-overlay-card-1-li-1": "P2P networks rely on some variant of DHT to route messages. DHT designs have to balance delivery guarantee and latency. SimpleX has both better delivery guarantee and lower latency than P2P, because the message can be redundantly passed via several servers in parallel, using the servers chosen by the recipient. In P2P networks the message is passed through O(log N) nodes sequentially, using nodes chosen by the algorithm.", + "simplex-network-overlay-card-1-p-1": "P2P messaging protocols and apps have various problems that make them less reliable than SimpleX, more complex to analyse, and vulnerable to several types of attack.", + "simplex-network-overlay-card-1-li-1": "P2P networks rely on some variant of DHT to route messages. DHT designs have to balance delivery guarantee and latency. SimpleX has both better delivery guarantee and lower latency than P2P, because the message can be redundantly passed via several servers in parallel, using the servers chosen by the recipient. In P2P networks the message is passed through O(log N) nodes sequentially, using nodes chosen by the algorithm.", "simplex-network-overlay-card-1-li-2": "SimpleX design, unlike most P2P networks, has no global user identifiers of any kind, even temporary, and only uses temporary pairwise identifiers, providing better anonymity and metadata protection.", - "simplex-network-overlay-card-1-li-3": "P2P does not solve MITM attack problem, and most existing implementations do not use out-of-band messages for the initial key exchange. SimpleX uses out-of-band messages or, in some cases, pre-existing secure and trusted connections for the initial key exchange.", - "simplex-network-overlay-card-1-li-4": "P2P implementations can be blocked by some Internet providers (like BitTorrent). SimpleX is transport agnostic — it can work over standard web protocols, e.g. WebSockets.", - "simplex-network-overlay-card-1-li-5": "All known P2P networks may be vulnerable to Sybil attack, because each node is discoverable, and the network operates as a whole. Known measures to mitigate it require either a centralized component or expensive proof of work. SimpleX network has no server discoverability, it is fragmented and operates as multiple isolated sub-networks, making network-wide attacks impossible.", - "simplex-network-overlay-card-1-li-6": "P2P networks may be vulnerable to DRDoS attack, when the clients can rebroadcast and amplify traffic, resulting in network-wide denial of service. SimpleX clients only relay traffic from known connection and cannot be used by an attacker to amplify the traffic in the whole network.", - "privacy-matters-overlay-card-1-p-1": "Many large companies use information about who you are connected with to estimate your income, sell you the products you don't really need, and to determine the prices.", + "simplex-network-overlay-card-1-li-3": "P2P does not solve MITM attack problem, and most existing implementations do not use out-of-band messages for the initial key exchange. SimpleX uses out-of-band messages or, in some cases, pre-existing secure and trusted connections for the initial key exchange.", + "simplex-network-overlay-card-1-li-4": "P2P implementations can be blocked by some Internet providers (like BitTorrent). SimpleX is transport agnostic — it can work over standard web protocols, e.g. WebSockets.", + "simplex-network-overlay-card-1-li-5": "All known P2P networks may be vulnerable to Sybil attack, because each node is discoverable, and the network operates as a whole. Known measures to mitigate it require either a centralized component or expensive proof of work. SimpleX network has no server discoverability, it is fragmented and operates as multiple isolated sub-networks, making network-wide attacks impossible.", + "simplex-network-overlay-card-1-li-6": "P2P networks may be vulnerable to DRDoS attack, when the clients can rebroadcast and amplify traffic, resulting in network-wide denial of service. SimpleX clients only relay traffic from known connection and cannot be used by an attacker to amplify the traffic in the whole network.", + "privacy-matters-overlay-card-1-p-1": "Many large companies use information about who you are connected with to estimate your income, sell you the products you don't really need, and to determine the prices.", "privacy-matters-overlay-card-1-p-2": "Online retailers know that people with lower incomes are more likely to make urgent purchases, so they may charge higher prices or remove discounts.", - "privacy-matters-overlay-card-1-p-3": "Some financial and insurance companies use social graphs to determine interest rates and premiums. It often makes people with lower incomes pay more — it is known as 'poverty premium'.", + "privacy-matters-overlay-card-1-p-3": "Some financial and insurance companies use social graphs to determine interest rates and premiums. It often makes people with lower incomes pay more — it is known as \"poverty premium\".", "privacy-matters-overlay-card-1-p-4": "SimpleX network protects the privacy of your connections better than any alternative, fully preventing your social graph becoming available to any companies or organizations. Even when people use servers preconfigured in SimpleX Chat apps, server operators do not know the number of users or their connections.", - "privacy-matters-overlay-card-2-p-1": "Not so long ago we observed the major elections being manipulated by a reputable consulting company that used our social graphs to distort our view of the real world and manipulate our votes.", + "privacy-matters-overlay-card-2-p-1": "Not so long ago we observed the major elections being manipulated by a reputable consulting company that used our social graphs to distort our view of the real world and manipulate our votes.", "privacy-matters-overlay-card-2-p-2": "To be objective and to make independent decisions you need to be in control of your information space. It is only possible if you use private communication network that does not have access to your social graph.", - "privacy-matters-overlay-card-2-p-3": "SimpleX is the first network that doesn't have any user identifiers by design, in this way protecting your connections graph better than any known alternative.", + "privacy-matters-overlay-card-2-p-3": "SimpleX is the first network that doesn't have any user identifiers by design, in this way protecting your connections graph better than any known alternative.", "privacy-matters-overlay-card-3-p-1": "Everyone should care about privacy and security of their communications — harmless conversations can put you in danger, even if you have nothing to hide.", - "privacy-matters-overlay-card-3-p-2": "One of the most shocking stories is the experience of Mohamedou Ould Salahi described in his memoir and shown in The Mauritanian movie. He was put into Guantanamo camp, without trial, and was tortured there for 15 years after a phone call to his relative in Afghanistan, under suspicion of being involved in 9/11 attacks, even though he lived in Germany for the previous 10 years.", - "privacy-matters-overlay-card-3-p-3": "Ordinary people get arrested for what they share online, even via their 'anonymous' accounts, even in democratic countries.", + "privacy-matters-overlay-card-3-p-2": "One of the most shocking stories is the experience of Mohamedou Ould Salahi described in his memoir and shown in The Mauritanian movie. He was put into Guantanamo camp, without trial, and was tortured there for 15 years after a phone call to his relative in Afghanistan, under suspicion of being involved in 9/11 attacks, even though he lived in Germany for the previous 10 years.", + "privacy-matters-overlay-card-3-p-3": "Ordinary people get arrested for what they share online, even via their \"anonymous\" accounts, even in democratic countries.", "privacy-matters-overlay-card-3-p-4": "It is not enough to use an end-to-end encrypted messenger, we all should use the messengers that protect the privacy of our personal networks — who we are connected with.", "simplex-unique-overlay-card-1-p-1": "Unlike other messaging networks, SimpleX has no identifiers assigned to the users. It does not rely on phone numbers, domain-based addresses (like email or XMPP), usernames, public keys or even random numbers to identify its users — SimpleX server operators don't know how many people use their servers.", - "simplex-unique-overlay-card-1-p-2": "To deliver messages SimpleX uses pairwise anonymous addresses of unidirectional message queues, separate for received and sent messages, usually via different servers.", + "simplex-unique-overlay-card-1-p-2": "To deliver messages SimpleX uses pairwise anonymous addresses of unidirectional message queues, separate for received and sent messages, usually via different servers.", "simplex-unique-overlay-card-1-p-3": "This design protects the privacy of who you are communicating with, hiding it from SimpleX network servers and from any observers. To hide your IP address from the servers, you can connect to SimpleX servers via Tor.", "simplex-unique-overlay-card-2-p-1": "Because you have no identifier on the SimpleX network, nobody can contact you unless you share a one-time or temporary user address, as a QR code or a link.", "simplex-unique-overlay-card-2-p-2": "Even with the optional user address, while it can be used to send spam contact requests, you can change or completely delete it without losing any of your connections.", @@ -132,8 +135,8 @@ "simplex-unique-overlay-card-3-p-3": "Unlike federated networks servers (email, XMPP or Matrix), SimpleX servers don't store user accounts, they only relay messages, protecting the privacy of both parties.", "simplex-unique-overlay-card-3-p-4": "There are no identifiers or ciphertext in common between sent and received server traffic — if anybody is observing it, they cannot easily determine who communicates with whom, even if TLS is compromised.", "simplex-unique-overlay-card-4-p-1": "You can use SimpleX with your own servers and still communicate with people who use the servers preconfigured in the apps.", - "simplex-unique-overlay-card-4-p-2": "SimpleX network uses an open protocol and provides SDK to create chat bots, allowing implementation of services that users can interact with via SimpleX Chat apps — we're really looking forward to see what SimpleX services you will build.", - "simplex-unique-overlay-card-4-p-3": "If you are considering developing for the SimpleX network, for example, the chat bot for SimpleX app users, or the integration of the SimpleX Chat library into your mobile apps, please get in touch for any advice and support.", + "simplex-unique-overlay-card-4-p-2": "SimpleX network uses an open protocol and provides SDK to create chat bots, allowing implementation of services that users can interact with via SimpleX Chat apps — we're really looking forward to see what SimpleX services you will build.", + "simplex-unique-overlay-card-4-p-3": "If you are considering developing for the SimpleX network, for example, the chat bot for SimpleX app users, or the integration of the SimpleX Chat library into your mobile apps, please get in touch for any advice and support.", "simplex-unique-card-1-p-1": "SimpleX protects the privacy of your profile, contacts and metadata, hiding it from SimpleX network servers and any observers.", "simplex-unique-card-1-p-2": "Unlike any other existing messaging network, SimpleX has no identifiers assigned to the users — not even random numbers.", "simplex-unique-card-2-p-1": "Because you have no identifier or fixed address on the SimpleX network, nobody can contact you unless you share a one-time or temporary user address, as a QR code or a link.", @@ -149,7 +152,7 @@ "sign-up-to-receive-our-updates": "Sign up to receive our updates", "enter-your-email-address": "Enter your email address", "get-simplex": "Get SimpleX desktop app", - "why-simplex-is-unique": "Why SimpleX is unique", + "why-simplex-is-unique": "Why SimpleX is unique", "learn-more": "Learn more", "more-info": "More info", "hide-info": "Hide info", @@ -164,7 +167,7 @@ "install-simplex-app": "Install SimpleX app", "connect-in-app": "Connect in app", "open-simplex-app": "Open Simplex app", - "tap-the-connect-button-in-the-app": "Tap the ‘connect’ button in the app", + "tap-the-connect-button-in-the-app": "Tap the \"connect\" button in the app", "scan-the-qr-code-with-the-simplex-chat-app": "Scan the QR code with the SimpleX Chat app", "scan-the-qr-code-with-the-simplex-chat-app-description": "The public keys and message queue address in this link are NOT sent over the network when you view this page —
they are contained in the hash fragment of the link URL.", "installing-simplex-chat-to-terminal": "Installing SimpleX chat to terminal", @@ -176,12 +179,12 @@ "if-you-already-installed": "If you already installed", "simplex-chat-for-the-terminal": "SimpleX Chat for the terminal", "copy-the-command-below-text": "copy the command below and use it in the chat:", - "privacy-matters-section-header": "Why privacy matters", - "privacy-matters-section-subheader": "Preserving the privacy of your metadata — who you talk with — protects you from:", - "privacy-matters-section-label": "Make sure your messenger can't access your data!", - "simplex-private-section-header": "What makes SimpleX private", + "privacy-matters-section-header": "Why privacy matters", + "privacy-matters-section-subheader": "Preserving the privacy of your metadata — who you talk with — protects you from:", + "privacy-matters-section-label": "Make sure your messenger can't access your data!", + "simplex-private-section-header": "What makes SimpleX private", "tap-to-close": "Tap to close", - "simplex-network-section-header": "SimpleX Network", + "simplex-network-section-header": "SimpleX Network", "simplex-network-section-desc": "Simplex Chat provides the best privacy by combining the advantages of P2P and federated networks.", "simplex-network-1-header": "Unlike P2P networks", "simplex-network-1-desc": "All messages are sent via the servers, both providing better metadata privacy and reliable asynchronous message delivery, while avoiding many", @@ -189,7 +192,7 @@ "simplex-network-2-header": "Unlike federated networks", "simplex-network-2-desc": "SimpleX relay servers do NOT store user profiles, contacts and delivered messages, do NOT connect to each other, and there is NO servers directory.", "simplex-network-3-header": "SimpleX network", - "simplex-network-3-desc": "servers provide unidirectional queues to connect the users, but they have no visibility of the network connection graph — only the users do.", + "simplex-network-3-desc": "servers provide unidirectional queues to connect the users, but they have no visibility of the network connection graph — only the users do.", "comparison-section-header": "Comparison with other protocols", "protocol-1-text": "Signal, big platforms", "protocol-2-text": "XMPP, Matrix", @@ -210,8 +213,8 @@ "comparison-section-list-point-2": "DNS-based addresses", "comparison-section-list-point-3": "Public key or some other globally unique ID", "comparison-section-list-point-4a": "SimpleX relays cannot compromise e2e encryption. Verify security code to mitigate attack on out-of-band channel", - "comparison-section-list-point-4": "If operator’s servers are compromised. Verify security code in Signal and some other apps to mitigate it", - "comparison-section-list-point-5": "Does not protect users' metadata privacy", + "comparison-section-list-point-4": "If operator's servers are compromised. Verify security code in Signal and some other apps to mitigate it", + "comparison-section-list-point-5": "Does not protect users' metadata privacy", "comparison-section-list-point-6": "While P2P are distributed, they are not federated — they operate as a single network", "comparison-section-list-point-7": "P2P networks either have a central authority or the whole network can be compromised", "see-here": "see here", @@ -232,7 +235,7 @@ "docs-dropdown-5": "Host XFTP Server", "docs-dropdown-6": "WebRTC servers", "docs-dropdown-7": "Translate SimpleX Chat", - "docs-dropdown-8": "SimpleX Directory Service", + "docs-dropdown-8": "SimpleX Directory", "docs-dropdown-9": "Downloads", "docs-dropdown-10": "Transparency", "docs-dropdown-11": "FAQ", @@ -247,13 +250,68 @@ "simplex-chat-via-f-droid": "SimpleX Chat via F-Droid", "simplex-chat-repo": "SimpleX Chat repo", "stable-and-beta-versions-built-by-developers": "Stable and beta versions built by the developers", - "f-droid-page-simplex-chat-repo-section-text": "To add it to your F-Droid client, scan the QR code or use this URL:", + "f-droid-page-simplex-chat-repo-section-text": "To add it to your F-Droid client, scan the QR code or use this URL:", "signing-key-fingerprint": "Signing key fingerprint (SHA-256)", "f-droid-org-repo": "F-Droid.org repo", "stable-versions-built-by-f-droid-org": "Stable versions built by F-Droid.org", "releases-to-this-repo-are-done-1-2-days-later": "The releases to this repo are done several days later", - "f-droid-page-f-droid-org-repo-section-text": "SimpleX Chat and F-Droid.org repositories sign builds with the different keys. To switch, please export the chat database and re-install the app.", + "f-droid-page-f-droid-org-repo-section-text": "SimpleX Chat and F-Droid.org repositories sign builds with the different keys. To switch, please export the chat database and re-install the app.", "jobs": "Join team", "please-enable-javascript": "Please enable JavaScript to see the QR code.", - "please-use-link-in-mobile-app": "Please use the link in the mobile app" + "please-use-link-in-mobile-app": "Please use the link in the mobile app", + "index-hero-h1": "Be
Free", + "index-hero-h2": "Freedom & Security
of Your Communications", + "index-hero-p1": "The first network where you own your identity, contacts, and groups.", + "index-hero-download-desktop-btn-title": "Download SimpleX Desktop App", + "index-testflight-title": "SimpleX iOS beta-release on TestFlight", + "index-f-droid-title": "SimpleX app via F-Droid", + "index-security-assessment-title": "Security Audits", + "index-security-review-2022-title": "Security Audit 2022", + "index-security-review-2024-title": "Security Audit 2024", + "index-security-audits-label": "Security
Audits", + "index-publications-privacy-guides-title": "Privacy Guides messenger recommendations", + "index-publications-whonix-title": "Whonix messenger recommendations", + "index-publications-heise-title": "Heise Online publications", + "index-publications-kuketz-title": "Review by Mike Kuketz", + "index-publications-optout-title": "OptOut podcast interview", + "worlds-most-secure-messaging": "World's Most Secure Messaging", + "index-messaging-p1": "SimpleX messaging has cutting-edge end-to-end encryption.", + "index-messaging-p2": "For your security and privacy, servers can't see your messages and who you talk to.", + "index-messaging-cta": "Learn more about SimpleX messaging", + "index-nextweb-h2": "You Own
The Next Web", + "index-nextweb-p1": "SimpleX is founded on the belief that you must own your identity, contacts and communities.", + "index-nextweb-p2": "Open and decentralized network lets you connect with people and share ideas: be free and secure.", + "index-token-h2": "Communities That Last", + "index-token-p1": "You will support your favorite groups with future Community Vouchers.", + "index-token-p2": "Vouchers will pay for servers, to let your communities stay free and independent.", + "index-token-cta": "Learn more and get a free access pass for early testing.", + "index-roadmap-h2": "SimpleX Roadmap to Free Internet", + "index-roadmap-2025": "2025", + "index-roadmap-2025-title": "Scale to Large Communities", + "index-roadmap-2025-desc": "Escaping centralized platforms", + "index-roadmap-2026": "2026", + "index-roadmap-2026-title": "Sustainable Communities & Servers", + "index-roadmap-2026-desc": "Launching Community Vouchers", + "index-roadmap-2027": "2027", + "index-roadmap-2027-title": "Make Your Communities Grow", + "index-roadmap-2027-desc": "Tools to promote your communities", + "index-directory-h2": "Join SimpleX Communities", + "index-directory-p1": "Hundreds of thousands people already trust SimpleX messaging.", + "index-directory-p2": "Find your communities in SimpleX directory and create your own!", + "index-directory-cta": "View SimpleX Directory", + "index-directory-users-group-title": "SimpleX users group", + "how-secure-comparison-title": "Comparison of end-to-end encryption security in different messengers", + "how-secure-message-padding": "Message padding", + "how-secure-repudiation-deniability": "Repudiation (deniability)", + "how-secure-forward-secrecy": "Forward secrecy", + "how-secure-break-in-recovery": "Post-compromise security", + "how-secure-two-factor-key-exchange": "2-factor key exchange", + "how-secure-post-quantum-hybrid-crypto": "Post-quantum hybrid crypto", + "messengers-comparison-section-list-point-1": "Briar pads messages to the size rounded up to 1024 bytes, Signal - to 160 bytes", + "messengers-comparison-section-list-point-2": "Repudiation does not include client-server connection.", + "messengers-comparison-section-list-point-3": "It appears that the usage of cryptographic signatures compromises repudiation (deniability), but it needs to be clarified.", + "messengers-comparison-section-list-point-4": "Multi-device implementation compromises post-compromise security of Double Ratchet", + "messengers-comparison-section-list-point-5": "2-factor key exchange is optional via security code verification.", + "messengers-comparison-section-list-point-6": "Post-quantum key agreement is \"sparse\" — it protects only some of the ratchet steps.", + "navbar-old-site": "Old site" } \ No newline at end of file diff --git a/website/langs/es.json b/website/langs/es.json index c4db631c95..3489fb3b9b 100644 --- a/website/langs/es.json +++ b/website/langs/es.json @@ -6,51 +6,51 @@ "simplex-explained-tab-2-text": "2. Cómo funciona la red", "simplex-network": "Red Simplex", "simplex-explained-tab-3-text": "3. Qué ven los servidores", - "simplex-explained-tab-2-p-2": "Los servidores solo transmiten mensajes en un sentido para no disponer de la imagen completa de la conversación y conexiones del usuario.", - "simplex-explained-tab-3-p-2": "El usuario puede mejorar aún más la privacidad de sus metadatos haciendo uso de la red Tor para acceder a los servidores, evitando así la correlación por dirección IP.", + "simplex-explained-tab-2-p-2": "Los servidores sólo transmiten en un sentido para no disponer de la conversación completa o las conexiones del usuario.", + "simplex-explained-tab-3-p-2": "El usuario puede mejorar aún más la privacidad de sus metadatos, haciendo uso de la red Tor para acceder a los servidores, evitando así la correlación por dirección IP.", "smp-protocol": "Protocolo SMP", "donate": "Donación", - "copyright-label": "© 2020-2025 SimpleX | Proyecto de Código Abierto", - "simplex-chat-protocol": "Protocolo de SimpleX Chat", + "copyright-label": "© 2020-2025 SimpleX Chat | Proyecto de Código Abierto", + "simplex-chat-protocol": "Protocolo SimpleX Chat", "terms-and-privacy-policy": "Política de Privacidad", "hero-header": "Privacidad redefinida", "hero-overlay-1-textlink": "¿Por qué los ID de usuario son perjudiciales para la privacidad?", "hero-overlay-2-textlink": "¿Cómo funciona SimpleX?", - "hero-2-header": "Haga una conexión privada", + "hero-2-header": "Crea una conexión privada", "hero-overlay-1-title": "¿Cómo funciona SimpleX?", "hero-overlay-2-title": "¿Por qué los ID de usuario son perjudiciales para la privacidad?", - "feature-1-title": "Mensajes cifrados E2E con sintáxis markdown y edición", - "feature-3-title": "Grupos descentralizados cifrados E2E — sólo los usuarios conocen su existencia", - "feature-6-title": "Llamadas y videollamadas con cifrado E2E", + "feature-1-title": "Mensajes cifrados E2E
con sintáxis markdown y edición", + "feature-3-title": "Grupos descentralizados cifrados E2E, sólo los usuarios saben de su existencia", + "feature-6-title": "Llamadas y videollamadas
con cifrado E2E", "simplex-network-overlay-1-title": "Comparativa con protocolos de mensajería P2P", "developers": "Desarrolladores", "features": "Características", "blog": "Blog", "home": "Inicio", "simplex-explained-tab-3-p-1": "Para cada cola los servidores disponen de credenciales separadas y anónimas, por lo que desconocen a qué usuarios pertenecen.", - "hero-p-1": "Las demás aplicaciones usan ID de usuario: Signal, Matrix, Session, Briar, Jami, Cwtch, etc.
SimpleX no los tiene, ni siquiera números aleatorios.
Esto mejora radicalmente su privacidad.", - "hero-2-header-desc": "El vídeo muestra cómo se conecta con sus amistades a través del código QR de un solo uso, en persona o a través de videollamada. También puede conectarse compartiendo un enlace de invitación.", - "feature-7-title": "Almacenamiento portable y cifrado — podrá transferir su perfil a otro dispositivo", + "hero-p-1": "Las demás aplicaciones usan ID de usuario: Signal, Matrix, Session, Briar, Jami, Cwtch, etc.
SimpleX no los tiene, ni siquiera usa números aleatorios.
Esto mejora radicalmente tu privacidad.", + "hero-2-header-desc": "El vídeo muestra cómo te conectas con tus amistades a través del código QR de un solo uso, en persona o a través de videollamada. También puedes conectarte compartiendo un enlace de invitación.", + "feature-7-title": "Almacenamiento portable y cifrado, podrás transferir tu perfil a otro dispositivo", "simplex-private-card-4-point-2": "Para usar SimpleX a través de Tor, instala la aplicación Orbot y activa el proxy SOCKS5 (o VPN en iOS).", "simplex-private-card-3-point-1": "Para las conexiones cliente servidor se usan exclusivamente el protocolo TLS 1.2/1.3 con algoritmos robustos.", - "simplex-private-card-4-point-1": "Para proteger tu dirección IP puedes acceder a los servidores a través de la red Tor u otras redes de transporte superpuesto.", + "simplex-private-card-4-point-1": "Para proteger tu dirección IP, puedes acceder a los servidores a través Tor u otra red superpuesta de transporte.", "simplex-private-card-5-point-2": "Esto hace que mensajes de distintos tamaños parezcan iguales desde el punto de vista de servidores y observadores de red.", "simplex-private-card-6-point-1": "Muchas redes de comunicaciónes son vulnerables a ataques MITM por parte de los servidores o los proveedores de red.", "simplex-private-card-7-point-1": "Para garantizar la integridad los mensajes se numeran secuencialmente e incluyen el hash del mensaje anterior.", "simplex-private-card-7-point-2": "Si se añade, elimina o modifica algún mensaje, el destinatario es avisado.", - "simplex-private-card-8-point-1": "Los servidores SimpleX actúan como nodos de mezcla de baja latencia — los mensajes entrantes y salientes siguen un orden diferente.", - "simplex-private-card-9-point-1": "Cada cola de mensajes transmite los mensajes en un solo sentido, con las distintas direcciones de envío y recepción.", + "simplex-private-card-8-point-1": "Los servidores SimpleX actúan como nodos de mezcla de baja latencia, los mensajes entrantes y salientes siguen un orden diferente.", + "simplex-private-card-9-point-1": "Cada cola de mensajes transmite los mensajes en un sol sentido. entre las direcciónes de envío y recepción.", "simplex-explained": "SimpleX explicado", - "simplex-explained-tab-1-p-1": "Puedes crear contactos y grupos, y mantener conversaciones bidireccionales como en cualquier aplicación de mensajería.", + "simplex-explained-tab-1-p-1": "Puedes crear contactos y grupos, y mantener conversaciones bidireccionales igual que en cualquier aplicación de mensajería.", "simplex-explained-tab-1-p-2": "¿Cómo puede funcionar con colas unidireccionales y sin identificadores de usuario?", - "simplex-explained-tab-2-p-1": "Por cada conexión se usan dos colas de mensajes separadas para que el envío y recepción se hagan a través de servidores diferentes.", + "simplex-explained-tab-2-p-1": "Por cada conexión se usan dos colas de mensajes separadas, para que el envío y la recepción se realicen a través de servidores diferentes.", "chat-bot-example": "Ejemplo de bot de Chat", "chat-protocol": "Protocolo Chat", "terminal-cli": "Terminal CLI", "hero-subheader": "La primera aplicación de mensajería
sin ID de usuario", "feature-2-title": "Cifrado E2E
de imágenes, vídeos y archivos", - "feature-8-title": "Modo incógnito —
exclusivo de SimpleX Chat", - "simplex-private-1-title": "Doble capa de
cifrado de extremo a extremo", + "feature-8-title": "Modo Incógnito
exclusivo de SimpleX Chat", + "simplex-private-1-title": "Doble capa de cifrado
de extremo a extremo", "simplex-private-2-title": "Capa de cifrado
adicional en el servidor", "simplex-private-3-title": "Transporte TLS
seguro y autenticado", "simplex-private-4-title": "Acceso opcional
a través de Tor", @@ -60,86 +60,86 @@ "simplex-private-6-title": "Intercambio de claves
fuera de banda", "simplex-private-5-title": "Múltiples capas
de contenido acolchado", "simplex-private-8-title": "Mezcla de mensajes
para prevenir la correlación", - "simplex-private-10-title": "Identificadores por pares anónimos temporales", + "simplex-private-10-title": "Identificadores por pares temporales y anónimos", "simplex-private-9-title": "Colas de mensajes
unidireccionales", "simplex-private-card-1-point-1": "Protocolo de 'Double Ratchet' —
Mensajería OTR con secreto perfecto hacia adelante y recuperación de intrusión.", - "simplex-private-card-1-point-2": "NaCL cryptobox por cada cola de mensajes para prevenir la correlación de tráfico entre colas si el protocolo TLS se ve comprometido.", - "simplex-private-card-2-point-1": "Capa de cifrado adicional en la entrega desde el servidor al destinatario, para prevenir la correlación entre los tráficos de recepción y envío del servidor si el protocolo TLS se ve comprometido.", + "simplex-private-card-1-point-2": "Y si el protocolo TLS se ve comprometido, NaCL cryptobox en cada cola de mensajes para prevenir la correlación de tráfico entre las colas.", + "simplex-private-card-2-point-1": "Capa de cifrado adicional desde el servidor al destinatario, para prevenir la correlación entre el tráficos de entrada y salida del servidor si el protocolo TLS se ve comprometido.", "simplex-private-card-3-point-2": "La huella digital del servidor y la vinculación de canales evitan los ataques de respuesta y MITM.", "simplex-private-card-3-point-3": "La reanudación de la conexión está deshabilitada para evitar ataques de sesión.", "simplex-private-card-5-point-1": "SimpleX utiliza relleno de contenido en cada capa de cifrado para frustrar los ataques al tamaño de los mensajes.", "simplex-private-card-6-point-2": "Para evitarlo las aplicaciones SimpleX pasan claves de un solo uso fuera de banda cuando compartes un enlace de dirección o un código QR.", - "simplex-private-card-9-point-2": "Esto reduce los vectores de ataque y los metadatos disponibles, en comparación con los agentes de mensajes tradicionales.", - "simplex-private-card-10-point-1": "SimpleX usa direcciones y credenciales temporales anónimas por pares para cada contacto de usuario o miembro de grupo.", + "simplex-private-card-9-point-2": "Esto reduce los vectores de ataque y los metadatos disponibles en comparación con los brokers de mensajes tradicionales.", + "simplex-private-card-10-point-1": "SimpleX usa direcciones y credenciales temporales y anónimas por pares para cada contacto o miembro del grupo.", "privacy-matters-1-title": "Publicidad y discriminación en precios", "privacy-matters-1-overlay-1-title": "La privacidad revierte en ahorro", "privacy-matters-1-overlay-1-linkText": "La privacidad revierte en ahorro", "privacy-matters-2-overlay-1-title": "La privacidad le da poder", "privacy-matters-3-title": "Enjuiciamiento por asociación inocente", - "privacy-matters-3-overlay-1-linkText": "La privacidad protege su libertad", - "simplex-unique-1-title": "Su privacidad es completa", - "simplex-unique-2-title": "Está protegido
contra el spam y abuso", - "simplex-unique-3-title": "Usted controla sus datos", - "simplex-unique-4-title": "La red SimpleX le pertenece", + "privacy-matters-3-overlay-1-linkText": "La privacidad protege tu libertad", + "simplex-unique-1-title": "La privacidad es completa", + "simplex-unique-2-title": "Estás protegido
contra el spam y el abuso", + "simplex-unique-3-title": "Tu gestionas tus datos", + "simplex-unique-4-title": "La red SimpleX te pertenece", "hero-overlay-card-1-p-1": "Muchos usuarios se preguntan: si SimpleX no tiene identificadores de usuario, ¿cómo puede saber dónde entregar los mensajes?", - "hero-overlay-card-1-p-4": "Este diseño evita que se filtren metadatos de usuario a nivel de aplicación. Para mejorar aún más la privacidad y proteger su dirección IP, puede conectarse a los servidores de mensajería a través de la red Tor.", - "hero-overlay-card-1-p-6": "Podrá encontrar más información en el informe técnico SimpleX.", - "hero-overlay-card-2-p-1": "Cuando los usuarios asumen identidades persistentes, aunque sólo se trate de un número aleatorio como un identificador de sesión, existe el riesgo de que el proveedor de red o un atacante puedan observar cómo se conectan los usuarios y la cantidad de mensajes que envían.", + "hero-overlay-card-1-p-4": "Este diseño evita que se filtren metadatos de usuario a nivel de aplicación. Para mejorar aún más la privacidad y proteger tu dirección IP, puedes conectarte a los servidores de mensajería a través de la red Tor.", + "hero-overlay-card-1-p-6": "Encontrarás más información en elSimpleX whitepaper.", + "hero-overlay-card-2-p-1": "Cuando los usuarios asumen identidades persistentes, aunque sólo se trate de un número aleatorio como un identificador de sesión, existe el riesgo de que el proveedor de red o un atacante puedan observar cómo se conectan y la cantidad de mensajes que envían.", "hero-overlay-card-2-p-2": "A continuación podrían correlacionar esta información con las redes sociales públicas existentes y averiguar las identidades reales de los usuarios.", - "hero-overlay-card-2-p-3": "Incluso con las aplicaciones más privadas que utilizan los servicios de Tor v3, si habla con dos contactos a través de un mismo perfil se puede probar que estas están conectados con la misma persona.", + "hero-overlay-card-2-p-3": "Incluso con las aplicaciones más privadas que usan los servicios de Tor v3, si hablas con dos contactos a través de un mismo perfil se puede probar que estos están conectados con la misma persona.", "privacy-matters-2-overlay-1-linkText": "La privacidad te da poder", - "simplex-private-card-10-point-2": "Esto permite entregar mensajes sin identificadores de perfil de usuario y proporcionando una mayor privacidad de metadatos que las alternativas.", - "privacy-matters-3-overlay-1-title": "La privacidad protege su libertad", - "simplex-unique-1-overlay-1-title": "Privacidad total de su identidad, perfil, contactos y metadatos", + "simplex-private-card-10-point-2": "Esto permite entregar mensajes sin identificadores del perfil de usuario y proporcionar mayor privacidad de metadatos que las alternativas.", + "privacy-matters-3-overlay-1-title": "La privacidad protege tu libertad", + "simplex-unique-1-overlay-1-title": "Privacidad total de tu identidad, perfil, contactos y metadatos", "privacy-matters-2-title": "Manipulación de elecciones", "simplex-unique-2-overlay-1-title": "La mejor protección contra el spam y el abuso", "simplex-unique-3-overlay-1-title": "Titularidad, control y seguridad de tus datos", - "simplex-unique-4-overlay-1-title": "Totalmente descentralizado — los usuarios son dueños de la red SimpleX", - "hero-overlay-card-1-p-2": "Para entregar los mensajes, en lugar del identificador de usuario empleado por las demás redes, SimpleX utiliza identificadores de colas de mensajes por pares, siendo temporales, anónimos e independientes por cada conexión — no hay identificadores a largo plazo.", + "simplex-unique-4-overlay-1-title": "Totalmente descentralizado, los usuarios son dueños de la red SimpleX", + "hero-overlay-card-1-p-2": "Para entregar los mensajes, en lugar del identificador de usuario empleado por las demás redes, SimpleX utiliza identificadores de colas de mensajes por pares, siendo temporales, anónimos e independientes por cada conexión. No hay identificadores a largo plazo.", "hero-overlay-card-1-p-5": "Los perfiles de usuario, contactos y grupos sólo se almacenan en los dispositivos cliente; los mensajes se envían con cifrado de doble capa de extremo a extremo .", - "hero-overlay-card-1-p-3": "Usted define qué servidor(es) se usan para recibir los mensajes; sus contactos — los servidores que se usan para enviar los mensajes. Es probable que cada conversación use dos servidores distintos.", - "hero-overlay-card-2-p-4": "SimpleX protege frente estos ataques al no disponer de ID de usuario por diseño. Y si usa el modo incógnito, tendrá un nombre mostrado diferente por cada contacto, evitando cualquier dato compartido entre ellos.", - "simplex-network-overlay-card-1-p-1": "Los protocolos y aplicaciones de mensajería P2P presentan varios problemas que los hacen menos fiables que SimpleX, más complejos de analizar y más vulnerables a varios tipos de ataques.", + "hero-overlay-card-1-p-3": "Tu defines qué servidor(es) se usan para recibir los mensajes. Tus contactos definenen qué servidores usarás para enviar los mensajes. Es muy probable que cada conversación use dos servidores distintos.", + "hero-overlay-card-2-p-4": "SimpleX protege frente estos ataques al no disponer de ID de usuario por diseño. Y si usas el modo incógnito, tendrás un nombre diferente por cada contacto, evitando cualquier dato compartido entre ellos.", + "simplex-network-overlay-card-1-p-1": "Los protocolos y aplicaciones de mensajería P2P presentan varios problemas que los hacen menos fiables que SimpleX, más complejos de analizar y más vulnerables a ciertos tipos de ataques.", "simplex-network-overlay-card-1-li-2": "Por diseño SimpleX, a diferencia de la mayoría de las redes P2P, no tiene identificadores globales de usuario de ningún tipo, ni siquiera temporales, y sólo usa identificadores temporales por pares, lo que proporciona un mejor anonimato y protección de los metadatos.", - "simplex-network-overlay-card-1-li-3": "P2P no resuelve el problema del ataque MITM , y la mayoría de las implementaciones existentes no utilizan mensajes fuera de banda para el intercambio inicial de claves. SimpleX utiliza mensajes fuera de banda, o en algunos casos conexiones seguras y de confianza preexistentes para el intercambio inicial de claves.", - "simplex-network-overlay-card-1-li-4": "Algunos proveedores de Internet pueden bloquear las aplicaciones P2P (como BitTorrent). SimpleX es independiente del transporte — puede trabajar con protocolos web estándar, por ejemplo WebSockets.", - "privacy-matters-overlay-card-1-p-4": "La red SimpleX protege la privacidad de sus conexiones mejor que cualquier alternativa, impidiendo por completo que su gráfico social esté disponible para empresas u organizaciones. Incluso cuando se emplean los servidores configurados en SimpleX Chat, los operadores de servidores desconocen el número de usuarios o sus conexiones.", - "privacy-matters-overlay-card-2-p-1": "No hace mucho que vimos cómo las principales elecciones eran manipuladas por una reputada consultora que usaba nuestros gráficos sociales para distorsionar nuestra visión del mundo real y manipular nuestros votos.", - "privacy-matters-overlay-card-2-p-2": "Para ser objetivo y tomar decisiones independientes usted necesita tener el controlar de su espacio de información. Sólo es posible si usa una red de comunicaciónes privada que no tenga acceso a su gráfico social.", - "privacy-matters-overlay-card-2-p-3": "SimpleX es la primera red que por diseño no tiene identificadores de usuario, protegiendo así su grafico de conexiones mejor que cualquier alternativa conocida.", - "privacy-matters-overlay-card-3-p-1": "Todos deberían preocuparse por la privacidad y la seguridad de sus comunicaciones — las conversaciones inofensivas pueden ponerle en peligro incluso aunque no tenga nada que ocultar.", - "simplex-unique-overlay-card-1-p-1": "A diferencia de otras redes de mensajería, SimpleX no tiene identificadores asignados a los usuarios. No depende de números de teléfono, direcciones basadas en dominios (como es el caso del email o XMPP), nombres de usuario, claves públicas o incluso números aleatorios para identificar a sus usuarios — Los operadores de servidores SimpleX desconocen cuántas personas utilizan sus servidores.", - "simplex-unique-overlay-card-1-p-3": "Este diseño protege la privacidad de la persona que se comunica con usted ocultándole a los servidores de la red SimpleX y de cualquier observador. Para ocultar su dirección IP a los servidores puede conectarse a los servidores SimpleX mediante Tor.", - "simplex-unique-overlay-card-2-p-1": "Al no tener identificadores en la red SimpleX, nadie puede ponerse en contacto con usted salvo que comparta una dirección de usuario de un solo uso o una dirección temporal en forma de enlace o código QR.", - "simplex-unique-overlay-card-2-p-2": "Incluso con la dirección de usuario opcional, aunque puede utilizarse para enviar solicitudes de contacto spam, puedes cambiarla o eliminarla por completo sin perder ninguna de tus conexiones.", - "simplex-unique-overlay-card-3-p-2": "Los mensajes cifrados de extremo a extremo (E2E) se mantienen temporalmente en los servidores relay SimpleX hasta que se reciben, y después son borrados permanentemente.", - "simplex-network-overlay-card-1-li-1": "Para enrutar mensajes las redes P2P se basan en alguna variante de DHT. Los diseños DHT tienen que equilibrar la garantía de entrega con la latencia. SimpleX ofrece mayor garantía de entrega y menor latencia que P2P, ya que un mensaje puede transmitirse en paralelo y de forma redundante a través de varios servidores elegidos por el destinatario. En las redes P2P el mensaje se transmite secuencialmente por O(log N) nodos, usando nodos elegidos por el algoritmo.", - "simplex-network-overlay-card-1-li-5": "Todas las redes P2P conocidas pueden ser vulnerables al ataque Sybil, porque cada nodo es susceptible de ser descubierto y la red funciona como una unidad. Las medidas de mitigación conocidas requieren un componente centralizado o bien pruebas de trabajo costosas. La red SimpleX no tiene capacidad para descubrir los servidores, está fragmentada y funciona como múltiples subredes aisladas haciendo imposibles los ataques a toda la red.", - "simplex-network-overlay-card-1-li-6": "Las redes P2P pueden ser vulnerables al ataque DRDoS, donde los clientes pueden retransmitir y amplificar el tráfico, provocando una denegación de servicio en toda la red. Los clientes SimpleX sólo retransmiten el tráfico desde una conexión conocida y no pueden ser utilizados por un atacante para amplificar el tráfico en toda la red.", - "privacy-matters-overlay-card-1-p-1": "Muchas grandes empresas usan la información de con quién está conectado para calcular sus ingresos, venderle productos que realmente no necesita y determinar su precio.", + "simplex-network-overlay-card-1-li-3": "P2P no resuelve el problema del ataque MITM , y la mayoría de implementaciones existentes no usan mensajes fuera de banda para el intercambio de claves inicial. SimpleX usa mensajes fuera de banda, o en algunos casos conexiones seguras y de confianza preexistentes para el intercambio inicial de claves.", + "simplex-network-overlay-card-1-li-4": "Algunos proveedores de Internet pueden bloquear las aplicaciones P2P (como BitTorrent). SimpleX es independiente del transporte. Puede trabajar con protocolos web estándar, por ejemplo WebSockets.", + "privacy-matters-overlay-card-1-p-4": "La red SimpleX protege la privacidad de tus conexiones mejor que cualquier alternativa, impidiendo por completo que tu gráfica social esté disponible para empresas u organizaciones. Incluso cuando se emplean los servidores configurados en SimpleX Chat, los operadores de servidores desconocen el número de usuarios o sus conexiones.", + "privacy-matters-overlay-card-2-p-1": "Hace poco vimos cómo las principales elecciones eran manipuladas por una reputada compañía consultora que usaba nuestras gráficas sociales para distorsionar nuestra visión del mundo real y manipular nuestros votos.", + "privacy-matters-overlay-card-2-p-2": "Para ser objetivo y tomar decisiones independientes necesitas tener el controlar de tu espacio de información. Sólo es posible si usas una red de comunicaciónes privada que no tenga acceso a tu gráfica social.", + "privacy-matters-overlay-card-2-p-3": "SimpleX es la primera red que por diseño no tiene identificadores de usuario, protegiendo así tu gráfica de conexiones mejor que cualquier alternativa conocida.", + "privacy-matters-overlay-card-3-p-1": "Todos deberían preocuparse por la privacidad y la seguridad de sus comunicaciones. Las conversaciones inofensivas pueden ponerte en peligro incluso aunque no tengas nada que ocultar.", + "simplex-unique-overlay-card-1-p-1": "A diferencia de otras redes de mensajería, SimpleX no tiene identificadores asignados a los usuarios. No depende de números de teléfono, direcciones basadas en dominios (como es el caso del email o XMPP), nombres de usuario, claves públicas o incluso números aleatorios para identificar a sus usuarios. Los operadores de servidores SimpleX desconocen cuántas personas usan sus servidores.", + "simplex-unique-overlay-card-1-p-3": "Este diseño protege la privacidad de la persona que se comunica contigo ocultándole a los servidores de la red SimpleX y de cualquier observador. Para ocultar tu dirección IP a los servidores puedes conectarte a los servidores SimpleX mediante Tor.", + "simplex-unique-overlay-card-2-p-1": "Al no tener identificadores en la red SimpleX, nadie puede ponerse en contacto contigo salvo que compartas una dirección de usuario de un solo uso o una dirección temporal en forma de enlace o código QR.", + "simplex-unique-overlay-card-2-p-2": "Incluso con la dirección de usuario opcional, aunque pueda usarse para enviar solicitudes de contacto spam, puedes cambiarla o eliminarla por completo sin perder ninguna de tus conexiones.", + "simplex-unique-overlay-card-3-p-2": "Los mensajes cifrados de extremo a extremo (E2E) se mantienen temporalmente en los servidores SimpleX hasta que se entregan al destinatario, y después se borran definitivamente.", + "simplex-network-overlay-card-1-li-1": "Para enrutar mensajes, las redes P2P se basan en alguna variante de DHT. Los diseños DHT tienen que equilibrar la garantía de entrega y latencia. SimpleX ofrece mayor garantía de entrega y menor latencia que P2P ya que el mensaje puede transmitirse en paralelo y de forma redundante a través de varios servidores elegidos por el destinatario. En las redes P2P el mensaje se transmite secuencialmente por O(log N) nodos, usando nodos elegidos por el algoritmo.", + "simplex-network-overlay-card-1-li-5": "Todas las redes P2P conocidas pueden ser vulnerables al ataque Sybil porque cada nodo es susceptible de ser descubierto y la red funciona como una unidad. Las medidas de mitigación conocidas requieren un componente centralizado o bien costosas pruebas de trabajo. La red SimpleX no tiene la capacidad para descubrir los servidores, está fragmentada y funciona como múltiples subredes aisladas haciendo imposibles los ataques a toda la red.", + "simplex-network-overlay-card-1-li-6": "Las redes P2P pueden ser vulnerables al ataque DRDoS, en el que los clientes retransmiten y amplifican el tráfico provocando el bloqueo del servicio en la red. Los clientes SimpleX sólo transmiten el tráfico desde una conexión conocida y no pueden ser usados por un atacante para amplificar el tráfico en toda la red.", + "privacy-matters-overlay-card-1-p-1": "Las grandes empresas usan la información relativa a tus contactos para calcular tus ingresos, venderte productos que realmente no necesitas y para determinar sus precios.", "privacy-matters-overlay-card-1-p-2": "Los minoristas en línea saben que las personas con menos ingresos son más propensas a hacer compras urgentes, por lo que pueden cobrar precios más altos o eliminar descuentos.", - "privacy-matters-overlay-card-1-p-3": "Algunas compañías financieras y de seguros usan gráficos sociales para determinar primas y tipos de interés. A menudo eso genera un mayor desembolso a personas con menos ingresos — se conoce como \"prima de pobreza\".", - "privacy-matters-overlay-card-3-p-2": "Una de las historias más estremecedoras es la experiencia de Mohamedou Ould Salahi descrita en sus memorias y plasmada en la película The Mauritanian. Fue internado en la prisión militar de Guantánamo sin juicio previo, donde fue torturado durante 15 años tras una llamada telefónica a su pariente en Afganistán, bajo sospecha de estar implicado en los atentados del 11-S a pesar de que vivía en Alemania desde hacía 10 años.", - "privacy-matters-overlay-card-3-p-4": "No basta con usar mensajería cifrada de extremo a extremo, todos deberíamos utilizar las aplicaciones de mensajería que protegen la privacidad de nuestras redes personales y con quién estamos conectados.", - "privacy-matters-overlay-card-3-p-3": "Personas corrientes son detenidas por lo que comparten en Internet, incluso a través de sus cuentas \"anónimas\" e incluso en países democráticos.", - "simplex-unique-overlay-card-1-p-2": "Para enviar mensajes, SimpleX utiliza direcciones anónimas por pares de colas de mensajes unidireccionales, separadas para mensajes recibidos y enviados, y normalmente a través de servidores diferentes.", + "privacy-matters-overlay-card-1-p-3": "Algunas compañías financieras y de seguros usan las gráficas sociales para determinar primas y tipos de interés. Eso genera a menudo un mayor desembolso a personas con menores ingresos, conocido como \"prima de pobreza\".", + "privacy-matters-overlay-card-3-p-2": "Una de las historias más estremecedoras es la experiencia de Mohamedou Ould Salahi descrita en sus memorias y plasmada en la película The Mauritanian. Fue internado en la prisión militar de Guantánamo sin juicio previo, donde fue torturado durante 15 años tras una llamada telefónica a su pariente en Afganistán, bajo la sospecha de estar implicado en los atentados del 11-S a pesar de que vivía en Alemania desde hacía 10 años.", + "privacy-matters-overlay-card-3-p-4": "No basta con usar mensajería cifrada de extremo a extremo, todos deberíamos usar las aplicaciones de mensajería que protejan la privacidad de nuestras redes personales y con quién nos comunicamos.", + "privacy-matters-overlay-card-3-p-3": "Personas corrientes son detenidas por lo que comparten en Internet, incluso a través de sus cuentas \"anónimas\" e incluso en países democráticos.", + "simplex-unique-overlay-card-1-p-2": "Para la entrega de mensajes, SimpleX usa direcciones anónimas por pares de colas de mensajes unidireccionales separadas para mensajes salientes y entrantes a través de servidores diferentes.", "simplex-unique-overlay-card-3-p-1": "SimpleX Chat almacena todos los datos de usuario únicamente en los dispositivos cliente usando un formato cifrado y portable de la base de datos, la cual puede ser exportada y transferida a cualquier dispositivo compatible.", "simplex-unique-overlay-card-3-p-3": "A diferencia de los servidores de redes federadas (correo electrónico, XMPP o Matrix), los servidores SimpleX no almacenan cuentas de usuario, sólo retransmiten mensajes, protegiendo así la privacidad de ambas partes.", - "simplex-unique-overlay-card-4-p-1": "Puede usar SimpleX con sus propios servidores y aún así comunicarse con personas que usen los servidores preconfigurados en la aplicación.", - "simplex-unique-card-1-p-1": "SimpleX protege la privacidad de su perfil, contactos y metadatos, ocultándolos de los servidores de la red SimpleX y de cualquier observador.", + "simplex-unique-overlay-card-4-p-1": "Puedes usar SimpleX con tus propios servidores y aún así comunicarte con personas que usen los servidores preconfigurados de la aplicación.", + "simplex-unique-card-1-p-1": "SimpleX protege la privacidad de tu perfil, contactos y metadatos, ocultándolos de los servidores SimpleX y de cualquier observador.", "simplex-unique-card-3-p-1": "SimpleX almacena todos los datos de usuario únicamente en los dispositivos cliente usando un formato cifrado y portable de la base de datos, la cual puede ser transferida a otro dispositivo.", - "simplex-unique-card-3-p-2": "Los mensajes cifrados de extremo a extremo (E2E) se mantienen temporalmente en los servidores relay SimpleX hasta que se reciben, y después son borrados permanentemente.", - "simplex-unique-card-4-p-2": "Puede usar SimpleX con sus propios servidores o con los servidores proporcionados por nosotros para contactar con cualquier usuario.", - "join": "Únase a", - "we-invite-you-to-join-the-conversation": "Le invitamos a unirse a la conversación", - "join-the-REDDIT-community": "Únase a la comunidad REDDIT", - "join-us-on-GitHub": "Únase a nosotros en GitHub", - "sign-up-to-receive-our-updates": "Suscríbase para recibir nuestras actualizaciones", - "donate-here-to-help-us": "Para ayudarnos haga una donación aquí", - "enter-your-email-address": "Escriba su dirección de correo electrónico", - "get-simplex": "Obtenga SimpleX desktop app", - "why-simplex-is-unique": "Por qué SimpleX es único", - "learn-more": "Descubra más", + "simplex-unique-card-3-p-2": "Los mensajes cifrados de extremo a extremo (E2E) se mantienen temporalmente en los servidores SimpleX hasta que se entregan al destinatario, y después se borran definitivamente.", + "simplex-unique-card-4-p-2": "Puedes usar SimpleX con tus propios servidores o con los servidores proporcionados por nosotros para contactar con cualquier usuario.", + "join": "Únete a", + "we-invite-you-to-join-the-conversation": "Te invitamos a unirte a la conversación", + "join-the-REDDIT-community": "Únete a la comunidad REDDIT", + "join-us-on-GitHub": "Únete a nosotros en GitHub", + "sign-up-to-receive-our-updates": "Suscríbete para recibir actualizaciones", + "donate-here-to-help-us": "Dona aquí para ayudarnos", + "enter-your-email-address": "Escribe tu correo electrónico", + "get-simplex": "Obtén SimpleX para desktop", + "why-simplex-is-unique": "Por qué SimpleX es único", + "learn-more": "Descubre más", "more-info": "Más información", "hide-info": "Ocultar información", "invitation-hero-header": "Has recibido un enlace de un solo uso para conectarte con SimpleX Chat", @@ -148,7 +148,7 @@ "install-simplex-app": "Instalar SimpleX Chat", "connect-in-app": "Conectar en la aplicación", "open-simplex-app": "Abrir la aplicación Simplex", - "tap-the-connect-button-in-the-app": "Pulse el botón ‘conectar’ en la aplicación", + "tap-the-connect-button-in-the-app": "Pulsa el botón \"conectar\" en la aplicación", "scan-the-qr-code-with-the-simplex-chat-app": "Escanear el código QR con la aplicación SimpleX Chat", "installing-simplex-chat-to-terminal": "Instalación de chat SimpleX en el terminal", "use-this-command": "Usa este comando:", @@ -158,12 +158,12 @@ "if-you-already-installed": "Si ya has instalado", "simplex-chat-for-the-terminal": "SimpleX Chat para el terminal", "copy-the-command-below-text": "copia el siguiente comando y úsalo en el chat:", - "privacy-matters-section-subheader": "Preservar la privacidad de sus metadatos — con quién habla — le protege de:", - "privacy-matters-section-label": "¡Asegúrese de que su aplicación de mensajería no puede acceder a sus datos!", + "privacy-matters-section-subheader": "Preservar la privacidad de tus metadatos, con quién te comunicas, te protege de:", + "privacy-matters-section-label": "¡Asegúrate que la mensajería no puede acceder a tus datos!", "tap-to-close": "Pulsa para cerrar", "simplex-network-1-overlay-linktext": "problemas asociados a las redes P2P", "simplex-network-3-header": "Red Simplex", - "simplex-network-3-desc": "Los servidores proporcionan colas unidireccionales para conectar a los usuarios, pero no pueden ver el mapa de conexión de la red — sólo la tienen los usuarios.", + "simplex-network-3-desc": "los servidores proporcionan colas unidireccionales para conectar a los usuarios pero no ven la gráfica de conexiónes red, sólo los usuarios pueden verla.", "comparison-section-header": "Comparación con otros protocolos", "protocol-1-text": "Signal, grandes plataformas", "protocol-2-text": "XMPP, Matrix", @@ -176,39 +176,39 @@ "yes": "Sí", "no": "No", "no-federated": "No - federado", - "comparison-section-list-point-1": "Generalmente basado en un número de teléfono, en algunos casos en nombres de usuario", + "comparison-section-list-point-1": "Generalmente basada en un número de teléfono, y en algunos casos en nombres de usuario", "comparison-section-list-point-2": "Direcciones basadas en DNS", - "comparison-section-list-point-4": "Si los servidores del operador se ven comprometidos. Verifique el código de seguridad en Signal y alguna otra aplicación para mitigarlo", + "comparison-section-list-point-4": "Si los servidores del operador se ven comprometidos. Verifica el código de seguridad en Signal y otras aplicaciónes para mitigarlo", "comparison-section-list-point-5": "No protege la privacidad de los metadatos del usuario", "comparison-section-list-point-3": "Clave pública o algun otro ID único a nivel global", - "comparison-section-list-point-6": "A pesar de que las redes P2P son distribuidas, no son federadas — funcionan como una única red", - "comparison-section-list-point-7": "Las redes P2P o bien tienen una autoridad central o toda la red puede verse comprometida", + "comparison-section-list-point-6": "A pesar de que las redes P2P son distribuidas, no son federadas. Funcionan como una única red", + "comparison-section-list-point-7": "Las redes P2P, o bien disponen de una autoridad central, o bien la red completa podría verse comprometida", "see-here": "ver aquí", - "simplex-unique-overlay-card-4-p-2": "La red SimpleX utiliza un protocolo abierto y proporciona SDK para crear chatbots, permitiendo implementar servicios con los que los usuarios pueden interactuar mediante las aplicaciones SimpleX Chat — esperamos conocer los servicios SimpleX que pueda usted crear.", - "simplex-unique-overlay-card-4-p-3": "Si está pensando en desarrollar para la red SimpleX, por ejemplo un chat bot para los usuarios de SimpleX o la integración de las librerías SimpleX Chat en sus aplicaciones móviles, por favor póngase en contacto para cualquier consejo o soporte.", - "simplex-unique-card-1-p-2": "A diferencia de cualquier otra red de mensajería existente, SimpleX no tiene identificadores asignados a los usuarios — ni siquiera números aleatorios .", - "simplex-unique-card-2-p-1": "Al no tener identificadores o dirección permanente en la red SimpleX, nadie puede ponerse en contacto con usted salvo que comparta una dirección de usuario de un solo uso o una dirección temporal en forma de enlace o código QR.", + "simplex-unique-overlay-card-4-p-2": "La red SimpleX usa un protocolo abierto y proporciona el SDK para crear chatbots, permitiendo implementar servicios con los que los usuarios interactúen con las aplicaciones SimpleX Chat — esperamos con interés ver los servicios SimpleX que creará usted.", + "simplex-unique-overlay-card-4-p-3": "Si estás considerando desarrollar para la red SimpleX, por ejemplo un chatbot para los usuarios de SimpleX o la integración de las librerías SimpleX Chat en tus aplicaciones móviles, por favor ponte en contacto para consejos y soporte.", + "simplex-unique-card-1-p-2": "A diferencia de cualquier otra red de mensajería existente, SimpleX no tiene identificadores asignados a los usuarios, ni siquiera números aleatorios.", + "simplex-unique-card-2-p-1": "Al no tener identificadores o dirección permanente en la red SimpleX, nadie puede ponerse en contacto contigo salvo que compartas una dirección de usuario de un solo uso o una dirección temporal en forma de enlace o código QR.", "simplex-unique-card-4-p-1": "La red SimpleX está completamente descentralizada y es independiente de cualquier criptodivisa o de cualquier red salvo Internet.", "contact-hero-header": "Has recibido una dirección para conectarte con SimpleX Chat", "the-instructions--source-code": "instrucciones para descargar o compilar desde el código fuente.", - "simplex-network-section-header": "Red SimpleX", + "simplex-network-section-header": "Red SimpleX", "contact-hero-subheader": "Escanéa el código QR en tu teléfono o tableta con la aplicación SimpleX Chat.", - "contact-hero-p-1": "Las claves públicas y la dirección de la cola de mensajes de este enlace NO se envían a través de la red cuando ves esta página — están contenidos en el fragmento hash del URL del enlace.", + "contact-hero-p-1": "Las claves públicas y la dirección de la cola de mensajes de este enlace NO se envían a través de la red cuando ves esta página. Están contenidos en el fragmento hash del URL del enlace.", "scan-qr-code-from-mobile-app": "Escanear el código QR desde la aplicación móvil", "to-make-a-connection": "Para establecer una conexión:", - "scan-the-qr-code-with-the-simplex-chat-app-description": "Las claves públicas y la dirección de la cola de mensajes de este enlace NO se envían a través de la red cuando ves esta página —
están contenidas en el fragmento hash del URL del enlace.", - "privacy-matters-section-header": "Por qué es importante la privacidad", - "simplex-private-section-header": "Qué hace que SimpleX sea privado", + "scan-the-qr-code-with-the-simplex-chat-app-description": "Las claves públicas y la dirección de la cola de mensajes de este enlace NO se envían a través de la red cuando ves esta página.
Están contenidos en el fragmento hash del URL del enlace.", + "privacy-matters-section-header": "Por qué es importante la privacidad", + "simplex-private-section-header": "Qué hace que SimpleX sea privado", "simplex-network-section-desc": "Simplex Chat proporciona la mejor privacidad al combinar las ventajas de P2P y las redes federadas.", - "simplex-network-1-header": "Distinto de las redes P2P", - "simplex-network-2-header": "Distinto de las redes federadas", - "simplex-network-2-desc": "Los servidores relay SimpleX NO almacenan perfiles de usuario, contactos ni mensajes entregados, NO se conectan entre sí y NO existe un directorio de servidores.", + "simplex-network-1-header": "Distinta de las redes P2P", + "simplex-network-2-header": "Distinta de las redes federadas", + "simplex-network-2-desc": "Los servidores de SimpleX NO almacenan perfiles de usuario, contactos, o mensajes entregados. NO contactan entre sí y NO existe un directorio de servidores.", "no-secure": "No - seguro", "no-resilient": "No - resiliente", "no-decentralized": "No - descentralizado", "no-private": "No - privado", - "simplex-network-1-desc": "Todos los mensajes se envían a través de los servidores, proporcionando mayor privacidad para los metadatos y una entrega asincrónica fiable de mensajes, evitando al mismo tiempo muchos de los", - "simplex-unique-overlay-card-3-p-4": "No hay identificadores ni texto cifrado en común entre el tráfico de servidor enviado y el recibido — si alguien lo está monitorizando, difícilmente podría determinar quién se comunica con quién, incluso si el protocolo TLS se ve comprometido.", + "simplex-network-1-desc": "Todos los mensajes se envían a través de los servidores, proporcionando mejor privacidad para los metadatos y entrega asíncrona fiable, evitando al mismo tiempo muchos de los", + "simplex-unique-overlay-card-3-p-4": "No hay identificadores ni texto cifrado en común entre el tráfico de servidor enviado y el recibido. Si alguien te está monitorizando, difícilmente podría determinar quién se comunica con quién, incluso si el protocolo TLS se ve comprometido.", "guide-dropdown-3": "Grupos secretos", "guide-dropdown-4": "Perfiles de chat", "guide-dropdown-5": "Gestión de datos", @@ -227,22 +227,22 @@ "docs-dropdown-6": "Servidores WebRTC", "docs-dropdown-7": "Traducir SimpleX Chat", "newer-version-of-eng-msg": "Hay una versión más reciente en inglés de esta página.", - "click-to-see": "Haga clic para ver", + "click-to-see": "Clica para ver", "menu": "Menú", "on-this-page": "En esta página", "back-to-top": "Volver arriba", "glossary": "Glosario", "stable-and-beta-versions-built-by-developers": "Versiones estables y beta compilados por los desarrolladores", - "f-droid-page-simplex-chat-repo-section-text": "Para añadirlo a tu cliente F-Droid escanea el código QR usa esta URL:", - "docs-dropdown-8": "Servicio Simplex Directory", + "f-droid-page-simplex-chat-repo-section-text": "Para añadirlo al cliente F-Droid, escanea el código QR o usa esta URL:", + "docs-dropdown-8": "Directorio SimpleX", "simplex-chat-repo": "Repositorio Simplex Chat", "simplex-chat-via-f-droid": "SimpleX Chat en F-Droid", "f-droid-org-repo": "Repositorio F-Droid.org", "stable-versions-built-by-f-droid-org": "Versión estable compilada por F-Droid.org", - "f-droid-page-f-droid-org-repo-section-text": "Los repositorios de SimpleX Chat y F-Droid.org firman con distinto certificado. Para cambiar, por favor exportar la base de datos y reinstala la aplicación.", - "signing-key-fingerprint": "Huella digital de la clave de firma (SHA-256)", - "releases-to-this-repo-are-done-1-2-days-later": "Las versiones aparecen varios días más tarde en este repositorio", - "comparison-section-list-point-4a": "Los servidores de retransmisión no pueden comprometer la encriptación e2e. Para evitar posibles ataques, verifique el código de seguridad mediante un canal alternativo", + "f-droid-page-f-droid-org-repo-section-text": "Los repositorios de SimpleX Chat y F-Droid.org firman con claves distintas. Para pasar de uno a otro, por favor exporta la base de datos y reinstala la aplicación.", + "signing-key-fingerprint": "Huella de la clave (SHA-256)", + "releases-to-this-repo-are-done-1-2-days-later": "Las versiones se publican varios días más tarde en este repositorio", + "comparison-section-list-point-4a": "Los servidores no pueden comprometer el cifrado E2E. Para evitar posibles ataques, verifica el código de seguridad mediante un canal alternativo", "hero-overlay-3-title": "Evaluaciones de seguridad", "hero-overlay-card-3-p-2": "Trail of Bits revisó la criptografía y los componentes de red de la plataforma SimpleX en noviembre de 2022. Saber más.", "jobs": "Únete al equipo", @@ -250,10 +250,66 @@ "hero-overlay-card-3-p-1": "Trail of Bits es una consultora de seguridad y tecnología líder cuyos clientes incluyen grandes tecnológicas, agencias gubernamentales e importantes proyectos de blockchain.", "docs-dropdown-9": "Descargas", "please-enable-javascript": "Habilita JavaScript para ver el código QR.", - "please-use-link-in-mobile-app": "Usa el enlace en la apliación móvil", + "please-use-link-in-mobile-app": "Por favor, usa el enlace en la apliación móvil", "docs-dropdown-10": "Transparencia", "docs-dropdown-11": "FAQ", "docs-dropdown-12": "Seguridad", "docs-dropdown-14": "SimpleX para empresas", - "hero-overlay-card-3-p-3": "Trail of Bits ha revisado el diseño criptográfico de los protocolos de red SimpleX en julio del 2024. Saber más." + "hero-overlay-card-3-p-3": "Trail of Bits ha revisado el diseño criptográfico de los protocolos de red SimpleX en julio del 2024. Saber más.", + "directory": "Directorio", + "about-and-contact-us": "Quiénes somos & Contacto", + "index-hero-h1": "Sé
Libre", + "index-hero-h2": "Libertad y Seguridad
en Tus Comunicaciones", + "index-hero-p1": "La primera red donde tu identidad, contactos y grupos te pertenecen.", + "index-hero-download-desktop-btn-title": "Descargar SimpleX Desktop App", + "index-testflight-title": "Betas SimpleX para iOS en TestFlight", + "index-f-droid-title": "SimpleX app vía F-Droid", + "index-security-assessment-title": "Auditorías de seguridad", + "index-security-review-2022-title": "Auditoría de Seguridad 2022", + "index-security-review-2024-title": "Auditoría de Seguridad 2024", + "index-security-audits-label": "Auditorías
en Seguridad", + "index-publications-privacy-guides-title": "Recomendaciones de mensajería de Privacy Guides", + "index-publications-whonix-title": "Recomendación de mensajería de Whonix", + "index-publications-heise-title": "Publicaciones Heise Online", + "index-publications-kuketz-title": "Revisión por Mike Kuketz", + "index-publications-optout-title": "Entrevista en podcast de OptOut", + "worlds-most-secure-messaging": "Mensajería Más Segura Del Mundo", + "index-messaging-p1": "La mensajería SimpleX posee un cifrado de extremo a extremo de vanguardia.", + "index-messaging-p2": "Para tu seguridad y privacidad los servidores no pueden ver tus mensajes ni con quién te comunicas.", + "index-messaging-cta": "Descubre más sobre la mensajería SimpleX", + "index-nextweb-h2": "La Web
Del Futuro
Te Pertenece", + "index-nextweb-p1": "SimpleX se funda en la creencia de que debes ser el propietario de tu identidad, contactos y comunidades.", + "index-nextweb-p2": "Una red abierta y descentralizada que te permite conectarte con personas y compartir ideas: libre y segura.", + "index-token-h2": "Comunidades Duraderas", + "index-token-p1": "Podrás apoyar a tus grupos favoritos con los futuros Vales Comunitarios.", + "index-token-p2": "Los vales costearán los servidores para que tus comunidades sigan siendo libres e independientes.", + "index-token-cta": "Descubre más y obtén tu NFT gratuito por participar en las pruebas.", + "index-roadmap-h2": "Ruta SimpleX hacía el Internet Libre", + "index-roadmap-2025": "2025", + "index-roadmap-2025-title": "Escalar a Comunidades Grandes", + "index-roadmap-2025-desc": "Huir de plataformas centralizadas", + "index-roadmap-2026": "2026", + "index-roadmap-2026-title": "Comunidades y Servidores Sostenibles", + "index-roadmap-2026-desc": "Lanzamiento de Vales Comunitarios", + "index-roadmap-2027": "2027", + "index-roadmap-2027-title": "Haz Crecer Tus Comunidades", + "index-roadmap-2027-desc": "Herramientas para la promoción", + "index-directory-h2": "Únete a las Comunidades SimpleX", + "index-directory-p1": "Miles de personas confían ya en la mensajería SimpleX.", + "index-directory-p2": "¡Encuentra comunidades en el directorio SimpleX y crea la tuya!", + "index-directory-cta": "Ir al Directorio SimpleX", + "index-directory-users-group-title": "SimpleX users group", + "how-secure-comparison-title": "Comparativa del cifrado de extremo a extremo en los distintos mensajeros", + "how-secure-message-padding": "Relleno de mensajes", + "how-secure-repudiation-deniability": "Repudio (negabilidad)", + "how-secure-forward-secrecy": "Secreto hacia adelante", + "how-secure-break-in-recovery": "Seguridad tras ser comprometido", + "how-secure-two-factor-key-exchange": "Intercambio de claves de dobre factor", + "how-secure-post-quantum-hybrid-crypto": "Cifrado híbrido postcuántico", + "messengers-comparison-section-list-point-1": "Briar rellena los mensajes hasta el tamaño redondeado a 1024 bytes, Signal a 160 bytes", + "messengers-comparison-section-list-point-2": "El repudio no incluye la conexión cliente‑servidor.", + "messengers-comparison-section-list-point-3": "Parece que el uso de firmas criptográficas compromete el repudio (negabilidad) pero es necesario aclararlo.", + "messengers-comparison-section-list-point-4": "La implementación multi‑dispositivo compromete la seguridad posterior a ser comprometida del Double Ratchet", + "messengers-comparison-section-list-point-5": "El intercambio de clave de doble factor es opcional mediante la verificación del código de seguridad.", + "messengers-comparison-section-list-point-6": "El acuerdo de claves postcuántico es \"parcial\", solo protege determinados pasos del ratchet." } diff --git a/website/langs/fa.json b/website/langs/fa.json index 5fce03d04a..250d1800ca 100644 --- a/website/langs/fa.json +++ b/website/langs/fa.json @@ -177,7 +177,7 @@ "docs-dropdown-5": "میزبانی سرور XFTP", "docs-dropdown-6": "سرورهای WebRTC", "docs-dropdown-7": "SimpleX Chat را ترجمه کنید", - "docs-dropdown-8": "سرویس دایرکتوری SimpleX", + "docs-dropdown-8": "سرویس دایرکتوری", "docs-dropdown-9": "دانلودها", "docs-dropdown-10": "شفافیت", "docs-dropdown-11": "سؤالات متداول", @@ -255,5 +255,7 @@ "simplex-network-overlay-card-1-li-1": "شبکه‌های P2P به نوعی از DHT برای مسیریابی پیام‌ها وابسته هستند. طراحی‌های DHT باید بین تضمین تحویل و تأخیر تعادل برقرار کنند. SimpleX هم تضمین تحویل بهتری دارد و هم تأخیر کمتری نسبت به P2P، زیرا پیام می‌تواند به صورت اضافی از طریق چندین سرور به طور موازی منتقل شود و از سرورهای انتخاب‌شده توسط گیرنده استفاده کند. در شبکه‌های P2P، پیام به صورت ترتیبی از طریق O(log N) گره منتقل می‌شود و این گره‌ها توسط الگوریتم انتخاب می‌شوند.", "simplex-network-overlay-card-1-li-2": "طراحی SimpleX، برخلاف اکثر شبکه‌های P2P، هیچ شناسه کاربری جهانی از هیچ نوعی، حتی موقتی، ندارد و فقط از شناسه‌های جفت‌به‌جفت موقتی استفاده می‌کند که این امر حریم خصوصی و محافظت از فراداده را بهتر فراهم می‌کند.", "simplex-network-overlay-card-1-li-3": "P2P مشکل حملات MITM را حل نمی‌کند و بیشتر پیاده‌سازی‌های موجود از پیام‌های خارج از باند برای تبادل کلید اولیه استفاده نمی‌کنند. SimpleX از پیام‌های خارج از باند یا در برخی موارد، از اتصالات امن و مورد اعتماد پیشین برای تبادل کلید اولیه استفاده می‌کند.", - "simplex-network-overlay-card-1-li-4": "پیاده‌سازی‌های P2P می‌توانند توسط برخی از ارائه‌دهندگان اینترنت (مانند BitTorrent) مسدود شوند. SimpleX مستقل از نوع حمل و نقل است — این امکان را دارد که بر روی پروتکل‌های وب استاندارد، مانند WebSockets، کار کند." + "simplex-network-overlay-card-1-li-4": "پیاده‌سازی‌های P2P می‌توانند توسط برخی از ارائه‌دهندگان اینترنت (مانند BitTorrent) مسدود شوند. SimpleX مستقل از نوع حمل و نقل است — این امکان را دارد که بر روی پروتکل‌های وب استاندارد، مانند WebSockets، کار کند.", + "directory": "دایرکتوری", + "about-and-contact-us": "درباره ما و تماس با ما" } diff --git a/website/langs/fi.json b/website/langs/fi.json index 460aa7989b..d30c465222 100644 --- a/website/langs/fi.json +++ b/website/langs/fi.json @@ -112,7 +112,7 @@ "simplex-explained-tab-1-p-1": "Voit luoda yhteyshenkilöitä ja ryhmiä sekä käydä kaksisuuntaisia keskusteluja kuten missä tahansa muussa viestisovelluksessa.", "simplex-explained-tab-3-p-1": "Palvelimilla on erilliset anonyymit tunnistetiedot kullekin jonolle, eivätkä ne tiedä, mille käyttäjille ne kuuluvat.", "donate": "Lahjoita", - "copyright-label": "© 2020-2025 SimpleX | Avoin projekti", + "copyright-label": "© 2020-2025 SimpleX Chat | Avoin projekti", "hero-p-1": "Muissa sovelluksissa on käyttäjätunnuksia: Signal, Matrix, Session, Briar, Jami, Cwtch, jne.
SimpleX ei käytä niitä, ei edes satunnaisia numeroita.
Tämä parantaa yksityisyyttäsi radikaalisti.", "simplex-private-1-title": "2 kerrosta päästä päähän salattua viestintää", "simplex-private-2-title": "Lisäkerros palvelimen salaukselle", diff --git a/website/langs/fr.json b/website/langs/fr.json index b3613bc3da..8ff71af8dc 100644 --- a/website/langs/fr.json +++ b/website/langs/fr.json @@ -21,7 +21,7 @@ "smp-protocol": "Protocole SMP", "chat-protocol": "Protocole de chat", "donate": "Faire un don", - "copyright-label": "© 2020-2025 SimpleX | Projet Open-Source", + "copyright-label": "© 2020-2025 SimpleX Chat | Projet Open-Source", "simplex-chat-protocol": "Protocole SimpleX Chat", "terminal-cli": "Terminal CLI", "terms-and-privacy-policy": "Politique de confidentialité", diff --git a/website/langs/he.json b/website/langs/he.json index 9673bea871..03dc671919 100644 --- a/website/langs/he.json +++ b/website/langs/he.json @@ -53,7 +53,7 @@ "smp-protocol": "פרוטוקול SMP", "chat-protocol": "פרוטוקול צ'אט", "donate": "תרומה", - "copyright-label": "© 2020-2025 SimpleX | פרויקט קוד פתוח", + "copyright-label": "© 2020-2025 SimpleX Chat | פרויקט קוד פתוח", "hero-p-1": "לאפליקציות אחרות יש מזהי משתמש: Signal, Matrix, Session, Briar, Jami, Cwtch וכו'.
ל-SimpleX אין, אפילו לא מספרים אקראיים.
זה משפר באופן קיצוני את הפרטיות שלך.", "hero-overlay-2-title": "מדוע מזהי משתמש מזיקים לפרטיות?", "feature-6-title": "שיחות שמע ווידאו
מוצפנות מקצה לקצה", diff --git a/website/langs/hu.json b/website/langs/hu.json index 05a2fea760..77d3ce878b 100644 --- a/website/langs/hu.json +++ b/website/langs/hu.json @@ -11,16 +11,16 @@ "simplex-explained-tab-1-text": "1. Felhasználói élmény", "simplex-explained-tab-2-text": "2. Hogyan működik", "simplex-explained-tab-3-text": "3. Mit látnak a kiszolgálók", - "simplex-explained-tab-1-p-1": "Létrehozhat kapcsolatokat és csoportokat, valamint kétirányú beszélgetéseket folytathat, ugyanúgy mint bármely más üzenetváltó-alkalmazásban.", - "simplex-explained-tab-1-p-2": "Hogyan működhet egyirányú üzenet-sorballítással és felhasználói profilazonosítók nélkül?", - "simplex-explained-tab-2-p-1": "Minden kapcsolathoz két különböző üzenetküldési sorbaállítást használ a különböző kiszolgálókon keresztül történő üzenetküldéshez és -fogadáshoz.", + "simplex-explained-tab-1-p-1": "Csoportokat hozhat létre, valamint kétirányú beszélgetéseket folytathat a partnereivel, ugyanúgy mint bármely más üzenetváltó-alkalmazásban.", + "simplex-explained-tab-1-p-2": "Hogyan működhet egyirányú várólistával és felhasználói profilazonosítók nélkül?", + "simplex-explained-tab-2-p-1": "Minden kapcsolathoz két különböző üzenetküldési várólistát használ a különböző kiszolgálókon keresztül történő üzenetküldéshez és -fogadáshoz.", "simplex-explained-tab-2-p-2": "A kiszolgálók csak egyetlen irányba továbbítják az üzeneteket, anélkül, hogy teljes képet kapnának a felhasználók beszélgetéseiről vagy kapcsolatairól.", - "simplex-explained-tab-3-p-1": "A kiszolgálók minden egyes üzenetsorbaállításhoz külön névtelen hitelesítő-adatokkal rendelkeznek, és nem tudják, hogy melyik felhasználóhoz tartoznak.", - "simplex-explained-tab-3-p-2": "A felhasználók tovább fokozhatják a metaadatok adatvédelmét, ha a Tor hálózat használatával férnek hozzá a kiszolgálókhoz, így megakadályozva az IP-cím szerinti korrelációt.", + "simplex-explained-tab-3-p-1": "A kiszolgálók minden egyes várólistához külön névtelen hitelesítő-adatokkal rendelkeznek, és nem tudják, hogy melyik felhasználóhoz tartoznak.", + "simplex-explained-tab-3-p-2": "A felhasználók tovább fokozhatják a metaadatok védelmét, ha a Tor hálózat használatával férnek hozzá a kiszolgálókhoz, így megakadályozva az IP-cím szerinti korrelációt.", "smp-protocol": "SMP-protokoll", "chat-protocol": "Csevegési protokoll", "donate": "Adományozás", - "copyright-label": "© 2020-2025 SimpleX | Nyílt forráskódú projekt", + "copyright-label": "© 2020-2025 SimpleX Chat | Nyílt forráskódú projekt", "simplex-chat-protocol": "A SimpleX Chat protokoll", "terminal-cli": "Terminál CLI", "terms-and-privacy-policy": "Adatvédelmi irányelvek", @@ -29,7 +29,7 @@ "hero-p-1": "Más alkalmazások felhasználói azonosítókkal rendelkeznek: Signal, Matrix, Session, Briar, Jami, Cwtch, stb.
A SimpleX azonban nem, még véletlenszerű számokkal sem.
Ez radikálisan javítja az adatvédelmet.", "hero-overlay-1-textlink": "Miért ártanak a felhasználói azonosítók az adatvédelemnek?", "hero-overlay-2-textlink": "Hogyan működik a SimpleX?", - "hero-overlay-3-textlink": "Biztonsági auditok", + "hero-overlay-3-textlink": "Biztonsági felmérések", "hero-2-header": "Privát kapcsolat létrehozása", "hero-2-header-desc": "A videó bemutatja, hogyan kapcsolódhat a partneréhez egy egyszer használható QR-kód segítségével, személyesen vagy videóhíváson keresztül. Ugyanakkor egy meghívási hivatkozás megosztásával is kapcsolódhat.", "hero-overlay-1-title": "Hogyan működik a SimpleX?", @@ -41,7 +41,7 @@ "feature-4-title": "Végpontok között titkosított hangüzenetek", "feature-5-title": "Eltűnő üzenetek", "feature-6-title": "Végpontok között titkosított
hang- és videóhívások", - "feature-7-title": "Hordozható titkosított alkalmazás-adattárolás — profil átköltöztetése egy másik eszközre", + "feature-7-title": "Hordozható, titkosított alkalmazás-adattárolás — profil átköltöztetése egy másik eszközre", "feature-8-title": "Az inkognitómód —
egyedülálló a SimpleX Chatben", "simplex-network-overlay-1-title": "Összehasonlítás más P2P-üzenetküldő protokollokkal", "simplex-private-1-title": "2 rétegű végpontok közötti titkosítás", @@ -51,82 +51,82 @@ "simplex-private-6-title": "Sávon kívüli
kulcscsere", "simplex-private-7-title": "Üzenetintegritás
hitelesítés", "simplex-private-8-title": "Üzenetek keverése
a korreláció csökkentése érdekében", - "simplex-private-9-title": "Egyirányú
üzenetsorbaállítás", + "simplex-private-9-title": "Egyirányú
várólista az üzenetekhez", "simplex-private-10-title": "Ideiglenes, névtelen, páronkénti azonosítók", - "simplex-private-card-1-point-1": "Double-Ratchet-protokoll —
OTR-üzenetküldés, sérülés utáni titkosság-védelemmel és -helyreállítással.", - "simplex-private-card-1-point-2": "NaCL cryptobox minden egyes üzenet sorbaállításához, hogy megakadályozza a forgalom korrelációját az üzenetsorbaállítások között, ha a TLS veszélybe kerül.", + "simplex-private-card-1-point-1": "Dupla racsnis protokoll —
OTR-üzenetküldés, kompromittálás előtti és utáni titkosság-védelemmel.", + "simplex-private-card-1-point-2": "NaCL cryptobox minden egyes várólistához, hogy megakadályozza a forgalom korrelációját az üzenetek várólistái között, ha a TLS veszélybe kerül.", "simplex-private-card-2-point-1": "Kiegészítő kiszolgáló titkosítási réteg a címzettnek történő kézbesítéshez, hogy megakadályozza a fogadott és az elküldött kiszolgálóforgalom közötti korrelációt, ha a TLS veszélybe kerül.", "simplex-private-card-3-point-1": "A kliens és a kiszolgálók közötti kapcsolatokhoz csak az erős algoritmusokkal rendelkező TLS 1.2/1.3 protokollt használja.", "simplex-private-card-3-point-2": "A kiszolgáló ujjlenyomata és a csatornakötés megakadályozza a MITM- és a visszajátszási támadásokat.", "simplex-private-card-3-point-3": "Az újrakapcsolódás le van tiltva a munkamenet elleni támadások megelőzése érdekében.", "simplex-private-card-4-point-1": "Az IP-címe védelme érdekében a kiszolgálókat a Tor hálózaton vagy más átvitelátfedő hálózaton keresztül is elérheti.", "simplex-private-card-6-point-1": "Számos kommunikációs hálózat sebezhető a kiszolgálók vagy a hálózat-szolgáltatók MITM-támadásaival szemben.", - "simplex-private-card-6-point-2": "Ennek megakadályozása érdekében a SimpleX-alkalmazások egyszeri kulcsokat adnak át sávon kívül, amikor egy címet hivatkozásként vagy QR-kódként oszt meg.", - "simplex-private-card-7-point-1": "Az integritás garantálása érdekében az üzenetek sorszámozással vannak ellátva, és tartalmazzák az előző üzenet hasítóértékét.", + "simplex-private-card-6-point-2": "Ennek megakadályozása érdekében a SimpleX alkalmazások egyszeri kulcsokat adnak át sávon kívül, amikor egy címet hivatkozásként vagy QR-kódként oszt meg.", + "simplex-private-card-7-point-1": "Az integritás garantálása érdekében az üzenetek sorszámozással vannak ellátva, és tartalmazzák az előző üzenet kivonatát.", "simplex-private-card-7-point-2": "Ha bármilyen üzenetet hozzáadnak, eltávolítanak vagy módosítanak, a címzett értesítést kap róla.", "simplex-private-card-8-point-1": "A SimpleX kiszolgálók alacsony késleltetésű keverési csomópontokként működnek — a bejövő és kimenő üzenetek sorrendje eltérő.", - "simplex-private-card-9-point-1": "Minden üzenetsorbaállítás egy irányba továbbítja az üzeneteket, a különböző küldési és vételi címekkel.", + "simplex-private-card-9-point-1": "Minden várólista az üzenetekhez egy irányba továbbítja az üzeneteket, a különböző küldési és fogadási címeken.", "simplex-private-card-9-point-2": "Kevesebb támadási felülettel rendelkezik, mint a hagyományos üzenetváltó-alkalmazások, és kevesebb metaadatot tesz elérhetővé.", "simplex-private-card-10-point-1": "A SimpleX ideiglenes, névtelen, páros címeket és hitelesítő adatokat használ minden egyes felhasználói kapcsolathoz vagy csoporttaghoz.", "simplex-private-card-10-point-2": "Lehetővé teszi az üzenetek felhasználói profilazonosítók nélküli kézbesítését, ami az alternatíváknál jobb metaadat-védelmet biztosít.", "privacy-matters-1-overlay-1-title": "Az adatvédelemmel pénzt spórol meg", "privacy-matters-1-overlay-1-linkText": "Az adatvédelemmel pénzt spórol meg", - "privacy-matters-2-title": "A választások manipulálása", + "privacy-matters-2-title": "Választások manipulálása", "privacy-matters-2-overlay-1-title": "Az adatvédelem hatalmat ad", "privacy-matters-2-overlay-1-linkText": "Az adatvédelem hatalmat ad", "privacy-matters-3-title": "Ártatlan összefüggés miatti vádemelés", "privacy-matters-3-overlay-1-title": "Az adatvédelem szabaddá tesz", "privacy-matters-3-overlay-1-linkText": "Az adatvédelem szabaddá tesz", "simplex-unique-1-title": "Teljes magánéletet élvezhet", - "simplex-unique-1-overlay-1-title": "Személyazonosságának, profiljának, kapcsolatainak és metaadatainak teljes körű védelme", - "simplex-unique-2-title": "Véd
a kéretlen tartalmaktól és a visszaélésektől", + "simplex-unique-1-overlay-1-title": "Személyazonosságának, profiljának, partnereinek és a metaadatok teljes körű védelme", + "simplex-unique-2-title": "Véd a kéretlen tartalmaktól
és a visszaélésektől", "simplex-unique-2-overlay-1-title": "A legjobb védelem a kéretlen tartalmak és a visszaélések ellen", - "simplex-unique-3-title": "Ön kezeli az adatait", + "simplex-unique-3-title": "Ön kezeli a saját adatait", "simplex-unique-3-overlay-1-title": "Az adatok biztonsága és kezelése az Ön kezében van", "simplex-unique-4-title": "Öné a SimpleX hálózat", "simplex-unique-4-overlay-1-title": "Teljesen decentralizált — a SimpleX hálózat a felhasználóké", "hero-overlay-card-1-p-1": "Sok felhasználó kérdezte: ha a SimpleXnek nincsenek felhasználói azonosítói, honnan tudja, hogy hová kell eljuttatni az üzeneteket?", - "hero-overlay-card-1-p-2": "Az üzenetek kézbesítéséhez az összes többi hálózat által használt felhasználói azonosítók helyett a SimpleX az üzenetek sorbaállításához ideiglenes, névtelen, páros azonosítókat használ, külön-külön minden egyes kapcsolathoz — nincsenek hosszú távú azonosítók.", + "hero-overlay-card-1-p-2": "Az üzenetek kézbesítéséhez az összes többi hálózat által használt felhasználói azonosítók helyett a SimpleX az üzenetek várólistába rendezéséhez ideiglenes, névtelen, páros azonosítókat használ, külön-külön minden egyes kapcsolathoz — nincsenek hosszú távú azonosítók.", "hero-overlay-card-1-p-4": "Ez a kialakítás megakadályozza a felhasználók metaadatainak kiszivárgását az alkalmazás szintjén. Az adatvédelem további javítása és az IP-cím védelme érdekében az üzenetküldő kiszolgálókhoz Tor hálózaton keresztül is kapcsolódhat.", - "hero-overlay-card-1-p-5": "Csak a kliensek tárolják a felhasználói profilokat, kapcsolatokat és csoportokat; az üzenetek küldése 2 rétegű végpontok közötti titkosítással történik.", - "hero-overlay-card-1-p-6": "További leírást a SimpleX ismertetőben olvashat.", + "hero-overlay-card-1-p-5": "Csak a kliensek tárolják a felhasználói profilokat, partnereket és csoportokat; az üzenetek küldése 2 rétegű végpontok közötti titkosítással történik.", + "hero-overlay-card-1-p-6": "További leírást a SimpleX ismertetőben olvashat.", "hero-overlay-card-2-p-1": "Ha a felhasználók állandó azonosítóval rendelkeznek, még akkor is, ha ez csak egy véletlenszerű szám, például egy munkamenet-azonosító, fennáll annak a veszélye, hogy a szolgáltató vagy egy támadó megfigyelheti, azt hogy hogyan kapcsolódnak a felhasználók egymáshoz, és hány üzenetet küldenek egymásnak.", "hero-overlay-card-2-p-2": "Ezt az információt aztán összefüggésbe hozhatják a meglévő nyilvános közösségi hálózatokkal, és meghatározhatnak néhány valódi személyazonosságot.", - "hero-overlay-card-2-p-3": "Még a Tor v3 szolgáltatásokat használó, legprivátabb alkalmazások esetében is, ha két különböző kapcsolattartóval beszél ugyanazon a profilon keresztül, bizonyítani tudják, hogy ugyanahhoz a személyhez kapcsolódnak.", + "hero-overlay-card-2-p-3": "Még a Tor v3 szolgáltatásokat használó, legprivátabb alkalmazások esetében is, ha két különböző partnerével beszél ugyanazon a profilon keresztül, bizonyítani tudják, hogy ugyanahhoz a személyhez kapcsolódnak.", "hero-overlay-card-2-p-4": "A SimpleX úgy védekezik ezen támadások ellen, hogy nem tartalmaz felhasználói azonosítókat. Ha pedig használja az inkognitómódot, akkor minden egyes létrejött kapcsolatban más-más felhasználó név jelenik meg, így elkerülhető a közöttük lévő összefüggések teljes bizonyítása.", "hero-overlay-card-3-p-1": "Trail of Bits egy vezető biztonsági és technológiai tanácsadó cég, amelynek az ügyfelei közé tartoznak nagy technológiai cégek, kormányzati ügynökségek és jelentős blokklánc projektek.", "hero-overlay-card-3-p-2": "A Trail of Bits 2022 novemberében auditálta a SimpleX protokollok kriptográfiai és hálózati komponenseit. További információk.", - "simplex-network-overlay-card-1-li-1": "A P2P-hálózatok az üzenetek továbbítására a DHT valamelyik változatát használják. A DHT kialakításakor egyensúlyt kell teremteni a kézbesítési garancia és a késleltetés között. A SimpleX jobb kézbesítési garanciával és alacsonyabb késleltetéssel rendelkezik, mint a P2P, mivel az üzenet redundánsan, a címzett által kiválasztott kiszolgálók segítségével több kiszolgálón keresztül párhuzamosan továbbítható. A P2P-hálózatokban az üzenet O(log N) csomóponton halad át szekvenciálisan, az algoritmus által kiválasztott csomópontok segítségével.", - "simplex-network-overlay-card-1-li-2": "A SimpleX kialakítása a legtöbb P2P-hálózattól eltérően nem rendelkezik semmiféle globális felhasználói azonosítóval, még ideiglenessel sem, és csak az üzenetekhez használ ideiglenes, páros azonosítókat, ami jobb névtelenséget és metaadatvédelmet biztosít.", - "simplex-network-overlay-card-1-li-3": "A P2P nem oldja meg a MITM-támadás problémát, és a legtöbb létező implementáció nem használ sávon kívüli üzeneteket a kezdeti kulcscseréhez. A SimpleX a kezdeti kulcscseréhez sávon kívüli üzeneteket, vagy bizonyos esetekben már meglévő biztonságos és megbízható kapcsolatokat használ.", - "simplex-network-overlay-card-1-li-6": "A P2P-hálózatok sebezhetőek lehetnek a DRDoS-támadással szemben, amikor a kliensek képesek a forgalmat újraközvetíteni és felerősíteni, ami az egész hálózatra kiterjedő szolgáltatásmegtagadást eredményez. A SimpleX kliensek csak az ismert kapcsolatból származó forgalmat továbbítják, és a támadó nem használhatja őket arra, hogy az egész hálózatban felerősítse a forgalmat.", - "simplex-network-overlay-card-1-li-5": "Minden ismert P2P-hálózat sebezhető Sybil támadással, mert minden egyes csomópont felderíthető, és a hálózat egészként működik. A támadások enyhítésére szolgáló ismert intézkedés lehet egy központi kiszolgáló (pl.: tracker), vagy egy drága tanúsítvány. A SimpleX hálózat nem ismeri fel a kiszolgálókat, töredezett és több elszigetelt alhálózatként működik, ami lehetetlenné teszi az egész hálózatra kiterjedő támadásokat.", + "simplex-network-overlay-card-1-li-1": "A P2P-hálózatok az üzenetek továbbítására a DHT valamelyik változatát használják. A DHT kialakításakor egyensúlyt kell teremteni a kézbesítési garancia és a késleltetés között. A SimpleX jobb kézbesítési garanciával és alacsonyabb késleltetéssel rendelkezik, mint a P2P, mivel az üzenet redundánsan, a címzett által kiválasztott kiszolgálók segítségével több kiszolgálón keresztül párhuzamosan továbbítható. A P2P-hálózatokban az üzenet O(log N) csomóponton halad át szekvenciálisan, az algoritmus által kiválasztott csomópontok segítségével.", + "simplex-network-overlay-card-1-li-2": "A SimpleX kialakítása a legtöbb P2P-hálózattól eltérően nem rendelkezik semmiféle globális felhasználói azonosítóval, még ideiglenessel sem, és csak az üzenetekhez használ ideiglenes, páros azonosítókat, ami jobb névtelenséget és metaadat-védelmet biztosít.", + "simplex-network-overlay-card-1-li-3": "A P2P nem oldja meg a MITM-támadás problémát, és a legtöbb létező implementáció nem használ sávon kívüli üzeneteket a kezdeti kulcscseréhez. A SimpleX a kezdeti kulcscseréhez sávon kívüli üzeneteket, vagy bizonyos esetekben már meglévő biztonságos és megbízható kapcsolatokat használ.", + "simplex-network-overlay-card-1-li-6": "A P2P-hálózatok sebezhetőek lehetnek a DRDoS-támadással szemben, amikor a kliensek képesek a forgalmat újraközvetíteni és felerősíteni, ami az egész hálózatra kiterjedő szolgáltatásmegtagadást eredményez. A SimpleX kliensek csak az ismert kapcsolatból származó forgalmat továbbítják, és a támadó nem használhatja őket arra, hogy az egész hálózatban felerősítse a forgalmat.", + "simplex-network-overlay-card-1-li-5": "Minden ismert P2P-hálózat sebezhető Sybil támadással, mert minden egyes csomópont felderíthető, és a hálózat egészként működik. A támadások enyhítésére szolgáló ismert intézkedés lehet egy központi kiszolgáló (például: tracker), vagy egy drága tanúsítvány. A SimpleX hálózat nem ismeri fel a kiszolgálókat, töredezett és több elszigetelt alhálózatként működik, ami lehetetlenné teszi az egész hálózatra kiterjedő támadásokat.", "privacy-matters-overlay-card-1-p-1": "Sok nagyvállalat arra használja fel az önnel kapcsolatban álló személyek adatait, hogy megbecsülje az ön jövedelmét, hogy olyan termékeket adjon el önnek, amelyekre valójában nincs is szüksége, és hogy meghatározza az árakat.", "privacy-matters-overlay-card-1-p-2": "Az online kiskereskedők tudják, hogy az alacsonyabb jövedelműek nagyobb valószínűséggel vásárolnak azonnal, ezért magasabb árakat számíthatnak fel, vagy eltörölhetik a kedvezményeket.", - "privacy-matters-overlay-card-1-p-3": "Egyes pénzügyi és biztosítótársaságok szociális grafikonokat használnak a kamatlábak és a díjak meghatározásához. Ez gyakran arra készteti az alacsonyabb jövedelmű embereket, hogy többet fizessenek — ez az úgynevezett „szegénységi prémium”.", + "privacy-matters-overlay-card-1-p-3": "Egyes pénzügyi és biztosítótársaságok szociális grafikonokat használnak a kamatlábak és a díjak meghatározásához. Ez gyakran arra készteti az alacsonyabb jövedelmű embereket, hogy többet fizessenek — ez az úgynevezett „szegénységi prémium”.", "privacy-matters-overlay-card-1-p-4": "A SimpleX hálózat minden alternatívánál jobban védi a kapcsolatainak adatait, teljes mértékben megakadályozva, hogy az ismeretségi-hálója bármilyen vállalat vagy szervezet számára elérhetővé váljon. Még ha az emberek a SimpleX Chat által előre beállított kiszolgálókat is használják, sem az alkalmazások, sem a kiszolgálók üzemeltetői nem ismerik, sem felhasználók számát, sem a kapcsolataikat.", - "privacy-matters-overlay-card-2-p-1": "Nem is olyan régen megfigyelhettük, hogy a nagy választásokat manipulálta egy neves tanácsadó cég, amely az ismeretségi-háló segítségével eltorzította a valós világról alkotott képünket, és manipulálta a szavazatainkat.", + "privacy-matters-overlay-card-2-p-1": "Nem is olyan régen megfigyelhettük, hogy a nagy választásokat manipulálta egy neves tanácsadó cég, amely az ismeretségi-háló segítségével eltorzította a valós világról alkotott képünket, és manipulálta a szavazatainkat.", "privacy-matters-overlay-card-2-p-2": "Ahhoz, hogy objektív legyen és független döntéseket tudjon hozni, az információs terét is kézben kell tartania. Ez csak akkor lehetséges, ha privát kommunikációs hálózatot használ, amely nem fér hozzá az ismeretségi-hálójához.", "privacy-matters-overlay-card-2-p-3": "A SimpleX az első olyan hálózat, amely eleve nem rendelkezik felhasználói azonosítókkal, így jobban védi az ismeretségi-hálóját, mint bármely ismert alternatíva.", "privacy-matters-overlay-card-3-p-1": "Mindenkinek törődnie kell a magánélet és a kommunikáció biztonságával — az ártalmatlan beszélgetések veszélybe sodorhatják, még akkor is, ha nincs semmi rejtegetnivalója.", - "privacy-matters-overlay-card-3-p-2": "Az egyik legmegdöbbentőbb a Mohamedou Ould Salahi memoárjában leírt és az „A mauritániai” c. filmben bemutatott történet. Őt bírósági tárgyalás nélkül a guantánamói táborba zárták, és ott kínozták 15 éven át, miután egy afganisztáni rokonát telefonon felhívta, akit azzal gyanúsítottak a hatóságok, hogy köze van a 9/11-es merényletekhez, holott Salahi az előző 10 évben Németországban élt.", - "privacy-matters-overlay-card-3-p-3": "Átlagos embereket letartóztatnak azért, amit online megosztanak, még „névtelen” fiókjaikon keresztül is, még demokratikus országokban is.", + "privacy-matters-overlay-card-3-p-2": "Az egyik legmegdöbbentőbb a Mohamedou Ould Salahi memoárjában leírt és az „A mauritániai” c. filmben bemutatott történet. Őt bírósági tárgyalás nélkül a guantánamói táborba zárták, és ott kínozták 15 éven át, miután egy afganisztáni rokonát telefonon felhívta, akit azzal gyanúsítottak a hatóságok, hogy köze van a 9/11-es merényletekhez, holott Salahi az előző 10 évben Németországban élt.", + "privacy-matters-overlay-card-3-p-3": "Átlagos embereket letartóztatnak azért, amit online megosztanak, még „névtelen” fiókjaikon keresztül is, még demokratikus országokban is.", "privacy-matters-overlay-card-3-p-4": "Nem elég csak egy végpontok között titkosított üzenetváltó-alkalmazást használnunk, mindannyiunknak olyan üzenetváltó-alkalmazásokat kell használnunk, amelyek védik a személyes partnereink magánéletét — akikkel kapcsolatban állunk.", "simplex-unique-overlay-card-1-p-1": "Más üzenetküldő hálózatoktól eltérően a SimpleX nem rendel azonosítókat a felhasználókhoz. Nem támaszkodik telefonszámokra, tartomány-alapú címekre (mint az e-mail, XMPP vagy a Matrix), felhasználónevekre, nyilvános kulcsokra vagy akár véletlenszerű számokra a felhasználók azonosításához — a SimpleX kiszolgálók üzemeltetői nem tudják, hogy hányan használják a kiszolgálóikat.", - "simplex-unique-overlay-card-1-p-2": "Az üzenetek kézbesítéséhez a SimpleX az egyirányú üzenet várakoztatást használ páronkénti névtelen címekkel, külön a fogadott és külön az elküldött üzenetek számára, általában különböző kiszolgálókon keresztül.", - "simplex-unique-overlay-card-1-p-3": "Ez a kialakítás védi partnerének adatait, elrejtve azt a SimpleX hálózat kiszolgálói és a külső megfigyelők elől. Az IP-címe elrejtésének érdekében aTor hálózaton keresztül is kapcsolódhat a SimpleX kiszolgálókhoz.", - "simplex-unique-overlay-card-2-p-1": "Mivel ön nem rendelkezik azonosítóval a SimpleX hálózaton, senki sem tud kapcsolatba lépni önnel, hacsak nem oszt meg egy egyszeri vagy ideiglenes felhasználói címet, például QR-kódot vagy hivatkozást.", + "simplex-unique-overlay-card-1-p-2": "Az üzenetek kézbesítéséhez a SimpleX egyirányú várólistákat használ az üzenetekhez, páronkénti, névtelen címekkel, külön a fogadott és külön az elküldött üzenetekhez, általában különböző kiszolgálókon keresztül.", + "simplex-unique-overlay-card-1-p-3": "Ez a kialakítás védi partnerei adatait, elrejtve azt a SimpleX hálózat kiszolgálói és a külső megfigyelők elől. Az IP-címe elrejtésének érdekében a Tor hálózaton keresztül is kapcsolódhat a SimpleX kiszolgálókhoz.", + "simplex-unique-overlay-card-2-p-1": "Mivel senki sem rendelkezik azonosítóval a SimpleX hálózaton, ezért senki sem tud kapcsolatba lépni Önnel, hacsak nem oszt meg egy egyszeri vagy ideiglenes felhasználói címet, például QR-kódot vagy hivatkozást.", "simplex-unique-overlay-card-2-p-2": "Még a felhasználói cím használata esetén is, aminek használata nem kötelező – ugyanakkor ez a kéretlen kapcsolatkérelmek küldésére is használható – módosíthatja vagy teljesen törölheti a címet anélkül, hogy elveszítené a meglévő kapcsolatait.", "simplex-unique-overlay-card-3-p-1": "A SimpleX Chat az összes felhasználói adatot kizárólag a klienseken tárolja egy hordozható titkosított adatbázis-formátumban, amely exportálható és átvihető bármely más támogatott eszközre.", - "simplex-unique-overlay-card-3-p-2": "A végpontok között titkosított üzenetek átmenetileg a SimpleX továbbítókiszolgálóin tartózkodnak, amíg be nem érkeznek a címzetthez, majd automatikusan véglegesen törlődnek onnan.", + "simplex-unique-overlay-card-3-p-2": "A végpontok között titkosított üzenetek átmenetileg a SimpleX továbbítókiszolgálóin tárolódnak, amíg meg nem érkeznek a címzetthez, majd automatikusan véglegesen törlődnek onnan.", "simplex-unique-overlay-card-3-p-3": "A föderált hálózatok kiszolgálóitól (e-mail, XMPP vagy Matrix) eltérően a SimpleX kiszolgálók nem tárolják a felhasználói fiókokat, csak továbbítják az üzeneteket, így védve mindkét fél magánéletét.", "simplex-unique-overlay-card-3-p-4": "A küldött és a fogadott kiszolgálóforgalom között nincsenek közös azonosítók vagy titkosított szövegek — ha bárki megfigyeli, nem tudja könnyen megállapítani, hogy ki kivel kommunikál, még akkor sem, ha a TLS-t kompromittálják.", "simplex-unique-overlay-card-4-p-1": "Használhatja a SimpleXet a saját kiszolgálóival, és továbbra is kommunikálhat azokkal, akik az előre beállított kiszolgálókat használják az alkalmazásban.", - "simplex-unique-overlay-card-4-p-2": "A SimpleX hálózat nyitott protokollt használ és SDK-t biztosít a csevegési botok létrehozásához, lehetővé téve olyan szolgáltatások megvalósítását, amelyekkel a felhasználók a SimpleX Chat alkalmazásokon keresztül léphetnek kapcsolatba — mi már nagyon várjuk, hogy milyen SimpleX szolgáltatásokat készítenek a lelkes közreműködők.", - "simplex-unique-overlay-card-4-p-3": "Ha a SimpleX hálózatra való fejlesztést fontolgatja, például a SimpleX alkalmazások felhasználóinak szánt csevegési botot, vagy a SimpleX Chat könyvtárbotjának integrálását más mobilalkalmazásba, lépjen velünk kapcsolatba, ha bármilyen tanácsot vagy támogatást szeretne kapni.", - "simplex-unique-card-1-p-1": "A SimpleX megvédi a profilhoz tartozó kapcsolatokat és metaadatokat, elrejtve azokat a SimpleX hálózat kiszolgálói és a megfigyelők elől.", + "simplex-unique-overlay-card-4-p-2": "A SimpleX hálózat nyitott protokollt használ és SDK-t biztosít a csevegési botok létrehozásához, lehetővé téve olyan szolgáltatások megvalósítását, amelyekkel a felhasználók a SimpleX Chat alkalmazásokon keresztül léphetnek kapcsolatba — mi már nagyon várjuk, hogy milyen SimpleX szolgáltatásokat készítenek a lelkes közreműködők.", + "simplex-unique-overlay-card-4-p-3": "Ha a SimpleX hálózatra való fejlesztést fontolgatja, például a SimpleX alkalmazások felhasználóinak szánt csevegési botot, vagy a SimpleX Chat könyvtárbotjának integrálását más mobilalkalmazásba, lépjen velünk kapcsolatba, ha bármilyen tanácsot vagy támogatást szeretne kapni.", + "simplex-unique-card-1-p-1": "A SimpleX megvédi a profilhoz tartozó partnereket és azok metaadatait is, elrejtve azokat a SimpleX hálózat kiszolgálói és a megfigyelők elől.", "simplex-unique-card-1-p-2": "Minden más létező üzenetküldő hálózattól eltérően a SimpleX nem rendelkezik a felhasználókhoz rendelt azonosítókkal — még véletlenszerű számokkal sem.", - "simplex-unique-card-2-p-1": "Mivel a SimpleX hálózaton nincs azonosítója vagy állandó címe, senki sem tud kapcsolatba lépni Önnel, hacsak nem oszt meg egy egyszeri vagy ideiglenes felhasználói címet, például QR-kódot vagy hivatkozást.", + "simplex-unique-card-2-p-1": "Mivel a SimpleX hálózaton senkinek sincs azonosítója vagy állandó címe, ezért senki sem tud kapcsolatba lépni Önnel, hacsak nem oszt meg egy egyszeri vagy ideiglenes felhasználói címet, például QR-kódot vagy hivatkozást.", "simplex-unique-card-3-p-1": "A SimpleX Chat az összes felhasználói adatot kizárólag a klienseken tárolja egy hordozható titkosított adatbázis-formátumban —, amely exportálható és átvihető bármely más támogatott eszközre.", "simplex-unique-card-3-p-2": "A végpontok között titkosított üzenetek átmenetileg a SimpleX továbbítókiszolgálóin tartózkodnak, amíg be nem érkeznek a címzetthez, majd automatikusan véglegesen törlődnek onnan.", "simplex-unique-card-4-p-1": "A SimpleX hálózat teljesen decentralizált és független bármely kriptopénztől vagy bármely más hálózattól, kivéve az internetet.", @@ -137,23 +137,23 @@ "join-us-on-GitHub": "Csatlakozzon hozzánk a GitHubon", "donate-here-to-help-us": "Adományozzon és segítsen nekünk", "sign-up-to-receive-our-updates": "Regisztráljon a hírleveleinkre, hogy ne maradjon le semmiről", - "enter-your-email-address": "Adja meg az e-mail-címét", + "enter-your-email-address": "Adjon meg egy e-mail-címet", "get-simplex": "A SimpleX számítógépes alkalmazásának letöltése", - "why-simplex-is-unique": "A SimpleX mitől egyedülálló", + "why-simplex-is-unique": "Miért egyedülálló a SimpleX", "learn-more": "Tudjon meg többet", "more-info": "További információ", "hide-info": "Információ elrejtése", "contact-hero-subheader": "Olvassa be a QR-kódot a SimpleX Chat alkalmazással a telefonjával vagy táblagépével.", - "contact-hero-p-1": "A hivatkozásban szereplő nyilvános kulcsokat és az üzenetek várakoztatási címét NEM küldi el a hálózaton keresztül, amikor megtekinti ezt az oldalt — azokat, a hivatkozás webcímének hasítóérték-töredéke tartalmazza.", + "contact-hero-p-1": "A hivatkozásban szereplő nyilvános kulcsokat és az üzenetek várólistájának címe NEM kerül elküldésre a hálózaton keresztül, amikor megtekinti ezt az oldalt — azokat, a hivatkozás webcímének kivonattöredéke tartalmazza.", "contact-hero-p-2": "Még nem töltötte le a SimpleX Chatet?", "contact-hero-p-3": "Az alkalmazás letöltéséhez használja az alábbi hivatkozásokat.", "scan-qr-code-from-mobile-app": "QR-kód beolvasása mobilalkalmazásból", "to-make-a-connection": "A kapcsolat létrehozásához:", "install-simplex-app": "Telepítse a SimpleX alkalmazást", "open-simplex-app": "Simplex alkalmazás megnyitása", - "tap-the-connect-button-in-the-app": "Koppintson a „kapcsolódás” gombra az alkalmazásban", + "tap-the-connect-button-in-the-app": "Koppintson a „kapcsolódás” gombra az alkalmazásban", "scan-the-qr-code-with-the-simplex-chat-app": "Olvassa be a QR-kódot a SimpleX Chat alkalmazással", - "scan-the-qr-code-with-the-simplex-chat-app-description": "A hivatkozásban szereplő nyilvános kulcsokat és az üzenetek várakoztatási címét NEM küldjük el a hálózaton keresztül, amikor ezt az oldalt megtekinti —
ezek a hivatkozás webcímének hasítóérték-töredékében szerepelnek.", + "scan-the-qr-code-with-the-simplex-chat-app-description": "A hivatkozásban szereplő nyilvános kulcsokat és az üzenetek várólistájának címe NEM kerül elküldésre a hálózaton keresztül, amikor ezt az oldalt megtekinti —
ezek a hivatkozás webcímének kivonattöredékében szerepelnek.", "installing-simplex-chat-to-terminal": "A SimpleX chat telepítése a terminálhoz", "use-this-command": "Használja ezt a parancsot:", "see-simplex-chat": "Az utasításokat megtekintheti a SimpleX Chat", @@ -163,23 +163,23 @@ "if-you-already-installed": "Ha már telepítette a", "simplex-chat-for-the-terminal": "SimpleX Chat a terminálhoz", "copy-the-command-below-text": "másolja be az alábbi parancsot, és használja a csevegésben:", - "privacy-matters-section-header": "Miért számít az adatvédelem", - "privacy-matters-section-subheader": "A metaadatok — pédául, hogy kivel beszélget — védelmének megőrzése biztonságot nyújt a következők ellen:", + "privacy-matters-section-header": "Miért számít az adatvédelem", + "privacy-matters-section-subheader": "A metaadatok — például, hogy kivel beszélget — védelmének megőrzése biztonságot nyújt a következők ellen:", "privacy-matters-section-label": "Győződjön meg arról, hogy az üzenetváltó-alkalmazás amit használ nem fér hozzá az adataihoz!", - "simplex-private-section-header": "Mitől lesz a SimpleX privát", - "simplex-network-section-header": "SimpleXhálózat", + "simplex-private-section-header": "Mitől lesz a SimpleX privát", + "simplex-network-section-header": "SimpleX hálózat", "simplex-network-section-desc": "A Simplex Chat a P2P- és a föderált hálózatok előnyeinek kombinálásával biztosítja a legjobb adatvédelmet.", "simplex-network-1-desc": "Minden üzenet a kiszolgálókon keresztül kerül elküldésre, ami jobb metaadat-védelmet és megbízható aszinkron üzenetkézbesítést biztosít, miközben elkerülhető a sok", "simplex-network-2-header": "A föderált hálózatokkal ellentétben", - "simplex-network-2-desc": "A SimpleX továbbítókiszolgálói NEM tárolnak felhasználói profilokat, kapcsolatokat és kézbesített üzeneteket, NEM kapcsolódnak egymáshoz, és NINCS kiszolgálókönyvtár.", + "simplex-network-2-desc": "A SimpleX továbbítókiszolgálói NEM tárolnak felhasználói profilokat, kapcsolatokat és kézbesített üzeneteket, NEM kapcsolódnak egymáshoz, és NINCS kiszolgálójegyzék.", "simplex-network-3-header": "SimpleX hálózat", - "simplex-network-3-desc": "a kiszolgálók egyirányú üzenet várakoztatásokat biztosítanak a felhasználók összekapcsolásához, de nem látják a hálózati kapcsolati gráfot; azt csak a felhasználók látják.", + "simplex-network-3-desc": "a kiszolgálók egyirányú várólistákat biztosítanak a felhasználók összekapcsolásához, de nem látják a hálózati kapcsolati gráfot; azt csak a felhasználók látják.", "comparison-section-header": "Összehasonlítás más protokollokkal", - "protocol-1-text": "Signal, nagy platformok", + "protocol-1-text": "Signal, és a nagy platformok", "protocol-2-text": "XMPP, Matrix", "protocol-3-text": "P2P-protokollok", "comparison-point-1-text": "Globális személyazonosságot igényel", - "comparison-point-2-text": "MITM lehetősége", + "comparison-point-2-text": "A MITM lehetősége", "comparison-point-4-text": "Egyetlen vagy központosított hálózat", "comparison-point-5-text": "Központi komponens vagy más hálózati szintű támadás", "no": "Nem", @@ -191,7 +191,7 @@ "comparison-section-list-point-1": "Általában telefonszám alapján, néhány esetben felhasználónév alapján", "comparison-section-list-point-2": "DNS-alapú címek", "comparison-section-list-point-3": "Nyilvános kulcs vagy más globális egyedi azonosító", - "comparison-section-list-point-4a": "A SimpleX továbbítókiszolgálói nem veszélyeztethetik az e2e titkosítást. Hitelesítse a biztonsági kódot a sávon kívüli csatorna elleni támadások veszélyeinek csökkentésére", + "comparison-section-list-point-4a": "A SimpleX továbbítókiszolgálói nem veszélyeztethetik a végpontok közötti titkosítást. Hitelesítse a biztonsági kódot a sávon kívüli csatorna elleni támadások veszélyeinek csökkentésére", "comparison-section-list-point-4": "Ha az üzemeltetett kiszolgálók veszélybe kerülnek. Hitelesítse a biztonsági kódot a Signal vagy más biztonságos üzenetküldő alkalmazás segítségével a támadások veszélyeinek csökkentésére", "comparison-section-list-point-5": "Nem védi a felhasználók metaadatait", "comparison-section-list-point-6": "Bár a P2P elosztott, de nem föderált — egyetlen hálózatként működnek", @@ -209,28 +209,28 @@ "docs-dropdown-1": "SimpleX hálózat", "docs-dropdown-2": "Android fájlok elérése", "docs-dropdown-3": "Hozzáférés a csevegési adatbázishoz", - "docs-dropdown-8": "SimpleX jegyzékszolgáltatás", + "docs-dropdown-8": "SimpleX csoportjegyzék", "docs-dropdown-9": "Letöltések", - "f-droid-page-simplex-chat-repo-section-text": "Ha hozzá szeretné adni az F-Droid klienséhez, olvassa be a QR-kódot, vagy használja ezt a webcímet:", + "f-droid-page-simplex-chat-repo-section-text": "Ha hozzá szeretné adni az F-Droid klienséhez, olvassa be a QR-kódot, vagy használja ezt a webcímet:", "signing-key-fingerprint": "Az aláírókulcs ujjlenyomata (SHA-256)", "f-droid-org-repo": "F-Droid-tároló", "stable-versions-built-by-f-droid-org": "F-Droid.org által készített stabil verziók", "releases-to-this-repo-are-done-1-2-days-later": "A kiadások ebben a tárolóban néhány napot késnek", - "f-droid-page-f-droid-org-repo-section-text": "A SimpleX Chat és az F-Droid-tároló különböző kulcsokkal írják alá az összeállításokat. A váltáshoz exportálja a csevegési adatbázist és telepítse újra az alkalmazást.", + "f-droid-page-f-droid-org-repo-section-text": "A SimpleX Chat és az F-Droid-tároló különböző kulcsokkal írják alá az összeállításokat. A váltáshoz exportálja a csevegési adatbázist és telepítse újra az alkalmazást.", "jobs": "Csatlakozzon a csapathoz", "please-enable-javascript": "Engedélyezze a JavaScriptet a QR-kód megjelenítéséhez.", "please-use-link-in-mobile-app": "Használja a mobilalkalmazásban található hivatkozást", "contact-hero-header": "Kapott egy meghívót a SimpleX Chaten való beszélgetéshez", "invitation-hero-header": "Kapott egy egyszer használható meghívót a SimpleX Chaten való beszélgetéshez", - "simplex-network-overlay-card-1-li-4": "A P2P-megvalósításokat egyes internetszolgáltatók blokkolhatják (mint például a BitTorrent). A SimpleX átvitel-független — a szabványos webes protokollokon, például WebSocketsen keresztül is működik.", + "simplex-network-overlay-card-1-li-4": "A P2P-megvalósításokat egyes internetszolgáltatók blokkolhatják (mint például a BitTorrent). A SimpleX átvitel-független — a szabványos webes protokollokon, például WebSocketsen keresztül is működik.", "simplex-private-card-4-point-2": "A SimpleX, Tor hálózaton keresztüli használatához telepítse az Orbot alkalmazást és engedélyezze a SOCKS5 proxyt (vagy a VPN-t az iOS-ban).", "simplex-private-card-5-point-1": "A SimpleX minden titkosítási réteghez tartalomkitöltést használ az üzenetméretre irányuló támadások meghiúsítása érdekében.", "simplex-private-card-5-point-2": "A kiszolgálók és a hálózatot megfigyelők számára a különböző méretű üzenetek egyformának tűnnek.", "privacy-matters-1-title": "Hirdetés és árdiszkrimináció", - "hero-overlay-card-1-p-3": "Ön határozza meg, hogy melyik kiszolgáló(ka)t használja az üzenetek fogadására, a kapcsolatokhoz — azokat a kiszolgálókat, amelyeket az üzenetek küldésére használ. Minden beszélgetés két különböző kiszolgálót használ.", - "simplex-network-overlay-card-1-p-1": "A P2P üzenetküldő protokollok és alkalmazások számos problémával küzdenek, amelyek miatt kevésbé megbízhatóak, mint a SimpleX, bonyolultabb az elemzésük és többféle támadással szemben sebezhetőek.", - "chat-bot-example": "Példa csevegési botra", - "simplex-private-3-title": "Biztonságos, hitelesített
TLS adatátvitel", + "hero-overlay-card-1-p-3": "Ön határozza meg, hogy melyik kiszolgáló(ka)t használja az üzenetek fogadására, a partnereihez — azokat a kiszolgálókat, amelyeket az üzenetek küldésére használ. Minden beszélgetés két különböző kiszolgálót használ.", + "simplex-network-overlay-card-1-p-1": "A P2P üzenetküldő protokollok és alkalmazások számos problémával küzdenek, amelyek miatt kevésbé megbízhatóak, mint a SimpleX, bonyolultabb az elemzésük és többféle támadással szemben sebezhetőek.", + "chat-bot-example": "Példa egy csevegési botra", + "simplex-private-3-title": "Biztonságos, hitelesített
TLS-adatátvitel", "github-repository": "GitHub-tárolójában", "tap-to-close": "Bezárás", "simplex-network-1-header": "A P2P-hálózatokkal ellentétben", @@ -243,7 +243,7 @@ "docs-dropdown-6": "WebRTC-kiszolgálók", "docs-dropdown-7": "SimpleX Chat honosítása", "docs-dropdown-10": "Átláthatóság", - "docs-dropdown-11": "GY.I.K.", + "docs-dropdown-11": "GYIK", "docs-dropdown-12": "Biztonság", "newer-version-of-eng-msg": "Ennek az oldalnak van egy újabb angol nyelvű változata.", "click-to-see": "Kattintson a megtekintéséhez", @@ -255,5 +255,61 @@ "simplex-chat-repo": "A SimpleX Chat tárolója", "stable-and-beta-versions-built-by-developers": "A fejlesztők által készített stabil és béta verziók", "hero-overlay-card-3-p-3": "A Trail of Bits 2024 júliusában ismét auditálta a SimpleX protokollok kriptográfiai és hálózati komponenseit. További információk.", - "docs-dropdown-14": "SimpleX üzleti célra" + "docs-dropdown-14": "SimpleX üzleti célra", + "directory": "Csoportjegyzék", + "about-and-contact-us": "Névjegy és kapcsolat", + "index-hero-h1": "Legyen
szabad", + "index-hero-p1": "Az első olyan hálózat, ahol Ön a tulajdonosa saját identitásának, partnereinek és csoportjainak.", + "index-hero-download-desktop-btn-title": "SimpleX számítógépes alkalmazásának letöltése", + "index-security-assessment-title": "Biztonsági auditok", + "index-security-review-2022-title": "Biztonsági audit 2022", + "index-security-review-2024-title": "Biztonsági audit 2024", + "index-security-audits-label": "Biztonsági
auditok", + "index-publications-heise-title": "A Heise Online kiadványai", + "index-hero-h2": "Az Ön kommunikációjának
szabadsága és biztonsága", + "index-testflight-title": "Nyilvános betekintés az iOS alkalmazás fejlesztésébe a TestFlighton", + "index-f-droid-title": "SimpleX alkalmazás az F-Droidon keresztül", + "index-publications-privacy-guides-title": "A Privacy Guides üzenetváltó ajánlásai", + "index-publications-whonix-title": "Whonix ajánlás", + "index-publications-kuketz-title": "Áttekintette: Mike Kuketz", + "index-publications-optout-title": "OptOut podcast interjú", + "worlds-most-secure-messaging": "A világ legbiztonságosabb üzenetváltó alkalmazása", + "index-messaging-p1": "Az üzenetváltás a SimpleXben élvonalbeli végpontok közötti titkosítással rendelkezik.", + "index-messaging-p2": "Az Ön biztonsága és magánszférájának védelme érdekében a kiszolgálók nem látják az üzeneteit, és azt sem, hogy kivel beszélget.", + "index-messaging-cta": "Tudjon meg többet a SimpleX üzenetváltó alkalmazásról", + "index-nextweb-h2": "Legyen az Öné
A jövő hálózata", + "index-nextweb-p1": "A SimpleX arra a meggyőződésre épül, hogy Önnek kell birtokolnia a saját identitását és a kapcsolatot partnereivel és a közösségeivel.", + "index-nextweb-p2": "A nyílt és decentralizált hálózat lehetővé teszi, hogy kapcsolatba lépjen másokkal és ötleteket osszon meg: legyen szabad biztonságban.", + "index-token-h2": "Időtálló közösségek", + "index-token-p1": "A jövőben közösségi utalványokkal támogathatja a kedvenc csoportjait.", + "index-token-p2": "Az utalványokkal fizetni tudja a kiszolgálókat, hogy a közösségek szabadok és függetlenek maradhassanak.", + "index-token-cta": "Tudjon meg többet, és szerezzen ingyenes NFT-t az előzetes tesztelésért.", + "index-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", + "index-roadmap-2025-desc": "Központosított platformok elhagyása", + "index-roadmap-2026": "2026", + "index-roadmap-2026-title": "Fenntartható közösségek és kiszolgálók", + "index-roadmap-2026-desc": "Közösségi utalványok elindítása", + "index-roadmap-2027": "2027", + "index-roadmap-2027-title": "Növelje közösségeit", + "index-roadmap-2027-desc": "Eszközök a közösségei népszerűsítéséhez", + "index-directory-h2": "Csatlakozzon a SimpleX közösségekhez", + "index-directory-p1": "Már emberek százezrei bíznak a SimpleXen való üzenetváltásban.", + "index-directory-p2": "Találja meg a kedvenc közösségeit a SimpleX csoportjegyzékében, vagy hozza létre a saját csoportját!", + "index-directory-cta": "SimpleX csoportjegyzék megtekintése", + "index-directory-users-group-title": "SimpleX felhasználók csoportja", + "how-secure-comparison-title": "A végpontok közötti titkosítás összehasonlítása más üzenetváltó alkalmazásokkal", + "how-secure-message-padding": "Tartalomkitöltés", + "how-secure-repudiation-deniability": "Letagadhatóság", + "how-secure-forward-secrecy": "Kompromittálás előtti titkosságvédelem", + "how-secure-break-in-recovery": "Kompromittálás utáni titkosságvédelem", + "how-secure-two-factor-key-exchange": "Kétlépcsős kulcscsere", + "how-secure-post-quantum-hybrid-crypto": "Kvantumbiztos, hibrid kriptográfia", + "messengers-comparison-section-list-point-1": "A Briar 1024 bájtra, a Signal pedig 160 bájtra kerekítve tölti ki az üzenetek tartalmát", + "messengers-comparison-section-list-point-2": "A letagadhatóság nem foglalja magába a kliens és a kiszolgáló közötti kapcsolatot.", + "messengers-comparison-section-list-point-3": "Úgy tűnik, hogy a kriptográfiai aláírások használata rontja a letagadhatóságot, de ez tisztázásra szorul.", + "messengers-comparison-section-list-point-4": "A többeszközös megvalósítás rontja a dupla racsni kompromittálás utáni biztonságát", + "messengers-comparison-section-list-point-5": "A kétlépcsős kulcscsere nem követelmény a biztonsági kód ellenőrzéséhez.", + "messengers-comparison-section-list-point-6": "A kvantumbiztos kulcscsere „ritka” — csak a racsnis lépések egy részét védi." } diff --git a/website/langs/id.json b/website/langs/id.json index 0967ef424b..4c3320c204 100644 --- a/website/langs/id.json +++ b/website/langs/id.json @@ -1 +1,315 @@ -{} +{ + "home": "Beranda", + "directory": "Direktori", + "developers": "Pengembang", + "reference": "Referensi", + "blog": "Blog", + "features": "Fitur", + "why-simplex": "Kenapa SimpleX", + "simplex-privacy": "Privasi SimpleX", + "simplex-network": "Jaringan SimpleX", + "simplex-explained": "SimpleX dijelaskan", + "donate": "Donasi", + "hero-overlay-2-textlink": "Bagaimana cara kerja SimpleX?", + "feature-5-title": "Pesan menghilang", + "hero-overlay-1-title": "Bagaimana cara kerja SimpleX?", + "terms-and-privacy-policy": "Kebijakan Privasi", + "about-and-contact-us": "Tentang & Hubungi Kami", + "feature-2-title": "Gambar, video, dan berkas
terenkripsi E2E", + "feature-4-title": "Pesan suara terenkripsi E2E", + "feature-6-title": "Panggilan suara dan video
terenkripsi E2E", + "hero-2-header": "Buat koneksi pribadi", + "simplex-private-1-title": "2 lapisan
enkripsi end-to-end", + "simplex-private-card-4-point-1": "Untuk melindungi alamat IP Anda, Anda dapat mengakses server melalui Tor atau lapisan jaringan transport lainnya.", + "simplex-unique-3-overlay-1-title": "Kepemilikan, kontrol, dan keamanan data Anda", + "simplex-private-card-10-point-2": "Mengirim pesan tanpa pengenal profil pengguna, menyediakan privasi meta-data yang lebih baik daripada alternatif lain.", + "terminal-cli": "Terminal CLI", + "hero-overlay-3-textlink": "Penilaian keamanan", + "chat-protocol": "Protokol obrolan", + "simplex-explained-tab-1-text": "1. Apa pengalaman pengguna", + "simplex-explained-tab-2-text": "2. Bagaimana cara kerjanya", + "simplex-chat-protocol": "Protokol SimpleX Chat", + "hero-overlay-2-title": "Mengapa ID pengguna buruk untuk privasi?", + "copyright-label": "© 2020-2025 SimpleX Chat | Open-Source Project", + "simplex-explained-tab-3-text": "3. Apa yang dilihat server", + "smp-protocol": "Protokol SMP", + "please-use-link-in-mobile-app": "Mohon gunakan tautan di aplikasi seluler", + "docs-dropdown-12": "Keamanan", + "please-enable-javascript": "Harap aktifkan JavaScript untuk melihat kode QR.", + "jobs": "Gabung tim", + "docs-dropdown-14": "SimpleX untuk bisnis", + "guide-dropdown-8": "Pengaturan aplikasi", + "guide-dropdown-9": "Membuat koneksi", + "guide-dropdown-6": "Panggilan Suara & Video", + "guide-dropdown-7": "Privasi & keamanan", + "guide-dropdown-5": "Mengelola data", + "guide-dropdown-4": "Profil obrolan", + "docs-dropdown-9": "Unduhan", + "docs-dropdown-11": "FAQ", + "back-to-top": "Kembali ke atas", + "docs-dropdown-10": "Transparansi", + "click-to-see": "Ketuk untuk lihat", + "guide": "Panduan", + "docs-dropdown-1": "Jaringan SimpleX", + "privacy-matters-3-overlay-1-title": "Privasi melindungi kebebasan Anda", + "privacy-matters-1-overlay-1-title": "Privasi menghemat uang Anda", + "privacy-matters-1-overlay-1-linkText": "Privasi menghemat uang Anda", + "simplex-unique-3-title": "Data Anda kendali Anda", + "privacy-matters-3-overlay-1-linkText": "Privasi melindungi kebebasan Anda", + "privacy-matters-1-title": "Privasi melindungi kebebasan Anda", + "simplex-unique-1-title": "Anda memiliki privasi penuh", + "simplex-unique-4-title": "Anda memiliki jaringan SimpleX", + "simplex-unique-4-overlay-1-title": "Sepenuhnya terdesentralisasi — pengguna memiliki jaringan SimpleX", + "hero-overlay-card-1-p-2": "Untuk mengirim pesan, alih-alih ID pengguna yang digunakan oleh semua jaringan lain, SimpleX menggunakan pengenal bersifat anonim sementara dari antrean pesan, terpisah untuk setiap koneksi — tidak ada pengenal jangka panjang.", + "hero-overlay-card-1-p-1": "Banyak pengguna bertanya: jika SimpleX tidak ada ID pengguna, bagaimana itu mengetahui ke mana pesan dikirim?", + "sign-up-to-receive-our-updates": "Daftar untuk menerima pembaruan kami", + "enter-your-email-address": "Masukkan alamat email Anda", + "learn-more": "Lebih lanjut", + "more-info": "Info lanjut", + "hide-info": "Tutup info", + "contact-hero-header": "Anda menerima alamat untuk terhubung di SimpleX Chat", + "invitation-hero-header": "Anda menerima tautan 1 kali untuk terhubung di SimpleX Chat", + "simplex-explained-tab-1-p-1": "Anda dapat membuat kontak dan grup, dan melakukan percakapan dua arah, seperti pada aplikasi perpesanan lainnya.", + "simplex-explained-tab-1-p-2": "Bagaimana cara kerjanya dengan antrean searah dan tanpa ID profil pengguna?", + "simplex-explained-tab-2-p-1": "Untuk setiap koneksi, Anda menggunakan dua antrean pesan terpisah untuk mengirim dan menerima pesan melalui server yang berbeda.", + "simplex-explained-tab-2-p-2": "Server hanya menyampaikan pesan satu arah, tanpa memiliki gambaran lengkap mengenai percakapan atau koneksi pengguna.", + "simplex-explained-tab-3-p-1": "Server memiliki kredensial anonim terpisah untuk setiap antrean, dan tidak mengetahui pengguna mana yang menjadi milik mereka.", + "simplex-explained-tab-3-p-2": "Pengguna dapat tingkatkan privasi metadata dengan memakai Tor untuk akses server, mencegah korelasi berdasarkan alamat IP.", + "chat-bot-example": "Contoh chat bot", + "hero-header": "Privasi diredefinisikan", + "hero-subheader": "Perpesanan pertama
tanpa ID pengguna", + "hero-p-1": "Aplikasi lain memiliki ID pengguna: Signal, Matrix, Session, Briar, Jami, Cwtch, dll.
SimpleX tidak, bahkan angka acak.
Ini secara radikal meningkatkan privasi Anda.", + "hero-overlay-1-textlink": "Mengapa ID pengguna buruk untuk privasi?", + "hero-2-header-desc": "Video ini menunjukkan cara terhubung dengan teman Anda melalui kode QR sekali pakai, secara langsung, atau melalui tautan video. Anda juga dapat terhubung dengan bagikan tautan undangan.", + "simplex-network-overlay-1-title": "Perbandingan dengan protokol perpesanan P2P", + "hero-overlay-3-title": "Penilaian keamanan", + "simplex-private-2-title": "Lapisan enkripsi
tambahan server", + "feature-1-title": "Pesan terenkripsi E2E dengan markdown dan pengeditan", + "feature-3-title": "Grup terenkripsi E2E yang terdesentralisasi — hanya pengguna yang tahu keberadaannya", + "docs-dropdown-6": "Server WebRTC", + "docs-dropdown-7": "Terjemahkan SimpleX Chat", + "docs-dropdown-2": "Mengakses berkas Android", + "guide-dropdown-1": "Mulai cepat", + "no": "Tidak", + "no-private": "Tidak - privat", + "no-secure": "Tidak - aman", + "no-resilient": "Tidak - tangguh", + "no-decentralized": "Tidak - terdesentralisasi", + "no-federated": "Tidak - terfederasi", + "comparison-section-list-point-1": "Biasanya berdasarkan nomor telepon, dalam beberapa kasus berdasarkan nama pengguna", + "protocol-2-text": "XMPP, Matrix", + "protocol-3-text": "Protokol P2P", + "comparison-point-1-text": "Membutuhkan identitas global", + "comparison-point-2-text": "Kemungkinan MITM", + "simplex-network-3-header": "Jaringan SimpleX", + "simplex-chat-for-the-terminal": "SimpleX Chat untuk terminal", + "copy-the-command-below-text": "salin perintah di bawah ini dan gunakan dalam obrolan:", + "protocol-1-text": "Signal, platform besar", + "guide-dropdown-2": "Mengirim pesan", + "guide-dropdown-3": "Grup rahasia", + "comparison-section-list-point-2": "Alamat berbasis DNS", + "comparison-section-list-point-3": "Kunci publik atau ID unik global lainnya", + "feature-7-title": "Penyimpanan aplikasi terenkripsi portabel — pindahkan profil ke perangkat lain", + "feature-8-title": "Mode penyamaran —
unik untuk SimpleX Chat", + "simplex-private-3-title": "Transport TLS terautentikasi
yang aman", + "simplex-private-4-title": "Akses opsional
via Tor", + "simplex-private-5-title": "Beberapa lapisan
kuat konten", + "simplex-private-6-title": "Pertukaran kunci
out-of-band", + "simplex-private-7-title": "Verifikasi integritas
pesan", + "simplex-private-8-title": "Campuran pesan
untuk kurangi korelasi", + "simplex-private-9-title": "Antrean pesan
searah", + "simplex-private-10-title": "Pengidentifikasi berpasangan anonim sementara", + "simplex-private-card-1-point-1": "Protokol double-ratchet —
Pesan OTR dengan perfect forward secrecy dan pemulihan pembobolan.", + "simplex-private-card-9-point-2": "Ini mengurangi vektor serangan, dibandingkan dengan perantara pesan tradisional, dan meta-data yang ada.", + "simplex-private-card-10-point-1": "SimpleX menggunakan alamat terhubung anonim sementara dan kredensial untuk setiap kontak pengguna atau anggota grup.", + "simplex-private-card-3-point-1": "Hanya TLS 1.2/1.3 dengan algoritma kuat yang digunakan untuk koneksi client-server.", + "simplex-private-card-3-point-2": "Sidikjari server dan pengikatan saluran mencegah serangan MITM secara berulang.", + "simplex-private-card-3-point-3": "Pemulihan koneksi dinonaktifkan untuk mencegah serangan sesi.", + "simplex-private-card-4-point-2": "Untuk menggunakan SimpleX melalui Tor, silakan instal aplikasi Orbot dan aktifkan proksi SOCKS5 (atau VPN di iOS).", + "simplex-private-card-5-point-1": "SimpleX menggunakan pengunci konten untuk setiap lapisan enkripsi guna menggagalkan serangan pesan.", + "simplex-private-card-5-point-2": "Ini membuat pesan dengan ukuran berbeda terlihat sama bagi server dan pengamat jaringan.", + "simplex-private-card-6-point-1": "Banyak jaringan komunikasi yang rentan terhadap serangan MITM oleh server atau penyedia jaringan.", + "simplex-private-card-6-point-2": "Untuk mencegahnya, aplikasi SimpleX meneruskan kunci 1-kali di luar jalur, saat bagikan alamat sebagai tautan atau kode QR.", + "simplex-unique-2-overlay-1-title": "Perlindungan terbaik dari spam dan penyalahgunaan", + "simplex-unique-2-title": "Anda terlindungi dari
spam dan penyalahgunaan", + "privacy-matters-2-overlay-1-title": "Privasi memberi Anda kekuatan", + "privacy-matters-2-overlay-1-linkText": "Privasi memberi Anda kekuatan", + "privacy-matters-3-title": "Penuntutan karena keterlibatan yang tidak bersalah", + "simplex-unique-1-overlay-1-title": "Privasi penuh atas identitas, profil, kontak, dan metadata Anda", + "privacy-matters-2-title": "Manipulasi pemilihan", + "simplex-private-card-7-point-1": "Untuk menjamin integritas, pesan diberi nomor urut dan menyertakan hash dari pesan sebelumnya.", + "simplex-private-card-7-point-2": "Jika ada pesan yang ditambah, dihapus atau diubah, penerima akan diberi tahu.", + "simplex-private-card-8-point-1": "Server SimpleX bertindak sebagai node campuran latensi rendah — pesan masuk dan keluar memiliki urutan yang berbeda.", + "simplex-private-card-9-point-1": "Setiap antrean pesan meneruskan pesan dalam satu arah, dengan alamat kirim dan terima yang berbeda.", + "simplex-private-card-1-point-2": "Kotak kripto NaCL di setiap antrean untuk mencegah korelasi lalu lintas antara antrean pesan jika TLS disusupi.", + "simplex-private-card-2-point-1": "Lapisan enkripsi server tambahan untuk pengiriman ke penerima, untuk mencegah korelasi antara lalu lintas server yang diterima dan dikirim jika TLS disusupi.", + "hero-overlay-card-1-p-5": "Hanya perangkat klien yang menyimpan profil pengguna, kontak, dan grup; pesan dikirim dengan enkripsi end-to-end 2 lapis.", + "hero-overlay-card-1-p-6": "Selengkapnya di Whitepaper SimpleX.", + "hero-overlay-card-2-p-1": "Bila pengguna memiliki identitas persisten, meskipun ini hanya angka acak, seperti ID Sesi, ada risiko bahwa penyedia atau penyerang dapat mengamati bagaimana pengguna terhubung dan berapa banyak pesan yang mereka kirim.", + "hero-overlay-card-2-p-2": "Mereka kemudian dapat menghubungkan informasi ini dengan jaringan sosial publik yang ada, dan menentukan beberapa identitas sebenarnya.", + "hero-overlay-card-2-p-3": "Bahkan dengan aplikasi paling pribadi yang menggunakan layanan Tor v3, jika Anda berbicara dengan dua kontak berbeda melalui profil yang sama, mereka dapat membuktikan bahwa mereka terhubung dengan orang yang sama.", + "hero-overlay-card-1-p-3": "Anda menentukan server mana yang akan digunakan untuk menerima pesan, dan kontak Anda — server yang Anda gunakan untuk mengirimkan pesan kepada mereka. Setiap percakapan kemungkinan akan menggunakan dua server yang berbeda.", + "hero-overlay-card-1-p-4": "Desain ini mencegah kebocoran metadata pengguna di tingkat aplikasi. Untuk meningkatkan privasi dan melindungi alamat IP Anda, Anda dapat terhubung ke server perpesanan melalui Tor.", + "join": "Gabung", + "we-invite-you-to-join-the-conversation": "Kami mengundang Anda untuk gabung ke percakapan", + "join-the-REDDIT-community": "Bergabung dengan komunitas REDDIT", + "join-us-on-GitHub": "Gabung dengan kami di GitHub", + "donate-here-to-help-us": "Donasi untuk bantu kami", + "why-simplex-is-unique": "Mengapa SimpleX unik", + "simplex-unique-card-1-p-2": "Tidak seperti jaringan perpesanan lain yang ada, SimpleX tidak memiliki ID tetap kepada pengguna — bahkan nomor acak.", + "simplex-unique-card-2-p-1": "Karena Anda tidak memiliki ID atau alamat tetap di jaringan SimpleX, tidak seorang pun dapat menghubungi Anda kecuali Anda membagikan alamat pengguna 1-kali atau sementara, seperti kode QR atau tautan.", + "simplex-unique-card-3-p-1": "SimpleX menyimpan semua data pengguna pada perangkat klien dalam format basis data terenkripsi portabel — data tersebut dapat ditransfer ke perangkat lain.", + "simplex-unique-card-3-p-2": "Pesan yang dienkripsi end-to-end disimpan sementara di server relay SimpleX hingga diterima, lalu dihapus secara permanen.", + "simplex-unique-card-4-p-1": "Jaringan SimpleX sepenuhnya terdesentralisasi dan independen dari crypto-currency atau jaringan lain apa pun, selain Internet.", + "simplex-unique-card-4-p-2": "Anda dapat menggunakan SimpleX dengan server Anda sendiri atau dengan server yang disediakan oleh kami — dan tetap terhubung dengan pengguna mana pun.", + "contact-hero-subheader": "Pindai kode QR dengan aplikasi SimpleX Chat di ponsel atau tablet Anda.", + "contact-hero-p-1": "Kunci publik dan alamat antrean pesan dalam tautan ini TIDAK dikirimkan melalui jaringan saat Anda melihat halaman ini — kunci tersebut terdapat dalam fragmen hash URL tautan.", + "contact-hero-p-2": "Belum unduh SimpleX Chat?", + "contact-hero-p-3": "Gunakan tautan di bawah untuk unduh aplikasi.", + "scan-qr-code-from-mobile-app": "Pindai kode QR dari aplikasi seluler", + "to-make-a-connection": "Untuk membuat koneksi:", + "install-simplex-app": "Instal aplikasi SimpleX", + "connect-in-app": "Hubungkan di aplikasi", + "open-simplex-app": "Buka aplikasi SimpleX", + "tap-the-connect-button-in-the-app": "Ketuk ‘hubungkan’ di aplikasi", + "scan-the-qr-code-with-the-simplex-chat-app": "Pindai kode QR dengan aplikasi SimpleX Chat", + "scan-the-qr-code-with-the-simplex-chat-app-description": "Kunci publik dan alamat antrean pesan dalam tautan ini TIDAK dikirim melalui jaringan saat Anda melihat halaman ini —
keduanya terdapat dalam fragmen hash URL tautan.", + "installing-simplex-chat-to-terminal": "Menginstal SimpleX Chat ke terminal", + "use-this-command": "Gunakan perintah ini:", + "see-simplex-chat": "Lihat SimpleX Chat", + "github-repository": "Repositori GitHub", + "the-instructions--source-code": "untuk petunjuk cara mengunduh atau mengompilasinya dari kode sumber.", + "if-you-already-installed-simplex-chat-for-the-terminal": "Jika Anda sudah menginstal SimpleX Chat untuk terminal", + "if-you-already-installed": "Jika Anda sudah menginstal", + "privacy-matters-section-header": "Mengapa privasi penting", + "privacy-matters-section-subheader": "Menjaga privasi metadata Anda — dengan siapa Anda berbicara — melindungi Anda dari:", + "privacy-matters-section-label": "Pastikan messenger Anda tidak dapat mengakses data Anda!", + "simplex-private-section-header": "Apa yang membuat SimpleX privat", + "tap-to-close": "Ketuk untuk tutup", + "simplex-network-section-header": "Jaringan SimpleX", + "simplex-network-section-desc": "SimpleX Chat memberikan privasi terbaik dengan menggabungkan keunggulan P2P dan jaringan terfederasi.", + "simplex-network-1-header": "Tidak seperti jaringan P2P", + "simplex-network-1-desc": "Semua pesan dikirim melalui server, keduanya memberikan privasi metadata yang lebih baik dan pengiriman pesan asinkron yang andal, sekaligus menghindari banyak", + "simplex-network-1-overlay-linktext": "masalah jaringan P2P", + "simplex-network-2-header": "Tidak seperti jaringan terfederasi", + "simplex-network-2-desc": "Server relay SimpleX TIDAK menyimpan profil pengguna, kontak dan pesan yang terkirim, TIDAK terhubung satu sama lain, dan TIDAK ada direktori server.", + "simplex-network-3-desc": "server menyediakan antrian searah untuk hubungkan pengguna, tetapi mereka tidak dapat melihat grafik koneksi jaringan — hanya pengguna yang dapat melihatnya.", + "comparison-section-header": "Perbandingan dengan protokol lain", + "comparison-point-3-text": "Ketergantungan pada DNS", + "comparison-point-4-text": "Jaringan tunggal atau terpusat", + "comparison-point-5-text": "Komponen pusat atau serangan di seluruh jaringan lainnya", + "yes": "Ya", + "see-here": "lihat disini", + "comparison-section-list-point-4a": "Relay SimpleX tidak dapat membahayakan enkripsi e2e. Verifikasi kode keamanan untuk memitigasi serangan pada saluran out-of-band", + "comparison-section-list-point-4": "Jika server operator disusupi. Verifikasi kode keamanan di Signal dan beberapa aplikasi lain untuk mengatasinya", + "comparison-section-list-point-5": "Tidak melindungi privasi metadata pengguna", + "comparison-section-list-point-6": "Meskipun P2P didistribusikan, mereka tidak terfederasi — mereka beroperasi sebagai jaringan tunggal", + "comparison-section-list-point-7": "Jaringan P2P memiliki otoritas pusat atau seluruh jaringan dapat terkompromi", + "docs-dropdown-3": "Mengakses database obrolan", + "docs-dropdown-4": "Host Server SMP", + "docs-dropdown-5": "Host Server XFTP", + "docs-dropdown-8": "Direktori SimpleX", + "newer-version-of-eng-msg": "Ada versi terbaru halaman ini dalam bahasa Inggris.", + "menu": "Menu", + "on-this-page": "Di halaman ini", + "glossary": "Glosarium", + "simplex-chat-via-f-droid": "SimpleX Chat melalui F-Droid", + "simplex-chat-repo": "Repo SimpleX Chat", + "stable-and-beta-versions-built-by-developers": "Versi stable dan beta yang dibuat oleh pengembang", + "f-droid-page-simplex-chat-repo-section-text": "Untuk menambahkannya ke klien F-Droid Anda, pindai kode QR atau gunakan URL ini:", + "signing-key-fingerprint": "Penandatanganan sidikjari kunci (SHA-256)", + "f-droid-org-repo": "Repo F-Droid.org", + "stable-versions-built-by-f-droid-org": "Versi stable yang dibuat oleh F-Droid.org", + "releases-to-this-repo-are-done-1-2-days-later": "Rilisan ke repo ini dilakukan beberapa hari kemudian", + "f-droid-page-f-droid-org-repo-section-text": "Repositori SimpleX Chat dan F-Droid.org menandatangani build dengan kunci berbeda. Untuk beralih, silakan ekspor basis data obrolan dan instal ulang aplikasi.", + "hero-overlay-card-2-p-4": "SimpleX melindungi dari serangan ini dengan tidak memiliki ID pengguna dalam desainnya. Dan, jika Anda gunakan mode Samaran, Anda akan memiliki nama tampilan berbeda untuk setiap kontak, sehingga mencegah data dibagikan di antara mereka.", + "hero-overlay-card-3-p-1": "Trail of Bits adalah konsultan keamanan dan teknologi terkemuka yang kliennya meliputi perusahaan teknologi besar, lembaga pemerintah, dan proyek blockchain besar.", + "hero-overlay-card-3-p-2": "Trail of Bits meninjau kriptografi jaringan SimpleX dan komponen jaringan pada November 2022. Baca selengkapnya.", + "hero-overlay-card-3-p-3": "Trail of Bits mengulas desain kriptografi protokol jaringan SimpleX pada Juli 2024. Baca selengkapnya.", + "simplex-network-overlay-card-1-p-1": "Protokol dan aplikasi perpesanan P2P memiliki berbagai masalah yang membuatnya kurang dapat diandalkan dibandingkan SimpleX, lebih rumit untuk dianalisis, dan rentan terhadap beberapa jenis serangan.", + "simplex-network-overlay-card-1-li-1": "Jaringan P2P andalkan beberapa varian DHT untuk merutekan pesan. Desain DHT harus menyeimbangkan jaminan pengiriman dan latensi. SimpleX memiliki jaminan pengiriman lebih baik dan latensi yang lebih rendah daripada P2P, karena pesan dapat diteruskan secara redundan melalui beberapa server secara paralel, menggunakan server yang dipilih oleh penerima. Dalam jaringan P2P, pesan diteruskan melalui node O(log N) secara berurutan, menggunakan node yang dipilih oleh algoritma.", + "simplex-network-overlay-card-1-li-2": "Desain SimpleX, tidak seperti kebanyakan jaringan P2P, tidak memiliki ID pengguna global apa pun, bahkan yang sementara, dan hanya menggunakan pengenal penghubung sementara, sehingga memberikan anonimitas dan perlindungan metadata yang lebih baik.", + "simplex-network-overlay-card-1-li-3": "P2P tidak menyelesaikan masalah serangan MITM, dan sebagian besar implementasi yang ada tidak menggunakan pesan out-of-band untuk pertukaran kunci awal. SimpleX menggunakan pesan out-of-band atau, dalam beberapa kasus, koneksi aman dan tepercaya yang sudah ada sebelumnya untuk pertukaran kunci awal.", + "simplex-network-overlay-card-1-li-4": "Implementasi P2P dapat diblokir oleh beberapa penyedia internet (seperti BitTorrent). SimpleX bersifat agnostik transportasi — ia dapat bekerja melalui protokol web standar, misalnya WebSockets.", + "simplex-network-overlay-card-1-li-5": "Semua jaringan P2P yang diketahui mungkin rentan terhadap serangan Sybil, karena setiap node dapat ditemukan, dan jaringan beroperasi secara keseluruhan. Langkah-langkah yang diketahui untuk memitigasinya memerlukan komponen terpusat atau Bukti kerja yang mahal. Jaringan SimpleX tidak memiliki kemampuan untuk ditemukan oleh server, terfragmentasi, dan beroperasi sebagai beberapa sub-jaringan yang terisolasi, sehingga mustahil untuk menyerang seluruh jaringan.", + "simplex-network-overlay-card-1-li-6": "Jaringan P2P mungkin rentan terhadap serangan DRDoS, ketika klien dapat menyiarkan ulang dan memperkuat lalu lintas, yang mengakibatkan penolakan layanan di seluruh jaringan. Klien SimpleX hanya meneruskan lalu lintas dari koneksi yang diketahui dan tidak dapat digunakan oleh penyerang untuk memperkuat lalu lintas di seluruh jaringan.", + "privacy-matters-overlay-card-1-p-1": "Banyak perusahaan besar menggunakan informasi mengenai siapa yang terhubung dengan Anda untuk memperkirakan pendapatan Anda, menjual produk yang sebenarnya tidak Anda butuhkan, dan menentukan harga.", + "privacy-matters-overlay-card-1-p-2": "Pengecer daring mengetahui bahwa orang-orang berpenghasilan rendah cenderung melakukan pembelian mendesak, sehingga mereka mungkin mengenakan harga lebih tinggi atau menghapus diskon.", + "privacy-matters-overlay-card-1-p-3": "Beberapa perusahaan keuangan dan asuransi menggunakan grafik sosial untuk menentukan suku bunga dan premium. Hal ini seringkali membuat orang berpenghasilan rendah membayar lebih banyak — dikenal sebagai 'premium kemelaratan'.", + "privacy-matters-overlay-card-1-p-4": "Jaringan SimpleX melindungi privasi koneksi Anda lebih baik daripada alternatif lainnya, sepenuhnya mencegah grafik sosial Anda diakses oleh perusahaan atau organisasi mana pun. Bahkan ketika orang menggunakan server yang telah dikonfigurasi sebelumnya di aplikasi SimpleX Chat, operator server tidak mengetahui jumlah pengguna atau koneksi mereka.", + "privacy-matters-overlay-card-2-p-1": "Belum lama ini kita menyaksikan pemilu besar dimanipulasi oleh sebuah perusahaan konsultan terkemuka yang menggunakan grafik sosial kita untuk mendistorsi pandangan kita terhadap dunia nyata dan memanipulasi suara kita.", + "privacy-matters-overlay-card-2-p-2": "Agar objektif dan dapat membuat keputusan yang independen, Anda perlu mengendalikan ruang informasi Anda. Hal ini hanya mungkin jika Anda menggunakan jaringan komunikasi pribadi yang tidak memiliki akses ke grafik sosial Anda.", + "privacy-matters-overlay-card-2-p-3": "SimpleX adalah jaringan pertama yang tidak memiliki ID pengguna apa pun secara desain, dengan cara ini melindungi grafik koneksi Anda lebih baik daripada alternatif yang diketahui.", + "privacy-matters-overlay-card-3-p-1": "Setiap orang harus peduli dengan privasi dan keamanan komunikasi mereka — percakapan yang tidak berbahaya dapat membahayakan Anda, bahkan jika Anda tidak menyembunyikan apa pun.", + "privacy-matters-overlay-card-3-p-2": "Salah satu kisah paling mengejutkan adalah pengalaman Mohamedou Ould Salahi yang diceritakan dalam memoarnya dan ditampilkan dalam film The Mauritanian. Ia dimasukkan ke kamp Guantanamo tanpa diadili, dan disiksa di sana selama 15 tahun setelah menelepon kerabatnya di Afghanistan, karena dicurigai terlibat dalam serangan 9/11, meskipun ia pernah tinggal di Jerman selama 10 tahun sebelumnya.", + "privacy-matters-overlay-card-3-p-3": "Orang biasa ditangkap karena apa yang mereka bagikan secara daring, bahkan melalui akun 'anonim' mereka, bahkan di negara demokrasi.", + "privacy-matters-overlay-card-3-p-4": "Tidaklah cukup jika hanya menggunakan layanan pesan terenkripsi end-to-end, kita semua harus menggunakan layanan pesan yang melindungi privasi jaringan pribadi kita — dengan siapa kita terhubung.", + "simplex-unique-overlay-card-1-p-1": "Berbeda dengan jaringan perpesanan lainnya, SimpleX tidak memiliki ID tetap kepada pengguna. SimpleX tidak bergantung pada nomor telepon, alamat berbasis domain (seperti email atau XMPP), nama pengguna, kunci publik, atau bahkan angka acak untuk mengidentifikasi penggunanya — operator server SimpleX tidak tahu berapa banyak orang yang menggunakan server mereka.", + "simplex-unique-overlay-card-1-p-2": "Untuk menyampaikan pesan, SimpleX menggunakan alamat anonim penghubung dari antrean pesan searah, terpisah untuk pesan yang diterima dan dikirim, biasanya melalui server yang berbeda.", + "simplex-unique-overlay-card-1-p-3": "Desain ini melindungi privasi orang yang Anda ajak berkomunikasi, menyembunyikannya dari server jaringan SimpleX dan dari pengamat mana pun. Untuk menyembunyikan alamat IP Anda dari server, Anda dapat terhubung ke server SimpleX melalui Tor.", + "simplex-unique-overlay-card-2-p-1": "Karena Anda tidak memiliki ID di jaringan SimpleX, tidak seorang pun dapat menghubungi Anda kecuali Anda membagikan alamat pengguna 1-kali atau sementara, seperti kode QR atau tautan.", + "simplex-unique-overlay-card-2-p-2": "Bahkan dengan alamat pengguna opsional, meskipun dapat digunakan untuk mengirim permintaan kontak spam, Anda dapat mengubah atau menghapusnya sepenuhnya tanpa kehilangan koneksi apa pun.", + "simplex-unique-overlay-card-3-p-1": "SimpleX Chat menyimpan semua data pengguna hanya pada perangkat klien menggunakan format basis data terenkripsi portabel yang dapat diekspor dan ditransfer ke perangkat apa pun yang didukung.", + "simplex-unique-overlay-card-3-p-2": "Pesan yang dienkripsi end-to-end disimpan sementara di server relay SimpleX hingga diterima, lalu dihapus secara permanen.", + "simplex-unique-overlay-card-3-p-3": "Tidak seperti server jaringan terfederasi (email, XMPP atau Matrix), server SimpleX tidak menyimpan akun pengguna, mereka hanya menyampaikan pesan, melindungi privasi kedua belah pihak.", + "simplex-unique-overlay-card-3-p-4": "Tidak ada pengenal atau teks sandi yang sama antara lalu lintas server yang dikirim dan diterima — jika ada orang yang mengamatinya, mereka tidak dapat dengan mudah menentukan siapa yang berkomunikasi dengan siapa, bahkan jika TLS disusupi.", + "simplex-unique-overlay-card-4-p-1": "Anda dapat menggunakan SimpleX dengan server Anda sendiri dan tetap berkomunikasi dengan orang yang menggunakan server yang dikonfigurasikan sebelumnya dalam aplikasi.", + "simplex-unique-overlay-card-4-p-2": "Jaringan SimpleX menggunakan protokol terbuka dan menyediakan SDK untuk membuat bot obrolan, yang memungkinkan penerapan layanan yang dapat berinteraksi dengan pengguna melalui aplikasi SimpleX Chat — kami benar-benar menantikan untuk melihat layanan SimpleX apa yang akan Anda buat.", + "simplex-unique-overlay-card-4-p-3": "Jika Anda mempertimbangkan pengembangan untuk jaringan SimpleX, misalnya, bot obrolan untuk pengguna aplikasi SimpleX, atau integrasi pustaka Obrolan SimpleX ke dalam aplikasi seluler Anda, silakan hubungi kami untuk saran dan dukungan apa pun.", + "simplex-unique-card-1-p-1": "SimpleX melindungi privasi profil, kontak, dan metadata Anda, menyembunyikannya dari server jaringan SimpleX dan pengamat mana pun.", + "get-simplex": "Dapatkan aplikasi desktop SimpleX", + "index-hero-h1": "Bebaslah", + "index-hero-h2": "Kebebasan & Keamanan
Komunikasi Anda", + "index-hero-p1": "Jaringan pertama tempat Anda memiliki identitas, kontak, dan grup Anda.", + "index-hero-download-desktop-btn-title": "Unduh Aplikasi Desktop SimpleX", + "index-testflight-title": "Pratinjau iOS publik di TestFlight", + "index-f-droid-title": "Repositori SimpleX F-Droid", + "index-security-assessment-title": "penilaian keamanan", + "index-security-review-2022-title": "Tinjauan Keamanan 2022", + "index-security-review-2024-title": "Tinjauan Keamanan 2024", + "index-security-audits-label": "Audit
Keamanan", + "index-publications-privacy-guides-title": "rekomendasi perpesanan", + "index-publications-whonix-title": "Rekomendasi perpesanan Whonix", + "index-publications-heise-title": "publikasi", + "index-publications-kuketz-title": "tinjauan", + "index-publications-optout-title": "wawancara podcast", + "worlds-most-secure-messaging": "Perpesanan Paling Aman di Dunia", + "index-messaging-p1": "Perpesanan SimpleX memiliki enkripsi end-to-end yang canggih.", + "index-messaging-p2": "Demi keamanan dan privasi Anda, server tidak dapat melihat pesan Anda atau dengan siapa Anda bicara.", + "index-messaging-cta": "Pelajari lebih lanjut tentang perpesanan SimpleX", + "index-nextweb-h2": "Anda Pemilik
Web Berikutnya", + "index-nextweb-p1": "SimpleX didirikan atas keyakinan bahwa Anda harus memiliki identitas, kontak, dan komunitas Anda sendiri.", + "index-nextweb-p2": "Jaringan yang terbuka dan terdesentralisasi membuat Anda terhubung dengan orang lain dan berbagi ide: bebas dan aman.", + "index-token-h2": "Komunitas yang Bertahan Lama", + "index-token-p1": "Anda akan mendukung grup favorit Anda dengan voucher Komunitas di masa mendatang.", + "index-token-p2": "Voucher akan membayar server, agar komunitas Anda tetap bebas dan independen.", + "index-token-cta": "Pelajari lebih lanjut tentang Voucher Komunitas", + "index-roadmap-h2": "SimpleX Roadmap Menuju Internet Bebas", + "index-roadmap-2025": "2025", + "index-roadmap-2025-title": "Skala ke Komunitas Besar", + "index-roadmap-2025-desc": "Menghindari platform terpusat", + "index-roadmap-2026": "2026", + "index-roadmap-2026-title": "Komunitas & Server Berkelanjutan", + "index-roadmap-2026-desc": "Perilisan Voucher Komunitas", + "index-roadmap-2027": "2027", + "index-roadmap-2027-title": "Buat Komunitas Anda Meningkat", + "index-roadmap-2027-desc": "Alat untuk promosikan komunitas Anda", + "index-directory-h2": "Gabung ke Komunitas SimpleX", + "index-directory-p1": "Ratusan ribu orang sudah memercayai perpesanan SimpleX.", + "index-directory-p2": "Temukan komunitas Anda di direktori SimpleX dan buat komunitas Anda sendiri!", + "index-directory-cta": "Lihat Direktori SimpleX", + "index-directory-users-group-title": "Grup pengguna SimpleX", + "how-secure-comparison-title": "Seberapa amankah enkripsi end-to-end di berbagai aplikasi perpesanan?", + "how-secure-message-padding": "Lapisan pesan", + "how-secure-repudiation-deniability": "Penolakan (penyangkalan)", + "how-secure-forward-secrecy": "Forward secrecy", + "how-secure-break-in-recovery": "Keamanan post-compromise", + "how-secure-two-factor-key-exchange": "Pertukaran kunci 2 faktor", + "how-secure-post-quantum-hybrid-crypto": "Gabungan kriptografi dan post-quantum", + "messengers-comparison-section-list-point-1": "Lapisan pesan ke ukuran yang dibulatkan hingga 1024 byte, Signal - hingga 160 byte", + "messengers-comparison-section-list-point-2": "Penolakan tidak mencakup koneksi klien-server.", + "messengers-comparison-section-list-point-3": "Tampaknya penggunaan signature kriptografi membahayakan penolakan (penyangkalan), tetapi hal ini perlu diperjelas.", + "messengers-comparison-section-list-point-4": "Implementasi multi-perangkat membahayakan keamanan post-compromise Double Ratchet", + "messengers-comparison-section-list-point-5": "Pertukaran kunci 2 faktor bersifat opsional via verifikasi kode keamanan.", + "messengers-comparison-section-list-point-6": "Kesepakatan kunci Post-quantum \"jarang\" — hanya melindungi beberapa langkah ratchet." +} diff --git a/website/langs/it.json b/website/langs/it.json index 7fdea37909..f6b35588f6 100644 --- a/website/langs/it.json +++ b/website/langs/it.json @@ -10,7 +10,7 @@ "simplex-explained-tab-3-p-1": "I server hanno credenziali anonime separate per ogni coda e non sanno a quali utenti appartengano.", "chat-protocol": "Protocollo di chat", "donate": "Dona", - "copyright-label": "© 2020-2025 SimpleX | Progetto Open-Source", + "copyright-label": "© 2020-2025 SimpleX Chat | Progetto Open-Source", "simplex-chat-protocol": "Protocollo di SimpleX Chat", "terminal-cli": "Terminale CLI", "terms-and-privacy-policy": "Informativa sulla privacy", @@ -56,24 +56,24 @@ "hero-overlay-card-1-p-1": "Molti utenti hanno chiesto: se SimpleX non ha identificatori utente, come può sapere dove recapitare i messaggi? ", "hero-overlay-card-1-p-3": "Sei tu a definire quale/i server usare per ricevere i messaggi, i tuoi contatti i server che usi per inviare loro i messaggi. È probabile che ogni conversazione utilizzi due server diversi.", "hero-overlay-card-1-p-5": "Solo i dispositivi client conservano profili utente, contatti e gruppi; i messaggi vengono inviati con crittografia end-to-end a 2 livelli.", - "hero-overlay-card-1-p-6": "Maggiori informazioni nel libro bianco di SimpleX.", + "hero-overlay-card-1-p-6": "Maggiori informazioni nel libro bianco di SimpleX.", "hero-overlay-card-2-p-2": "Potrebbero quindi correlare queste informazioni con i social network pubblici esistenti e determinare alcune identità reali.", "hero-overlay-card-2-p-4": "SimpleX protegge da questi attacchi non avendo alcun ID utente per design. Inoltre, se usi la modalità di modalità in incognito, il tuo nome mostrato sarà diverso per ogni contatto, evitando dati condivisi tra di loro.", "simplex-network-overlay-card-1-li-2": "Il design di SimpleX, a differenza della maggior parte delle reti P2P, non ha identificatori utente globali di alcun tipo, nemmeno temporanei, e usa solo identificatori temporanei a coppie, garantendo una maggiore protezione dell'anonimato e dei metadati.", - "simplex-network-overlay-card-1-li-4": "Le implementazioni P2P possono essere bloccate da alcuni fornitori di internet (come BitTorrent). SimpleX è indipendente dal trasporto — può funzionare su protocolli web standard, es. WebSocket.", + "simplex-network-overlay-card-1-li-4": "Le implementazioni P2P possono essere bloccate da alcuni fornitori di internet (come BitTorrent). SimpleX è indipendente dal trasporto — può funzionare su protocolli web standard, es. WebSocket.", "hero-overlay-card-2-p-1": "Quando gli utenti hanno identità permanenti, anche se si tratta solo di un numero casuale, come un Session ID, c'è il rischio che il fornitore o un malintenzionato possano osservare come gli utenti sono connessi e quanti messaggi inviano.", - "simplex-network-overlay-card-1-li-6": "Le reti P2P possono essere vulnerabili all'attacco DRDoS, quando i client possono ritrasmettere e amplificare il traffico, con conseguente \"denial of service\" a livello di rete. I client SimpleX si limitano a inoltrare il traffico da una connessione nota e non possono essere usati da un aggressore per amplificare il traffico nell'intera rete.", + "simplex-network-overlay-card-1-li-6": "Le reti P2P possono essere vulnerabili all'attacco DRDoS, quando i client possono ritrasmettere e amplificare il traffico, con conseguente \"denial of service\" a livello di rete. I client SimpleX si limitano a inoltrare il traffico da una connessione nota e non possono essere usati da un aggressore per amplificare il traffico nell'intera rete.", "privacy-matters-overlay-card-1-p-4": "La rete di SimpleX protegge la privacy delle tue connessioni meglio di qualsiasi alternativa, impedendo completamente che il tuo grafico sociale sia disponibile a qualsiasi azienda o organizzazione. Anche quando le persone usano i server preconfigurati in SimpleX Chat, gli operatori dei server non conoscono il numero di utenti o le loro connessioni.", "privacy-matters-overlay-card-2-p-3": "SimpleX è la prima rete che non ha alcun identificatore utente per design, proteggendo così il tuo grafico delle connessioni meglio di qualsiasi alternativa conosciuta.", "privacy-matters-overlay-card-3-p-1": "Tutti dovrebbero preoccuparsi della privacy e della sicurezza delle proprie comunicazioni — conversazioni innocue possono metterti in pericolo, anche se non hai nulla da nascondere.", - "privacy-matters-overlay-card-3-p-3": "Le persone comuni vengono arrestate per ciò che condividono online, anche tramite i loro account \"anonimi\", anche nei Paesi democratici.", + "privacy-matters-overlay-card-3-p-3": "Le persone comuni vengono arrestate per ciò che condividono online, anche tramite i loro account \"anonimi\", anche nei Paesi democratici.", "simplex-unique-overlay-card-1-p-3": "Questo design protegge la privacy di chi stai comunicando, nascondendola ai server della rete SimpleX e a qualsiasi osservatore. Per nascondere il tuo indirizzo IP ai server, puoi connetterti ai server SimpleX tramite Tor.", "simplex-unique-overlay-card-2-p-1": "Poiché non hai alcun identificatore sulla rete SimpleX, nessuno può contattarti a meno che tu non condivida un indirizzo utente una tantum o temporaneo, come un codice QR o un link.", "simplex-unique-overlay-card-2-p-2": "Anche l'indirizzo utente opzionale, che può essere usato per inviare richieste di contatto spam, è possibile modificarlo o eliminarlo completamente senza perdere alcuna connessione.", "simplex-unique-overlay-card-3-p-1": "SimpleX Chat conserva tutti i dati utente solo sui dispositivi client usando un formato trasferibile di database crittografato che può essere esportato e trasferito su qualsiasi dispositivo supportato.", "simplex-unique-overlay-card-3-p-2": "I messaggi crittografati end-to-end vengono conservati temporaneamente sui server di inoltro SimpleX fino alla ricezione, quindi vengono eliminati definitivamente.", "simplex-unique-overlay-card-4-p-1": "Puoi usare SimpleX con i tuoi server personali e continuare a comunicare con le persone che usano i server preconfigurati nelle app.", - "simplex-unique-overlay-card-4-p-3": "Se stai pensando di sviluppare per la rete SimpleX, ad esempio, il chat bot per gli utenti dell'app SimpleX o l'integrazione della libreria SimpleX Chat nelle tue app mobili, contattaci per qualsiasi consiglio e supporto.", + "simplex-unique-overlay-card-4-p-3": "Se stai pensando di sviluppare per la rete SimpleX, ad esempio, il chat bot per gli utenti dell'app SimpleX o l'integrazione della libreria SimpleX Chat nelle tue app mobili, contattaci per qualsiasi consiglio e supporto.", "simplex-unique-card-1-p-1": "SimpleX protegge la privacy del tuo profilo, contatti e metadati, nascondendoli ai server della rete SimpleX e ad eventuali osservatori.", "simplex-unique-card-1-p-2": "A differenza di qualsiasi altra rete di messaggistica esistente, SimpleX non ha identificatori assegnati agli utenti — nemmeno numeri casuali.", "simplex-unique-card-2-p-1": "Poiché non hai un identificatore o un indirizzo fisso sulla rete SimpleX, nessuno può contattarti a meno che tu non condivida un indirizzo utente una tantum o temporaneo, come codice un QR o un link.", @@ -82,7 +82,7 @@ "we-invite-you-to-join-the-conversation": "Ti invitiamo ad unirti alla conversazione", "enter-your-email-address": "Inserisci il tuo indirizzo email", "get-simplex": "Ottieni l'app desktop di SimpleX", - "why-simplex-is-unique": "Perché SimpleX è unico", + "why-simplex-is-unique": "Perché SimpleX è unico", "learn-more": "Maggiori informazioni", "more-info": "Mostra info", "hide-info": "Nascondi info", @@ -96,7 +96,7 @@ "install-simplex-app": "Installa l'app SimpleX", "connect-in-app": "Connettiti nell'app", "open-simplex-app": "Apri l'app SimpleX", - "tap-the-connect-button-in-the-app": "Tocca il pulsante 'connetti' nell'app", + "tap-the-connect-button-in-the-app": "Tocca il pulsante \"connetti\" nell'app", "scan-the-qr-code-with-the-simplex-chat-app": "Scansiona il codice QR con l'app SimpleX Chat", "installing-simplex-chat-to-terminal": "Installazione di SimpleX Chat nel terminale", "use-this-command": "Usa questo comando:", @@ -105,9 +105,9 @@ "the-instructions--source-code": "per le istruzioni su come scaricarlo o compilarlo dal codice sorgente.", "if-you-already-installed-simplex-chat-for-the-terminal": "Se hai già installato SimpleX Chat per il terminale", "if-you-already-installed": "Se hai già installato", - "privacy-matters-section-subheader": "Preservare la privacy dei tuoi metadati — con chi parli — ti protegge da:", + "privacy-matters-section-subheader": "Preservare la privacy dei tuoi metadati — con chi parli — ti protegge da:", "privacy-matters-section-label": "Assicurati che il tuo messenger non possa accedere ai tuoi dati!", - "simplex-network-section-header": "Rete di SimpleX", + "simplex-network-section-header": "Rete di SimpleX", "simplex-network-section-desc": "Simplex Chat offre la migliore privacy combinando i vantaggi del P2P e delle reti federate.", "simplex-network-1-header": "A differenza delle reti P2P", "simplex-network-1-overlay-linktext": "problemi delle reti P2P", @@ -173,20 +173,20 @@ "simplex-private-card-3-point-3": "Ripresa della connessione disattivata per evitare attacchi alla sessione.", "hero-overlay-card-1-p-4": "Questo design impedisce la fuoriuscita di metadati degli utenti a livello di applicazione. Per aumentare ulteriormente la privacy e proteggere il tuo indirizzo IP puoi connetterti ai server di messaggistica tramite Tor.", "hero-overlay-card-2-p-3": "Anche con le app più private che usano i servizi Tor v3, se parli con due contatti diversi tramite lo stesso profilo, questi possono dimostrare di essere connessi alla stessa persona.", - "simplex-network-overlay-card-1-p-1": "I protocolli e le app di messaggistica P2P hanno diversi problemi che li rendono meno affidabili di SimpleX, più complessi da analizzare e vulnerabili a diversi tipi di attacco.", - "simplex-network-overlay-card-1-li-1": "Le reti P2P si basano su alcune varianti di DHT per instradare i messaggi. I progetti DHT devono bilanciare la garanzia di consegna e la latenza. SimpleX ha sia una migliore garanzia di consegna che una latenza minore rispetto al P2P, perché il messaggio può essere passato in modo ridondante attraverso diversi server in parallelo, usando i server scelti dal destinatario. Nelle reti P2P il messaggio viene passato attraverso i nodi O(log N) in sequenza, usando nodi scelti dall'algoritmo.", - "simplex-network-overlay-card-1-li-3": "Il P2P non risolve il problema dell'attacco MITM e la maggior parte delle implementazioni esistenti non usa messaggi fuori banda per lo scambio iniziale di chiavi. SimpleX usa messaggi fuori banda o, in alcuni casi, connessioni sicure e attendibili preesistenti per lo scambio iniziale di chiavi.", + "simplex-network-overlay-card-1-p-1": "I protocolli e le app di messaggistica P2P hanno diversi problemi che li rendono meno affidabili di SimpleX, più complessi da analizzare e vulnerabili a diversi tipi di attacco.", + "simplex-network-overlay-card-1-li-1": "Le reti P2P si basano su alcune varianti di DHT per instradare i messaggi. I progetti DHT devono bilanciare la garanzia di consegna e la latenza. SimpleX ha sia una migliore garanzia di consegna che una latenza minore rispetto al P2P, perché il messaggio può essere passato in modo ridondante attraverso diversi server in parallelo, usando i server scelti dal destinatario. Nelle reti P2P il messaggio viene passato attraverso i nodi O(log N) in sequenza, usando nodi scelti dall'algoritmo.", + "simplex-network-overlay-card-1-li-3": "Il P2P non risolve il problema dell'attacco MITM e la maggior parte delle implementazioni esistenti non usa messaggi fuori banda per lo scambio iniziale di chiavi. SimpleX usa messaggi fuori banda o, in alcuni casi, connessioni sicure e attendibili preesistenti per lo scambio iniziale di chiavi.", "privacy-matters-overlay-card-1-p-1": "Molte grandi aziende usano le informazioni sulle persone con cui sei connesso per stimare il tuo reddito, venderti i prodotti di cui non hai realmente bisogno e determinare i prezzi.", - "simplex-network-overlay-card-1-li-5": "Tutte le reti P2P conosciute possono essere vulnerabili all'attacco di Sybil, perché ogni nodo è rilevabile e la rete opera come un tutt'uno. Le misure note per mitigarlo richiedono un componente centralizzato o proof-of-work costosi. La rete di SimpleX non ha la possibilità di scoprire i server, è frammentata e opera come più sottoreti isolate, rendendo impossibili gli attacchi a livello di rete.", + "simplex-network-overlay-card-1-li-5": "Tutte le reti P2P conosciute possono essere vulnerabili all'attacco di Sybil, perché ogni nodo è rilevabile e la rete opera come un tutt'uno. Le misure note per mitigarlo richiedono un componente centralizzato o proof-of-work costosi. La rete di SimpleX non ha la possibilità di scoprire i server, è frammentata e opera come più sottoreti isolate, rendendo impossibili gli attacchi a livello di rete.", "privacy-matters-overlay-card-1-p-2": "I rivenditori online sanno che le persone con redditi più bassi sono più propense a fare acquisti urgenti, quindi possono applicare prezzi più alti o rimuovere sconti.", - "privacy-matters-overlay-card-1-p-3": "Alcune società finanziarie e assicurative usano grafici sociali per determinare i tassi di interesse e i premi. Spesso ciò fa pagare di più le persone con redditi più bassi — è noto come \"premio di povertà\" .", - "privacy-matters-overlay-card-2-p-1": "Non molto tempo fa abbiamo assistito alla manipolazione delle principali elezioni da una rispettabile società di consulenza che ha usato i nostri grafici sociali per distorcere la nostra visione del mondo reale e manipolare i nostri voti.", + "privacy-matters-overlay-card-1-p-3": "Alcune società finanziarie e assicurative usano grafici sociali per determinare i tassi di interesse e i premi. Spesso ciò fa pagare di più le persone con redditi più bassi — è noto come \"premio di povertà\".", + "privacy-matters-overlay-card-2-p-1": "Non molto tempo fa abbiamo assistito alla manipolazione delle principali elezioni da una rispettabile società di consulenza che ha usato i nostri grafici sociali per distorcere la nostra visione del mondo reale e manipolare i nostri voti.", "privacy-matters-overlay-card-2-p-2": "Per essere obiettivi e prendere decisioni indipendenti devi avere il controllo del tuo spazio informativo. È possibile solo se utilizzi una rete di comunicazione privata che non ha accesso al tuo grafico sociale.", - "privacy-matters-overlay-card-3-p-2": "Una delle storie più scioccanti è l'esperienza di Mohamedou Ould Salahi descritta nel suo libro di memorie e mostrata nel film The Mauritanian. È stato rinchiuso nel campo di Guantánamo, senza processo, e lì è stato torturato per 15 anni dopo una telefonata a un suo parente in Afghanistan, sospettato di essere coinvolto negli attacchi dell'11/9, nonostante avesse vissuto in Germania per i precedenti 10 anni.", + "privacy-matters-overlay-card-3-p-2": "Una delle storie più scioccanti è l'esperienza di Mohamedou Ould Salahi descritta nel suo libro di memorie e mostrata nel film The Mauritanian. È stato rinchiuso nel campo di Guantánamo, senza processo, e lì è stato torturato per 15 anni dopo una telefonata a un suo parente in Afghanistan, sospettato di essere coinvolto negli attacchi dell'11/9, nonostante avesse vissuto in Germania per i precedenti 10 anni.", "join-us-on-GitHub": "Unisciti a noi su GitHub", "simplex-chat-for-the-terminal": "SimpleX Chat per il terminale", "privacy-matters-overlay-card-3-p-4": "Non è sufficiente usare un messenger crittografato end-to-end, tutti dovremmo usare i messenger che proteggono la privacy delle nostre reti personali — con chi siamo connessi.", - "simplex-unique-overlay-card-1-p-2": "Per recapitare i messaggi, SimpleX usa indirizzi anonimi a coppie di code di messaggi unidirezionali, separate per i messaggi ricevuti e inviati, di solito tramite server diversi.", + "simplex-unique-overlay-card-1-p-2": "Per recapitare i messaggi, SimpleX usa indirizzi anonimi a coppie di code di messaggi unidirezionali, separate per i messaggi ricevuti e inviati, di solito tramite server diversi.", "simplex-unique-overlay-card-1-p-1": "A differenza di altre reti di messaggistica, SimpleX non ha alcun identificatore assegnato agli utenti. Non si basa su numeri di telefono, indirizzi basati su domini (come email o XMPP), nomi utente, chiavi pubbliche o persino numeri casuali per identificare i suoi utenti — gli operatori dei server non sanno quante persone usano i loro server.", "simplex-unique-overlay-card-3-p-3": "A differenza dei server di reti federate (email, XMPP o Matrix), i server SimpleX non conservano gli account utente, ma trasmettono solo i messaggi, proteggendo la privacy di entrambe le parti.", "simplex-unique-card-3-p-2": "I messaggi crittografati end-to-end vengono conservati temporaneamente sui server di inoltro SimpleX fino alla ricezione, quindi vengono eliminati definitivamente.", @@ -194,17 +194,17 @@ "join-the-REDDIT-community": "Unisciti alla comunità di REDDIT", "sign-up-to-receive-our-updates": "Iscriviti per ricevere i nostri aggiornamenti", "simplex-unique-overlay-card-3-p-4": "Non ci sono identificatori o testi cifrati in comune tra il traffico del server inviato e quello ricevuto — se qualcuno lo osserva, non può determinare facilmente chi comunica con chi, anche se il TLS è compromesso.", - "simplex-unique-overlay-card-4-p-2": "La rete di SimpleX usa un protocollo aperto e fornisce un SDK per creare chat bot, consentendo l'implementazione di servizi con cui gli utenti possono interagire tramite le app SimpleX Chat — siamo impazienti di vedere quali servizi SimpleX creerai.", + "simplex-unique-overlay-card-4-p-2": "La rete di SimpleX usa un protocollo aperto e fornisce un SDK per creare chat bot, consentendo l'implementazione di servizi con cui gli utenti possono interagire tramite le app SimpleX Chat — siamo impazienti di vedere quali servizi SimpleX creerai.", "simplex-unique-card-4-p-1": "La rete SimpleX è completamente decentralizzata e indipendente da qualsiasi criptovaluta o altra rete, ad eccezione di internet.", "donate-here-to-help-us": "Dona qui per aiutarci", "to-make-a-connection": "Per stabilire una connessione:", "scan-the-qr-code-with-the-simplex-chat-app-description": "Le chiavi pubbliche e l'indirizzo della coda di messaggi in questo link NON vengono inviati in rete quando vedi questa pagina —
sono contenuti nel frammento hash dell'URL del link.", "copy-the-command-below-text": "copia il comando qui sotto e usalo nella chat:", - "privacy-matters-section-header": "Perché la privacy è importante", - "simplex-private-section-header": "Cosa rende SimpleX privato", + "privacy-matters-section-header": "Perché la privacy è importante", + "simplex-private-section-header": "Cosa rende SimpleX privato", "tap-to-close": "Tocca per chiudere", "simplex-network-1-desc": "tutti i messaggi vengono inviati tramite i server, garantendo una migliore privacy dei metadati e una consegna asincrona dei messaggi affidabile, evitando molti", - "simplex-network-3-desc": "i server forniscono code unidirezionali per connettere gli utenti, ma non hanno visibilità del grafo delle connessioni di rete — solo gli utenti.", + "simplex-network-3-desc": "i server forniscono code unidirezionali per connettere gli utenti, ma non hanno visibilità del grafo delle connessioni di rete — solo gli utenti.", "comparison-point-5-text": "Componente centrale o altro attacco a livello di rete", "comparison-section-list-point-5": "Non protegge la privacy dei metadati degli utenti", "comparison-section-list-point-3": "Chiave pubblica o altro ID univoco globale", @@ -233,15 +233,15 @@ "docs-dropdown-7": "Traduci SimpleX Chat", "glossary": "Glossario", "releases-to-this-repo-are-done-1-2-days-later": "Le pubblicazioni su questo repo avvengono diversi giorni dopo", - "f-droid-page-f-droid-org-repo-section-text": "I repository di SimpleX Chat e F-Droid.org firmano i pacchetti con chiavi diverse. Per passare da uno all'altro, esporta il database della chat e reinstalla l'app.", + "f-droid-page-f-droid-org-repo-section-text": "I repository di SimpleX Chat e F-Droid.org firmano i pacchetti con chiavi diverse. Per passare da uno all'altro, esporta il database della chat e reinstalla l'app.", "signing-key-fingerprint": "Impronta della chiave di firma (SHA-256)", "f-droid-org-repo": "Repo di F-Droid.org", "stable-versions-built-by-f-droid-org": "Versioni stabili compilate da F-Droid.org", - "docs-dropdown-8": "Servizio directory di SimpleX", + "docs-dropdown-8": "Directory di SimpleX", "simplex-chat-via-f-droid": "SimpleX Chat via F-Droid", "simplex-chat-repo": "Repo di SimpleX Chat", "stable-and-beta-versions-built-by-developers": "Versioni stabili e beta compilate dagli sviluppatori", - "f-droid-page-simplex-chat-repo-section-text": "Per aggiungerlo al tuo client F-Droid scansiona il codice QR o usa questo URL:", + "f-droid-page-simplex-chat-repo-section-text": "Per aggiungerlo al tuo client F-Droid scansiona il codice QR o usa questo URL:", "comparison-section-list-point-4a": "I relay di SimpleX non possono compromettere la crittografia e2e. Verifica il codice di sicurezza per mitigare gli attacchi sul canale fuori banda", "hero-overlay-3-title": "Valutazioni della sicurezza", "hero-overlay-card-3-p-2": "Trail of Bits ha revisionato i componenti di crittografia e di rete della piattaforma SimpleX nel novembre 2022. Maggiori informazioni.", @@ -255,5 +255,61 @@ "docs-dropdown-12": "Sicurezza", "docs-dropdown-11": "Domande frequenti", "hero-overlay-card-3-p-3": "Trail of Bits ha analizzato la progettazione crittografica dei protocolli della rete SimpleX nel luglio 2024. Leggi di più.", - "docs-dropdown-14": "SimpleX per il lavoro" + "docs-dropdown-14": "SimpleX per il lavoro", + "about-and-contact-us": "Informazioni e contatti", + "directory": "Directory", + "index-hero-h2": "Libertà e sicurezza
delle tue comunicazioni", + "index-hero-p1": "La prima rete in cui possiedi la tua identità, i contatti e i gruppi.", + "index-hero-download-desktop-btn-title": "Scarica l'app desktop di SimpleX", + "index-testflight-title": "Anteprima pubblica per iOS su TestFlight", + "index-f-droid-title": "SimpleX via F-Droid", + "index-security-assessment-title": "Analisi della sicurezza", + "index-security-review-2022-title": "Analisi della sicurezza 2022", + "index-security-review-2024-title": "Analisi della sicurezza 2024", + "index-hero-h1": "Vivi
libero", + "index-security-audits-label": "Analisi della
sicurezza", + "index-publications-privacy-guides-title": "Consigli sulla messaggistica di Privacy Guides", + "index-publications-whonix-title": "consigli sulla messaggistica di Whonix", + "index-publications-heise-title": "Pubblicazioni di Heise Online", + "index-publications-kuketz-title": "Recensione di Mike Kuketz", + "index-publications-optout-title": "Intervista podcast di OptOut", + "worlds-most-secure-messaging": "La messaggistica più sicuro del mondo", + "index-messaging-p1": "SimpleX usa una crittografia end-to-end all'avanguardia.", + "index-messaging-p2": "Per la tua sicurezza e privacy, i server non possono vedere i messaggi e con chi parli.", + "index-messaging-cta": "Scopri di più sui messaggi di SimpleX", + "index-nextweb-h2": "Il nuovo web
è tuo", + "index-nextweb-p1": "SimpleX è fondato sulla convinzione che devi possedere la tua identità, i tuoi contatti e le tue comunità.", + "index-nextweb-p2": "La rete aperta e decentralizzata consente di connetterti con persone e condividere idee: sii libero e al sicuro.", + "index-token-h2": "Comunità fatte per restare", + "index-token-p1": "Sosterrai i tuoi gruppi preferiti con futuri buoni comunitari.", + "index-token-p2": "I buoni pagheranno i server, per consentire alle tue comunità di rimanere libere e indipendenti.", + "index-token-cta": "Scopri di più e ricevi un NFT gratuito per provarlo in anticipo.", + "index-roadmap-h2": "Tabella di marcia per un internet libero", + "index-roadmap-2025": "2025", + "index-roadmap-2025-title": "Scalabilità per comunità numerose", + "index-roadmap-2025-desc": "Fuga da piattaforme centralizzate", + "index-roadmap-2026": "2026", + "index-roadmap-2026-title": "Comunità e server sostenibili", + "index-roadmap-2026-desc": "Pubblicazione di buoni comunitari", + "index-roadmap-2027": "2027", + "index-roadmap-2027-title": "Fare crescere le tue comunità", + "index-roadmap-2027-desc": "Strumenti per promuovere le tue comunità", + "index-directory-h2": "Unisciti alle comunità di SimpleX", + "index-directory-p1": "Centinaia di migliaia di persone si fidano già di SimpleX.", + "index-directory-p2": "Trova le comunità nella directory di SimpleX e creane una tua!", + "index-directory-cta": "Vedi la directory di SimpleX", + "index-directory-users-group-title": "Gruppo utenti SimpleX", + "how-secure-comparison-title": "Confronto della sicurezza di crittografia end-to-end in diverse app di messaggistica", + "how-secure-message-padding": "Riempimento dei messaggi", + "how-secure-repudiation-deniability": "Ripudio (negabilità)", + "how-secure-forward-secrecy": "Forward secrecy", + "how-secure-break-in-recovery": "Sicurezza post-compromissione", + "how-secure-two-factor-key-exchange": "Scambio di chiavi a 2 fattori", + "how-secure-post-quantum-hybrid-crypto": "Crittografia ibrida quantistica", + "messengers-comparison-section-list-point-1": "Briar riempie i messaggi ad una dimensione arrotondata fino a 1024 byte, Signal a 160 byte", + "messengers-comparison-section-list-point-2": "Il ripudio non include la connessione client-server.", + "messengers-comparison-section-list-point-3": "Sembra che l'uso di firme crittografiche comprometta il ripudio (negabilità), ma occorrono chiarimenti.", + "messengers-comparison-section-list-point-4": "L'implementazione multi-dispositivo compromette la sicurezza post-compromissione di Double Ratchet", + "messengers-comparison-section-list-point-5": "Lo scambio di chiavi a 2 fattori è facoltativo tramite la verifica del codice di sicurezza.", + "messengers-comparison-section-list-point-6": "L'accordo sulle chiavi post-quantistico è “scarno” — protegge solo alcuni dei passaggi del ratchet." } diff --git a/website/langs/ja.json b/website/langs/ja.json index 1f71aad624..31ef2669ef 100644 --- a/website/langs/ja.json +++ b/website/langs/ja.json @@ -52,7 +52,7 @@ "chat-protocol": "チャットプロトコル", "chat-bot-example": "チャットボットの例", "donate": "寄付", - "copyright-label": "© 2020-2025 SimpleX | Open-Source Project", + "copyright-label": "© 2020-2025 SimpleX Chat | Open-Source Project", "hero-p-1": "他のアプリにはユーザー ID があります: Signal、Matrix、Session、Briar、Jami、Cwtch など。
SimpleX にはありません。乱数さえもありません
これにより、プライバシーが大幅に向上します。", "copy-the-command-below-text": "以下のコマンドをコピーしてチャットで使用します:", "simplex-private-card-9-point-1": "各メッセージ キューは、異なる送信アドレスと受信アドレスを使用してメッセージを一方向に渡します。", diff --git a/website/langs/nl.json b/website/langs/nl.json index a6a0a740ae..f8297e2841 100644 --- a/website/langs/nl.json +++ b/website/langs/nl.json @@ -17,7 +17,7 @@ "chat-bot-example": "Chatbot voorbeeld", "smp-protocol": "SMP protocol", "donate": "Doneer", - "copyright-label": "© 2020-2025 SimpleX | Open-sourceproject", + "copyright-label": "© 2020-2025 SimpleX Chat | Open-sourceproject", "simplex-chat-protocol": "SimpleX Chat protocol", "terminal-cli": "Terminal CLI", "terms-and-privacy-policy": "Privacybeleid", @@ -241,7 +241,7 @@ "stable-versions-built-by-f-droid-org": "Stabiele versies gebouwd door F-Droid.org", "releases-to-this-repo-are-done-1-2-days-later": "De releases voor deze repository vinden enkele dagen later plaats", "f-droid-page-f-droid-org-repo-section-text": "SimpleX Chat- en F-Droid.org-repository's ondertekenen builds met de verschillende sleutels. Om over te stappen, alstublieft exporteer de chatdatabase en installeer de app opnieuw.", - "docs-dropdown-8": "SimpleX Directory Service", + "docs-dropdown-8": "SimpleX Directory", "comparison-section-list-point-4a": "SimpleX relais kunnen de e2e-versleuteling niet in gevaar brengen. Controleer de beveiligingscode om aanvallen op out-of-band kanalen te beperken", "hero-overlay-3-title": "Beveiligings beoordeling", "hero-overlay-card-3-p-2": "Trail of Bits heeft in november 2022 de cryptografie en netwerkcomponenten van het SimpleX-platform beoordeeld. Lees meer in de aankondiging.", @@ -255,5 +255,7 @@ "docs-dropdown-11": "FAQ", "docs-dropdown-12": "Beveiliging", "hero-overlay-card-3-p-3": "Trail of Bits heeft in juli 2024 het cryptografische ontwerp van SimpleX-netwerkprotocollen beoordeeld. Lees meer.", - "docs-dropdown-14": "SimpleX voor bedrijven" + "docs-dropdown-14": "SimpleX voor bedrijven", + "directory": "Directory", + "about-and-contact-us": "Over ons & Contact" } diff --git a/website/langs/pl.json b/website/langs/pl.json index d45207a709..54a552685f 100644 --- a/website/langs/pl.json +++ b/website/langs/pl.json @@ -15,7 +15,7 @@ "smp-protocol": "Protokół SMP", "chat-protocol": "Protokół czatu", "donate": "Darowizna", - "copyright-label": "© 2020-2025 SimpleX | Projekt Open-Source", + "copyright-label": "© 2020-2025 SimpleX Chat | Projekt Open-Source", "simplex-chat-protocol": "Protokół SimpleX Chat", "terminal-cli": "Terminal CLI", "terms-and-privacy-policy": "Polityka prywatności", @@ -46,7 +46,7 @@ "developers": "Deweloperzy", "simplex-explained-tab-1-text": "1. Jakie są doświadczenia użytkowników", "simplex-explained-tab-1-p-1": "Możesz tworzyć kontakty i grupy oraz prowadzić dwukierunkowe rozmowy, jak w każdym innym komunikatorze.", - "simplex-explained-tab-2-p-2": "Serwery przekazują wiadomości tylko w jedną stronę, nie mając pełnego obrazu konwersacji i połączeń użytkownika.", + "simplex-explained-tab-2-p-2": "Serwery przekazują wiadomości tylko w jedną stronę, nie mając pełnego obrazu konwersacji ani połączeń użytkownika.", "simplex-explained-tab-3-p-2": "Użytkownicy mogą jeszcze bardziej zwiększyć prywatność metadanych, używając Tor do uzyskania dostępu do serwerów, co zapobiega korelacji na podstawie adresu IP.", "hero-p-1": "Inne aplikacje posiadają identyfikatory użytkowników (ID): Signal, Matrix, Session, Briar, Jami, Cwtch itp.
SimpleX nie, nie posiada nawet losowych numerów.
To radykalnie poprawia Twoją prywatność.", "feature-2-title": "Obrazy, wideo i pliki
zaszyfrowane przez E2E", @@ -63,7 +63,7 @@ "simplex-private-card-3-point-2": "Odcisk palca serwera i wiązanie kanałów zapobiegają atakom MITM i replay.", "simplex-private-card-3-point-3": "Wznowienie połączenia jest wyłączone, aby zapobiec atakom na sesje.", "simplex-private-card-4-point-1": "Aby chronić swój adres IP, możesz uzyskać dostęp do serwerów przez Tor lub inną sieć nakładki transportowej.", - "simplex-private-card-6-point-1": "Wiele platform komunikacyjnych jest podatnych na ataki MITM ze strony serwerów lub dostawców sieci.", + "simplex-private-card-6-point-1": "Wiele sieci komunikacyjnych jest podatnych na ataki MITM ze strony serwerów lub dostawców sieci.", "simplex-private-card-6-point-2": "Aby temu zapobiec aplikacje SimpleX przekazują klucze jednorazowe out-of-band, gdy udostępniasz adres jako link lub kod QR.", "simplex-private-card-7-point-1": "Aby zagwarantować integralność, wiadomości są kolejno numerowane i zawierają hash poprzedniej wiadomości.", "simplex-private-card-7-point-2": "Jeśli jakaś wiadomość zostanie dodana, usunięta lub zmieniona, odbiorca zostanie o tym powiadomiony.", @@ -112,7 +112,7 @@ "simplex-unique-4-title": "Jesteś właścicielem sieci SimpleX", "simplex-unique-4-overlay-1-title": "W pełni zdecentralizowana — użytkownicy są właścicielami sieci SimpleX", "hero-overlay-card-1-p-1": "Wielu użytkowników pytało: jeśli SimpleX nie ma identyfikatorów użytkowników, skąd może wiedzieć, gdzie dostarczyć wiadomości?", - "hero-overlay-card-1-p-2": "Aby dostarczyć wiadomości, zamiast identyfikatorów użytkownika używanych przez wszystkie inne platformy, SimpleX używa tymczasowych anonimowych identyfikatorów kolejek wiadomości, oddzielnych dla każdego z twoich połączeń — nie ma żadnych długoterminowych identyfikatorów.", + "hero-overlay-card-1-p-2": "Aby dostarczyć wiadomości, zamiast identyfikatorów użytkownika używanych przez wszystkie inne sieci, SimpleX używa tymczasowych anonimowych identyfikatorów kolejek wiadomości, oddzielnych dla każdego z twoich połączeń — nie ma żadnych długoterminowych identyfikatorów.", "simplex-network-overlay-card-1-li-1": "Sieci P2P opierają się na pewnym wariancie DHT do przekazywania wiadomości. DHT muszą równoważyć gwarancję dostarczenia i opóźnienie. SimpleX ma zarówno lepszą gwarancję dostarczenia, jak i mniejsze opóźnienia niż P2P, ponieważ wiadomość może być nadmiarowo przepuszczona przez kilka serwerów równolegle, z wykorzystaniem serwerów wybranych przez odbiorcę. W sieciach P2P wiadomość jest przepuszczana przez , O(log N) węzłów sekwencyjnie, z wykorzystaniem węzłów wybranych przez algorytm.", "privacy-matters-overlay-card-3-p-3": "Zwykli ludzie są aresztowani za to, co udostępniają w sieci, nawet za pośrednictwem swoich \"anonimowych\" kont, nawet w krajach demokratycznych.", "hero-overlay-card-1-p-6": "Więcej w SimpleX whitepaper.", @@ -121,18 +121,18 @@ "hero-overlay-card-2-p-4": "SimpleX chroni przed tymi atakami, nie mając w swojej konstrukcji żadnych identyfikatorów użytkowników. A jeśli używasz trybu Incognito, będziesz miał inną nazwę wyświetlania dla każdego kontaktu, unikając wszelkich wspólnych danych między nimi.", "simplex-network-overlay-card-1-p-1": "P2P protokoły i aplikacje do przesyłania wiadomości mają różne problemy, które czynią je mniej niezawodnymi niż SimpleX, bardziej skomplikowanymi w analizie i podatnymi na kilka rodzajów ataków.", "simplex-network-overlay-card-1-li-2": "Projekt SimpleX, w przeciwieństwie do większości sieci P2P, nie posiada żadnych globalnych identyfikatorów użytkowników, nawet tymczasowych, a jedynie używa tymczasowych identyfikatorów parami, zapewniając lepszą anonimowość i ochronę metadanych.", - "simplex-network-overlay-card-1-li-4": "Implementacje P2P mogą być blokowane przez niektórych dostawców Internetu (np. BitTorrent). SimpleX jest agnostykiem transportowym - może działać przez standardowe protokoły internetowe, np. WebSockets.", + "simplex-network-overlay-card-1-li-4": "Implementacje P2P mogą być blokowane przez niektórych dostawców Internetu (np. BitTorrent). SimpleX jest agnostykiem transportowym — może działać przez standardowe protokoły internetowe, np. WebSockets.", "privacy-matters-overlay-card-1-p-3": "Niektóre firmy finansowe i ubezpieczeniowe wykorzystują wykresy społeczne do określania stóp procentowych i składek. Często powoduje to, że osoby o niższych dochodach płacą więcej — jest to znane jako 'premia za ubóstwo'.", - "privacy-matters-overlay-card-1-p-4": "Platforma SimpleX chroni prywatność Twoich połączeń lepiej niż jakakolwiek alternatywa, w pełni zapobiegając udostępnieniu Twojego wykresu społecznego jakimkolwiek firmom lub organizacjom. Nawet gdy ludzie korzystają z serwerów udostępnianych przez SimpleX Chat, nie znamy liczby użytkowników ani ich połączeń.", + "privacy-matters-overlay-card-1-p-4": "Sieć SimpleX chroni prywatność Twoich połączeń lepiej niż jakakolwiek alternatywa, w pełni zapobiegając udostępnieniu Twojego wykresu społecznego jakimkolwiek firmom lub organizacjom. Nawet gdy ludzie korzystają z serwerów prekonfigurowanych przez SimpleX Chat, aplikacje i operatorzy serwerów nie znają liczby użytkowników ani ich połączeń.", "privacy-matters-overlay-card-1-p-2": "Sprzedawcy internetowi wiedzą, że osoby o niższych dochodach częściej dokonują pilnych zakupów, więc mogą naliczać wyższe ceny lub usuwać rabaty.", - "privacy-matters-overlay-card-2-p-2": "Aby być obiektywnym i podejmować niezależne decyzje musisz mieć kontrolę nad swoją przestrzenią informacyjną. Jest to możliwe tylko wtedy, gdy korzystasz z prywatnej platformy komunikacyjnej, która nie ma dostępu do Twojego wykresu społecznego.", - "privacy-matters-overlay-card-2-p-3": "SimpleX to pierwsza platforma, która z założenia nie posiada żadnych identyfikatorów użytkowników, w ten sposób chroniąc Twój wykres połączeń lepiej niż jakakolwiek znana alternatywa.", + "privacy-matters-overlay-card-2-p-2": "Aby być obiektywnym i podejmować niezależne decyzje musisz mieć kontrolę nad swoją przestrzenią informacyjną. Jest to możliwe tylko wtedy, gdy korzystasz z prywatnej sieci komunikacyjnej, która nie ma dostępu do Twojego wykresu społecznego.", + "privacy-matters-overlay-card-2-p-3": "SimpleX to pierwsza sieć, która z założenia nie posiada żadnych identyfikatorów użytkowników, w ten sposób chroniąc Twój wykres połączeń lepiej niż jakakolwiek znana alternatywa.", "simplex-network-overlay-card-1-li-5": "Wszystkie znane sieci P2P mogą być podatne na atak Sybil, ponieważ każdy węzeł jest możliwy do odkrycia, a sieć działa jako całość. Znane środki łagodzące wymagają albo scentralizowanego komponentu, albo kosztownego proof of work. SimpleX nie ma możliwości odkrywania serwerów, jest pofragmentowana i działa jako wiele odizolowanych podsieci, co uniemożliwia ataki na całą sieć.", "privacy-matters-overlay-card-2-p-1": "Jeszcze nie tak dawno obserwowaliśmy, jak główne wybory były manipulowane przez szacowną firmę konsultingową, która wykorzystywała nasze wykresy społeczne do zniekształcania naszego spojrzenia na świat rzeczywisty i manipulowania naszymi głosami.", "privacy-matters-overlay-card-3-p-1": "Każdy powinien dbać o prywatność i bezpieczeństwo swojej komunikacji — nieszkodliwe rozmowy mogą narazić Cię na niebezpieczeństwo, nawet jeśli nie masz nic do ukrycia.", "privacy-matters-overlay-card-3-p-2": "Jedną z najbardziej wstrząsających historii jest doświadczenie Mohamedou Ould Salahi opisane w jego pamiętniku i pokazane w filmie Mauretańczyk (2021). Został on umieszczony w obozie Guantanamo, bez procesu, i był tam torturowany przez 15 lat po telefonie do swojego krewnego w Afganistanie, pod zarzutem udziału w atakach 9/11, mimo że przez poprzednie 10 lat mieszkał w Niemczech.", "privacy-matters-overlay-card-3-p-4": "Nie wystarczy używać komunikatora szyfrowanego end-to-end, wszyscy powinniśmy używać komunikatorów, które chronią prywatność naszych sieci osobistych — z kim jesteśmy połączeni.", - "simplex-unique-overlay-card-1-p-1": "W przeciwieństwie do innych platform komunikacyjnych, SimpleX ma brak identyfikatorów przypisanych do użytkowników. Nie polega na numerach telefonów, adresach opartych na domenie (jak e-mail lub XMPP), nazwach użytkowników, kluczach publicznych czy nawet losowych liczbach, aby zidentyfikować swoich użytkowników — nie wiemy, ile osób korzysta z naszych serwerów SimpleX.", + "simplex-unique-overlay-card-1-p-1": "W przeciwieństwie do innych sieci komunikacyjnych, SimpleX ma brak identyfikatorów przypisanych do użytkowników. Nie polega na numerach telefonów, adresach opartych na domenie (jak e-mail lub XMPP), nazwach użytkowników, kluczach publicznych czy nawet losowych liczbach, aby zidentyfikować swoich użytkowników — operatorzy serwerów SimpleX nie wiedzą, ile osób korzysta z ich serwerów.", "hero-overlay-card-2-p-3": "Nawet w przypadku najbardziej prywatnych aplikacji, które korzystają z trzeciej wersji usług Tor, jeśli rozmawiasz z dwoma różnymi kontaktami za pośrednictwem tego samego profilu, mogą one udowodnić, że są połączone z tą samą osobą.", "simplex-network-overlay-card-1-li-3": "P2P nie rozwiązuje ataku MITM problemu, a większość istniejących implementacji nie używa wiadomości out-of-band do początkowej wymiany klucza. SimpleX używa wiadomości out-of-band lub, w niektórych przypadkach, wcześniej istniejących bezpiecznych i zaufanych połączeń do początkowej wymiany klucza.", "hero-overlay-card-1-p-5": "Tylko urządzenia klienckie przechowują profile użytkowników, kontakty i grupy; wiadomości są przesyłane z 2-warstwowym szyfrowaniem end-to-end.", @@ -141,9 +141,9 @@ "privacy-matters-overlay-card-1-p-1": "Wiele dużych firm wykorzystuje informacje o tym, z kim jesteś połączony, aby oszacować Twoje dochody, sprzedać Ci produkty, których tak naprawdę nie potrzebujesz, oraz ustalić ceny.", "simplex-unique-overlay-card-2-p-2": "Nawet w przypadku opcjonalnego adresu użytkownika, podczas gdy może on być używany do wysyłania spamowych zapytań o kontakt, możesz go zmienić lub całkowicie usunąć bez utraty jakichkolwiek połączeń.", "simplex-unique-overlay-card-1-p-2": "Do dostarczania wiadomości SimpleX używa parami anonimowych adresów jednokierunkowych kolejek z wiadomościami, oddzielnych dla wiadomości odbieranych i wysyłanych, zwykle przez różne serwery.", - "simplex-unique-overlay-card-1-p-3": "Taka konstrukcja chroni prywatność tego, z kim się komunikujesz, ukrywając ją przed serwerami platformy SimpleX i przed wszelkimi obserwatorami. Aby ukryć swój adres IP przed serwerami, możesz połączyć się z serwerami SimpleX za pośrednictwem sieci Tor.", + "simplex-unique-overlay-card-1-p-3": "Taka konstrukcja chroni prywatność tego, z kim się komunikujesz, ukrywając ją przed serwerami sieci SimpleX i przed wszelkimi obserwatorami. Aby ukryć swój adres IP przed serwerami, możesz połączyć się z serwerami SimpleX za pośrednictwem sieci Tor.", "simplex-unique-overlay-card-3-p-4": "Nie ma żadnych identyfikatorów ani szyfrogramów wspólnych między wysyłanym i odbieranym ruchem serwera — jeśli ktokolwiek to obserwuje, nie może łatwo określić, kto komunikuje się z kim, nawet jeśli bezpieczeństwo protokołu TLS zostało zagrożone.", - "simplex-unique-overlay-card-2-p-1": "Ponieważ nie masz identyfikatora na platformie SimpleX, nikt nie może się z Tobą skontaktować, chyba że udostępnisz jednorazowy lub tymczasowy adres użytkownika, w postaci kodu QR lub linku.", + "simplex-unique-overlay-card-2-p-1": "Ponieważ nie masz identyfikatora w sieci SimpleX, nikt nie może się z Tobą skontaktować, chyba że udostępnisz jednorazowy lub tymczasowy adres użytkownika, w postaci kodu QR lub linku.", "simplex-unique-overlay-card-3-p-1": "SimpleX Chat przechowuje wszystkie dane użytkowników tylko na urządzeniach klienckich przy użyciu przenośnego formatu zaszyfrowanej bazy danych, który można wyeksportować i przenieść na dowolne obsługiwane urządzenie.", "simplex-unique-overlay-card-3-p-3": "W przeciwieństwie do serwerów sieci federacyjnych (e-mail, XMPP czy Matrix), serwery SimpleX nie przechowują kont użytkowników, a jedynie przekazują wiadomości, chroniąc prywatność obu stron.", "simplex-unique-overlay-card-4-p-1": "Możesz używać SimpleX z własnymi serwerami i nadal komunikować się z osobami, które korzystają z dostarczonych przez nas prekonfigurowanych serwerów.", diff --git a/website/langs/pt_BR.json b/website/langs/pt_BR.json index 77854f5be1..3b7cc23703 100644 --- a/website/langs/pt_BR.json +++ b/website/langs/pt_BR.json @@ -25,7 +25,7 @@ "smp-protocol": "Protocolo SMP", "chat-protocol": "Protocolo de bate-papo", "donate": "Doar", - "copyright-label": "© 2020-2025 SimpleX | Projeto de Código Livre", + "copyright-label": "© 2020-2025 SimpleX Chat | Projeto de Código Livre", "simplex-chat-protocol": "Protocolo Chat SimpleX", "terminal-cli": "CLI Terminal", "hero-header": "Privacidade redefinida", @@ -64,17 +64,17 @@ "privacy-matters-overlay-card-1-p-1": "Muitas grandes empresas usam informações sobre com quem você está conectado para estimar sua renda, vender produtos que você realmente não precisa e determinar os preços.", "privacy-matters-overlay-card-1-p-2": "Os varejistas online sabem que as pessoas com renda mais baixa têm maior probabilidade de fazer compras urgentes e, por isso, podem cobrar preços mais altos ou remover descontos.", "privacy-matters-overlay-card-1-p-4": "A plataforma SimpleX protege a privacidade de suas conexões melhor do que qualquer alternativa, impedindo totalmente que seu gráfico social fique disponível para quaisquer empresas ou organizações. Mesmo quando as pessoas usam servidores fornecidos pelo SimpleX Chat, não sabemos o número de usuários ou suas conexões.", - "privacy-matters-overlay-card-2-p-2": "Para ser objetivo e tomar decisões independentes, você precisa ter o controle do seu espaço de informações. Isso só é possível se você usar uma plataforma de comunicação privada que não tenha acesso ao seu gráfico social.", + "privacy-matters-overlay-card-2-p-2": "Para ser objetivo e tomar decisões independentes, você precisa ter o controle do seu espaço de informações. Isso só é possível se você usar uma rede de comunicação privada que não tenha acesso ao seu gráfico social.", "privacy-matters-overlay-card-3-p-1": "Todos devem se preocupar com a privacidade e a segurança de suas comunicações — conversas inofensivas podem colocá-lo em perigo, mesmo que você não tenha nada a esconder.", "simplex-unique-overlay-card-3-p-1": "O SimpleX Chat armazena todos os dados do usuário somente em dispositivos clientes usando um formato de banco de dados criptografado portátil que pode ser exportado e transferido para qualquer dispositivo compatível.", "privacy-matters-overlay-card-3-p-2": "Uma das histórias mais chocantes é a experiência de Mohamedou Ould Salahi, descrita em seu livro de memórias e mostrada no filme O Mauritano. Ele foi colocado no campo de Guantánamo, sem julgamento, e lá foi torturado por 15 anos após um telefonema para seu parente no Afeganistão, sob suspeita de estar envolvido nos ataques de 11 de setembro, embora tenha vivido na Alemanha nos 10 anos anteriores.", - "simplex-unique-overlay-card-1-p-1": "Ao contrário de outras plataformas de mensagens, o SimpleX não tem identificadores atribuídos aos usuários. Ele não depende de números de telefone, endereços baseados em domínio (como email ou XMPP), nomes de usuário, chaves públicas ou mesmo números aleatórios para identificar seus usuários — não sabemos quantas pessoas usam nossos servidores SimpleX.", + "simplex-unique-overlay-card-1-p-1": "Ao contrário de outras redes de mensagens, o SimpleX não tem identificadores atribuídos aos usuários. Ele não depende de números de telefone, endereços baseados em domínio (como email ou XMPP), nomes de usuário, chaves públicas ou mesmo números aleatórios para identificar seus usuários — Os operadores dos servidores não sabem quantas pessoas usam os servidores SimpleX.", "simplex-private-card-1-point-1": "Protocolo de dupla catraca —
mensagens OTR com Sigilo de Encaminhamento Perfeito (Perfect Forward Secrecy) e recuperação de invasão.", "simplex-private-8-title": "Mistura de mensagens
para reduzir a correlação", - "simplex-unique-overlay-card-4-p-2": "A plataforma SimpleX usa um protocolo aberto e fornece um SDK para criar bots de chat, permitindo a implementação de serviços com os quais os usuários podem interagir por meio dos aplicativos SimpleX Chat — estamos' realmente ansiosos para ver quais serviços SimpleX você pode criar.", + "simplex-unique-overlay-card-4-p-2": "A rede SimpleX usa um protocolo aberto e fornece um SDK para criar bots de chat, permitindo a implementação de serviços com os quais os usuários podem interagir por meio dos aplicativos SimpleX Chat — estamos' realmente ansiosos para ver quais serviços SimpleX você pode criar.", "simplex-unique-overlay-card-3-p-4": "Não há identificadores ou texto cifrado em comum entre o tráfego de servidor enviado e recebido — se alguém estiver observando, não poderá determinar facilmente quem se comunica com quem, mesmo que o TLS esteja comprometido.", - "simplex-unique-overlay-card-4-p-3": "Se estiver pensando em desenvolver para a plataforma SimpleX, por exemplo, o bot de chat para os usuários do aplicativo SimpleX ou a integração da biblioteca SimpleX Chat em seus aplicativos móveis, entre em contato para qualquer orientação e suporte.", - "simplex-unique-card-2-p-1": "Como você não tem um identificador ou endereço fixo na plataforma SimpleX, ninguém pode entrar em contato com você, a menos que você compartilhe um endereço de usuário único ou temporário, como um QR code ou um link.", + "simplex-unique-overlay-card-4-p-3": "Se estiver pensando em desenvolver para a rede SimpleX, por exemplo, o bot de chat para os usuários do aplicativo SimpleX ou a integração da biblioteca SimpleX Chat em seus aplicativos móveis, entre em contato para qualquer orientação e suporte.", + "simplex-unique-card-2-p-1": "Como você não tem um identificador ou endereço fixo na rede SimpleX, ninguém pode entrar em contato com você, a menos que você compartilhe um endereço de usuário único ou temporário, como um QR code ou um link.", "simplex-unique-card-3-p-1": "O SimpleX armazena todos os dados do usuário nos dispositivos clientes em um formato de banco de dados criptografado portátil — que pode ser transferido para outro dispositivo.", "hide-info": "Ocultar informações", "contact-hero-p-2": "Ainda não baixou o SimpleX Chat?", @@ -124,20 +124,20 @@ "simplex-network-overlay-card-1-li-6": "As redes P2P podem ser vulneráveis a ataques DRDoS, quando os clientes podem retransmitir e amplificar o tráfego, resultando em uma negação de serviço em toda a rede. Os clientes SimpleX apenas retransmitem o tráfego de uma conexão conhecida e não podem ser usados por um invasor para amplificar o tráfego em toda a rede.", "privacy-matters-overlay-card-1-p-3": "Algumas empresas financeiras e de seguros usam gráficos sociais para determinar taxas de juros e prêmios. Isso geralmente faz com que as pessoas com renda mais baixa paguem mais — isso é conhecido como \"prêmio de pobreza\".", "privacy-matters-overlay-card-2-p-1": "Não faz muito tempo que observamos as eleições sendo manipuladas por uma empresa de consultoria respeitável que usou nossos gráficos sociais para distorcer nossa visão do mundo real e manipular nossos votos.", - "privacy-matters-overlay-card-2-p-3": "O SimpleX é a primeira plataforma que não tem nenhum identificador de usuário por design, protegendo assim seu gráfico de conexões melhor do que qualquer alternativa conhecida.", + "privacy-matters-overlay-card-2-p-3": "O SimpleX é a primeira rede que não tem nenhum identificador de usuário por design, protegendo assim seu gráfico de conexões melhor do que qualquer alternativa conhecida.", "privacy-matters-overlay-card-3-p-3": "Pessoas comuns são presas pelo que compartilham online, mesmo por meio de suas contas \"anônimas\", mesmo em países democráticos.", "privacy-matters-overlay-card-3-p-4": "Não basta usar um mensageiro criptografado de ponta-a-ponta, todos nós devemos usar os mensageiros que protegem a privacidade de nossas redes pessoais — com quem estamos conectados.", "simplex-unique-overlay-card-1-p-2": "Para entregar mensagens, o SimpleX usa endereços anônimos em pares de filas de mensagens unidirecionais, separadas para mensagens recebidas e enviadas, geralmente por meio de servidores diferentes.", - "simplex-unique-overlay-card-1-p-3": "Esse design protege a privacidade de quem está se comunicando com você, ocultando-a dos servidores da plataforma SimpleX e de quaisquer observadores. Para ocultar seu endereço IP dos servidores, você pode se conectar aos servidores do SimpleX via Tor.", + "simplex-unique-overlay-card-1-p-3": "Esse design protege a privacidade com quem você está se comunicando, ocultando-a dos servidores da rede SimpleX e de quaisquer observadores. Para ocultar seu endereço IP dos servidores, você pode se conectar aos servidores do SimpleX via Tor.", "simplex-unique-overlay-card-2-p-2": "Mesmo com o endereço de usuário opcional, embora ele possa ser usado para enviar solicitações de contato de spam, você pode alterá-lo ou excluí-lo completamente sem perder nenhuma das suas conexões.", - "simplex-unique-overlay-card-2-p-1": "Como você não tem um identificador na plataforma SimpleX, ninguém pode entrar em contato com você, a menos que compartilhe um endereço de usuário único ou temporário, como um QR code ou um link.", + "simplex-unique-overlay-card-2-p-1": "Como você não tem um identificador na rede SimpleX, ninguém pode entrar em contato com você, a menos que compartilhe um endereço de usuário único ou temporário, como um QR code ou um link.", "simplex-unique-overlay-card-3-p-2": "As mensagens criptografadas de ponta-a-ponta são mantidas temporariamente nos servidores de retransmissão SimpleX até serem recebidas e, em seguida, são excluídas permanentemente.", "simplex-unique-overlay-card-3-p-3": "Diferente dos servidores de redes federadas (email, XMPP ou Matrix), os servidores SimpleX não armazenam contas de usuários, apenas retransmitem mensagens, protegendo a privacidade de ambas as partes.", "simplex-unique-overlay-card-4-p-1": "Você pode usar o SimpleX com seus próprios servidores e ainda se comunicar com pessoas que usam os servidores pré-configurados fornecidos por nós.", - "simplex-unique-card-1-p-1": "O SimpleX protege a privacidade do seu perfil, contatos e metadados, ocultando-os dos servidores da plataforma SimpleX e de quaisquer observadores.", - "simplex-unique-card-1-p-2": "Diferente de qualquer outra plataforma de mensagens existente, o SimpleX não tem identificadores atribuídos aos usuários — nem mesmo números aleatórios.", + "simplex-unique-card-1-p-1": "O SimpleX protege a privacidade do seu perfil, contatos e metadados, ocultando-os dos servidores da rede SimpleX e de quaisquer observadores.", + "simplex-unique-card-1-p-2": "Diferente de qualquer outra rede de mensagens existente, o SimpleX não tem identificadores atribuídos aos usuários — nem mesmo números aleatórios.", "simplex-unique-card-3-p-2": "As mensagens criptografadas de ponta-a-ponta são mantidas temporariamente nos servidores de retransmissão SimpleX até serem recebidas e, em seguida, são excluídas permanentemente.", - "simplex-unique-card-4-p-1": "A rede SimpleX é totalmente descentralizada e independente de qualquer criptomoeda ou de qualquer outra plataforma, exceto a Internet.", + "simplex-unique-card-4-p-1": "A rede SimpleX é totalmente descentralizada e independente de qualquer criptomoeda ou de qualquer outra rede, exceto a Internet.", "simplex-unique-card-4-p-2": "Você pode usar o SimpleX com seus próprios servidores ou com os servidores fornecidos por nós — e ainda assim se conectar a qualquer usuário.", "we-invite-you-to-join-the-conversation": "Convidamos você a participar da conversa", "join": "Junte-se ao", @@ -190,7 +190,7 @@ "guide-dropdown-3": "Grupos secretos", "guide-dropdown-7": "Privacidade e segurança", "guide": "Manual", - "docs-dropdown-1": "Plataforma SimpleX", + "docs-dropdown-1": "Rede SimpleX", "docs-dropdown-3": "Acessando o banco de dados do bate-papo", "on-this-page": "Nesta página", "newer-version-of-eng-msg": "Há uma versão mais recente desta página em inglês.", @@ -201,7 +201,7 @@ "scan-the-qr-code-with-the-simplex-chat-app-description": "As chaves públicas e o endereço da fila de mensagens nesse link NÃO são enviados pela rede quando você visualiza essa página —
eles estão contidos no fragmento de hash do URL do link.", "installing-simplex-chat-to-terminal": "Instalação do SimpleX chat para o terminal", "see-simplex-chat": "Veja o SimpleX Chat", - "the-instructions--source-code": "as instruções sobre como fazer download ou compilá-lo a partir do código-fonte.", + "the-instructions--source-code": "para as instruções sobre como fazer download ou compilá-lo a partir do código-fonte.", "if-you-already-installed": "Se você já instalou", "simplex-chat-for-the-terminal": "SimpleX Chat para terminal", "simplex-network-1-desc": "Todas as mensagens são enviadas por meio dos servidores, o que proporciona melhor privacidade de metadados e entrega de mensagens assíncronas confiáveis, além de evitar muitos dos", @@ -227,7 +227,7 @@ "no-federated": "Não - federado", "comparison-section-list-point-1": "Geralmente com base em um número de telefone, em alguns casos em nomes de usuário", "comparison-section-list-point-2": "Endereços baseados no DNS", - "comparison-section-list-point-6": "Embora os P2P sejam distribuídos, eles não são federados - operam como uma única rede", + "comparison-section-list-point-6": "Embora os P2P sejam distribuídos, eles não são federados &mdash - operam como uma única rede", "comparison-section-list-point-7": "As redes P2P têm uma autoridade central ou toda a rede pode ser comprometida", "protocol-2-text": "Matrix, XMPP", "see-here": "veja aqui", @@ -249,11 +249,13 @@ "f-droid-page-f-droid-org-repo-section-text": "Os repositórios SimpleX Chat e F-Droid.org assinam compilações com chaves diferentes. Para mudar, exporte o banco de dados de bate-papo e reinstale o aplicativo.", "please-enable-javascript": "Por favor habilite o JavaScript para ver o QR code.", "jobs": "Junte-se à equipe", - "docs-dropdown-8": "Serviço de Diretório SimpleX", + "docs-dropdown-8": "Diretório SimpleX", "docs-dropdown-9": "Baixar", "docs-dropdown-11": "FAQ", "docs-dropdown-10": "Transparência", "docs-dropdown-12": "Segurança", - "hero-overlay-card-3-p-3": "Trail of Bits revisou o design criptografico das redes utilizadas pelo SimpleX em julho de 2024", - "docs-dropdown-14": "SimpleX para negócios" + "hero-overlay-card-3-p-3": "Trail of Bits revisou o design criptografico das redes utilizadas pelo SimpleX em julho de 2024. Read more.", + "docs-dropdown-14": "SimpleX para negócios", + "directory": "Diretório", + "about-and-contact-us": "Sobre e Contato" } diff --git a/website/langs/ro.json b/website/langs/ro.json new file mode 100644 index 0000000000..cfe489967f --- /dev/null +++ b/website/langs/ro.json @@ -0,0 +1,56 @@ +{ + "home": "Acasă", + "developers": "Dezvoltatori", + "reference": "Referință", + "features": "­Funcționalități", + "why-simplex": "De ce SimpleX", + "simplex-privacy": "Confidențialitatea SimpleX", + "simplex-network": "Rețeaua SimpleX", + "simplex-explained": "SimpleX, explicat", + "simplex-explained-tab-1-text": "1. Ce experimentează utilizatorii", + "simplex-explained-tab-2-text": "2. Cum funcționează", + "simplex-explained-tab-3-text": "3. Ce văd serverele", + "simplex-explained-tab-1-p-1": "Poți crea contacte și grupuri, și poți avea conversații bidirecționale, ca în oricare altă aplicație de mesagerie.", + "simplex-explained-tab-1-p-2": "Cum poate funcționa cu liste de așteptare unidirecționale și fără identificatori de profil ai utilizatorului?", + "simplex-explained-tab-2-p-1": "Pentru fiecare conexiune, folosești două cozi de mesaje separate pentru a trimite și a primi mesaje prin servere diferite.", + "simplex-explained-tab-2-p-2": "Serverele doar transmit mesaje într-un singur sens, fără a deține imaginea de ansamblu a conversațiilor sau a conexiunilor utilizatorului.", + "simplex-explained-tab-3-p-1": "Serverele au credențiale anonime separate pentru fiecare coadă și nu știu cui aparțin acestea.", + "simplex-explained-tab-3-p-2": "Utilizatorii pot îmbunătăți și mai mult confidențialitatea metadatelor folosind Tor pentru a accesa serverele, prevenind astfel corelarea prin adresa IP.", + "chat-bot-example": "Exemplu de bot de chat", + "smp-protocol": "Protocolul SMP", + "chat-protocol": "Protocol de chat", + "donate": "Donează", + "copyright-label": "© 2020-2025 SimpleX Chat | Proiect Open-Source", + "simplex-chat-protocol": "Protocolul SimpleX Chat", + "terminal-cli": "Terminal CLI", + "terms-and-privacy-policy": "Politică de confidențialitate", + "hero-header": "Confidențialitate redefinită", + "hero-subheader": "Primul serviciu de mesagerie
fără identificatori de utilizator", + "hero-p-1": "Alte aplicații au identificatori de utilizator: Signal, Matrix, Session, Briar, Jami, Cwtch, etc.
SimpleX nu are, nici măcar numere aleatorii.
Acest lucru îți îmbunătățește radical confidențialitatea.", + "hero-overlay-1-textlink": "De ce ID-urile de utilizator compromit confidențialitatea?", + "hero-overlay-2-textlink": "Cum funcționează SimpleX?", + "hero-overlay-3-textlink": "Evaluări de securitate", + "hero-2-header": "Creează o conexiune privată", + "hero-2-header-desc": "Videoclipul arată cum te conectezi cu prietenul tău cu ajutorul codului QR de unică folosință al acestuia, fie față în față, fie printr-un link video. De asemenea, te poți conecta prin partajarea unui link de invitație.", + "invitation-hero-header": "Ai primit un link de unică folosință pentru a te conecta pe SimpleX Chat", + "hero-overlay-1-title": "Cum funcționează SimpleX?", + "hero-overlay-2-title": "De ce ID-urile de utilizator compromit confidențialitatea?", + "hero-overlay-3-title": "Evaluări de securitate", + "feature-1-title": "Mesaje criptate E2E, cu formatare Markdown și editare", + "feature-2-title": "Imagini, videoclipuri și fișiere
criptate E2E", + "feature-3-title": "Grupuri descentralizate criptate E2E — doar utilizatorii știu că există", + "feature-4-title": "Mesaje vocale criptate E2E", + "feature-5-title": "Mesaje care dispar", + "feature-6-title": "Apeluri audio și video
criptate E2E", + "feature-7-title": "Stocare portabilă criptată a aplicației — mută profilul pe alt dispozitiv", + "feature-8-title": "Mod incognito —
unic în SimpleX Chat", + "simplex-network-overlay-1-title": "Comparație cu protocoalele de mesagerie P2P", + "simplex-private-1-title": "2 straturi de
criptare end-to-end", + "simplex-private-2-title": "Strat adițional de
criptare pe server", + "simplex-private-3-title": "Transport TLS
securizat și autentificat", + "simplex-private-4-title": "Acces opțional
prin Tor", + "simplex-private-5-title": "Straturi multiple de
padding al conținutului", + "simplex-private-6-title": "Schimb de chei
în afara benzii", + "simplex-private-7-title": "Verificarea integrității
mesajului", + "simplex-private-8-title": "Amestecare mesaje
pentru a reduce corelația" +} diff --git a/website/langs/ru.json b/website/langs/ru.json index ea7766db1b..fbfa6d3539 100644 --- a/website/langs/ru.json +++ b/website/langs/ru.json @@ -1,10 +1,10 @@ { "copy-the-command-below-text": "скопируйте приведенную ниже команду и используйте ее в чате:", - "copyright-label": "© 2020-2025 SimpleX | Проект с открытым исходным кодом", + "copyright-label": "© 2020-2025 SimpleX Chat | Проект с открытым исходным кодом", "chat-bot-example": "Пример Чат бота", "simplex-private-card-9-point-1": "Каждая очередь сообщений передает сообщения в одном направлении с разными адресами отправки и получения.", "simplex-private-card-1-point-2": "NaCL cryptobox в каждой очереди для предотвращения корреляции трафика между очередями сообщений, в случае компрометированного TLS.", - "contact-hero-p-1": "Публичные ключи и адрес очереди сообщений в этой ссылке НЕ отправляются по сети при просмотре этой страницы — они содержатся в хэш-фрагменте URL-адреса ссылки.", + "contact-hero-p-1": "Публичные ключи и адрес очереди сообщений в этой ссылке НЕ отправляются по сети при просмотре этой страницы — они содержатся в хеш-фрагменте URL-адреса ссылки.", "guide-dropdown-5": "Управление данными", "scan-the-qr-code-with-the-simplex-chat-app": "Отсканируйте QR-код с помощью приложения SimpleX Chat", "simplex-private-card-9-point-2": "Это уменьшает векторы атак и доступные метаданные, по сравнению с традиционными серверами доставки сообщений.", @@ -14,7 +14,7 @@ "hero-2-header": "Установите конфиденциальное соединение", "simplex-unique-overlay-card-3-p-3": "В отличие от серверов федеративных сетей (электронной почты, XMPP или Matrix), серверы SimpleX не хранят учетные записи пользователей, они только ретранслируют сообщения, защищая конфиденциальность обеих сторон.", "hero-subheader": "Первый мессенджер
без идентификаторов пользователей", - "privacy-matters-overlay-card-3-p-2": "Одна из самых шокирующих историй — это опыт Слахи, Мохаммеда Ульда, описанный в его мемуарах и показанный в фильме Мавританец. Он был помещен в лагерь Гуантанамо без суда и следствия и подвергался там пыткам в течение 15 лет после телефонного звонка своему родственнику в Афганистане, из за подозрения в причастности к терактам 11 сентября, хотя предыдущие 10 лет он жил в Германии.", + "privacy-matters-overlay-card-3-p-2": "Одна из самых шокирующих историй — это опыт Слахи, Мохаммеда Ульда, описанный в его мемуарах и показанный в фильме Мавританец. Он был помещен в лагерь Гуантанамо без суда и следствия и подвергался там пыткам в течение 15 лет после телефонного звонка своему родственнику в Афганистане, из-за подозрения в причастности к терактам 11 сентября, хотя предыдущие 10 лет он жил в Германии.", "signing-key-fingerprint": "Идентификатор ключа подписи (SHA-256)", "simplex-network-2-desc": "Серверные узлы SimpleX НЕ хранят профили пользователей, контакты и доставленные сообщения, НЕ подключаются друг к другу, и НЕ имеют каталога серверов.", "simplex-privacy": "Конфиденциальность SimpleX", @@ -55,7 +55,7 @@ "docs-dropdown-1": "Сеть SimpleX", "hero-overlay-card-1-p-5": "Только клиентские устройства хранят профили пользователей, контакты и группы; сообщения отправляются с двухуровневым сквозным шифрованием.", "simplex-chat-for-the-terminal": "SimpleX Chat для терминала", - "simplex-network-overlay-card-1-li-3": "P2P не решает проблему MITM-атаки (Атака посредника), и многие сети не используют сообщения вне протокола для первоначального обмена ключами. SimpleX использует сообщения вне протокола или, в некоторых случаях, ранее существовавшие защищенные и доверенные соединения для первоначального обмена ключами.", + "simplex-network-overlay-card-1-li-3": "P2P не решает проблему MITM-атаки (Атака посредника), и многие сети не используют сообщения вне протокола для первоначального обмена ключами. SimpleX использует сообщения вне протокола или, в некоторых случаях, ранее существовавшие защищенные и доверенные соединения для первоначального обмена ключами.", "the-instructions--source-code": "SimpleX Chat.", "simplex-network-section-desc": "SimpleX Chat обеспечивает наилучшую конфиденциальность, сочетая преимущества P2P и федеративных сетей.", "privacy-matters-section-subheader": "Сохранение конфиденциальности Ваших метаданных — с кем Вы общаетесь — защищает Вас от:", @@ -103,7 +103,7 @@ "install-simplex-app": "Установите приложение SimpleX", "hero-overlay-3-textlink": "Аудит безопасности", "comparison-point-2-text": "Возможность MITM", - "scan-the-qr-code-with-the-simplex-chat-app-description": "Публичные ключи и адрес очереди сообщений, указанные в этой ссылке, НЕ отправляются по сети при просмотре этой страницы —
они содержатся в хэш-фрагменте URL-адреса ссылки.", + "scan-the-qr-code-with-the-simplex-chat-app-description": "Публичные ключи и адрес очереди сообщений, указанные в этой ссылке, НЕ отправляются по сети при просмотре этой страницы —
они содержатся в хеш-фрагменте URL-адреса ссылки.", "guide-dropdown-8": "Настройки приложения", "simplex-explained-tab-2-p-2": "Серверы передают сообщения только в одну сторону, не имея полной информации о разговорах и контактах пользователя.", "smp-protocol": "Протокол SMP", @@ -127,7 +127,7 @@ "simplex-unique-overlay-card-2-p-2": "Хоть злоумышленники и могут использовать постоянный адрес для отправки нежелательных запросов или спама, Вы можете легко его изменить или просто удалить, не теряя связи с уже установленными контактами.", "simplex-unique-4-overlay-1-title": "Полностью децентрализованная — пользователи владеют сетью SimpleX", "guide-dropdown-2": "Отправка сообщений", - "simplex-network-overlay-card-1-li-5": "Все известные P2P-сети могут быть уязвимы для Атаки Сивиллы, поскольку каждый узел доступен для обнаружения, и сеть работает как единое целое. Известные меры по уменьшению риска требуют либо централизованного компонента, либо дорогостоящего Proof-of-work. Сеть SimpleX не позволяет обнаруживать серверы, она фрагментирована и работает как множество изолированных подсетей, из-за чего провести атаку на всю сеть невозможно.", + "simplex-network-overlay-card-1-li-5": "Все известные P2P-сети могут быть уязвимы для Атаки Сивиллы, поскольку каждый узел доступен для обнаружения, и сеть работает как единое целое. Известные меры по уменьшению риска требуют либо централизованного компонента, либо дорогостоящего Proof-of-work. Сеть SimpleX не позволяет обнаруживать серверы, она фрагментирована и работает как множество изолированных подсетей, из-за чего провести атаку на всю сеть невозможно.", "simplex-private-2-title": "Дополнительный уровень
шифрования с сервером", "hero-overlay-card-1-p-4": "Этот подход предотвращает утечку любых пользовательских метаданных на уровне приложения. Для дальнейшего улучшения конфиденциальности и защиты Вашего IP-адреса Вы можете подключиться к серверам обмена сообщениями через сеть Tor.", "f-droid-org-repo": "Репозиторий F-Droid.org", @@ -172,6 +172,7 @@ "simplex-private-card-10-point-2": "Это позволяет доставлять сообщения без идентификаторов профиля пользователя, и обеспечивает лучшую конфиденциальность метаданных, чем альтернативы.", "privacy-matters-2-title": "Манипулирование выборами", "home": "Главная", + "directory": "Каталог", "chat-protocol": "Протокол чата", "simplex-private-card-5-point-2": "Это делает сообщениям разного размера одинаковыми для серверов и сети.", "hero-overlay-card-1-p-1": "Многие спрашивают: Если у SimpleX нет никаких идентификаторов пользователя, то как приложение знает, куда доставлять сообщения?", @@ -182,11 +183,11 @@ "privacy-matters-overlay-card-3-p-3": "Обычных людей арестовывают за то, чем они делятся в Интернете, даже через свои \"анонимные\" аккаунты, даже в демократических странах.", "simplex-unique-overlay-card-3-p-2": "Сквозные зашифрованные сообщения временно хранятся на серверах SimpleX до получения, после чего они удаляются безвозвратно.", "blog": "Блог", - "simplex-private-card-7-point-1": "Для обеспечения неизменности, сообщения нумеруются по порядку и содержат хэш предыдущего сообщения.", + "simplex-private-card-7-point-1": "Для обеспечения неизменности, сообщения нумеруются по порядку и содержат хеш предыдущего сообщения.", "simplex-unique-overlay-card-4-p-2": "Сеть SimpleX использует открытый протокол и предоставляет SDK для создания чат-ботов, позволяя внедрять сервисы, с которыми пользователи могут взаимодействовать через приложение SimpleX Chat — мы с нетерпением ждем сервисы SimpleX, которые Вы создадите.", "simplex-explained-tab-1-p-1": "Вы можете создавать контакты и группы, а также вести двусторонние беседы, как и в любом другом мессенджере.", "contact-hero-p-2": "Еще не скачали SimpleX Chat?", - "why-simplex-is-unique": "Почему SimpleX уникальный", + "why-simplex-is-unique": "Почему SimpleX уникален", "simplex-network-section-header": "Сеть SimpleX", "simplex-private-10-title": "Временные анонимные парные идентификаторы", "privacy-matters-1-overlay-1-linkText": "Конфиденциальность экономит Ваши деньги", @@ -217,7 +218,7 @@ "hero-2-header-desc": "В видео показано, как подключиться к Вашему другу через одноразовый QR-код, при встрече или во время видеосвязи. Вы также можете соединится, поделившись ссылкой-приглашением.", "simplex-network-overlay-card-1-li-6": "Сети P2P могут быть уязвимы для DRDoS атаки, когда клиенты могут ретранслировать и увеличивать трафик, что приводит к отказу всей сети. Клиенты SimpleX ретранслируют трафик только из известного соединения и не могут быть использованы злоумышленником для создания трафика во всей сети.", "if-you-already-installed-simplex-chat-for-the-terminal": "Если Вы уже установили SimpleX Chat для терминала", - "docs-dropdown-8": "Служба Каталогов SimpleX", + "docs-dropdown-8": "Каталог SimpleX", "simplex-private-card-1-point-1": "Протокол двойного обновления ключей —
\"отрицаемые\" сообщения с идеальной прямой секретностью и восстановлением после взлома.", "simplex-private-card-8-point-1": "Серверы SimpleX действуют как узлы-миксеры с низкой задержкой — входящие и исходящие сообщения имеют разный порядок.", "simplex-unique-overlay-card-2-p-1": "Поскольку у Вас нет идентификатора в сети SimpleX, никто не сможет связаться с Вами, если Вы сами не предоставите одноразовый или временный адрес в виде QR-кода или ссылки.", @@ -233,9 +234,10 @@ "simplex-private-7-title": "Проверка неизменности
сообщений", "privacy-matters-overlay-card-1-p-4": "Сеть SimpleX защищает конфиденциальность Ваших контактов лучше, чем альтернативы, предотвращая доступ к Вашей социальной сети каким-либо компаниям или организациям. Даже когда люди используют серверы, предоставляемые SimpleX Chat, мы не знаем точное количество пользователей или с кем они общаются.", "hero-overlay-card-1-p-6": "Подробнее читайте в техническом описании SimpleX.", - "simplex-network-overlay-card-1-p-1": "Протоколы и приложения для обмена сообщениями P2P имеют различные проблемы, которые делают их менее надежными, чем SimpleX, более сложными для анализа и уязвимыми для нескольких типов атак.", + "simplex-network-overlay-card-1-p-1": "Протоколы и приложения для обмена сообщениями P2P имеют различные проблемы, которые делают их менее надежными, чем SimpleX, более сложными для анализа и уязвимыми для нескольких типов атак.", "terms-and-privacy-policy": "Политика Конфиденциальности", - "simplex-network-overlay-card-1-li-1": "Сети P2P используют DHT (распределенные хэш-таблицы) для маршрутизации сообщений. DHT должны обеспечивать баланс между гарантией доставки и задержкой. SimpleX имеет как лучшую гарантию доставки, так и меньшую задержку, чем P2P. В сетях P2P сообщение передается через нескольких узлов, последовательно, кол-во узлов-посредников будет расти параллельно размеру сети — O(log N).", + "about-and-contact-us": "O нас и наши контакты", + "simplex-network-overlay-card-1-li-1": "Сети P2P используют DHT (распределенные хеш-таблицы) для маршрутизации сообщений. DHT должны обеспечивать баланс между гарантией доставки и задержкой. SimpleX имеет как лучшую гарантию доставки, так и меньшую задержку, чем P2P. В сетях P2P сообщение передается через нескольких узлов, последовательно, кол-во узлов-посредников будет расти параллельно размеру сети — O(log N).", "privacy-matters-section-label": "Убедитесь, что Ваш мессенджер не может получить доступ к Вашим данным!", "simplex-unique-overlay-card-3-p-1": "SimpleX Chat хранит все пользовательские данные на клиентских устройствах в портативном формате зашифрованной базы данных которую можно перенести на другое устройство.", "simplex-network-3-desc": "серверы предоставляют однонаправленные очереди для подключения пользователей, но у них нет видимости графика сетевых подключений — это делают только пользователи.", @@ -255,5 +257,59 @@ "docs-dropdown-10": "Запросы данных", "docs-dropdown-12": "Безопасность", "docs-dropdown-11": "Часто задаваемые вопросы", - "docs-dropdown-14": "SimpleX для бизнеса" + "docs-dropdown-14": "SimpleX для бизнеса", + "index-hero-h1": "
Жить
Свободно
", + "index-hero-h2": "Свобода и Безопасность
Ваших Коммуникаций", + "index-hero-p1": "Первая сеть, в которой Вам принадлежат Ваши данные, контакты и группы.", + "index-hero-download-desktop-btn-title": "Загрузить приложение SimpleX для компьютера", + "index-testflight-title": "Бета-релиз для iOS на TestFlight", + "index-f-droid-title": "Загрузить через F-Droid", + "index-security-assessment-title": "Аудиты безопасности", + "index-security-review-2022-title": "Аудит безопасности в 2022", + "index-security-review-2024-title": "Аудит безопасности в 2024", + "index-security-audits-label": "Аудиты
безопасности", + "index-publications-privacy-guides-title": "Рекомендации мессенджеров Privacy Guides", + "index-publications-whonix-title": "Рекомендации мессенджеров от Whonix", + "index-publications-heise-title": "Публикации Heise Online", + "index-publications-kuketz-title": "Обзор от Mike Kuketz", + "index-publications-optout-title": "OptOut подкаст интервью", + "worlds-most-secure-messaging": "Самый Безопасный Мессенджер в Мире", + "index-messaging-p1": "Сообщения в SimpleX имеют самое передовое сквозное шифрование (end-to-end).", + "index-messaging-p2": "Для Вашей безопасности, серверы не могут видеть ваши сообщения и с кем Вы разговариваете.", + "index-messaging-cta": "Узнать больше про сообщения в SimpleX", + "index-nextweb-h2": "Ваш Интернет Будущего", + "index-nextweb-p1": "SimpleX основан на идее что Ваши данные, контакты и группы должны принадлежать Вам.", + "index-nextweb-p2": "Открытая децентрализованная сеть позволяет Вам соединяться с другими людьми и общаться свободно и безопасно.", + "index-token-h2": "Стабильные Сообщества", + "index-token-p1": "Вы сможете поддерживать Ваши любимые группы с помощью будущих Ваучеров Групп.", + "index-token-p2": "Ваучеры будут использоваться для оплаты за серверы, чтобы группы оставались свободными и независимыми.", + "index-token-cta": "Узнайте больше и возьмите бесплатный пропуск, чтобы участвовать в тестировании.", + "index-roadmap-h2": "Путь Сети SimpleX к Свободному Интернету", + "index-roadmap-2025": "2025", + "index-roadmap-2025-title": "Большие каналы и группы", + "index-roadmap-2025-desc": "Чтобы Вы могли покинуть централизованные платформы", + "index-roadmap-2026": "2026", + "index-roadmap-2026-title": "Самодостаточные группы и серверы", + "index-roadmap-2026-desc": "Запуск Ваучеров Групп", + "index-roadmap-2027": "2027", + "index-roadmap-2027-title": "Поддержка роста Ваших групп", + "index-roadmap-2027-desc": "Инструменты для продвижения групп", + "index-directory-h2": "Вступайте в Группы SimpleX", + "index-directory-p1": "Сотни тысяч людей уже доверяют мессенджеру SimpleX.", + "index-directory-p2": "Найдите группы по душе в каталоге SimpleX и создайте свои!", + "index-directory-cta": "Открыть каталог SimpleX", + "index-directory-users-group-title": "Группа пользователей SimpleX", + "how-secure-comparison-title": "Сравнение безопасности сквозного шифрования в мессенджерах", + "how-secure-message-padding": "Дополнение сообщений", + "how-secure-repudiation-deniability": "Отрицаемые сообщения", + "how-secure-forward-secrecy": "Прямая секретность", + "how-secure-break-in-recovery": "Безопасность после взлома", + "how-secure-two-factor-key-exchange": "2-факторный обмен ключами", + "how-secure-post-quantum-hybrid-crypto": "Гибридное постквантовое шифрование", + "messengers-comparison-section-list-point-1": "Briar дополняет сообщения до размера кратного 1024 байтов, Signal - до кратного 160 байтов.", + "messengers-comparison-section-list-point-2": "Отрицаемость сообщений не включает в себя соединеие между клиентом и сервером.", + "messengers-comparison-section-list-point-3": "По всей видимости, использование криптографической подписи исключает отражаемость, но это требует уточнения.", + "messengers-comparison-section-list-point-4": "Реализация поддержки нескольких устройств подрывает безопасность после взлома алгоритма Double Ratchet.", + "messengers-comparison-section-list-point-5": "2-факторный обмен ключами опциональный, через верификацию кода безопасности.", + "messengers-comparison-section-list-point-6": "Пост-квантовое согласование ключей происходит не всегда, защищая только часть согласований." } diff --git a/website/langs/tr.json b/website/langs/tr.json index c6f6d24c93..ad8914fa18 100644 --- a/website/langs/tr.json +++ b/website/langs/tr.json @@ -21,7 +21,7 @@ "smp-protocol": "SMP Protokolü", "chat-protocol": "Sohbet Protokolü", "donate": "Bağış Yap", - "copyright-label": "© 2020-2025 SimpleX | Açık Kaynak Projesi", + "copyright-label": "© 2020-2025 SimpleX Chat | Açık Kaynak Projesi", "simplex-chat-protocol": "SimpleX Sohbet Protokolü", "terminal-cli": "Terminal Komut Satırı Arayüzü", "terms-and-privacy-policy": "Gizlilik Politikası", diff --git a/website/langs/uk.json b/website/langs/uk.json index 1c1780edb3..c2b02d7cf2 100644 --- a/website/langs/uk.json +++ b/website/langs/uk.json @@ -78,7 +78,7 @@ "smp-protocol": "Протокол SMP", "chat-protocol": "Протокол чату", "donate": "Пожертвувати", - "copyright-label": "© 2020-2025 SimpleX | Проект з відкритим кодом", + "copyright-label": "© 2020-2025 SimpleX Chat | Проект з відкритим кодом", "simplex-chat-protocol": "Протокол чату SimpleX", "terminal-cli": "Термінал CLI", "hero-header": "Приватність переосмислена", diff --git a/website/langs/zh_Hans.json b/website/langs/zh_Hans.json index c0b843ca9e..6ac8bdf833 100644 --- a/website/langs/zh_Hans.json +++ b/website/langs/zh_Hans.json @@ -50,14 +50,14 @@ "simplex-explained-tab-3-text": "3. 服务器能看到什么", "hero-header": "重新定义隐私", "simplex-explained-tab-1-p-2": "它是如何利用单向消息队列并不利用用户识别符工作的?", - "simplex-explained-tab-2-p-2": "服务器只会单向传输消息,而无法掌握用户的对话或连接的全貌。", + "simplex-explained-tab-2-p-2": "服务器只单向传输消息,无法掌握用户的对话或连接的全貌。", "simplex-explained-tab-3-p-1": "服务器对每个队列都有单独的匿名凭证,并且不知道这些凭证属于哪些用户。", "donate": "捐赠", "simplex-explained-tab-2-p-1": "对于每个连接,您都会使用两个单独的消息队列,通过不同的服务器发送和接收消息。", "simplex-chat-protocol": "SimpleX 聊天协议", "smp-protocol": "SMP协议", "chat-protocol": "聊天协议", - "copyright-label": "© 2020-2025 SimpleX | 开源项目", + "copyright-label": "© 2020-2025 SimpleX Chat | 开源项目", "terminal-cli": "命令行程式", "simplex-explained-tab-1-p-1": "您可以创建联系人和群组,并进行双向对话,就像是任何其他即时通讯软件一样。", "hero-p-1": "其他应用——如Signal、Matrix、Session、Briar、Jami、Cwtch 等——都需要用户 ID。
而SimpleX 不需要用户ID,连随机生成的也不需要。
这从根本上改善了您的隐私。", @@ -108,34 +108,34 @@ "hero-overlay-card-1-p-3": "您定义用于接收消息的服务器、您的联系人—— 您用来向他们发送消息的服务器。 每个会话都很可能会使用两个不同的服务器。", "hero-overlay-card-1-p-4": "此设计可防止在应用程序级别泄漏任何用户元数据。 为了进一步改善隐私并保护您的 IP 地址,您可以通过 Tor 连接到消息服务器。", "hero-overlay-card-1-p-5": "只有客户端设备存储用户配置文件、联系人和群组; 消息均使用两层端到端加密发送。", - "simplex-network-overlay-card-1-p-1": "P2P 消息传递协议和应用程序存在各种问题,使得它们不如 SimpleX 可靠,分析起来更复杂,并且 容易受到多种类型的攻击。", - "hero-overlay-card-1-p-6": "在 SimpleX 白皮书 中阅读更多内容。", + "simplex-network-overlay-card-1-p-1": "P2P 消息传递协议和应用程序存在各种问题,使得它们不如 SimpleX 可靠,分析起来更复杂,并且 容易受到多种类型的攻击。", + "hero-overlay-card-1-p-6": "阅读 SimpleX 白皮书了解更多信息。", "hero-overlay-card-2-p-2": "然后他们可以将这些信息与现有的公共社交网络相关联,并确定一些真实身份。", "hero-overlay-card-2-p-1": "当用户具有持久身份时,即使这只是一个随机数(如会话 ID),提供商或攻击者也有可能观察到用户如何连接以及他们发送了多少消息。", "hero-overlay-card-2-p-3": "即使是使用 Tor v3 服务的最私密的应用程序,如果您通过同一个人资料与两个不同的联系人交谈,他们也可以证明他们与同一个人有联系。", "hero-overlay-card-2-p-4": "为防止这些攻击,SimpleX 在其设计中不包含任何用户 ID 。 而且,如果您使用隐身模式,您将为每个联系人显示不同的名称,避免他们之间共享任何数据。", - "simplex-network-overlay-card-1-li-1": "P2P 网络依赖于 分布式散列表(DHT) 的某些变体来路由消息。 DHT 在设计上必须平衡可达性和延迟。 SimpleX 比 P2P 具有更好的可达性和更低的延迟,因为消息可以通过通讯双方选择的多个服务器并行地冗余传递。若是在 P2P 网络中,消息则需要使用算法选择,并依次通过 O(log N) 个节点。", - "simplex-network-overlay-card-1-li-4": "P2P 实现(如 BitTorrent)可能会被某些互联网提供商阻止。 SimpleX 与传输协议无关 — 它可以在标准网络协议上工作,例如 WebSockets。", - "simplex-network-overlay-card-1-li-3": "P2P 并未解决中间人攻击(MITM Attack) 问题。大多数现有的 P2P 实现没有使用带外通讯来进行初始密钥的交换,而 SimpleX 使用带外通讯,或者在某些情况下,使用预先存在的安全和可信连接来进行初始密钥交换。", - "simplex-network-overlay-card-1-li-5": "所有已知的 P2P 网络都可能受到 Sybil 攻击,因为每个节点都是可发现的,并且网络作为一个整体运行。 已知的缓解措施不是需要一个中心化的组件就是需要昂贵的工作量证明。而 SimpleX 网络没有服务器可发现性,它是碎片化的并且作为多个隔离的子网运行,这样全网络范围的攻击便无从实现。", - "simplex-network-overlay-card-1-li-6": "P2P 网络可能受到 DRDoS 攻击 。客户端有能力重新广播和放大流量,从而导致整个网络范围内的服务中断。 SimpleX 客户端仅中继来自已知连接的流量,因此不能被攻击者用来放大整个网络的流量。", + "simplex-network-overlay-card-1-li-1": "P2P 网络依赖于分布式散列表(DHT) 的某些变体来路由消息。 DHT 在设计上必须平衡可达性和延迟。 SimpleX 比 P2P 具有更好的可达性和更低的延迟,因为消息可以通过通讯双方选择的多个服务器并行地冗余传递。若是在 P2P 网络中,消息则需要使用算法选择,并依次通过 O(log N) 个节点。", + "simplex-network-overlay-card-1-li-4": "P2P 实现(如BitTorrent)可能会被某些互联网提供商阻止。 SimpleX 与传输协议无关 — 它可以在标准网络协议上工作,例如 WebSockets。", + "simplex-network-overlay-card-1-li-3": "P2P 并未解决中间人攻击(MITM Attack) 问题。大多数现有的 P2P 实现没有使用带外通讯来进行初始密钥的交换,而 SimpleX 使用带外通讯,或者在某些情况下,使用预先存在的安全和可信连接来进行初始密钥交换。", + "simplex-network-overlay-card-1-li-5": "所有已知的 P2P 网络都可能受到Sybil 攻击,因为每个节点都是可发现的,并且网络作为一个整体运行。 已知的缓解措施不是需要一个中心化的组件就是需要昂贵的工作量证明。而 SimpleX 网络没有服务器可发现性,它是碎片化的并且作为多个隔离的子网运行,这样全网络范围的攻击便无从实现。", + "simplex-network-overlay-card-1-li-6": "P2P 网络可能受到 分布式反射拒绝服务攻击 。客户端有能力重新广播和放大流量,从而导致整个网络范围内的服务中断。 SimpleX 客户端仅中继来自已知连接的流量,因此不能被攻击者用来放大整个网络的流量。", "privacy-matters-overlay-card-1-p-2": "在线零售商知道收入较低的人更有可能在紧急情况下购买商品,因此他们可能会收取更高的价格或取消折扣。", - "privacy-matters-overlay-card-1-p-3": "一些金融和保险公司使用社交图谱来确定利率和保费。 它通常会让收入较低的人支付更多—— 它被称为“贫困溢价”。", + "privacy-matters-overlay-card-1-p-3": "一些金融和保险公司使用社交图谱来确定利率和保费。 它通常会让收入较低的人支付更多—它被称为“贫困溢价”。", "privacy-matters-overlay-card-1-p-4": "SimpleX 网络比任何替代方案都能更好地保护您人际关系层面的隐私,防止您的社交图谱被任何公司或组织使用。 即使人们使用 SimpleX Chat 应用预配置的服务器,服务器运营方也不知道用户数量或他们的连接数。", - "privacy-matters-overlay-card-2-p-1": "不久前,我们观察到几次大选被一家知名咨询公司操纵,该公司使用我们的社交图谱扭曲我们对现实世界的看法并操纵我们的选票。", + "privacy-matters-overlay-card-2-p-1": "不久前,我们观察到几次大选被一家知名咨询公司操纵,该公司使用我们的社交图谱扭曲我们对现实世界的看法并操纵我们的选票。", "privacy-matters-overlay-card-2-p-2": "为了客观并做出独立的决定,您需要控制您的信息空间。 而这只有当您使用没有能力访问您的社交图谱的,注重隐私的通信网络时,这才有可能。", "privacy-matters-overlay-card-2-p-3": "SimpleX 是第一个没有设计任何用户标识符的网络,这样能比任何已知的替代方案都更好地保护您的连接图谱。", - "privacy-matters-overlay-card-3-p-2": "最令人震惊的故事之一是 Mohamedou Ould Salahi 在他的回忆录中描述并在毛里塔尼亚电影中展示的经历。 他在未经审判的情况下被关进关塔那摩集中营,并在打电话给他在阿富汗的亲戚后在那里遭受了 15 年的折磨,他被怀疑参与了 9/11 袭击,尽管他在过去 10 年住在德国。", + "privacy-matters-overlay-card-3-p-2": "最令人震惊的故事之一是 Mohamedou Ould Salahi 在他的回忆录中描述并在毛里塔尼亚电影中展示的经历。 他在未经审判的情况下被关进关塔那摩集中营,并在打电话给他在阿富汗的亲戚后在那里遭受了 15 年的折磨,他被怀疑参与了 9/11 袭击,尽管他在过去 10 年住在德国。", "privacy-matters-overlay-card-3-p-1": "每个人都应该关心他们通信的隐私和安全——无害的谈话会让您处于危险之中,即使您没有什么可隐瞒的。", - "privacy-matters-overlay-card-3-p-3": "普通人会因为他们在网上分享的内容而被捕,即使是通过他们的“匿名”帐户,即使是在民主国家。", + "privacy-matters-overlay-card-3-p-3": "普通人会因为他们在网上分享的内容而被捕,即使是通过他们的“匿名”账户,即使是在民主国家。", "privacy-matters-overlay-card-3-p-4": "使用端到端加密的即时通讯软件还不够,我们都应该使用保护我们个人网络隐私——即我们与谁有联系的即时通讯软件。", "simplex-unique-overlay-card-1-p-1": "与其他消息网络不同,SimpleX 没有分配给用户的标识符。 它不依赖电话号码、基于域的地址(如电子邮件或 XMPP)、用户名、公钥甚至随机数来识别其用户—— SimpleX 服务器运营方不知道有多少人使用其服务器。", - "simplex-unique-overlay-card-1-p-2": "为了传递消息,SimpleX 使用单向消息队列的成对匿名地址,通常通过不同的服务器将接收和发送的消息分开。", + "simplex-unique-overlay-card-1-p-2": "为了传递消息,SimpleX 使用单向消息队列的成对匿名地址,通常通过不同的服务器将接收和发送的消息分开。", "simplex-unique-overlay-card-1-p-3": "这种设计保护了您正在与之通信的人的隐私,将其隐藏在 SimpleX 网络的服务器和任何观察者之外。 要对服务器隐藏您的 IP 地址,您可以通过 Tor 连接到 SimpleX 服务器。", "simplex-unique-overlay-card-4-p-1": "您可以将 SimpleX 与您自己的服务器一起使用,并且仍然可以与使用应用中预配置服务器的人们进行通信。", "simplex-unique-card-3-p-2": "端到端加密的消息在被收到前会暂时保存在 SimpleX 中继服务器上,传送完成后它们会被永久删除。", "simplex-unique-card-1-p-1": "SimpleX 保护您的个人资料、联系人和元数据的隐私,不让 SimpleX 网络服务器和任何观察者看到它们。", - "simplex-unique-overlay-card-4-p-3": "如果您正在考虑为在SimpleX 网络上开发,例如,为 SimpleX 应用程序用户开发聊天机器人,或将 SimpleX 聊天库集成到您的移动应用程序中,请 联系我们 以获取建议和支持。", + "simplex-unique-overlay-card-4-p-3": "如果您正在考虑为在SimpleX 网络上开发,例如,为 SimpleX 应用程序用户开发聊天机器人,或将 SimpleX 聊天库集成到您的移动应用,请 联系我们获取建议和支持。", "simplex-unique-card-4-p-2": "您可以搭配自己的服务器来使用 SimpleX 或使用我们提供的服务器 — 并仍然连接到任何用户。", "simplex-unique-card-2-p-1": "因为您在 SimpleX 网络上没有标识符或固定地址,所以除非您以二维码或链接的形式分享一次性或临时用户地址,没有人可以联系您。", "simplex-unique-card-3-p-1": "SimpleX 以便携式加密数据库格式将所有用户数据存储在客户端设备上—— 它可以转移到另一个设备。", @@ -143,7 +143,7 @@ "we-invite-you-to-join-the-conversation": "我们邀请您加入对话", "enter-your-email-address": "输入您的电子邮箱地址", "join-the-REDDIT-community": "加入 REDDIT 社区", - "why-simplex-is-unique": "为什么 SimpleX 是 独特的", + "why-simplex-is-unique": "为什么 SimpleX 是独特的", "learn-more": "了解更多", "join-us-on-GitHub": "在 GitHub 上加入我们", "more-info": "更多信息", @@ -157,30 +157,30 @@ "see-simplex-chat": "查看 SimpleX 聊天", "install-simplex-app": "安装 SimpleX 应用程序", "connect-in-app": "在应用程序中连接", - "tap-the-connect-button-in-the-app": "点按应用中的 “连接” 按钮", + "tap-the-connect-button-in-the-app": "点按应用中的 “连接” 按钮", "scan-the-qr-code-with-the-simplex-chat-app": "使用 SimpleX Chat 应用程序扫描二维码", "scan-the-qr-code-with-the-simplex-chat-app-description": "当您查看此页面时,此链接中的公钥和消息队列地址不会通过网络发送 ——它们包含在链接 URL 的哈希片段中。", "installing-simplex-chat-to-terminal": "将 SimpleX 聊天安装到终端", "github-repository": "GitHub 仓库", "copy-the-command-below-text": "复制下面的命令并在聊天中使用它:", - "privacy-matters-section-header": "为什么隐私很重要", + "privacy-matters-section-header": "为什么隐私很重要", "the-instructions--source-code": "如何下载或从源代码下载进行编译的说明。", "if-you-already-installed-simplex-chat-for-the-terminal": "如果您已经为终端安装了 SimpleX Chat", "if-you-already-installed": "如果您已经安装", "simplex-chat-for-the-terminal": "用于终端的 SimpleX Chat", "privacy-matters-section-label": "确保您的即时通讯软件无法访问您的数据!", - "privacy-matters-section-subheader": "保护您的元数据的隐私—— 您与谁交谈 — 保护您免受:", - "simplex-private-section-header": "是什么让 SimpleX 能够保密", + "privacy-matters-section-subheader": "保护您的元数据的隐私— 您与谁交谈 — 保护您免受:", + "simplex-private-section-header": "是什么让 SimpleX 能够保密", "simplex-network-2-desc": "SimpleX 中继服务器不存储用户配置文件、联系人和传递的消息,不相互连接,并且没有服务器目录。", "tap-to-close": "点击关闭", - "simplex-network-section-header": "SimpleX 网络", + "simplex-network-section-header": "SimpleX 网络", "simplex-network-section-desc": "Simplex Chat 通过结合 P2P 和联邦网络的优势使其保密性无与伦比。", "no-private": "否 - 私密", "simplex-network-1-desc": "所有消息都通过服务器发送,既能更好地保护元数据隐私和可靠地传递异步消息,同时也能避免许多", "simplex-network-3-header": "SimpleX 网络", "protocol-1-text": "Signal、其他大平台", "comparison-point-4-text": "单一或中心化网络", - "simplex-network-3-desc": "服务器提供单向队列来连接用户,但是他们看不到网络连接图图谱— 只有用户可以。", + "simplex-network-3-desc": "服务器提供单向队列来连接用户,但是他们看不到网络连接图图谱— 只有用户可以。", "comparison-section-header": "与其他协议的比较", "protocol-3-text": "P2P协议", "no": "否", @@ -190,7 +190,7 @@ "comparison-point-2-text": "中间人攻击的可能性", "comparison-point-5-text": "中央组件或其他全网攻击", "yes": "是", - "comparison-section-list-point-5": "不保护用户的元数据", + "comparison-section-list-point-5": "不保护用户的元数据隐私", "no-resilient": "不依赖 - 有韧性", "no-decentralized": "不依赖 - 去中心化的", "comparison-section-list-point-3": "公钥或其他一些全球唯一的 ID", @@ -201,7 +201,7 @@ "see-here": "参见此处", "comparison-section-list-point-7": "P2P 网络要么拥有中央权威,要么整个网络可能被攻陷", "simplex-private-card-5-point-1": "SimpleX 为每个加密层进行内容填充来对抗长度扩展攻击。", - "simplex-unique-overlay-card-4-p-2": "SimpleX 网络使用开放协议并提供用于创建聊天机器人的 SDK, 允许用户实现通过 SimpleX Chat 应用程序与之交互的服务—— 我们真的很期待看到您会依托SimpleX构建哪些服务。", + "simplex-unique-overlay-card-4-p-2": "SimpleX 网络使用开放协议并提供用于创建聊天机器人的 SDK, 允许用户实现通过 SimpleX Chat 应用程序与之交互的服务—我们真的很期待看到您会依托SimpleX构建哪些服务。", "simplex-unique-overlay-card-2-p-2": "即使使用可选的用户地址,当它被用于发送垃圾邮件联系请求,您可以更改或完全删除它而不会丢失任何连接。", "simplex-unique-overlay-card-3-p-1": "SimpleX Chat 使用便携式加密数据库格式仅将所有用户数据存储在客户端设备上,该格式可以导出并传输到任何支持的设备。", "donate-here-to-help-us": "在这里捐款来帮助我们", @@ -239,10 +239,10 @@ "stable-versions-built-by-f-droid-org": "由 F-Droid.org 构建的稳定版本", "simplex-chat-repo": "SimpleX 存储库", "stable-and-beta-versions-built-by-developers": "开发人员构建的稳定版和测试版", - "f-droid-page-simplex-chat-repo-section-text": "要将其添加到您的 F-Droid 客户端,请扫描二维码或使用以下 URL:", + "f-droid-page-simplex-chat-repo-section-text": "要将其添加到您的 F-Droid 客户端,请扫描二维码或使用以下 URL:", "comparison-section-list-point-4a": "SimpleX 中继无法破坏 e2e 加密。 验证安全代码以减轻对带外通道的攻击", - "docs-dropdown-8": "SimpleX 目录服务", - "f-droid-page-f-droid-org-repo-section-text": "SimpleX Chat 和 F-Droid.org 存储库使用不同的密钥对构建进行签名。 如需切换,请导出聊天数据库并重新安装应用。", + "docs-dropdown-8": "SimpleX 目录", + "f-droid-page-f-droid-org-repo-section-text": "SimpleX Chat 和 F-Droid.org 存储库使用不同的密钥对构建进行签名。 如需切换,请导出聊天数据库并重新安装应用。", "hero-overlay-3-title": "安全性评估", "hero-overlay-card-3-p-2": "2022年11月份,Trail of Bits 审核了 SimpleX 平台的密码学和网络部件。更多内容见 该公告。", "jobs": "加入团队", @@ -255,5 +255,7 @@ "docs-dropdown-11": "常问问题", "docs-dropdown-12": "安全性", "hero-overlay-card-3-p-3": "Trail of Bits 于 2024 年 7 月审核了 SimpleX 网络协议的加密设计。了解更多信息。", - "docs-dropdown-14": "企业版 SimpleX" + "docs-dropdown-14": "企业版 SimpleX", + "directory": "目录", + "about-and-contact-us": "关于 & 联系我们" } diff --git a/website/langs/zh_Hant.json b/website/langs/zh_Hant.json index 1896fa3478..88dbdf107b 100644 --- a/website/langs/zh_Hant.json +++ b/website/langs/zh_Hant.json @@ -19,7 +19,7 @@ "simplex-explained-tab-2-p-2": "伺服器僅單向傳遞消息,無法全面瞭解使用者的對話記錄或連接。", "simplex-explained-tab-2-p-1": "對於每個連接,您可以使用兩個單獨的消息佇列通過不同的伺服器發送和接收消息。", "chat-protocol": "聊天協定", - "copyright-label": "© 2020-2025 SimpleX |開源專案", + "copyright-label": "© 2020-2025 SimpleX Chat |開源專案", "donate": "捐助", "simplex-explained-tab-1-p-1": "你可以建立聯絡人和群組,並進行雙向對話,就像在任何其他即時通訊軟件中一樣。", "simplex-explained-tab-1-p-2": "它如何在沒有使用者個人檔案識別符的情況下使用單向佇列?", diff --git a/website/package.json b/website/package.json index bbe3a17a68..4192563164 100644 --- a/website/package.json +++ b/website/package.json @@ -20,6 +20,7 @@ "@11ty/eleventy-plugin-rss": "^1.2.0", "@simplex-chat/webrtc": "^0.1.1", "common-tags": "^1.8.2", + "ethers": "^6.15.0", "fast-uri": "^2.1.0", "markdown-it-anchor": "^8.6.4", "markdown-it-replace-link": "^1.1.0", diff --git a/website/run.sh b/website/run.sh new file mode 100755 index 0000000000..a0a8dd0e50 --- /dev/null +++ b/website/run.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +set -e + +cd .. +./website/web.sh +cd website +npm run start diff --git a/website/src/_data/docs_dropdown.json b/website/src/_data/docs_dropdown.json index f97c2aeff2..94bc69f8f3 100644 --- a/website/src/_data/docs_dropdown.json +++ b/website/src/_data/docs_dropdown.json @@ -13,8 +13,8 @@ "url": "/docs/directory.html" }, { - "title": "docs-dropdown-3", - "url": "/docs/sql.html" + "title": "docs-dropdown-2", + "url": "/docs/android.html" }, { "title": "docs-dropdown-4", diff --git a/website/src/_data/glossary.json b/website/src/_data/glossary.json index a16c2b9541..155b732c52 100644 --- a/website/src/_data/glossary.json +++ b/website/src/_data/glossary.json @@ -103,6 +103,10 @@ "term": "Post-quantum cryptography", "definition": "Post-quantum cryptography" }, + { + "term": "Post-quantum hybrid crypto", + "definition": "Post-quantum cryptography" + }, { "term": "Proxied peer-to-peer", "definition": "Proxied peer-to-peer" diff --git a/website/src/_data/languages.json b/website/src/_data/languages.json index 340cc59da9..0736597953 100644 --- a/website/src/_data/languages.json +++ b/website/src/_data/languages.json @@ -4,7 +4,8 @@ "label": "en", "name": "English", "flag": "/img/flags/en.svg", - "enabled": true + "enabled": true, + "home": true }, { "label": "ar", @@ -25,13 +26,15 @@ "label": "de", "name": "Deutsch", "flag": "/img/flags/de.svg", - "enabled": true + "enabled": true, + "home": true }, { "label": "es", "name": "Español", "flag": "/img/flags/es.svg", - "enabled": true + "enabled": true, + "home": true }, { "label": "fi", @@ -56,13 +59,22 @@ "label": "hu", "name": "Magyar", "flag": "/img/flags/hu.svg", - "enabled": true + "enabled": true, + "home": true + }, + { + "label": "id", + "name": "Indonesia", + "flag": "/img/flags/id.svg", + "enabled": true, + "home": true }, { "label": "it", "name": "Italiano", "flag": "/img/flags/it.svg", - "enabled": true + "enabled": true, + "home": true }, { "label": "ja", @@ -110,7 +122,8 @@ "label": "ru", "name": "Русский", "flag": "/img/flags/ru.svg", - "enabled": true + "enabled": true, + "home": true } ] } \ No newline at end of file diff --git a/website/src/_includes/footer.html b/website/src/_includes/footer.html index 340473ab3e..01b22d8b97 100644 --- a/website/src/_includes/footer.html +++ b/website/src/_includes/footer.html @@ -1,5 +1,5 @@ {% set lang = page.url | getlang %} -
+